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
81 changes: 81 additions & 0 deletions .github/workflows/issue-recovery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Issue recovery regressions
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: issue-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
focused:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: msgtest
POSTGRES_PASSWORD: msgtest
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U msgtest"
--health-interval 5s --health-timeout 5s --health-retries 10
valkey:
image: valkey/valkey:9.0.6
ports:
- 6379:6379
options: >-
--health-cmd "valkey-cli ping"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
MSG_TEST_VALKEY_URL: redis://127.0.0.1:6379/0
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: '3.15'
allow-prereleases: true
cache: pip
- name: Install recovery tools and project dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq age
python -m pip install -e '.[dev]'
- name: Record the exact tested source
env:
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
mkdir -p artifacts
python - <<'PY'
import json, os, platform, subprocess
from pathlib import Path
def git(*args):
return subprocess.check_output(['git', *args], text=True).strip()
Path('artifacts/source.json').write_text(json.dumps({
'head_sha': os.environ['EVENT_HEAD_SHA'],
'checkout_sha': git('rev-parse', 'HEAD'),
'tree_sha': git('rev-parse', 'HEAD^{tree}'),
'run_id': os.environ['GITHUB_RUN_ID'],
'attempt': os.environ['GITHUB_RUN_ATTEMPT'],
'python': platform.python_version(),
'scope': 'isolated-postgresql-regressions-not-production'
}, indent=2) + '\n')
PY
- name: Exercise real PostgreSQL recovery regressions
run: >-
python -m pytest tests/test_recovery_current_authority.py
tests/test_task_a_recovery_checkpoint.py tests/test_backup_retirement_attestation.py
--junitxml=artifacts/recovery-current.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: issue-recovery-evidence
path: artifacts/
if-no-files-found: error
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 变更记录

## 1.0.0a1 — 重构预览

以 1.x 预发布版本标识当前重构,与 PyPI 上的 0.x 系列区分。新版本使用 Python 3.15、PostgreSQL 权威状态、版本化操作与签名授权,并通过 `uv_build` 构建。

这只是源码版本调整,尚未发布到 PyPI,也不表示生产部署、旧数据迁移或恢复验收已经完成。

## 0.1.0a1 — 本地独立重写交付

从项目设计契约重新实现模块化单体。删除旧实现,不包含旧数据库迁移器或线上自动升级动作。
Expand Down
24 changes: 19 additions & 5 deletions docs/CUSTODIAL_HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,25 @@ Test Root, rewraps, locally decrypts, ACKs and finalizes it. It refuses a produc
application without the isolated selftest namespace.

`history_recoverable` covers **enumerated and client-verified revisions only**.
`online_key_retired` describes online vault material only. `backup_retired` and
`server_key_retired` remain false until a separate independently verified offline
retirement procedure exists. Neither a boolean supplied by a network caller nor a
green test run can establish that old backups lost a key. Restore/revocation replay
and real operator acceptance are tracked separately in issues #69 and #70.
`online_key_retired` describes online vault material only. `backup_retired` stays
false unless a root-signed backup retirement record was imported at the physical
console (`RootAdmin.sign_backup_retirement` / `RootAdmin.import_backup_retirement`,
both behind `require_local_console`). The record binds the subject, both old key
IDs, a non-empty list of named backup sets and a validity window of at most 400
days; it is signed with purpose `custodial-backup-retirement-v1`. Every read
re-verifies it against the active, unrevoked root credential, so an altered,
expired, misbound or pre-rotation record reports `backup_retired=false`
(`backup_status=local_attestation_invalid|expired`). `server_key_retired` is true
only when that record and observable online retirement both hold.

The record is an operator attestation for the **listed backup sets only**. It
does not prove that no other copy exists and it does not claim that production
backups were destroyed. No network operation writes, accepts or forwards it; a
`backup_retirement` value inside the network-writable migration body is ignored.
Neither a boolean supplied by a network caller nor a green test run can establish
that old backups lost a key. There is no `msg-admin` CLI wiring yet. Restore/
revocation replay and real operator acceptance are tracked separately in issues
#69 and #70.

## Published contract compatibility

