From 6171893d3b25ede58935af802557421f9870ee58 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 1 Oct 2026 03:13:40 +0800 Subject: [PATCH 1/3] Support explicit interactive SSH money administration --- docs/DEPLOYMENT.md | 6 +- src/msg/admin/money.py | 11 +++- src/msg/daemon.py | 22 ++++--- tests/test_ssh_bank_grant.py | 113 +++++++++++++++++++++++++++++++---- 4 files changed, 128 insertions(+), 24 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 42b7d630..1019aa21 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -52,7 +52,11 @@ ONLINE_CA_REQUEST_ID 取自 init 输出。签发时检查显示的权限范围 经部署操作者明确授权,可使用 `msgd init --allow-ssh` 和 `msgd cert issue CSR_ID --allow-ssh` 从 SSH 终端执行这两项操作。此选项仅对当前命令生效,仍要求 OS root、SSH 伪终端、安全配置目录及交互 PIN/申请摘要确认;不开放 HTTP 根管理,也不放宽其他根命令的物理控制台限制。默认不带选项时仍拒绝 SSH。 -Bank-role grants may explicitly use `msgd money bank add @lightjunction --allow-ssh`. This permits only that role grant and still requires OS root, an SSH pseudoterminal, a secure configuration directory, the Root PIN, and confirmation of the exact action digest. It neither mints currency nor funds the account. Bank removal/funding, minting, burning, Root transfers, and offer administration still require a physical console. +Root money administration supports explicit SSH opt-in on `money mint`, `money burn`, `money transfer`, and `money bank add/remove/fund`. Each command must include `--allow-ssh`; the default still requires a physical console. SSH opt-in requires OS root, an interactive SSH pseudoterminal (`SSH_CONNECTION` must survive `sudo`), a root-owned configuration directory that is not group/world writable, the Root PIN/passphrase, and exact preview-digest confirmation. Bank funding still requires separate role and payment approvals. Root signatures, stale-preview checks, atomic ledger writes and append-only audits remain unchanged; audits record the SSH operator/channel. This does not expose HTTP Root administration or permit unattended `--yes`/PIN arguments. Offer administration remains physical-console only. + +For example, in an SSH session with a pseudoterminal, use `sudo --preserve-env=SSH_CONNECTION msgd money mint 10000 --allow-ssh`, then `sudo --preserve-env=SSH_CONNECTION msgd money transfer --from @root --to @lightjunction 10000 --allow-ssh`. If sudo policy does not permit preserving this variable, configure that policy locally rather than fabricating an SSH context. These commands still prompt for confirmation and the existing Root passphrase. + +There is no default Root PIN: initialization requests an operator-selected passphrase twice. `msgd root change-pin` needs the current passphrase, and `msgd root recover BACKUP` needs the backup passphrase and restores only missing private-key material. Forgotten passphrases cannot be recovered from the encrypted envelope. The separate physical-console `msgd root rotate --lost-key` recovery ceremony creates a new Root key and requires reissuing all existing certificate chains; it is not an ordinary PIN reset and must be planned explicitly. 根初始化会保留 @root、公钥、根证书,以及待授权基础在线 CA。普通注册在 CA 尚未授权时返回 issuer_not_ready,不借用根私钥。 diff --git a/src/msg/admin/money.py b/src/msg/admin/money.py index 414b2ddf..9cd576c4 100644 --- a/src/msg/admin/money.py +++ b/src/msg/admin/money.py @@ -1,4 +1,4 @@ -"""Root money administration, with an explicit SSH opt-in for Bank-role grants only.""" +"""Root money administration, with an explicit interactive SSH opt-in.""" from __future__ import annotations @@ -426,7 +426,14 @@ def __init__(self, config_dir): def execute(self, action, *, amount=None, subject_id=None, allow_ssh=False): if allow_ssh: - require(action == 'bank_add' and amount is None, 'ssh_bank_role_grant_only') + require( + action in {'mint', 'burn', 'transfer', 'bank_add', 'bank_remove', 'bank_fund'}, + 'invalid_money_action', + ) + require( + action not in {'bank_add', 'bank_remove'} or amount is None, + 'invalid_money_amount', + ) operator = require_ssh_administrator(self.config_dir) else: operator = require_local_console(self.config_dir) diff --git a/src/msg/daemon.py b/src/msg/daemon.py index bfead88b..b5da35c7 100644 --- a/src/msg/daemon.py +++ b/src/msg/daemon.py @@ -174,20 +174,18 @@ def parser(): retirement_sign.add_argument('source', type=Path) retirement_sign.add_argument('destination', type=Path) retirement_sub.add_parser('import').add_argument('source', type=Path) - money = sub.add_parser('money', help='Physical-console central bank administration') + money = sub.add_parser('money', help='Interactive Root central bank administration') ms = money.add_subparsers(dest='money_command', required=True) - ms.add_parser('mint').add_argument('amount') - ms.add_parser('burn').add_argument('amount') + mint = ms.add_parser('mint') + mint.add_argument('amount') + burn = ms.add_parser('burn') + burn.add_argument('amount') bank = ms.add_parser('bank') bs = bank.add_subparsers(dest='bank_command', required=True) bank_add = bs.add_parser('add') bank_add.add_argument('subject_id') - bank_add.add_argument( - '--allow-ssh', - action='store_true', - help='Allow an OS root SSH terminal for this Bank-role grant only', - ) - bs.add_parser('remove').add_argument('subject_id') + bank_remove = bs.add_parser('remove') + bank_remove.add_argument('subject_id') bank_fund = bs.add_parser( 'fund', help='Confirm BankRole and Root funding separately, commit together' ) @@ -197,6 +195,12 @@ def parser(): transfer.add_argument('--from', dest='from_subject', required=True, choices=['@root']) transfer.add_argument('--to', dest='to_subject', required=True) transfer.add_argument('amount') + for command in (mint, burn, bank_add, bank_remove, bank_fund, transfer): + command.add_argument( + '--allow-ssh', + action='store_true', + help='Allow an OS root SSH terminal for this action only; requires PIN and exact confirmation', + ) offer = ms.add_parser('offer') ops = offer.add_subparsers(dest='offer_command', required=True) offer_set = ops.add_parser('set') diff --git a/tests/test_ssh_bank_grant.py b/tests/test_ssh_bank_grant.py index 9e187a34..89ab6169 100644 --- a/tests/test_ssh_bank_grant.py +++ b/tests/test_ssh_bank_grant.py @@ -1,4 +1,4 @@ -"""SSH opt-in grants only a Bank role; Root money movement stays console-only.""" +"""Interactive SSH money administration retains the Root confirmation and signing ceremony.""" import asyncio import shutil @@ -12,31 +12,35 @@ from msg.daemon import parser -def test_bank_grant_flag_has_no_money_movement_or_revocation_path(): - assert parser().parse_args(['money', 'bank', 'add', '@alice', '--allow-ssh']).allow_ssh - assert not parser().parse_args(['money', 'bank', 'add', '@alice']).allow_ssh - for arguments in ( +@pytest.mark.parametrize( + 'arguments', + [ + ['money', 'bank', 'add', '@alice'], ['money', 'bank', 'remove', '@alice'], ['money', 'bank', 'fund', '@alice', '20'], ['money', 'mint', '20'], ['money', 'burn', '20'], ['money', 'transfer', '--from', '@root', '--to', '@alice', '20'], - ): - with pytest.raises(SystemExit): - parser().parse_args([*arguments, '--allow-ssh']) + ], +) +def test_money_actions_have_explicit_opt_in_without_unattended_approval(arguments): + assert parser().parse_args([*arguments, '--allow-ssh']).allow_ssh + assert not parser().parse_args(arguments).allow_ssh + with pytest.raises(SystemExit): + parser().parse_args([*arguments, '--yes']) -@pytest.mark.parametrize('action', ['mint', 'burn', 'transfer', 'bank_fund', 'bank_remove']) -def test_programmatic_opt_in_cannot_expand_to_other_root_actions(monkeypatch, action): +@pytest.mark.parametrize('action', ['offer_set', 'rotate', 'unknown']) +def test_programmatic_opt_in_cannot_expand_to_unrelated_actions(monkeypatch, action): monkeypatch.setattr( money, 'require_ssh_administrator', lambda _: pytest.fail('SSH gate reached') ) - with pytest.raises(Failure, match='ssh_bank_role_grant_only'): + with pytest.raises(Failure, match='invalid_money_action'): money.MoneyAdmin('/etc/msgd').execute(action, allow_ssh=True) def test_grant_opt_in_cannot_carry_an_amount(): - with pytest.raises(Failure, match='ssh_bank_role_grant_only'): + with pytest.raises(Failure, match='invalid_money_amount'): money.MoneyAdmin('/etc/msgd').execute( 'bank_add', subject_id='@alice', amount='1', allow_ssh=True ) @@ -108,3 +112,88 @@ async def test_ssh_grant_still_requires_exact_confirmation_pin_and_root_signatur assert (await call(app, 'money.state', {})).data['total_supply_minor'] == 0 async with app.metadata.transaction(write=False) as tx: assert tx.one('SELECT COUNT(*) FROM money_ledger')[0] == 0 + + +@pytest.mark.parametrize( + 'action', ['mint', 'burn', 'transfer', 'bank_add', 'bank_remove', 'bank_fund'] +) +def test_all_money_defaults_refuse_ssh_before_loading(monkeypatch, action): + setup_terminal(monkeypatch) + with pytest.raises(Failure, match='remote_admin_forbidden'): + money.MoneyAdmin('/etc/msgd').execute(action) + + +@pytest.mark.parametrize('action', ['mint', 'burn', 'transfer', 'bank_remove', 'bank_fund']) +@pytest.mark.asyncio +async def test_ssh_money_preserves_confirm_pin_atomic_writes_and_audit( + installed, installation_seed, monkeypatch, action +): + app, signer = installed + _, subject, _ = await register(app, 'ssh-money-test') + await money.apply_money( + app, signer, action='mint', operator='test-setup', amount_minor=20_000_000 + ) + if action == 'bank_remove': + await money.apply_money( + app, signer, action='bank_add', operator='test-setup', subject_id=subject + ) + arguments = {'allow_ssh': True} + if action != 'bank_remove': + arguments['amount'] = '2' + if action in {'transfer', 'bank_fund', 'bank_remove'}: + arguments['subject_id'] = '@ssh-money-test' + operator = 'uid:0:ssh:/dev/pts/3:192.0.2.1 1234 192.0.2.2 22' + monkeypatch.setattr(money, 'require_ssh_administrator', lambda _: operator) + admin = money.MoneyAdmin(app.settings.config_dir) + + async def snapshot(): + async with app.metadata.transaction(write=False) as tx: + return tuple( + tx.one('SELECT COUNT(*) FROM ' + table)[0] + for table in ('money_ledger', 'money_bank_roles', 'audit', 'events') + ) + + before = await snapshot() + prompts = [] + + def refuse_last(prompt): + prompts.append(prompt) + if action == 'bank_fund' and len(prompts) == 1: + return prompt.removeprefix('Type ').removesuffix(' to continue: ') + return 'WRONG' + + monkeypatch.setattr('builtins.input', refuse_last) + monkeypatch.setattr(money.getpass, 'getpass', lambda _: pytest.fail('PIN before confirmation')) + with pytest.raises(Failure, match='approval_cancelled'): + await asyncio.to_thread(admin.execute, action, **arguments) + assert len(prompts) == (2 if action == 'bank_fund' else 1) + assert await snapshot() == before + seed, _, _, _ = installation_seed + shutil.copytree( + root.root_envelope(seed / 'etc').parent, root.root_envelope(app.settings.config_dir).parent + ) + monkeypatch.setattr( + 'builtins.input', lambda p: p.removeprefix('Type ').removesuffix(' to continue: ') + ) + monkeypatch.setattr(money.getpass, 'getpass', lambda _: 'wrong-test-pin') + with pytest.raises(Failure): + await asyncio.to_thread(admin.execute, action, **arguments) + assert await snapshot() == before + monkeypatch.setattr(money.getpass, 'getpass', lambda _: 'correct-horse-test-passphrase') + result = await asyncio.to_thread(admin.execute, action, **arguments) + expected_supply = ( + 22_000_000 if action == 'mint' else 18_000_000 if action == 'burn' else 20_000_000 + ) + expected_root = ( + 22_000_000 if action == 'mint' else 20_000_000 if action == 'bank_remove' else 18_000_000 + ) + assert result['total_supply_minor'] == expected_supply + assert result['root_balance_minor'] == expected_root + assert len(result['audit_event_ids']) == (2 if action == 'bank_fund' else 1) + async with app.metadata.transaction(write=False) as tx: + for event_id in result['audit_event_ids']: + event = money.loads(tx.one('SELECT body FROM events WHERE event_id=?', (event_id,))[0]) + assert event['data']['operator'] == operator + assert event['data']['root_signature'] + if action in {'transfer', 'bank_fund'}: + assert money._balance(tx, subject) == 2_000_000 From fa3618948a3e9e3cf99f0db9238c356c4bd127c6 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 1 Oct 2026 03:14:43 +0800 Subject: [PATCH 2/3] Use canonical event ID in SSH audit verification --- src/msg/admin/money.py | 4 ++-- tests/test_ssh_bank_grant.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/msg/admin/money.py b/src/msg/admin/money.py index 9cd576c4..77194595 100644 --- a/src/msg/admin/money.py +++ b/src/msg/admin/money.py @@ -179,7 +179,7 @@ async def apply_money( subject_id: str | None = None, expected_state: dict | None = None, ): - """Internal local use case; callers must establish the OS-console boundary. + """Internal Root use case; callers must establish the authorized OS terminal boundary. Each invocation creates a fresh local request id. PG serializes all writers, so balance and supply checks are re-evaluated within the committing transaction. @@ -337,7 +337,7 @@ async def apply_money( async def _confirmed_money(app, action, *, operator, amount, subject_id, emit, confirm, unlock): """The command's approval pipeline, shared with an isolated TestConsole. - The production caller must establish the physical-console boundary first. + The production caller must establish the local or explicitly opted-in SSH boundary first. There is no network operation, config option or command flag selecting IO adapters. The diagnostic replaces only IO/unlock on its fresh Test Root. """ diff --git a/tests/test_ssh_bank_grant.py b/tests/test_ssh_bank_grant.py index 89ab6169..8f0f1eeb 100644 --- a/tests/test_ssh_bank_grant.py +++ b/tests/test_ssh_bank_grant.py @@ -192,7 +192,7 @@ def refuse_last(prompt): assert len(result['audit_event_ids']) == (2 if action == 'bank_fund' else 1) async with app.metadata.transaction(write=False) as tx: for event_id in result['audit_event_ids']: - event = money.loads(tx.one('SELECT body FROM events WHERE event_id=?', (event_id,))[0]) + event = money.loads(tx.one('SELECT body FROM events WHERE id=?', (event_id,))[0]) assert event['data']['operator'] == operator assert event['data']['root_signature'] if action in {'transfer', 'bank_fund'}: From 0a3722558ba9c7faa4f53223a6711b0ee088b98d Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 1 Oct 2026 03:16:04 +0800 Subject: [PATCH 3/3] Rename native server package to msgd with legacy replacement metadata --- docs/NATIVE_PACKAGES.md | 12 +++++++----- packaging/build-native.py | 23 ++++++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/NATIVE_PACKAGES.md b/docs/NATIVE_PACKAGES.md index 678f42d5..b6dcb6d4 100644 --- a/docs/NATIVE_PACKAGES.md +++ b/docs/NATIVE_PACKAGES.md @@ -9,7 +9,7 @@ | 私有 Python 3.15 运行时 | `/usr/lib/msgd/python3.15/` | | systemd 单元 | `/usr/lib/systemd/system/msgd*.service` | | 用户和目录定义 | `/usr/lib/sysusers.d/msgd.conf`、`/usr/lib/tmpfiles.d/msgd.conf` | -| 文档及构建摘要 | `/usr/share/doc/msgctl-server/` | +| 文档及构建摘要 | `/usr/share/doc/msgd/` | | 管理员配置及公共信任 | `/etc/msgd/` | | 业务持久数据 | `/var/lib/msgd/` | | 根私有材料 | `/var/lib/msgd-root/`,root:root 0700 | @@ -28,14 +28,16 @@ python packaging/build-native.py \ --source-revision COMMIT_AND_PATCH_DIGEST --output /build/packages --format all ``` -构建输入必须从已通过验收的产物取得,不能复制生产 venv。wheel 和依赖锁校验失败会停止;Python 精确版本和输入摘要写入 `/usr/share/doc/msgctl-server/build.json`。原生扩展须在兼容基线构建。软件包生成不代表已在每种发行版完成安装验收。 +构建输入必须从已通过验收的产物取得,不能复制生产 venv。wheel 和依赖锁校验失败会停止;Python 精确版本和输入摘要写入 `/usr/share/doc/msgd/build.json`。原生扩展须在兼容基线构建。软件包生成不代表已在每种发行版完成安装验收。 ```sh -sudo pacman -U ./msgctl-server-*.pkg.tar.zst -sudo apt install ./msgctl-server_*.deb -sudo dnf install ./msgctl-server-*.rpm +sudo pacman -U ./msgd-*.pkg.tar.zst +sudo apt install ./msgd_*.deb +sudo dnf install ./msgd-*.rpm ``` Arch 的 systemd hooks 处理用户、目录与单元重载。Debian/RPM 安装脚本执行 sysusers、tmpfiles 和 daemon-reload。首次配置数据库、初始化 CA、整理服务权限及启动服务,按 DEPLOYMENT.md 完成。软件包不包含数据库、PIN、证书、私钥和用户身份,不自动初始化 CA 或启动服务。 升级前保存保护备份,停止 writer/worker,再用包管理器升级并启动验证;卸载前停止服务。卸载保留 `/etc/msgd`、`/var/lib/msgd` 和 `/var/lib/msgd-root`。从旧 `/opt/msgd` 包升级时,包管理器删除其拥有的旧代码,管理员归档不归软件包所有的旧发布记录后移除空 `/opt/msgd` 目录,不能删除业务或根私有数据。 + +The native server package is named `msgd`; the Python wheel remains `msgctl`. Packages declare the previous `msgctl-server` name as provided and conflicting, with replacement/obsoletion metadata for migration. Install through the package manager so shared files transfer ownership rather than installing both packages side by side. On Arch, if `pacman -U` requests removal of the conflicting `msgctl-server` package, confirm that replacement; repository upgrades also recognize `replaces`. Neither package owns generated configuration, keys, database contents or other runtime state, and uninstall hooks do not delete them. The rename does not change `/etc/msgd`, `/var/lib/msgd`, `/var/lib/msgd-root`, service names or executable names. diff --git a/packaging/build-native.py b/packaging/build-native.py index 45b4be0d..10579163 100644 --- a/packaging/build-native.py +++ b/packaging/build-native.py @@ -120,7 +120,7 @@ def main(): target.write_text( 'd /etc/msgd 0755 root root -\nd /var/lib/msgd 0750 msgd msgd -\nd /var/cache/msgd 0750 msgd msgd -\nd /run/msgd 0750 msgd msgd -\nd /var/lib/msgd-root 0700 root root -\n' ) - docs = root / 'usr/share/doc/msgctl-server' + docs = root / 'usr/share/doc/msgd' docs.mkdir(parents=True) for name in ('DEPLOYMENT.md', 'NATIVE_PACKAGES.md'): shutil.copyfile(repo / 'docs' / name, docs / name) @@ -156,13 +156,16 @@ def main(): arch.mkdir() shutil.copyfile(archive, arch / 'payload.tar.gz') checksum = hashlib.sha256(archive.read_bytes()).hexdigest() - (arch / 'PKGBUILD').write_text(f"""pkgname=msgctl-server + (arch / 'PKGBUILD').write_text(f"""pkgname=msgd pkgver={version} pkgrel={release} pkgdesc='msg communication service with locked Python runtime' arch=('x86_64') url='https://github.com/TokenNotIncluded/msg.lmm.best' license=('MIT') +provides=('msgctl-server={version}-{release}') +conflicts=('msgctl-server') +replaces=('msgctl-server') depends=('glibc' 'gcc-libs' 'git' 'openssh' 'postgresql' 'bubblewrap' 'age' 'git-lfs') options=('!strip' '!debug') source=('payload.tar.gz') @@ -187,9 +190,12 @@ def main(): ) // 1024 ) - (control / 'control').write_text(f"""Package: msgctl-server + (control / 'control').write_text(f"""Package: msgd Version: {version}-{release} Architecture: amd64 +Provides: msgctl-server (= {version}-{release}) +Conflicts: msgctl-server +Replaces: msgctl-server Maintainer: TokenNotIncluded Installed-Size: {size} Depends: libc6 (>= 2.28), libgcc-s1, git, openssh-server, postgresql, bubblewrap, age, git-lfs, systemd @@ -209,7 +215,7 @@ def main(): '--root-owner-group', '--build', str(root), - str(output / f'msgctl-server_{version}-{release}_amd64.deb'), + str(output / f'msgd_{version}-{release}_amd64.deb'), ) shutil.rmtree(control) else: @@ -217,8 +223,8 @@ def main(): for name in ('SOURCES', 'SPECS', 'BUILD', 'BUILDROOT', 'RPMS', 'SRPMS'): (rpm / name).mkdir(parents=True) shutil.copyfile(archive, rpm / 'SOURCES/payload.tar.gz') - spec = rpm / 'SPECS/msgctl-server.spec' - spec.write_text(f"""Name: msgctl-server + spec = rpm / 'SPECS/msgd.spec' + spec.write_text(f"""Name: msgd Version: {version} Release: {release} Summary: msg service with independent locked Python runtime @@ -227,6 +233,9 @@ def main(): Source0: payload.tar.gz BuildArch: x86_64 AutoReqProv: no +Provides: msgctl-server = {version}-{release} +Conflicts: msgctl-server +Obsoletes: msgctl-server Requires: glibc >= 2.28, libgcc, git, openssh-server, postgresql-server, bubblewrap, age, git-lfs, systemd %description Configuration and CA initialization are explicit administrator operations. @@ -249,7 +258,7 @@ def main(): /usr/lib/systemd/system/msgd-worker.service /usr/lib/sysusers.d/msgd.conf /usr/lib/tmpfiles.d/msgd.conf -/usr/share/doc/msgctl-server +/usr/share/doc/msgd """) run( 'rpmbuild',