Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,不借用根私钥。

Expand Down
12 changes: 7 additions & 5 deletions docs/NATIVE_PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
23 changes: 16 additions & 7 deletions packaging/build-native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand All @@ -209,16 +215,16 @@ 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:
rpm = work / 'rpm'
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
Expand All @@ -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.
Expand All @@ -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',
Expand Down
15 changes: 11 additions & 4 deletions src/msg/admin/money.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 13 additions & 9 deletions src/msg/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand All @@ -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')
Expand Down
113 changes: 101 additions & 12 deletions tests/test_ssh_bank_grant.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
)
Expand Down Expand Up @@ -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 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
Loading