Expand Down
6 changes: 4 additions & 2 deletions docs/ISSUES_68_70_PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ only the challenge's new signing key may inspect its completed result. The
read reports `identity_switched`, `history_recoverable`, `online_retired`,
`backup_retired`, `server_key_retired` and a separate `completion_status`.
Online retirement requires the new primary keys, retired old keys, revoked old
signing credential and empty destroyed vault fields. No backup-destruction
attestation exists yet, so `backup_retired` and `server_key_retired` remain false.
signing credential and empty destroyed vault fields. `backup_retired` becomes true
only for one exact root-signed record imported at the physical console and
re-verified on each read (see `CUSTODIAL_HISTORY.md`); `server_key_retired`
additionally needs online retirement. Without that record both remain false.
The legacy `status=completed` from an empty-inventory identity switch is not a
claim that every server-held backup has been destroyed; use the independent
retirement fields.
Expand Down
44 changes: 44 additions & 0 deletions docs/ISSUE_PROGRESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 当前 issue 推进笔记

源请求:2026-09-28T19:16Z。此文件记录本轮代码与验收证据;权威设计和已完成归档仍有效,入口见 issue #83。只更新当前笔记,不重复堆叠状态文件。

## 基线及集成

- 开始 main:`01e39345c755d6ec8f584534de20d9cd265ca922`,tree `f51cadf931d32c53c64501af8db33dbde43eacbb`。
- 主线 Rewrite CI `36465610565`、Recovery safety `36465610588` 已读为 success。
- 集成分支:`integrate/issue-recovery-cloud-20260928`。以 main 为第一父提交,完整保留 #145 `f2ebc093fb8333c2a0f196b6ebd86cfdf717affa` 与 #154 `3490750dd8fd59c8070e9816a104292db0937cea` 的历史,不修改原分支。
- 完整读过权威设计及完成归档、两份 PR 的改动及 review/CI;旧 #103/#112 不再作为开放 PR 队列。

## 已复现与本次修复

失败基线:head `4bfeece690a26970b66f86b06cd3817dbb0b38cd`,tree `9f2b8db3861e8e299c6522d21a18d03d3d4b5dcd`。

云端专项 `36471956362` / job `109096152390` 使用真实 PostgreSQL 和 Python 3.15.0rc2,55 个用例中 **15 failed / 40 passed / 0 skipped / 0 errors**;原有两文件 36 个用例全部通过。已下载 artifact `10992276490`,实际核对 ZIP SHA256 `01d114f54d75777640e49df670f7a0dbb0c7f9404c726768065daeee201fe9d4`,source.json 与以上 head/tree 一致。

本次仅修实现,不修改失败断言:

1. `topic_ban.apply` 不再继承旧快照中的 expires_at;无到期字段的检查点恢复为 active/NULL,保留原 actor/reason,重复与并发重放仍只改变一次。
2. 退役证明读取和导入共同重验当前 Root 自签证书结构、签名、有效期及 signing credential 有效期;到期边界不再误报 verified,失败读取不写业务状态,失败导入不写审计。
3. 有效期用时间差而非向不可信日期加 400 天,避免 year 9999 上溢;未来证明仍受当前时间检查拒绝。

本次修复后的 CI 尚待回读,不将预期通过写成通过。专项 source.json 绑定实际 checkout SHA/tree、PR head、run/attempt;完整四分片、逐 node-ID gate、conformance、构建及恢复专项不变。下一步检查本机命令接入及安全文件边界,再回读最终组合 head 的完整 CI。

## 全部开放项(尚无整项关闭证据)

| Issue | 剩余工作入口 |
|---|---|
| #64 | 实际生产代理/日志/APM 全链材料;代码和隔离证据不可代替 |
| #65 | 授权真实旧库快照与账本迁移/回滚验收 |
| #68 | 当前集成备份退役证明;全历史清单/ACK/真实备份退役仍分开验收 |
| #69 | 当前集成禁言重放;完整当前政策/独立新鲜 pin/promotion 仍未完成 |
| #70 | 存量 CA 差异治理和真实物理控制台验收 |
| #71–72 | 刷新已合入市场代码与官方清算/悬赏命令矩阵,不重建旧 #103 |
| #76–79 | 授权、个人内容、读取和 Revision 全契约逐项证据 |
| #80–82 | 存储一致性、通知协作、真实客户端与隔离限制矩阵 |
| #83 | Registry/config/规则及逐条验收映射;此处汇总当前进度 |
| #84 | 生产/容量/恢复材料与授权独立缺失,保持 blocked |
| #85 | 各模块行为保持收敛;不为减少类或行数放宽安全契约 |

## 操作边界

只进行源码、测试、CI 和 PR 工作。没有生产部署、资金操作、真实外发、Root/PIN 使用、备份销毁或恢复放行。完整 CI 绿灯不等于现场验收;部分切片只用 Refs,不自动关闭总 issue。
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "msg-lmm-best"
version = "0.1.0a1"
version = "1.0.0a1"
description = "Atomic communication infrastructure for sandboxed agents"
readme = "README.md"
requires-python = ">=3.15"
Expand Down
2 changes: 1 addition & 1 deletion src/msg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Atomic communication for sandboxed agents."""
__version__ = "0.1.0a1"
__version__ = "1.0.0a1"
56 changes: 56 additions & 0 deletions src/msg/admin/backup_retirement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Local import of a root-signed custodial backup retirement record.

Never imported by a network adapter, plugin or worker. RootAdmin calls these use
cases only after require_local_console(); tests call them against disposable
databases. The record is verified again on every read, so this import is not a
trusted cache of a success flag.
"""
from __future__ import annotations

from msg.constants import ROOT_SUBJECT
from msg.core.codec import digest, loads
from msg.core.errors import require
from msg.core.models import AuditEvent, Event, ResourceRef
from msg.plugins.common import new_id
from msg.security.backup_retirement import FIELDS, DOMAIN, check, root_verifier, setting_key


def unsigned_statement(value):
require(isinstance(value, dict) and set(value) == FIELDS and value.get('domain') == DOMAIN,
'backup_retirement_invalid')
return value


def completed_migration(tx, subject_id, old_identity_key_id):
"""The one completed migration of this subject that retired exactly this old key."""
matches = [loads(body) for (body,) in tx.rows(
"SELECT body FROM custodial_upgrades WHERE subject=? AND status='completed' ORDER BY id",
(subject_id,)) if loads(body).get('old_identity_key_id') == old_identity_key_id]
require(len(matches) == 1, 'backup_retirement_migration_not_found')
return matches[0]


async def import_record(tx, record, *, now, operator):
body = record.get('statement') if isinstance(record, dict) else None
require(isinstance(body, dict) and isinstance(body.get('subject_id'), str) and
isinstance(body.get('old_identity_key_id'), str), 'backup_retirement_invalid')
subject = body['subject_id']
details = completed_migration(tx, subject, body['old_identity_key_id'])
attestation = check(record, await root_verifier(tx, now=now), subject, details, now=now)
key = setting_key(subject, attestation.old_identity_key_id)
previous = tx.setting(key)
tx.set_setting(key, record)
data = {'subject_id': subject, 'old_identity_key_id': attestation.old_identity_key_id,
'old_encryption_key_id': attestation.old_encryption_key_id,
'backup_sets': list(attestation.backup_sets), 'record_digest': attestation.record_digest,
'signer_key_id': attestation.signer_key_id, 'operator': operator,
'scope': 'listed_backup_sets_only'}
event = Event(id=new_id('audit'), type='root.custodial.backup_retirement', time=now,
request_id=new_id('local'), actor=ROOT_SUBJECT, subject=subject,
resources=(ResourceRef(id=subject),), data=data)
await tx.append_audit(AuditEvent(event=event,
authority=(ResourceRef(id=tx.setting('active_root_certificate')),),
before_digest=digest(previous) if previous is not None else None,
after_digest=attestation.record_digest, previous_digest=None, entry_digest='',
result='imported'))
return attestation
33 changes: 29 additions & 4 deletions src/msg/admin/recovery_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@
FORMAT = 'msg-revocation-checkpoint-v1'
SUPPORTED_FACTS = frozenset({'credential.revoke', 'certificate.revoke', 'share_grant.revoke',
'share_grant_v2.revoke', 'share_link.revoke', 'membership.remove',
'topic_membership.remove', 'topic_ban.lift', 'identity_key.retire',
'topic_membership.remove', 'topic_ban.apply', 'topic_ban.lift', 'identity_key.retire',
'encryption_key.retire', 'vault.destroy'})
PROMOTION_BLOCKED_REASONS = (
'quarantine_remains',
'supported_facts_are_deny_only',
'backup_retirement_not_attested',
)
_SHARE_TABLES = {'share_grant.revoke': 'share_grants', 'share_grant_v2.revoke': 'share_grants_v2',
'share_link.revoke': 'share_links'}
_MAX_ENTRIES = 10000
Expand Down Expand Up @@ -157,6 +162,23 @@ async def _apply(tx, fact):
(target, subject), write=True)
return True
return False
if kind == 'topic_ban.apply':
require(tx.one('SELECT id FROM resources WHERE id=?', (target,)) is not None,
'recovery_fact_missing')
row = tx.one('SELECT status,expires_at FROM topic_bans WHERE topic=? AND subject=?', (target, subject))
if row is None:
tx.execute('''INSERT INTO topic_bans
(topic,subject,actor,created_at,expires_at,reason,status)
VALUES (?,?,?,?,NULL,?,?)''',
(target, subject, ROOT_SUBJECT, at, 'recovery-replay', 'active'), write=True)
return True
# This checkpoint fact has no expiry. Do not inherit one from an older
# snapshot and silently lift the restored deny, now or in the future.
if row[0] != 'active' or row[1] is not None:
tx.execute("UPDATE topic_bans SET status='active',expires_at=NULL WHERE topic=? AND subject=?",
(target, subject), write=True)
return True
return False
if kind == 'topic_ban.lift':
row = tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', (target, subject))
require(row is not None, 'recovery_fact_missing')
Expand Down Expand Up @@ -209,8 +231,9 @@ async def _replay(store, packet, *, pin, operator):

Always re-apply facts, including after a repeated invocation/restart: cached
watermarks are not evidence that an authorization row has not been restored.
This neither imports keys, emits effects, nor clears quarantine. topic_ban.lift
is the only topic-ban fact; it does not reconcile ACLs or promote production.
This neither imports keys, emits effects, nor clears quarantine. topic_ban.apply
only records an active ban, and topic_ban.lift only marks an existing ban lifted.
Neither reconciles ACLs nor promotes production.
"""
require(isinstance(operator, str) and bool(operator), 'recovery_operator_required')
body = verify_checkpoint(packet, pin=pin)
Expand All @@ -233,7 +256,9 @@ async def _replay(store, packet, *, pin, operator):
changed += bool(await _apply(tx, fact))
receipt = {'format': FORMAT, 'checkpoint_digest': pin.digest, 'sequence': pin.sequence,
'prefix_digest': digest(body['entries']), 'scope': 'supported_deny_only_facts',
'promotion': 'blocked', 'backup_retired': False}
'promotion': 'blocked',
'promotion_blocked_reasons': list(PROMOTION_BLOCKED_REASONS),
'backup_retired': False}
if changed:
tx.set_setting('authorization_epoch', tx.setting('authorization_epoch', 0) + 1)
if changed or previous != receipt:
Expand Down
37 changes: 37 additions & 0 deletions src/msg/admin/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,43 @@ async def read():
return asyncio.run(_revoke(app,certificate_id,Ed25519Signer.from_bytes(private),reason=reason,operator=operator))


def sign_backup_retirement(self,source,destination):
"""Root-sign an operator's statement that listed backup sets lost one old key."""
require_local_console(self.config_dir)
from msg.admin.backup_retirement import unsigned_statement
from msg.security.backup_retirement import sign_record
target=Path(destination)
require(not target.exists() and not target.is_symlink(),'backup_retirement_destination_exists')
body=unsigned_statement(loads(Path(source).read_bytes()))
print(canonical({'statement':body,'digest':digest(body),
'claim':'listed_backup_sets_only'}).decode())
require(input('Type ATTEST BACKUP RETIREMENT '+digest(body)+': ')==
'ATTEST BACKUP RETIREMENT '+digest(body),'approval_cancelled')
private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: '))
record=sign_record(body,Ed25519Signer.from_bytes(private))
durable_write(target,canonical(record),mode=0o600)
return {'status':'backup_retirement_signed','path':str(target),'digest':digest(record)}

def import_backup_retirement(self,source):
"""Only this console path persists a record; reads re-verify it every time."""
operator=require_local_console(self.config_dir)
from msg.admin.backup_retirement import import_record
app=self._app()
record=loads(Path(source).read_bytes())
print(canonical({'record':record,'digest':digest(record)}).decode())
require(input('Type IMPORT BACKUP RETIREMENT '+digest(record)+': ')==
'IMPORT BACKUP RETIREMENT '+digest(record),'approval_cancelled')
async def run():
await app.load()
try:
async with app.metadata.transaction(write=True) as tx:
return await import_record(tx,record,now=app.clock(),operator=operator)
finally:
await app.close()
attestation=asyncio.run(run())
return {'status':'backup_retirement_imported','scope':'listed_backup_sets_only',
**attestation.view()}

def backup(self,destination):
require_local_console(self.config_dir)
target=Path(destination)
Expand Down
1 change: 1 addition & 0 deletions src/msg/data/recovery-checkpoint.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"share_link.revoke",
"membership.remove",
"topic_membership.remove",
"topic_ban.apply",
"topic_ban.lift",
"identity_key.retire",
"encryption_key.retire",
Expand Down
Loading
Loading