diff --git a/README.md b/README.md index 92eb0a83..c64faf4e 100644 --- a/README.md +++ b/README.md @@ -71,5 +71,6 @@ Do not reintroduce the old root `apps/`、`services/`、`packages/`、`sdks/`、 6. **外部阻塞与研发并行。** 政策、域名、证书、Provider、生产主机和人工审批单独列出责任人及期限;材料缺失不得被包装为新的产品开发阶段。 7. **任务必须有终点。** 每个任务开始时写明唯一交付物、退出条件和时间盒;完成后只报告候选 SHA、通过/失败、剩余 blocker、责任人和下一项必要动作,不自动创造“下一阶段”。 8. **提速不能绕过底线。** 身份与租户隔离、合同事实完整性、版本保护、备份恢复、迁移兼容、TLS/密钥、失败接管、正式政策及最终公众切流授权不得为赶工而取消。 +9. **单一人工发布审核人。** 本轮唯一人工发布审核人为陈钰麒(总经理);技术门禁、数据库/备份/恢复核验和证据比对由系统自动完成,不再追加第二名发布审核人。60 资产+14 Prompt 只作一次批次签名批准,系统生成 74 项应用证据;Provider、公开政策、恢复与回退也只接受该审核人的具名决定,其中每次恢复或回退只接受一条 `release_owner` 批准。离线根密钥与日常审批密钥必须用途隔离且不得复用。两名普通 UAT 账号只是租户隔离测试身份,不是审核人;租户自己的业务审批策略不受本条改变。 本轮收口唯一执行顺序是 `FREEZE → BUILD → RC05-RUN → RC06-UAT → RC07-RECOVERY → RC08-PRODUCTION`。在真实证据形成前,内部受控试点和公众开放均保持 `NO_GO`;不得把本地测试、AI 自动审核或模拟证据描述为法律批准、第三方存证或公众生产放行。效率规则不构成生产操作的长期授权;生产部署、迁移、回退、切流、真实凭据或数据使用、风险接受及法律签署仍须具名授权人明确批准。 diff --git a/docs/qa/multiscene-closeout-20260910/rc05-rc08-release-blocker-closeout-20260921.md b/docs/qa/multiscene-closeout-20260910/rc05-rc08-release-blocker-closeout-20260921.md index b57e5368..51860968 100644 --- a/docs/qa/multiscene-closeout-20260910/rc05-rc08-release-blocker-closeout-20260921.md +++ b/docs/qa/multiscene-closeout-20260910/rc05-rc08-release-blocker-closeout-20260921.md @@ -18,6 +18,8 @@ 代码门禁通过不等于真实放行证据已经形成。当前工作树尚未冻结为新的唯一 commit/tag/source manifest,也未重建当前字节对应的镜像、SBOM、安全扫描和安装包。 +> 2026-09-27 更新:单审核人、RC-06 批次签名和恢复/回退契约修改已经使本文 2026-09-21 记录的“最终文件 SHA-256”失效。旧 hash 与 Linux 结果仅作历史追溯,不代表当前工作树;在新的唯一提交冻结前不生成替代正式 hash、镜像或放行证据。 + ## 2. RC-05 真实模型评测门禁 ### 已完成 @@ -59,13 +61,13 @@ ### 已完成 -- RC-06 manifest schema v2 强制消费当前 RC-05 完整终态、账本身份、campaign 身份和逐场景 operation coverage。 +- RC-06 manifest schema v3 强制消费当前 RC-05 完整终态、账本身份、campaign 身份和逐场景 operation coverage。 - 所有计数必须为非 bool、非负、不超预算,并在 case、operation、ledger、combined、historical 之间总和一致;耗时 observed 也必须一致。 - 144 个场景必须逐项验证 declared、mandatory、actual、call count、missing、extra 和 repair pair;coverage SHA 纳入终态身份。 - 每场景三个自动质量字段必须显式为 true,human/legal 字段必须明确保持未完成,`not_legal_approval=true`。 - 历史 `release_eligible` 仅接受 provider succeeded、非 timeout、business succeeded/resolved。 - 冻结源码和数据传递闭包先以安全文件描述符固定到私有临时树,再从固定字节加载执行;不再执行校验后的可变工作树模块。 -- 旧的批次级 approval/apply 收据验证器保持硬拒绝;真实 60 资产和 14 Prompt 必须逐项签名批准、应用和在线快照。 +- v3 只接受陈钰麒对绑定真实 60 资产、14 Prompt、31 项处置及候选/RC-05 身份的一个批次作一次 Ed25519 签名批准;审批 keyring 必须由冻结 pin 对应的离线根密钥签发,发布执行过程须由冻结 pin 对应的服务密钥签名 74 项 apply result 和在线 hash。验证器逐项核验,旧 schema、无根信任、无服务签名或其他审核人均硬拒绝。 ### 验证 @@ -78,7 +80,8 @@ ### 未完成 - RC-05 正式真实矩阵终态不存在。 -- 60 资产+14 Prompt 的逐项签名批准、apply receipt 和在线 hash 快照不存在。 +- 陈钰麒对 60 资产+14 Prompt 精确 hash 的一次批次签名批准尚不存在,系统据此生成的 74 项 apply receipt 和在线 hash 快照也不存在。 +- RC-06 离线根公钥、当前审批 keyring payload 和 apply 服务公钥的冻结 pin 尚未配置;在新候选冻结前验证器会保持 fail-closed。 - 1 个起草 Prompt+8 个复核 Prompt 的真实运行时可执行快照不存在。 - 管理员+2 名普通成员、2 个工作区的完整 API/UI UAT 未执行。 @@ -86,7 +89,7 @@ ### 已完成 -- keyring/evidence/plan schema v2;当前 keyring payload pin 和最小 epoch 未配置时严格拒绝,旧 keyring 在后续吊销状态发布后不能重放。 +- 单审核人恢复契约已版本化:首发 evidence/keyring/approval/plan/readiness 为 v2;前序版本回退 evidence/plan 为 v4、keyring 为 v3、approval 为 v2。当前 keyring payload pin 和最小 epoch 未配置时严格拒绝,旧 keyring 在后续吊销状态发布后不能重放。 - 审批签名时间不得早于备份完成、恢复测试和兼容性评估。 - 当前和前序包、镜像、环境、三份 Compose 实际 SHA、源码闭包、环境/主机/Docker daemon/cluster/source DB/restore DB/Compose project 均进入签名绑定。 - 备份 artifact 的路径、digest、size 按实际字节验证;恢复证据必须绑定相同目标、manifest、artifact 和 schema。 @@ -96,7 +99,7 @@ ### 未完成 - 组织根公钥、当前 keyring pin 和 epoch 仍故意为空,真实使用会安全拒绝。 -- 真实 UAT target、备份 artifact、恢复、兼容性、双人独立签名和现场复探未完成。 +- 真实 UAT target、备份 artifact、恢复、兼容性、陈钰麒(总经理)的单一具名签名和现场复探未完成。 - rollback/forward、RPO/RTO 和数据库记录/hash 核验未执行。 ## 5. RC-08 公众生产门禁 @@ -145,8 +148,8 @@ 2. 基于新 commit 重建 source manifest、Linux 镜像、SBOM、安全扫描和安装/回退包;旧 `cac183b2` 候选不能继续代表当前字节。 3. 从新不可变候选重新生成正式 RC-05 计划;初始化唯一 campaign 根并绑定历史回执 v3。 4. 先执行矩阵内代表场景;若同类失败立即停组修根因。稳定后最多 2 并发完成 144 场景,所有失败、取消、未知、降级和人工接管进入分母,不自动重试、不启用 fallback。 -5. 仅在 RC-05 终态全绿后,准备 RC-06 的 74 项逐项签名批准/应用证据并进行管理员+2 普通成员、2 工作区 UAT。 -6. 用同一候选和真实备份完成 RC-07 恢复、rollback/forward、RPO/RTO、双签和内部 Go/No-Go。 +5. 仅在 RC-05 终态全绿后,由陈钰麒对绑定 60 资产+14 Prompt 精确 hash 的一个批次作一次具名批准,系统自动生成和逐项核验 74 项应用证据;随后进行管理员+2 普通成员、2 工作区 UAT。两个普通成员账号是隔离测试身份,不是审核人。 +6. 用同一候选和真实备份完成 RC-07 恢复、rollback/forward、RPO/RTO、唯一审核人签名和内部 Go/No-Go;其余技术检查全部自动执行。 7. 最后补齐 RC-08 外部材料与生产现场验证;blocker 为 0 且发布负责人明确授权后才允许公众切流。 在上述第 4~7 步完成前,不得将本地门禁测试描述为真实模型质量证明、法律批准、第三方存证、司法效力、真实恢复演练或公众生产放行。 diff --git a/docs/qa/multiscene-closeout-20260910/remaining-closure-register-20260919.json b/docs/qa/multiscene-closeout-20260910/remaining-closure-register-20260919.json index 432aaa20..828a390e 100644 --- a/docs/qa/multiscene-closeout-20260910/remaining-closure-register-20260919.json +++ b/docs/qa/multiscene-closeout-20260910/remaining-closure-register-20260919.json @@ -20,6 +20,30 @@ "RC-08 external production prerequisites are incomplete" ] }, + "human_release_review_policy": { + "observed_at": "2026-09-27T06:47:12+08:00", + "mode": "single_named_reviewer", + "approver_id": "chen-yuqi", + "name": "陈钰麒", + "title": "总经理", + "approval_role": "release_owner", + "approvals_per_recovery_or_rollback": 1, + "rc06_asset_prompt_approval": "one_signed_batch_for_60_assets_and_14_prompt_modules", + "provider_approval": "one_signed_batch_for_exact_provider_and_product_evidence", + "public_policy_reviewer": "same_named_reviewer_with_separate_purpose_keys", + "technical_gate_execution": "automated_fail_closed", + "key_separation": { + "offline_root_key": "keyring_issue_rotation_and_revocation_only", + "operational_approval_key": "exact_release_recovery_or_rollback_evidence_only", + "rc06_apply_service_key": "system_generated_74_item_apply_and_online_snapshot_receipts_only", + "same_public_key_across_purposes_allowed": false + }, + "additional_human_release_reviewers_required": false, + "uat_member_accounts_are_reviewers": false, + "changes_tenant_business_approval_controls": false, + "formal_signature_or_release_authorization_recorded": false, + "release_decision_unchanged": "NO_GO" + }, "source_state": { "observed_at": "2026-09-20T05:54:57+08:00", "git_head": "af091cdc4ff1751038b845bb0f3277b6d645f8f3", @@ -301,7 +325,7 @@ "title": "Deploy, explicitly publish and complete two-workspace member UAT", "status": "waiting_for_rc04_rc05_and_external_uat_inputs", "dependencies": ["RC-04", "RC-05"], - "exit": "Back up the target before migration; record both database heads; deploy exact RC image digests; explicitly approve, preview, apply and verify the 60-asset and 14-Prompt hashes; each of the 31 Golden assets has either named legal approval or a named, scoped, expiring internal-pilot risk acceptance with compensating controls and use limits, and unresolved assets cannot enter the RC-07 internal Go decision; admin plus two ordinary members in two workspaces cover each asset's applicable normal API flow and the eight core UI flows, tenant isolation, cancellation, idempotency, page continuation, worker restart, handoff and original-version preservation." + "exit": "Back up the target before migration; record both database heads; deploy exact RC image digests; the sole named reviewer makes one batch decision bound to the exact 60-asset and 14-Prompt hashes, while the system previews, applies and verifies every item; that batch records for each of the 31 Golden assets either legal approval or a scoped, expiring internal-pilot risk acceptance with compensating controls and use limits, and unresolved assets cannot enter the RC-07 internal Go decision; admin plus two ordinary member test accounts in two workspaces cover each asset's applicable normal API flow and the eight core UI flows, tenant isolation, cancellation, idempotency, page continuation, worker restart, handoff and original-version preservation." }, { "id": "RC-07", @@ -309,7 +333,7 @@ "title": "Rehearse recovery and issue the internal release decision", "status": "waiting_for_rc06", "dependencies": ["RC-06"], - "exit": "Restore the exact candidate into an isolated namespace; measure RPO and RTO; verify version, hashes and critical records; complete rollback and forward recovery; deliver install, upgrade, operations, rollback, release, known-limit and evidence-index documents; issue an independent internal controlled-use Go/No-Go." + "exit": "Restore the exact candidate into an isolated namespace; measure RPO and RTO; verify version, hashes and critical records; complete rollback and forward recovery; deliver install, upgrade, operations, rollback, release, known-limit and evidence-index documents; sole named release reviewer Chen Yuqi (陈钰麒, General Manager/总经理) issues the internal controlled-use Go/No-Go after automated gates pass." }, { "id": "RC-08", @@ -318,7 +342,7 @@ "title": "Satisfy external production-release prerequisites", "status": "waiting_external_materials_and_production_validation", "dependencies": ["RC-07"], - "exit": "Named owners approve and activate policies and consent; make an applicability decision for AI filing and labels and provide real effective identifiers where applicable; accept the production Provider and exact release; pass production preflight, TLS, secrets, monitoring, alerts, anti-abuse and fresh-backup checks; public blockers equal zero; the release owner authorizes traffic change." + "exit": "Sole named reviewer Chen Yuqi (陈钰麒, General Manager/总经理) approves the exact policy and Provider batches; the system verifies signatures, hashes and activation evidence; make an applicability decision for AI filing and labels and provide real effective identifiers where applicable; pass production preflight, TLS, secrets, monitoring, alerts, anti-abuse and fresh-backup checks; public blockers equal zero; the same release owner separately authorizes traffic change." } ], "execution_order": ["RC-01", "RC-02", "RC-03", "RC-04", "RC-05", "RC-06", "RC-07", "RC-08"], diff --git a/project-docs/AI合同平台-多场景完整交付与开发收口方案-2026-09-10.md b/project-docs/AI合同平台-多场景完整交付与开发收口方案-2026-09-10.md index 47bc864d..d102229d 100644 --- a/project-docs/AI合同平台-多场景完整交付与开发收口方案-2026-09-10.md +++ b/project-docs/AI合同平台-多场景完整交付与开发收口方案-2026-09-10.md @@ -411,7 +411,7 @@ Gate A 没有启动服务、访问实时数据库、调用真实模型、部署 | RC-03 同候选确定性质量 | `closed_current_candidate` | Windows:synthetic 144/144、Python 1,210 pass+3 platform skip、扩展 319/319、Web 192/192、Golden 31/31+32/32、OpenAPI fresh/tool pass;Linux:同一 ledger 实现和测试字节的 output/ledger/attempts 3/3 pass、0 skip。补验后 452 source pins missing 0、mismatch 0,自包含 v3 聚合索引已绑定收据。失效的 synthetic v1~v4 C-TEMP 及两个 invalid-c-temp 仍单独保留,未拼接 | 当前字节 RC-03 退出条件已满足,严格转入 RC-04。若后续源码、安全实现、测试或冻结输入发生变化,则只作废并重跑受影响门禁,同时递增索引版本;不得改写原始 Windows/Linux JUnit | | RC-04 不可变可安装候选 | `rc04_1_and_rc04_2_rejected_rc04_3_clean_checkout_fix_pending` | 已完成 9,358 条预冻结路径的独立审阅:500 纳入、8,858 保留排除,secret/runtime、manual、gap、missing anchor、ownership blocker 均为 0。`.1` commit `2770d738185589c399c5740c4fc6cc25a4d65c2f` / tag `v2-multiscene-rc04-20260920.1` 保持不可变,但 manifest 实跑把 5 个合法密钥托管源码路径误判成密钥材料,已失败关闭且不得构建。`.2` commit `62d7d4476c08c8d72419665c3c0e1a6b3ef1c0f9` / tag `v2-multiscene-rc04-20260920.2` 成功生成确定性 manifest 和源码包,但冻结后干净 checkout 回归为 43 项中 1 项失败:测试错误强制要求 ownership 明确排除的原始 QA 证据,先前的脏工作区掩盖了该依赖;`.2` 因此也已不可变地否决。规则现收窄为只允许 `src/tests/migrations` 下的明确代码后缀,实际密钥材料仍拒绝;回归现要求发布源码/测试必须存在,而外置保留的 generated evidence 只在存在时复验哈希。四个 Dockerfile 基础镜像已固定到 Linux amd64 manifest digest,根 `.dockerignore` 已隔离本地缓存、文档、环境文件和运行态数据;Contract 38 与 AI 41 依赖锁的 pinned audit 为 0 known vulnerability。数据库共址主机当前根盘 93%、健康门禁失败且有容器反复重启,禁止直接使用默认 Docker/buildx 构建 | 只纳入干净 checkout 回归修复、回归和状态证据,独立复核后生成递增 `.3` commit/tag;必须先在冻结后干净 checkout 重跑 43 项并通过,才允许生成 source manifest/archive。Linux 构建仅允许在独立 builder,或在健康门禁恢复后的维护窗口使用 `/home` 独立 BuildKit/containerd root、串行和严格资源限额,绝不写入既有 `/var/lib/docker`;随后记录 image/base digest、SBOM、扫描、import/startup/health 及安装/回退收据 | | RC-05 真实模型质量与耗时 | `blocked_by_rc04_not_started_full_real_matrix` | 当前 RC 没有真实矩阵;已知历史真实调用 4 次已计入,总预算最多剩 276 次、修复调用最多剩 12 次;尚未验证最终候选 `codex_cli / gpt-5.6-sol / medium / 300 / fallback=false` | 冻结 144 个任务,代表探针属于矩阵首批而不是额外调用;历史调用和所有新调用共用 280 次全局上限;0 unknown;失败/取消/降级/接管全进分母;关键主体、金额、日期、事实无静默丢失,正常链 schema/终态无阻断;按 operation 报 p50/p95/超时,未达三分钟只披露异步体验 | -| RC-06 部署、发布与双工作区 UAT | `waiting_for_rc04_rc05_and_external_uat_inputs` | 未完成目标备份、两库 current、迁移、RC 镜像部署、60资产/14 Prompt 批次批准和 apply/hash;没有管理员+2 普通成员+2 工作区的完整证据;31 项资产仍为 `needs_legal_review` | 获得目标主机、备份/迁移/部署授权、发布负责人和两个普通账号;具名批次批准必须逐项记录法律批准,或仅对内部受控试点记录有 owner/期限/范围/补偿控制的风险接受和使用限制,未处置项不得进入 RC-07 内部 Go;每个资产走适用正常 API 链,8 核心走 UI;验证跨租户、取消/幂等/关页续接/worker restart/人工接管、下载及原稿不覆盖。法律文书不强迫走不适用的签署/撤回 | +| RC-06 部署、发布与双工作区 UAT | `waiting_for_rc04_rc05_and_external_uat_inputs` | 未完成目标备份、两库 current、迁移、RC 镜像部署、60资产/14 Prompt 批次批准和 apply/hash;没有管理员+2 普通成员测试账号+2 工作区的完整证据;31 项资产仍为 `needs_legal_review` | 获得目标主机、备份/迁移/部署授权和两个普通测试账号;唯一审核人陈钰麒对绑定 60 资产+14 Prompt 精确 hash 的一个批次作一次决定,系统逐项记录法律批准,或仅对内部受控试点记录有 owner/期限/范围/补偿控制的风险接受和使用限制,未处置项不得进入 RC-07 内部 Go;每个资产走适用正常 API 链,8 核心走 UI;验证跨租户、取消/幂等/关页续接/worker restart/人工接管、下载及原稿不覆盖。两个普通账号不是审核人;法律文书不强迫走不适用的签署/撤回 | | RC-07 恢复与内部放行 | `waiting_for_rc06` | 旧恢复证据不绑定当前 RC;没有当前候选的隔离恢复、RPO/RTO、rollback/forward、操作包、证据索引和内部 Go/No-Go | 在隔离 namespace 恢复当前候选,实测 RPO/RTO并核验版本/hash/关键记录;完成 rollback/forward;交付安装、升级、运维、回退、发布清单、已知限制和证据索引;单独作出内部受控 Go/No-Go | | RC-08 对外公众放行 | `waiting_external_materials_and_production_validation`,`public_release_blocker=true` | 四份正式政策和同意证据、AI 备案/标识适用性判定及必要真实编号、生产 Provider、域名/TLS、密钥、监控告警、反滥用、生产 preflight、备份新鲜度、切流授权均未闭环 | 先由具名责任人形成适用性结论;适用的备案/标识必须有真实生效证明,不使用占位值;production blocker=0,warning 可有登记接受;发布负责人最后授权切流。内部例外不能授权公众开放 | @@ -442,6 +442,7 @@ Gate A 没有启动服务、访问实时数据库、调用真实模型、部署 9. UAT 按 `document_kind` 走适用流程;通知、决议、意见书、授权委托书不强制套双边签署/履约/撤回。 10. 每次只处理当前最前置未通过门禁;一般样式、额外措辞和非阻断性能优化继续留在后续,不得挤入本轮。 11. AI Platform 的共享 service key 是服务到服务认证,不是最终用户认证。生产只允许受信 BFF/Contract API/worker 断言 `X-User-Id`;密钥不得进入浏览器或普通客户端。若网络拓扑不能保证该条件,则在 RC-08 前改为签名用户上下文并重跑受影响门禁。 +12. 本轮唯一人工发布审核人为陈钰麒(总经理)。恢复、回退、政策与最终 Go/No-Go 不再追加第二名发布审核人;AI 自动完成技术核验但不得冒签。每次具体恢复/回退只接收一条 `release_owner` 签名,离线根密钥与日常审批密钥必须用途隔离且公钥不同。两个普通成员账号和两个工作区只是 UAT 测试身份,不是审核人;租户业务审批能力不在本条简化范围内。 ### 6. 外部输入清单 diff --git a/project-docs/AI合同平台-生产上线加速与开发效率整改方案-2026-09-21.md b/project-docs/AI合同平台-生产上线加速与开发效率整改方案-2026-09-21.md index 4e7667b9..89feab1e 100644 --- a/project-docs/AI合同平台-生产上线加速与开发效率整改方案-2026-09-21.md +++ b/project-docs/AI合同平台-生产上线加速与开发效率整改方案-2026-09-21.md @@ -176,7 +176,7 @@ - 每天只设置两个状态检查点;中间只在新增 P0、需要用户材料或任务完成时通知。 - 同一阻断连续出现两次后停止重跑,先做根因分析。 - 运行时间和等待时间分开统计:编码/修复、自动测试、真实模型、环境等待、外部审批分别记账。 -- 一名发布负责人拥有范围和 Go/No-Go 决策权,避免每个审查意见都自动升级为首发功能。 +- 陈钰麒(总经理)是本轮唯一人工发布审核人,拥有范围和 Go/No-Go 决策权;技术门禁自动执行,不再追加第二名发布审核人。 ## 七、最快上线排期 @@ -185,9 +185,9 @@ | 时间 | 并行执行 | 退出条件 | | --- | --- | --- | | 第 0 天,0~4 小时 | 审阅 41 个 tracked 状态项和约 8,906 个未跟踪文件;移出原始证据;保护用户已有 `docs/architecture/local-development.md`、`start-dev.ps1`;合并并冻结唯一 commit/manifest;同时发出外部材料清单 | 无未归属源码;唯一候选 SHA;发布工作树只读 | -| 第 1 天 | A 构建一次镜像/SBOM/扫描/安装包;B 部署 `192.168.3.62`;C 代表模型场景通过后,以已验证的最多 2 并发或现有串行 runner 启动全矩阵;D 并行准备 74 项审批与政策/域名/证书材料 | 构建产物 hash 一致;UAT 健康;代表场景无系统性失败 | +| 第 1 天 | A 构建一次镜像/SBOM/扫描/安装包;B 部署 `192.168.3.62`;C 代表模型场景通过后,以已验证的最多 2 并发或现有串行 runner 启动全矩阵;D 并行准备绑定 60 资产+14 Prompt 精确 hash 的一份批次审批及政策/域名/证书材料 | 构建产物 hash 一致;UAT 健康;代表场景无系统性失败 | | 第 2 天 | 收口 RC-05;生成 RC-06 批次;执行管理员+2 名普通成员、2 个工作区的 API/UI UAT | 144 场景都有终态;无未知、静默降级或未解释失败;运行时快照一致 | -| 第 3 天 | 完成 RC-06;用同一候选执行真实备份、恢复、rollback/forward、RPO/RTO 和双签 | 数据和 hash 核验通过;可作出内部受控试点 Go/No-Go | +| 第 3 天 | 完成 RC-06;用同一候选执行真实备份、恢复、rollback/forward、RPO/RTO 和单一具名审核人签名 | 数据和 hash 核验通过;可作出内部受控试点 Go/No-Go | | 第 4~5 天 | 收口真实 Provider、正式域名/TLS、双 Redis、密钥、监控告警、正式政策和切流/回退演练 | RC-08 公众 blocker 为 0;发布负责人明确授权公众切流 | 若外部材料尚未齐全,工程侧仍以第 3 天形成完整多场景内部受控试点候选为目标,继续关闭公众注册并维持公众开放 `NO_GO`。这不是缩减产品场景,只是把代码完成与外部放行分开判断。 @@ -203,12 +203,14 @@ AI 可以: AI 不能冒充完成: - 具名法务人员的正式政策签署或法律结论。 -- 两名独立审批人的身份、私钥或签名。 +- 唯一具名审核人陈钰麒的身份、私钥或签名;AI 只能预填材料和自动核验,不能冒签。 - 真实生产 Provider、域名、证书、密钥和告警接收人的提供。 - 风险接受、破坏性生产回退批准和最终公众切流授权。 这些外部批准不齐时,系统可以完成开发并进入白名单内部试点,但不能被描述为已经获得公众生产正式批准。 +本轮恢复和回退采用“一个人、一次人工决定、一个运营签名”的口径。公开政策也只由陈钰麒一人负责;法律文本签名与公钥信任确认仍使用两把用途隔离密钥形成两条技术签名,但不再要求第二名审核人,材料生成与验签由系统自动完成。离线恢复根密钥只用于签发、轮换或吊销恢复 keyring,日常恢复审批密钥只用于具体证据;两者必须为不同 Ed25519 公钥。两个普通成员账号和两个工作区属于自动化 UAT 覆盖,不是额外审核人。企业租户可配置的业务审批、特权身份操作等产品能力不在本次发布审核简化范围内。 + ## 九、效率指标与上线看板 从下一任务开始只跟踪以下指标: diff --git a/project-docs/V2-named-legal-offline-signing-runbook-2026-08-11.md b/project-docs/V2-named-legal-offline-signing-runbook-2026-08-11.md index 38edf159..84134b00 100644 --- a/project-docs/V2-named-legal-offline-signing-runbook-2026-08-11.md +++ b/project-docs/V2-named-legal-offline-signing-runbook-2026-08-11.md @@ -21,8 +21,10 @@ prints it. helper reruns all 31 deterministic cases and refuses a stale or mismatched summary. - The reserved public HTTPS evidence URL on port 443: `https://api.contact.yuqei.com/provider/legal/provider-legal-review-evidence.json`. -- The reviewer's real name, stable approver ID, role, organization, key ID, approval - time, and expiry time. Approval validity cannot exceed 30 days. +- The sole configured reviewer is `chen-yuqi / 陈钰麒 / 总经理 / + 内蒙古钰麒科技有限公司`; the command rejects every other identity. Supply the + reviewer-controlled key ID, approval time, and expiry time. Approval validity cannot + exceed 30 days. The current eligible shared-staging package was created on 2026-08-11 for tenant `default`, Provider configuration `provider-914ba619fe4b`, Provider `ccswitch`, and @@ -47,10 +49,10 @@ python yuqei-infra/scripts/prepare-provider-legal-approval.py prepare ` --golden-summary-file .runtime/v2-contract-product-golden/final-20260810/contract-product-golden-summary.json ` --evidence-ref https://api.contact.yuqei.com/provider/legal/provider-legal-review-evidence.json ` --key-id ` - --approver-id ` - --reviewer "" ` - --role "" ` - --organization "" ` + --approver-id chen-yuqi ` + --reviewer 陈钰麒 ` + --role 总经理 ` + --organization 内蒙古钰麒科技有限公司 ` --approved-at ` --expires-at ` --evidence-output .runtime/production-closure/provider/provider-legal-review-evidence.json ` @@ -77,17 +79,19 @@ openssl pkey -in legal-approval-private.pem -pubout -out legal-approval-public.p ``` Create a one-line password file outside the repository with access limited to the -reviewer. Then create a candidate public keyring. Security/operations must independently -approve and install this public keyring on shared staging; generation is not trust. +reviewer. Then create a candidate public keyring. The same sole reviewer confirms the +public-key fingerprint before installation; automated validation verifies the identity, +key binding, hashes and signature. No second human reviewer is required, and generation +alone is not trust. ```powershell python yuqei-infra/scripts/prepare-provider-legal-approval.py keyring ` --public-key-file legal-approval-public.pem ` --key-id ` - --approver-id ` - --reviewer "" ` - --role "" ` - --organization "" ` + --approver-id chen-yuqi ` + --reviewer 陈钰麒 ` + --role 总经理 ` + --organization 内蒙古钰麒科技有限公司 ` --not-before ` --not-after ` --output .runtime/production-closure/provider/legal-trusted-keyring.candidate.json @@ -134,6 +138,7 @@ register the immutable acceptance manifest, and issue the separate promotion rec - The 31-case product suite is stale, mismatched, incomplete, or technically failed. - Evidence or unsigned payload bytes were reformatted or changed. - Identity differs between the payload and trusted keyring. +- Identity is not exactly `chen-yuqi / 陈钰麒 / 总经理 / 内蒙古钰麒科技有限公司`. - Key is unknown, duplicated, revoked, outside its validity window, or not Ed25519. - Approval is future-dated, expired, longer than policy, tampered, or cross-tenant. - Evidence URL is not HTTPS port 443 or contains credentials, query, or fragment. diff --git a/project-docs/V2-production-external-gate-closure-2026-08-11.md b/project-docs/V2-production-external-gate-closure-2026-08-11.md index 171fbc58..2cb9e49f 100644 --- a/project-docs/V2-production-external-gate-closure-2026-08-11.md +++ b/project-docs/V2-production-external-gate-closure-2026-08-11.md @@ -45,16 +45,12 @@ ## 用户最小配合项 -请提供一位实际承担审批责任的法务人员的: +唯一人工审核人固定为陈钰麒(总经理)。执行时只需准备: -1. 真实姓名; -2. 稳定的工作 ID,优先使用工作邮箱; -3. 职务; -4. 组织全称; -5. 法务公钥 `key_id`,建议 `provider-legal-2026q3`; -6. 批准有效窗口,最长 30 天。 +1. 审核人本人控制的法务公钥 `key_id`,建议 `provider-legal-2026q3`; +2. 批准有效窗口,最长 30 天。 -法务本人需在受控电脑上生成并保管 Ed25519 私钥,按 `V2-named-legal-offline-signing-runbook-2026-08-11.md` 审阅、签署。私钥和口令不得上传仓库、聊天、共享预发布或 CI artifact。安全/运维只接收法务公钥并将其加入 trusted keyring。 +陈钰麒本人需在受控电脑上生成并保管 Ed25519 私钥,按 `V2-named-legal-offline-signing-runbook-2026-08-11.md` 对绑定 18 条 Provider 结果与 31 条产品黄金用例的精确批次作一次审阅、签署。私钥和口令不得上传仓库、聊天、共享预发布或 CI artifact。同一审核人确认公钥指纹后,系统自动校验身份、hash 和签名并安装 trusted keyring;不再增加安全/运维第二审核人。 ## 签名后的固定自动顺序 @@ -70,6 +66,8 @@ 具名法务审批、共享预发布 `finalize`、production receipt pin、production preflight、备份恢复和双节点只读验证已按上述顺序完成。真实验收运行 ID 为 `provider-acceptance-41663d1022d3f656769ed757`,receipt 为 `provider-receipt-de755dae13b02c79bdce5302`,receipt SHA-256 为 `6602584347833b389c1d714a4ce08ccffdcbbc8c289657d2d9bd3bff5c6e70c7`。 +> 2026-09-27 边界更新:本节 receipt 和签批记录仅作历史审计,未绑定当前固定审核人 `chen-yuqi / 陈钰麒 / 总经理` 的新规则,不能用于当前 BUILD/RC 候选或公众放行。当前候选必须由陈钰麒对新精确批次重新签名,并重新生成、固定和验签 Provider receipt。 + 现场演练结果:receipt 公钥撤销在两个节点于 `2.64s` 内传播;临时关闭 kill switch 后两节点均以 `ready`、`release_allowed=true` 和相同 release/receipt/key/image 指纹通过 canary;随后两节点均恢复为 kill switch `true`、`blocked`、`release_allowed=false`。完整证据和文件 SHA-256 见 [V2-provider-promotion-field-evidence-2026-08-12.md](V2-provider-promotion-field-evidence-2026-08-12.md)。 本补录只关闭 Provider promotion 的现场门禁,不代表公网流量切换已获准。`edge` 未启动,DNS/业务流量未改变,support access、身份执行器和破坏性身份写 API 继续关闭。 diff --git a/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md b/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md index 65ff1437..57e2223b 100644 --- a/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md +++ b/project-docs/V2-public-policy-offline-signing-runbook-2026-08-12.md @@ -6,15 +6,17 @@ 本流程只自动完成精确文本绑定、Ed25519 签名和验签,不替代具名审批人的法律判断。候选文本、未签名 payload、候选 keyring 或技术测试通过均不表示政策已经获得批准或已经生效。 -加密私钥和密码必须始终保存在审批人控制的本机,不得进入仓库、`.runtime`、聊天、邮件、共享预发布、生产服务器或 CI artifact。生产和共享预发布只安装经过独立确认的公钥 keyring。 +加密私钥和密码必须始终保存在审批人控制的本机,不得进入仓库、`.runtime`、聊天、邮件、共享预发布、生产服务器或 CI artifact。生产和共享预发布只安装经过具名审核人确认的公钥 keyring。 -## 当前候选包 +> 2026-09-27 流程更新:下文保留的 v1 独立复核 hash 和陈勇记录仅是历史材料,不再作为当前候选的放行依据。当前 v2 流程固定由陈钰麒(总经理)一人完成法律审阅和信任确认,不再增加第二名审核人;法律签名密钥与信任确认密钥必须是不同 Ed25519 公钥。AI 可以生成和核验材料,但不能代替陈钰麒签名。新的 v2 材料尚未签发,内部试点、UAT、生产和公众开放继续 `NO_GO`。 + +## 历史 v1 候选包(不可用于当前放行) - 运营者:内蒙古钰麒科技有限公司 - 统一社会信用代码:`91150203MA7YPGQ49E` - 审批人:陈钰麒 - approver ID:`cyuqei@gmail.com` -- 职务:CEO +- 职务:CEO(历史 v1 记录) - policy key ID:`policy-legal-2026q3` - 公钥文件 SHA-256:`a4bd58fd010965dc1a5911454b67a720c34c5d5c6a7510bd30e94ddd7dc16739` - 正式版本:`2026-08-12` @@ -44,9 +46,32 @@ - independent trust approval SHA-256:`5093462cefff6a75e5f9a320f8bd4e033d9327e0234f84e9bbc258c36148fbbc` - trusted keyring SHA-256:`41f9517dcd375afbeea4aaa4dce5d8bbe07db8f71e2706d47e7aec5ccd8bd388` -review package 和 pending payload 仍是历史输入材料。陈勇已作为独立运营复核人签署精确信任 payload;工具从原始 trust request、法务批准、候选 keyring、四份政策字节和独立公钥重新验证后生成 trusted keyring,并在未启用 candidate override 的情况下完成四份政策验签。`trust_basis.policy_activation_authorized=false`,所以该结果只关闭公钥信任审批门禁,不表示政策已经发布或生效。trusted keyring 当前仅保存在本机受控 `.runtime` 目录,尚未安装到生产。 +上述 review package、pending payload、固定 hash、陈勇独立复核和 trusted keyring 均为历史 v1 记录,只能用于审计追溯,禁止复制到当前 v2 命令或作为当前放行输入。`trust_basis.policy_activation_authorized=false`,所以历史结果也不表示政策已经发布或生效。 -## 审批人审阅清单 +## 当前 v2 材料生成 + +当前流程必须先用现行工具重新生成 v2 材料;不得复用上一节的路径、固定 hash、审批 ID 或 keyring。以下输出目录仅用于本机受控准备,不得提交仓库: + +```powershell +python yuqei-infra/scripts/prepare-policy-legal-approval.py prepare ` + --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` + --public-key-file ` + --key-id ` + --approver-id chen-yuqi ` + --reviewer 陈钰麒 ` + --role 总经理 ` + --organization 内蒙古钰麒科技有限公司 ` + --prepared-at ` + --key-not-before ` + --key-not-after ` + --review-package-output .runtime/production-closure/policies/v2-current/policy-review-package.json ` + --payload-output .runtime/production-closure/policies/v2-current/policy-approval-pending-payload.json ` + --keyring-output .runtime/production-closure/policies/v2-current/policy-legal-keyring.candidate.json +``` + +工具只接受固定审核人 `chen-yuqi / 陈钰麒 / 总经理 / 内蒙古钰麒科技有限公司`。命令输出的新 hash 才是本次审阅依据;任何 v1 固定 hash 都不得沿用。 + +## 当前 v2 审批人审阅清单 审批人必须逐份审阅四个 Markdown 文件,并确认: @@ -55,7 +80,7 @@ review package 和 pending payload 仍是历史输入材料。陈勇已作为独 3. 用户协议、服务范围、费用、知识产权、责任边界和数据退出规则可接受; 4. AI 输出边界、人工复核、高风险事项和禁止用途可接受; 5. 法律适用、争议管辖、版本、生效时间和重新同意策略准确; -6. review package、pending payload、公钥和四份文件的 SHA-256 与本手册一致。 +6. review package、pending payload、公钥和四份文件的 SHA-256 与本次 `prepare` 命令输出一致。 任何文本、空白、标点、编码或审批字段发生变化,必须重新运行 `prepare` 并重新审阅全部新 hash。 @@ -65,13 +90,13 @@ review package 和 pending payload 仍是历史输入材料。陈勇已作为独 ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py finalize-payload ` - --pending-payload-file .runtime/production-closure/policies/2026-08-12/policy-approval-pending-payload.json ` + --pending-payload-file .runtime/production-closure/policies/v2-current/policy-approval-pending-payload.json ` --approved-at ` --expires-at ` - --output .runtime/production-closure/policies/2026-08-12/policy-approval-final-payload.json + --output .runtime/production-closure/policies/v2-current/policy-approval-final-payload.json ``` -记录命令输出的最终 payload SHA-256。签名前,审批人必须再次核对批准时间、失效时间和 exact hash;本次最终 payload hash 为 `7969de1c7c8384657b42a3fd16e70bb83a0430d0100b33088c4a094c48b17312`。 +记录命令输出的最终 payload SHA-256。签名前,审批人必须再次核对批准时间、失效时间和本次新生成的 exact hash;不得使用历史 v1 hash。 ## 离线签名 @@ -79,94 +104,94 @@ python yuqei-infra/scripts/prepare-policy-legal-approval.py finalize-payload ` ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py sign ` - --payload-file .runtime/production-closure/policies/2026-08-12/policy-approval-final-payload.json ` + --payload-file .runtime/production-closure/policies/v2-current/policy-approval-final-payload.json ` --private-key-file ` --private-key-password-file ` --confirmation I_APPROVE_THIS_EXACT_POLICY_PAYLOAD ` - --output .runtime/production-closure/policies/2026-08-12/named-policy-legal-approval.json + --output .runtime/production-closure/policies/v2-current/named-policy-legal-approval.json ``` 签名命令会拒绝错误确认字符串、非最终 payload、错误密码、非 Ed25519 私钥或与 payload 公钥绑定不一致的私钥。 -本次签名已完成并通过验签,产物为 `policy-legal-approval.json`,`approval_id` 为 `policy-approval-0887bbd5149bf7a1c41cb35e`。该结果仅证明精确政策 payload 已由具名审批人签署,不代表政策已发布或已生效。 +当前 v2 尚未签发;只有陈钰麒使用本人控制的法律签名私钥完成上述命令后,才会产生新的 approval ID。签名仅证明精确政策 payload 已由具名审批人签署,不代表政策已发布或已生效。 -## 独立信任申请 +## 同一审核人分钥信任确认 法务签名只证明审批人签署了精确政策 payload,不会自动使候选公钥成为生产信任。签名完成后,先生成不可变的信任申请: ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py prepare-trust-request ` - --approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json ` - --candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json ` + --approval-file .runtime/production-closure/policies/v2-current/named-policy-legal-approval.json ` + --candidate-keyring-file .runtime/production-closure/policies/v2-current/policy-legal-keyring.candidate.json ` --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` --requested-at ` - --output .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json + --output .runtime/production-closure/policies/v2-current/policy-key-trust-request.json ``` -该命令会在审批有效期内重新验签,并绑定签名文件、候选 keyring 和四份政策原始字节。输出状态固定为 `pending_independent_trust_approval`,`activation_authorized` 固定为 `false`;它不会生成 trusted keyring、发布政策、切换 `effective`、启动 edge 或修改公网流量。 +该命令会在审批有效期内重新验签,并绑定签名文件、候选 keyring 和四份政策原始字节。输出状态固定为 `pending_single_reviewer_trust_approval`,`activation_authorized` 固定为 `false`;它不会生成 trusted keyring、发布政策、切换 `effective`、启动 edge 或修改公网流量。 -安全或运维负责人必须通过签名流程之外的渠道核对审批人身份、授权范围和公钥指纹,具名批准信任申请并保留证据。不得仅把候选 JSON 的 `status` 字段手工改为 `trusted` 作为信任证据。 +陈钰麒必须核对自己的授权范围以及两把用途隔离公钥的指纹,具名确认信任申请并保留证据。不得仅把候选 JSON 的 `status` 字段手工改为 `trusted` 作为信任证据。 -独立审批人必须与法务审批人不同,并提供:真实姓名、稳定工作邮箱或 approver ID、职务、组织全称、独立 `key_id`、本人控制的 Ed25519 公钥 PEM、公钥有效期和本次批准有效期。approver ID 及公钥都不得与法务审批人相同;本次批准最长 7 天,且不得晚于原法务批准失效时间。 +信任确认人与法务审批人必须是同一个具名人员:陈钰麒(总经理),并使用相同 approver ID、姓名、职务和组织。信任确认必须使用另一把本人控制的 Ed25519 密钥;公钥不得与法律签名密钥相同。本次确认最长 7 天,且不得晚于原法律批准失效时间。 先生成精确待签 payload: ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py prepare-trust-approval ` - --trust-request-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json ` - --approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json ` - --candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json ` + --trust-request-file .runtime/production-closure/policies/v2-current/policy-key-trust-request.json ` + --approval-file .runtime/production-closure/policies/v2-current/named-policy-legal-approval.json ` + --candidate-keyring-file .runtime/production-closure/policies/v2-current/policy-legal-keyring.candidate.json ` --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` - --trust-approver-public-key-file ` - --key-id ` - --approver-id ` - --reviewer ` - --role ` - --organization ` + --trust-approver-public-key-file ` + --key-id ` + --approver-id chen-yuqi ` + --reviewer 陈钰麒 ` + --role 总经理 ` + --organization 内蒙古钰麒科技有限公司 ` --responsibility ` --approved-at ` --expires-at ` --key-not-before ` --key-not-after ` - --output .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval-payload.json + --output .runtime/production-closure/policies/v2-current/policy-key-trust-approval-payload.json ``` -审批人核对命令输出的 `trust_approval_payload_sha256` 后,使用其本人控制且与法务密钥不同的私钥签名: +陈钰麒核对命令输出的 `trust_approval_payload_sha256` 后,使用本人控制且与法律签名密钥不同的信任私钥签名: -待签 payload 固定包含三项不可省略声明:已通过线下渠道核验法务审批人身份和权限、已通过线下渠道核验法务签名公钥指纹,以及本人承担独立安全或运营复核责任。审批人必须在签名前真实完成这些核验;工具不会替审批人作此事实判断。 +待签 payload 固定包含三项不可省略声明:确认同一具名审核人的身份和权限、核验法律签名公钥指纹,以及本人使用独立用途密钥承担安全或运营确认责任。陈钰麒必须在签名前真实完成这些确认;工具不会替审核人作此事实判断。 ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py sign-trust-approval ` - --payload-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval-payload.json ` - --private-key-file ` + --payload-file .runtime/production-closure/policies/v2-current/policy-key-trust-approval-payload.json ` + --private-key-file ` --private-key-password-file ` --confirmation I_APPROVE_THIS_EXACT_POLICY_KEY_TRUST_PAYLOAD ` - --output .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval.json + --output .runtime/production-closure/policies/v2-current/policy-key-trust-approval.json ``` 最后从所有原始材料重新验签并生成新 keyring 文件: ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py issue-trusted-keyring ` - --trust-approval-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-approval.json ` - --trust-request-file .runtime/production-closure/policies/2026-08-12/policy-key-trust-request.json ` - --approval-file .runtime/production-closure/policies/2026-08-12/policy-legal-approval.json ` - --candidate-keyring-file .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.candidate.json ` + --trust-approval-file .runtime/production-closure/policies/v2-current/policy-key-trust-approval.json ` + --trust-request-file .runtime/production-closure/policies/v2-current/policy-key-trust-request.json ` + --approval-file .runtime/production-closure/policies/v2-current/named-policy-legal-approval.json ` + --candidate-keyring-file .runtime/production-closure/policies/v2-current/policy-legal-keyring.candidate.json ` --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` - --trust-approver-public-key-file ` + --trust-approver-public-key-file ` --checked-at ` - --output .runtime/production-closure/policies/2026-08-12/policy-legal-keyring.trusted.json + --output .runtime/production-closure/policies/v2-current/policy-legal-keyring.trusted.json ``` -`issue-trusted-keyring` 会拒绝过期审批、同一 approver ID、相同公钥、手工翻转 `status`、任一 hash/文件字节漂移和签名错误。输出中的 `trust_basis.policy_activation_authorized` 固定为 `false`;trusted keyring 只建立验签公钥信任,仍不代表政策已经发布或生效。 +`issue-trusted-keyring` 会拒绝过期审批、不同 approver ID/姓名/职务/组织、相同公钥、手工翻转 `status`、任一 hash/文件字节漂移和签名错误。输出中的 `trust_basis.policy_activation_authorized` 固定为 `false`;trusted keyring 只建立验签公钥信任,仍不代表政策已经发布或生效。 ## 验签与生产边界 -候选 keyring 不能直接作为生产信任。只有上述 `issue-trusted-keyring` 生成且自带可重验独立签名证据的文件才能用于后续验签。随后运行: +候选 keyring 不能直接作为生产信任。只有上述 `issue-trusted-keyring` 生成且自带可重验的同人分钥签名证据文件才能用于后续验签。随后运行: ```powershell python yuqei-infra/scripts/prepare-policy-legal-approval.py verify ` - --approval-file .runtime/production-closure/policies/2026-08-12/named-policy-legal-approval.json ` + --approval-file .runtime/production-closure/policies/v2-current/named-policy-legal-approval.json ` --keyring-file ` --policy-dir project-docs/legal-policy-candidates/2026-08-12 ` --checked-at diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py index dff4fd15..a4a0f044 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_acceptance_receipt.py @@ -21,6 +21,7 @@ from yuqei_ai_platform_api.provider_acceptance import ( expected_acceptance_inventory, provider_acceptance_manifest_hash, ) +from yuqei_ai_platform_api.provider_legal_approval import SOLE_PROVIDER_LEGAL_REVIEWER RECEIPT_VERSION = "provider-acceptance-receipt-v2" @@ -392,6 +393,14 @@ def _validate_embedded_acceptance_run( problems.append("accepted manifest has incomplete named legal approval metadata") if run.legal_approval_status != "approved" or not has_named_approval: problems.append("accepted manifest lacks a complete named legal approval") + embedded_reviewer = { + "approver_id": run.legal_approval_approver_id, + "reviewer": run.legal_reviewer, + "role": run.legal_approval_role, + "organization": run.legal_approval_organization, + } + if embedded_reviewer != SOLE_PROVIDER_LEGAL_REVIEWER: + problems.append("accepted manifest must use the configured sole named reviewer") if has_named_approval: legal_expiry = _as_utc(run.legal_approval_expires_at) # type: ignore[arg-type] if legal_expiry <= receipt_issued_at: diff --git a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py index 6965b03c..ddbeaefb 100644 --- a/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py +++ b/yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/provider_legal_approval.py @@ -22,6 +22,12 @@ MAX_LEGAL_APPROVAL_FILE_BYTES = 256 * 1024 MAX_LEGAL_KEYRING_FILE_BYTES = 256 * 1024 SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$") SHARED_EVIDENCE_SCHEMES = {"https", "s3", "gs", "az"} +SOLE_PROVIDER_LEGAL_REVIEWER = { + "approver_id": "chen-yuqi", + "reviewer": "陈钰麒", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", +} class StrictModel(BaseModel): @@ -144,6 +150,12 @@ def verify_legal_approval( problems.append("legal approval Provider identity does not match") if payload.technical_evidence_sha256 != technical_evidence_sha256: problems.append("legal approval technical evidence hash does not match") + payload_identity = { + field: getattr(payload, field) + for field in ("approver_id", "reviewer", "role", "organization") + } + if payload_identity != SOLE_PROVIDER_LEGAL_REVIEWER: + problems.append("legal approval must use the configured sole named reviewer") matches = [item for item in keyring.keys if item.key_id == payload.key_id] if len(matches) != 1: @@ -160,6 +172,9 @@ def verify_legal_approval( if approved_at < key_not_before or approved_at >= key_not_after: problems.append("legal approval was signed outside the key validity window") identity_fields = ("approver_id", "reviewer", "role", "organization") + key_identity = {field: getattr(key_entry, field) for field in identity_fields} + if key_identity != SOLE_PROVIDER_LEGAL_REVIEWER: + problems.append("legal approval key must belong to the configured sole named reviewer") if any(getattr(payload, field) != getattr(key_entry, field) for field in identity_fields): problems.append("legal approval identity does not match the trusted key owner") try: diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py index f3508a1e..25fcf502 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_receipt.py @@ -138,7 +138,7 @@ def _acceptance_run(repository: InMemoryAiPlatformRepository, provider_config_id suite_manifest_sha256=str(inventory["suite_manifest_sha256"]), knowledge_manifest_sha256=str(inventory["knowledge_manifest_sha256"]), legal_approval_status="approved", - legal_reviewer="Li Legal", + legal_reviewer="陈钰麒", legal_approved_at=datetime.now(UTC), legal_approval_evidence_ref="https://evidence.invalid/provider-acceptance.json", legal_approval_evidence_sha256=hashlib.sha256(b"named legal evidence").hexdigest(), @@ -146,9 +146,9 @@ def _acceptance_run(repository: InMemoryAiPlatformRepository, provider_config_id legal_approval_id="provider-legal-approval-" + "1" * 24, legal_approval_key_id="named-legal-2026q3", legal_approval_signature="A" * 86, - legal_approval_approver_id="li.legal@example.invalid", - legal_approval_role="Legal Counsel", - legal_approval_organization="Yuqei Legal", + legal_approval_approver_id="chen-yuqi", + legal_approval_role="总经理", + legal_approval_organization="内蒙古钰麒科技有限公司", legal_approval_expires_at=datetime.now(UTC) + timedelta(days=1), ) manifest = manifest.model_copy( @@ -386,6 +386,30 @@ def test_receipt_payload_tampering_breaks_signature() -> None: ) +def test_resigned_receipt_rejects_unconfigured_embedded_reviewer() -> None: + receipt, keyring, private_pem, now, repository, settings = _signed_fixture() + unconfigured_run = receipt.payload.acceptance_run.model_copy( + update={ + "legal_reviewer": "Other Reviewer", + "legal_approval_approver_id": "other-reviewer", + "legal_approval_role": "General Manager", + "legal_approval_organization": "内蒙古钰麒科技有限公司", + } + ) + resigned = sign_receipt_payload( + receipt.payload.model_copy(update={"acceptance_run": unconfigured_run}), + private_pem, + ) + + assert "accepted manifest must use the configured sole named reviewer" in _verify( + resigned, + keyring, + repository, + settings, + now=now, + ) + + @pytest.mark.parametrize( ("field", "value", "problem"), [ diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py index 073fe9df..99bcff6d 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_acceptance_registration.py @@ -100,10 +100,10 @@ def _registration_fixture(tmp_path): key_id=key_id, decision="approved", tenant_id="default", - approver_id="li.legal@example.com", - reviewer="Li Legal", - role="Named legal counsel", - organization="Yuqei Legal", + approver_id="chen-yuqi", + reviewer="陈钰麒", + role="总经理", + organization="内蒙古钰麒科技有限公司", approved_at=approved_at, expires_at=approved_at + timedelta(days=7), provider_config_id=provider.id, @@ -194,7 +194,7 @@ def test_protected_registration_verifies_named_approval_and_persists_metadata( result = response.json() assert result["legal_approval_id"] == registration["legal_approval"]["approval_id"] assert result["legal_approval_key_id"] == "legal-approver-2026q3" - assert result["legal_approval_approver_id"] == "li.legal@example.com" + assert result["legal_approval_approver_id"] == "chen-yuqi" assert result["technical_package_sha256"] == registration["technical_package_sha256"] assert result["legal_approval_attestation"] is None assert len(evidence_calls) == 1 @@ -203,8 +203,8 @@ def test_protected_registration_verifies_named_approval_and_persists_metadata( reopened = SqlAlchemyAiPlatformRepository(database_url, auto_create=False) persisted = reopened.get_provider_acceptance_run(result["id"]) assert persisted.legal_approval_signature == registration["legal_approval"]["signature"] - assert persisted.legal_approval_role == "Named legal counsel" - assert persisted.legal_approval_organization == "Yuqei Legal" + assert persisted.legal_approval_role == "总经理" + assert persisted.legal_approval_organization == "内蒙古钰麒科技有限公司" assert persisted.legal_approval_expires_at is not None receipt_payload = client.get( diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py index b61c1930..8e778c46 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_provider_legal_approval.py @@ -42,10 +42,10 @@ def _payload(**updates) -> ProviderLegalApprovalPayload: "key_id": "legal-2026q3", "decision": "approved", "tenant_id": "default", - "approver_id": "legal-reviewer@example.com", - "reviewer": "Named Legal Reviewer", - "role": "Senior Legal Counsel", - "organization": "Yuqei Legal", + "approver_id": "chen-yuqi", + "reviewer": "陈钰麒", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", "approved_at": NOW, "expires_at": NOW + timedelta(days=7), "provider_config_id": "provider-config-1", @@ -80,10 +80,10 @@ def _key_material(*, key_id="legal-2026q3", revoked=False): key_id=key_id, algorithm=LEGAL_APPROVAL_ALGORITHM, public_key_pem=public_pem, - approver_id="legal-reviewer@example.com", - reviewer="Named Legal Reviewer", - role="Senior Legal Counsel", - organization="Yuqei Legal", + approver_id="chen-yuqi", + reviewer="陈钰麒", + role="总经理", + organization="内蒙古钰麒科技有限公司", not_before=NOW - timedelta(days=1), not_after=NOW + timedelta(days=30), revoked=revoked, @@ -169,6 +169,23 @@ def test_tampering_and_trusted_identity_mismatch_fail() -> None: assert "legal approval signature is invalid" in problems +def test_self_consistent_unconfigured_reviewer_is_rejected() -> None: + identity = { + "approver_id": "unconfigured-reviewer", + "reviewer": "Unconfigured Reviewer", + "role": "General Manager", + "organization": "内蒙古钰麒科技有限公司", + } + private_pem, key = _key_material() + key = key.model_copy(update=identity) + approval = sign_legal_approval(_payload(**identity), private_pem) + + problems = _verify(approval, ProviderLegalApprovalKeyring(version=1, keys=[key])) + + assert "legal approval must use the configured sole named reviewer" in problems + assert "legal approval key must belong to the configured sole named reviewer" in problems + + def test_unknown_revoked_and_rotated_keys() -> None: old_private, old_key = _key_material() approval = sign_legal_approval(_payload(), old_private) diff --git a/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py index ba68eac5..5839169a 100644 --- a/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py +++ b/yuqei-ai-platform/services/ai-platform-api/tests/test_release_readiness.py @@ -134,7 +134,7 @@ def _acceptance_manifest(provider_config_id: str, repository: InMemoryAiPlatform suite_manifest_sha256=inventory["suite_manifest_sha256"], knowledge_manifest_sha256=inventory["knowledge_manifest_sha256"], legal_approval_status="approved", - legal_reviewer="李法务", + legal_reviewer="陈钰麒", legal_approved_at=datetime.now(UTC), legal_approval_evidence_ref="https://gitea.example/legal/provider-acceptance.pdf", legal_approval_evidence_sha256=hashlib.sha256(b"named legal approval evidence").hexdigest(), diff --git a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py index 588cdb61..49daa053 100644 --- a/yuqei-infra/deploy/plan-first-release-snapshot-restore.py +++ b/yuqei-infra/deploy/plan-first-release-snapshot-restore.py @@ -22,18 +22,19 @@ from pathlib import Path, PurePosixPath from typing import Any, Callable, Mapping from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -EVIDENCE_SCHEMA = "yuqei.first-release-snapshot-evidence.v1" -KEYRING_SCHEMA = "yuqei.first-release-recovery-keyring.v1" -APPROVAL_SCHEMA = "yuqei.first-release-recovery-approval.v1" +EVIDENCE_SCHEMA = "yuqei.first-release-snapshot-evidence.v2" +KEYRING_SCHEMA = "yuqei.first-release-recovery-keyring.v2" +APPROVAL_SCHEMA = "yuqei.first-release-recovery-approval.v2" NO_PRIOR_RELEASE_SCHEMA = "yuqei.no-prior-successful-release.v1" SNAPSHOT_RECEIPT_SCHEMA = "yuqei.host-snapshot-receipt.v1" BACKUP_MANIFEST_SCHEMA = "yuqei-production-backup.v2" RESTORE_TEST_SCHEMA = "yuqei.first-release-restore-test.v1" -PLAN_SCHEMA = "yuqei.first-release-snapshot-restore-plan.v1" -READINESS_RECEIPT_SCHEMA = "yuqei.first-release-recovery-readiness-receipt.v1" +PLAN_SCHEMA = "yuqei.first-release-snapshot-restore-plan.v2" +READINESS_RECEIPT_SCHEMA = "yuqei.first-release-recovery-readiness-receipt.v2" IMAGE_IDENTITY_SCHEMA = "yuqei.release-image-identity.v2" PACKAGE_SCHEMA = "yuqei.release-package.v2" READ_ONLY_PACKAGE_SCHEMAS = frozenset( @@ -45,7 +46,11 @@ INSTALLABLE_MANIFEST_SCHEMA = "yuqei.installable-release-manifest.v1" RECOVERY_STRATEGY = "first_release_snapshot_restore" RECOVERY_EVIDENCE_STATUS = "runtime_signed_evidence_required" -REQUIRED_ROLES = frozenset({"release_manager", "database_owner"}) +REQUIRED_ROLES = frozenset({"release_owner"}) +REQUIRED_NAMED_REVIEWERS = 1 +SOLE_REVIEWER_ID = "chen-yuqi" +SOLE_REVIEWER_NAME = "陈钰麒" +SOLE_REVIEWER_TITLE = "总经理" REQUIRED_COMPONENTS = ( "host_runtime_state", "postgres_consistent_backup", @@ -697,10 +702,14 @@ def _validate_keyring( if issued > now + MAX_CLOCK_SKEW or now >= expires or expires <= issued: raise SnapshotRestorePlanError("recovery keyring is expired or not yet valid") raw_keys = keyring.get("keys") - if not isinstance(raw_keys, list) or len(raw_keys) < 2: + if not isinstance(raw_keys, list) or not raw_keys: raise SnapshotRestorePlanError("recovery keyring has insufficient keys") result: dict[str, dict[str, Any]] = {} public_keys: set[bytes] = set() + root_public_raw = root_public_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) for raw in raw_keys: item = _exact( raw, @@ -718,6 +727,8 @@ def _validate_keyring( key_id = _identifier(item.get("key_id"), label="key_id") if key_id in result: raise SnapshotRestorePlanError("recovery keyring repeats a key_id") + if _identifier(item.get("approver_id"), label="approver_id") != SOLE_REVIEWER_ID: + raise SnapshotRestorePlanError("recovery keyring contains an unauthorized reviewer") role = item.get("role") if role not in REQUIRED_ROLES: raise SnapshotRestorePlanError("recovery key has an unsupported role") @@ -730,7 +741,11 @@ def _validate_keyring( raise SnapshotRestorePlanError("recovery public key is invalid") from exc if public_raw in public_keys: raise SnapshotRestorePlanError( - "recovery keyring reuses an Ed25519 public key across identities" + "recovery keyring reuses an Ed25519 public key across recovery roles" + ) + if public_raw == root_public_raw: + raise SnapshotRestorePlanError( + "recovery approval key must differ from the offline root key" ) public_keys.add(public_raw) not_before = _timestamp(item.get("not_before"), label="key not_before") @@ -1004,8 +1019,8 @@ def _validate_approvals( evidence_not_before: datetime, now: datetime, ) -> list[dict[str, Any]]: - if not isinstance(value, list) or len(value) != 2: - raise SnapshotRestorePlanError("exactly two recovery approvals are required") + if not isinstance(value, list) or len(value) != 1: + raise SnapshotRestorePlanError("exactly one recovery approval is required") roles: set[str] = set() people: set[str] = set() key_ids: set[str] = set() @@ -1031,6 +1046,8 @@ def _validate_approvals( raise SnapshotRestorePlanError("recovery approvals repeat an approval_id") approval_ids.add(approval_id) person = _identifier(item.get("approver_id"), label="approver_id") + if person != SOLE_REVIEWER_ID: + raise SnapshotRestorePlanError("recovery approval uses the wrong named reviewer") role = item.get("role") if role not in REQUIRED_ROLES: raise SnapshotRestorePlanError("recovery approval role is invalid") @@ -1061,8 +1078,14 @@ def _validate_approvals( people.add(person) key_ids.add(key_id) approvals.append(item) - if roles != REQUIRED_ROLES or len(people) != 2 or len(key_ids) != 2: - raise SnapshotRestorePlanError("recovery approvals require two roles, people, and keys") + if ( + roles != REQUIRED_ROLES + or len(people) != REQUIRED_NAMED_REVIEWERS + or len(key_ids) != len(REQUIRED_ROLES) + ): + raise SnapshotRestorePlanError( + "recovery approval requires the sole release owner and one operational key" + ) return approvals @@ -1239,6 +1262,11 @@ def validate_evidence( "evidence_sha256": restore_sha, "tested_at": restore["tested_at"], }, + "reviewer": { + "approver_id": SOLE_REVIEWER_ID, + "name": SOLE_REVIEWER_NAME, + "title": SOLE_REVIEWER_TITLE, + }, "approvals": approvals, "keyring": { "epoch": keyring["epoch"], @@ -1271,6 +1299,7 @@ def build_plan( trusted_root_public_key_sha256, label="trusted root public key SHA-256" ), "keyring": dict(validated_evidence["keyring"]), + "reviewer": dict(validated_evidence["reviewer"]), "approval_ids": sorted( item["approval_id"] for item in validated_evidence["approvals"] ), @@ -1391,6 +1420,7 @@ def build_install_readiness_receipt( "keyring" ], }, + "reviewer": dict(validated_evidence["reviewer"]), "approvals": [dict(item) for item in validated_evidence["approvals"]], "snapshot": dict(validated_evidence["snapshot"]), "backup": dict(validated_evidence["backup"]), diff --git a/yuqei-infra/deploy/plan-rollback-rehearsal.py b/yuqei-infra/deploy/plan-rollback-rehearsal.py index 478ac4fd..f54a9cf5 100644 --- a/yuqei-infra/deploy/plan-rollback-rehearsal.py +++ b/yuqei-infra/deploy/plan-rollback-rehearsal.py @@ -2,8 +2,8 @@ """Validate trusted rollback evidence and emit a non-writing rehearsal plan. This entry point intentionally has no apply mode. It validates both immutable -release packages, a separately pinned evidence document, and two independent -Ed25519 approvals before producing a plan. It never imports images, invokes +release packages, a separately pinned evidence document, and one Ed25519 +approval by the named release owner before producing a plan. It never imports images, invokes Docker, changes traffic or DNS, or performs a database downgrade. """ @@ -26,19 +26,24 @@ from pathlib import Path, PurePosixPath from typing import Any, Mapping from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey REPO_ROOT = Path(__file__).resolve().parents[2] -EVIDENCE_SCHEMA = "yuqei.rollback-rehearsal-evidence.v3" -KEYRING_SCHEMA = "yuqei.rollback-rehearsal-keyring.v2" -APPROVAL_SCHEMA = "yuqei.rollback-rehearsal-approval.v1" -PLAN_SCHEMA = "yuqei.rollback-rehearsal-plan.v3" +EVIDENCE_SCHEMA = "yuqei.rollback-rehearsal-evidence.v4" +KEYRING_SCHEMA = "yuqei.rollback-rehearsal-keyring.v3" +APPROVAL_SCHEMA = "yuqei.rollback-rehearsal-approval.v2" +PLAN_SCHEMA = "yuqei.rollback-rehearsal-plan.v4" BACKUP_MANIFEST_SCHEMA = "yuqei.rollback-backup-manifest.v2" RESTORE_EVIDENCE_SCHEMA = "yuqei.rollback-restore-evidence.v2" COMPATIBILITY_EVIDENCE_SCHEMA = "yuqei.rollback-compatibility-evidence.v2" COMPOSE_GATE_SCHEMA = "yuqei.production-compose-merge-gate.v1" -REQUIRED_APPROVAL_ROLES = frozenset({"release_manager", "database_owner"}) +REQUIRED_APPROVAL_ROLES = frozenset({"release_owner"}) +REQUIRED_NAMED_REVIEWERS = 1 +SOLE_REVIEWER_ID = "chen-yuqi" +SOLE_REVIEWER_NAME = "陈钰麒" +SOLE_REVIEWER_TITLE = "总经理" MAX_JSON_BYTES = 1024 * 1024 MAX_COMPOSE_GATE_BYTES = 4 * 1024 * 1024 MAX_BACKUP_ARTIFACT_BYTES = 64 * 1024 * 1024 * 1024 @@ -643,6 +648,10 @@ def _validate_keyring( result: dict[str, dict[str, Any]] = {} key_ids: set[str] = set() public_keys: set[bytes] = set() + root_public_raw = trusted_root_public_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) for index, raw in enumerate(keys): item = _require_exact_fields( raw, @@ -662,7 +671,10 @@ def _validate_keyring( if key_id in key_ids: raise RehearsalProofError("Rollback rehearsal keyring contains duplicate key_id values.") key_ids.add(key_id) - _identifier(item.get("approver_id"), label="key approver_id") + if _identifier(item.get("approver_id"), label="key approver_id") != SOLE_REVIEWER_ID: + raise RehearsalProofError( + "Rollback rehearsal keyring contains an unauthorized reviewer." + ) if item.get("role") not in REQUIRED_APPROVAL_ROLES: raise RehearsalProofError("Rollback rehearsal key has an unsupported approval role.") if item.get("algorithm") != "ed25519": @@ -686,7 +698,11 @@ def _validate_keyring( raise RehearsalProofError("Rollback rehearsal public key is invalid.") from exc if public_raw in public_keys: raise RehearsalProofError( - "Rollback rehearsal keyring must not reuse one public key for multiple identities." + "Rollback rehearsal keyring must not reuse one public key across approval roles." + ) + if public_raw == root_public_raw: + raise RehearsalProofError( + "Rollback approval key must differ from the offline root key." ) public_keys.add(public_raw) if item["revoked"] is False: @@ -1068,8 +1084,8 @@ def _validate_approvals( evidence_not_before: datetime, now: datetime, ) -> list[dict[str, Any]]: - if not isinstance(approvals, list) or len(approvals) != len(REQUIRED_APPROVAL_ROLES): - raise RehearsalProofError("Exactly two independent rollback approvals are required.") + if not isinstance(approvals, list) or len(approvals) != 1: + raise RehearsalProofError("Exactly one rollback approval is required.") approved_roles: set[str] = set() approvers: set[str] = set() key_ids: set[str] = set() @@ -1095,6 +1111,8 @@ def _validate_approvals( raise RehearsalProofError("Rollback approval_id values must be unique.") approval_ids.add(approval_id) approver_id = _identifier(item.get("approver_id"), label="approver_id") + if approver_id != SOLE_REVIEWER_ID: + raise RehearsalProofError("Rollback approval uses the wrong named reviewer.") role = item.get("role") if role not in REQUIRED_APPROVAL_ROLES: raise RehearsalProofError("Rollback approval has an unsupported role.") @@ -1130,9 +1148,13 @@ def _validate_approvals( approvers.add(approver_id) key_ids.add(key_id) validated.append(item) - if approved_roles != REQUIRED_APPROVAL_ROLES or len(approvers) != 2 or len(key_ids) != 2: + if ( + approved_roles != REQUIRED_APPROVAL_ROLES + or len(approvers) != REQUIRED_NAMED_REVIEWERS + or len(key_ids) != len(REQUIRED_APPROVAL_ROLES) + ): raise RehearsalProofError( - "Rollback approvals must cover both required roles using two people and two keys." + "Rollback approval must use the sole release owner and one operational key." ) return validated @@ -1253,6 +1275,11 @@ def validate_rehearsal_evidence( }, "backup": dict(backup), "compatibility": dict(compatibility), + "reviewer": { + "approver_id": SOLE_REVIEWER_ID, + "name": SOLE_REVIEWER_NAME, + "title": SOLE_REVIEWER_TITLE, + }, "approvals": approvals, "evidence_payload_sha256": evidence_hash, } @@ -1287,6 +1314,7 @@ def build_rehearsal_plan( ], "trusted_root_key_sha256": trusted_root_key_sha256, "evidence_payload_sha256": validated_evidence["evidence_payload_sha256"], + "reviewer": dict(validated_evidence["reviewer"]), "target": dict(validated_evidence["target"]), "env_file_sha256": validated_evidence["runtime"]["env_file_sha256"], "production_preflight_sha256": validated_evidence["runtime"][ diff --git a/yuqei-infra/deploy/release-package.py b/yuqei-infra/deploy/release-package.py index 46d2aa12..eaa2b95b 100644 --- a/yuqei-infra/deploy/release-package.py +++ b/yuqei-infra/deploy/release-package.py @@ -102,7 +102,7 @@ FIRST_RELEASE_EXTERNAL_EVIDENCE_REQUIREMENTS = ( "signed_no_prior_successful_release_receipt", "preinstall_quiesced_system_and_data_snapshot", "independent_snapshot_restore_test", - "two_person_release_and_database_approval", + "single_named_release_owner_approval", ) CONFIRMATIONS = { diff --git a/yuqei-infra/scripts/build-rc06-publication-batch.py b/yuqei-infra/scripts/build-rc06-publication-batch.py index e29a63c2..84913118 100644 --- a/yuqei-infra/scripts/build-rc06-publication-batch.py +++ b/yuqei-infra/scripts/build-rc06-publication-batch.py @@ -9,6 +9,7 @@ API, a database, a model provider, or a publication endpoint. from __future__ import annotations import argparse +import base64 import csv import hashlib import json @@ -18,27 +19,53 @@ import stat import subprocess import sys import tempfile -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path, PurePath from typing import Any, Iterable, Mapping +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -SCHEMA_VERSION = "yuqei.rc06-publication-batch.v2" -DISPOSITION_SCHEMA_VERSION = "yuqei.rc06-golden-dispositions.v1" + +SCHEMA_VERSION = "yuqei.rc06-publication-batch.v3" +DISPOSITION_SCHEMA_VERSION = "yuqei.rc06-golden-dispositions.v2" IMAGE_SCHEMA_VERSION = "yuqei.rc06-image-identities.v1" -APPROVAL_SCHEMA_VERSION = "yuqei.rc06-publication-item-approval.v2" -APPLY_SCHEMA_VERSION = "yuqei.rc06-publication-item-apply-receipt.v2" +APPROVAL_SCHEMA_VERSION = "yuqei.rc06-publication-batch-approval.v3" +APPROVAL_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-publication-batch-approval-payload.v1" +APPROVAL_KEYRING_SCHEMA_VERSION = "yuqei.rc06-publication-approval-keyring.v2" +APPLY_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-receipt.v3" +APPLY_PAYLOAD_SCHEMA_VERSION = "yuqei.rc06-publication-system-item-apply-payload.v1" +APPROVAL_SIGNATURE_DOMAIN = b"YUQEI_RC06_PUBLICATION_BATCH_APPROVAL_V1\0" +APPROVAL_KEYRING_SIGNATURE_DOMAIN = b"YUQEI_RC06_APPROVAL_KEYRING_V1\0" +APPLY_SIGNATURE_DOMAIN = b"YUQEI_RC06_SYSTEM_APPLY_RECEIPT_V1\0" +TRUSTED_APPROVAL_ROOT_KEY_ID = "yuqei-rc06-approval-root-v1" +# Provision these immutable pins only when the source candidate is frozen. Empty +# values deliberately keep approval/apply validation fail-closed in development. +TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256 = "" +TRUSTED_APPROVAL_KEYRING_PAYLOAD_SHA256 = "" +MIN_APPROVAL_KEYRING_EPOCH = 0 +TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256 = "" RC05_SUMMARY_SCHEMA_VERSION = "multiscene-evaluation-run.v1" RC05_LEDGER_SCHEMA_VERSION = "multiscene-call-ledger.v1" RC05_CAMPAIGN_SCHEMA_VERSION = "multiscene-call-campaign.v1" -# RC-06 does not yet have a trusted item-level signer/verifier or immutable -# runtime Prompt snapshot collector. Keep those gaps machine-readable and -# make every command fail closed until both controls are implemented. +# Each tuple entry is missing evidence, not a request for another human reviewer. +# Validation removes the first two entries as the single-reviewer approval and +# system-generated per-item apply evidence are supplied. Runtime Prompt snapshots +# remain a separate UAT gate. BLOCKED_CONTROLS = ( - "item_level_signed_approval_and_apply_receipts", + "single_reviewer_batch_approval_receipt", + "system_generated_item_apply_receipts_and_online_snapshots", "runtime_executable_prompt_snapshots_1_drafting_plus_8_review", ) +SOLE_RELEASE_REVIEWER = { + "approver_id": "chen-yuqi", + "name": "陈钰麒", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", +} +MAX_APPROVAL_WINDOW = timedelta(days=7) RC05_HISTORICAL_ATTEMPT_KEYS = { "attempt_id", "plan_sha256", "case_id", "operation", "provider_status", "business_status", "elapsed_seconds", "timed_out", @@ -1860,12 +1887,14 @@ def _empty_disposition(case: Mapping[str, Any]) -> dict[str, Any]: def _validate_decision_maker(value: Any, label: str) -> dict[str, str]: - row = _require_keys(value, label, ("name", "role", "organization")) - return { - "name": _require_string(row["name"], f"{label}.name", max_length=256), - "role": _require_string(row["role"], f"{label}.role", max_length=256), - "organization": _require_string(row["organization"], f"{label}.organization", max_length=256), + row = _require_keys(value, label, ("approver_id", "name", "role", "organization")) + normalized = { + field: _require_string(row[field], f"{label}.{field}", max_length=256) + for field in ("approver_id", "name", "role", "organization") } + if normalized != SOLE_RELEASE_REVIEWER: + raise PublicationBatchError("Golden dispositions must use the configured sole release reviewer") + return normalized def validate_disposition(value: Any, case: Mapping[str, Any]) -> dict[str, Any]: @@ -1902,8 +1931,8 @@ def validate_disposition(value: Any, case: Mapping[str, Any]) -> dict[str, Any]: raise PublicationBatchError("unresolved disposition must not contain a decision or evidence") return dict(row) - if row["human_confirmed"] is not True or row["decision_source"] != "external_human_record": - raise PublicationBatchError("resolved disposition requires an external human record") + if row["human_confirmed"] is not True or row["decision_source"] != "single_reviewer_batch": + raise PublicationBatchError("resolved disposition must be included in the sole reviewer batch") maker = _validate_decision_maker(row["decision_maker"], "decision maker") decided = _parse_time(row["decided_at"], "decided_at") evidence = _require_sha(row["evidence_sha256"], "disposition evidence SHA-256") @@ -1919,6 +1948,8 @@ def validate_disposition(value: Any, case: Mapping[str, Any]) -> dict[str, Any]: ("owner", "scope", "expires_at", "controls", "use_limits"), ) owner = _require_string(risk["owner"], "risk acceptance owner", max_length=256) + if owner != SOLE_RELEASE_REVIEWER["name"]: + raise PublicationBatchError("risk acceptance owner must be the configured sole release reviewer") scope = _require_string(risk["scope"], "risk acceptance scope", max_length=4000) expires = _parse_time(risk["expires_at"], "risk acceptance expires_at") if expires <= decided: @@ -2032,7 +2063,7 @@ def assemble_manifest( "golden_dispositions_complete": unresolved == 0, "approval_receipt_attached": False, "apply_receipt_attached": False, - "item_level_signed_receipts_complete": False, + "single_reviewer_batch_and_system_item_receipts_complete": False, "runtime_executable_prompt_snapshots_complete": False, "blocked_controls": list(BLOCKED_CONTROLS), "release_ready": False, @@ -2050,7 +2081,9 @@ def assemble_manifest( "This offline manifest is not legal approval and does not fabricate a signature.", "This offline manifest does not authorize or perform publication.", "The separate 46 default Prompt assets are not included in the 14-module batch.", - "Item-level signed approval/apply evidence is not implemented and remains blocking.", + "No reviewer approval or apply evidence is embedded in this offline manifest.", + "One signed batch approval must bind all 60 assets, 14 Prompt modules, and 31 dispositions.", + "The apply service must emit 74 machine-verifiable item results and online hashes.", "One drafting plus eight review executable runtime Prompt snapshots are not implemented and remain blocking.", ], } @@ -2310,7 +2343,7 @@ def validate_manifest(payload: Any) -> dict[str, Any]: expected_control_keys = { "rc05_terminal_evidence_complete", "golden_dispositions_complete", "approval_receipt_attached", "apply_receipt_attached", - "item_level_signed_receipts_complete", + "single_reviewer_batch_and_system_item_receipts_complete", "runtime_executable_prompt_snapshots_complete", "blocked_controls", "release_ready", "approval_receipt_schema", "apply_receipt_schema", "apply_performed_by_this_tool", @@ -2323,7 +2356,7 @@ def validate_manifest(payload: Any) -> dict[str, Any]: or control["rc05_terminal_evidence_complete"] is not True or control["approval_receipt_attached"] is not False or control["apply_receipt_attached"] is not False - or control["item_level_signed_receipts_complete"] is not False + or control["single_reviewer_batch_and_system_item_receipts_complete"] is not False or control["runtime_executable_prompt_snapshots_complete"] is not False or control["blocked_controls"] != list(BLOCKED_CONTROLS) or control["release_ready"] is not False @@ -2343,32 +2376,195 @@ def validate_manifest(payload: Any) -> dict[str, Any]: return manifest -def validate_approval_receipt(manifest: Mapping[str, Any], receipt: Any) -> dict[str, Any]: - raise PublicationBatchError( - "RC-06 item-level signed approval receipts are not implemented; release remains blocked" +def _require_sole_release_reviewer(value: Any, label: str) -> dict[str, Any]: + reviewer = _require_keys( + value, + label, + ("approver_id", "name", "role", "organization", "human_confirmed"), ) - # Retained schema code below is intentionally unreachable until a trusted - # signer, per-item preview evidence, and authorization verifier exist. - row = _require_keys( - receipt, - "approval receipt", + actual = { + field: _require_string(reviewer[field], f"{label} {field}", max_length=256) + for field in ("approver_id", "name", "role", "organization") + } + if actual != SOLE_RELEASE_REVIEWER: + raise PublicationBatchError(f"{label} must be the configured sole release reviewer") + if reviewer["human_confirmed"] is not True: + raise PublicationBatchError("AI or automated self-approval is forbidden") + return reviewer + + +def _public_key_raw(public_key: Ed25519PublicKey) -> bytes: + return public_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + + +def _read_pinned_raw_public_key(path: Path, *, expected_sha256: str, label: str) -> Ed25519PublicKey: + if not HEX64_RE.fullmatch(expected_sha256): + raise PublicationBatchError(f"{label} pin is not provisioned in the frozen source") + raw = secure_read_bytes(path, label, maximum_bytes=32) + if len(raw) != 32 or _sha256_bytes(raw) != expected_sha256: + raise PublicationBatchError(f"{label} does not match the frozen trust anchor") + try: + return Ed25519PublicKey.from_public_bytes(raw) + except ValueError as exc: + raise PublicationBatchError(f"{label} is not a valid Ed25519 public key") from exc + + +def _validate_approval_keyring( + keyring: Any, + *, + root_public_key: Ed25519PublicKey, + approved_at: datetime, +) -> list[dict[str, Any]]: + trusted = _require_keys( + keyring, + "approval keyring", ( - "schema_version", "batch_manifest_payload_sha256", "decision", "approved_at", - "approver", "asset_payload_sha256s", "prompt_module_payload_sha256s", - "golden_disposition_inventory_sha256", "evidence_sha256", + "schema_version", "root_key_id", "epoch", "issued_at", "expires_at", "keys", + "keyring_payload_sha256", "root_signature_base64", ), ) - if row["schema_version"] != APPROVAL_SCHEMA_VERSION or row["decision"] != "approved": - raise PublicationBatchError("approval receipt is not an approved RC-06 receipt") - if row["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]: + if trusted["schema_version"] != APPROVAL_KEYRING_SCHEMA_VERSION: + raise PublicationBatchError("approval keyring schema is unsupported") + unsigned = { + field: trusted[field] + for field in ( + "schema_version", "root_key_id", "epoch", "issued_at", "expires_at", "keys" + ) + } + payload_sha256 = _require_sha( + trusted["keyring_payload_sha256"], + "approval keyring payload SHA-256", + ) + if _sha256_json(unsigned) != payload_sha256: + raise PublicationBatchError("approval keyring payload hash drift") + if not HEX64_RE.fullmatch(TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256): + raise PublicationBatchError("approval root public key pin is not provisioned") + if _sha256_bytes(_public_key_raw(root_public_key)) != TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256: + raise PublicationBatchError("approval root public key does not match the frozen pin") + if not HEX64_RE.fullmatch(TRUSTED_APPROVAL_KEYRING_PAYLOAD_SHA256): + raise PublicationBatchError("approval keyring payload pin is not provisioned") + if payload_sha256 != TRUSTED_APPROVAL_KEYRING_PAYLOAD_SHA256: + raise PublicationBatchError("approval keyring is not the currently trusted revocation state") + if trusted["root_key_id"] != TRUSTED_APPROVAL_ROOT_KEY_ID: + raise PublicationBatchError("approval keyring uses the wrong root key") + epoch = trusted["epoch"] + if ( + not isinstance(epoch, int) + or isinstance(epoch, bool) + or epoch < 1 + or not isinstance(MIN_APPROVAL_KEYRING_EPOCH, int) + or isinstance(MIN_APPROVAL_KEYRING_EPOCH, bool) + or MIN_APPROVAL_KEYRING_EPOCH < 1 + ): + raise PublicationBatchError("approval keyring epoch policy is not provisioned") + if epoch < MIN_APPROVAL_KEYRING_EPOCH: + raise PublicationBatchError("approval keyring epoch is older than the trusted minimum") + issued_at = _parse_time(trusted["issued_at"], "approval keyring issued_at") + expires_at = _parse_time(trusted["expires_at"], "approval keyring expires_at") + if expires_at <= issued_at or approved_at < issued_at or approved_at >= expires_at: + raise PublicationBatchError("approval keyring is not valid at approval time") + try: + signature = base64.b64decode(trusted["root_signature_base64"], validate=True) + root_public_key.verify( + signature, + APPROVAL_KEYRING_SIGNATURE_DOMAIN + _canonical(unsigned), + ) + except (TypeError, ValueError, InvalidSignature) as exc: + raise PublicationBatchError("approval keyring root signature is invalid") from exc + keys = trusted["keys"] + if not isinstance(keys, list) or not keys: + raise PublicationBatchError("approval keyring keys are invalid") + root_raw = _public_key_raw(root_public_key) + validated: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + seen_public_keys: set[bytes] = set() + for value in keys: + key = _require_keys( + value, + "approval key", + ( + "key_id", "algorithm", "public_key_pem", "approver_id", "name", "role", + "organization", "not_before", "not_after", "revoked", + ), + ) + key_id = _require_string(key["key_id"], "approval key_id", max_length=128) + if key_id in seen_ids: + raise PublicationBatchError("approval keyring repeats a key_id") + seen_ids.add(key_id) + if key["algorithm"] != "Ed25519": + raise PublicationBatchError("approval key uses an unsupported algorithm") + if key["revoked"] is not False: + raise PublicationBatchError("revoked approval keys are forbidden") + key_reviewer = { + field: key[field] + for field in ("approver_id", "name", "role", "organization") + } + if key_reviewer != SOLE_RELEASE_REVIEWER: + raise PublicationBatchError("approval key must belong to the configured sole release reviewer") + public_pem = _require_string(key["public_key_pem"], "approval public key", max_length=16384) + try: + public_key = serialization.load_pem_public_key(public_pem.encode("ascii")) + except (UnicodeError, ValueError) as exc: + raise PublicationBatchError("approval public key is invalid") from exc + if not isinstance(public_key, Ed25519PublicKey): + raise PublicationBatchError("approval public key must be Ed25519") + public_raw = _public_key_raw(public_key) + if public_raw == root_raw: + raise PublicationBatchError("approval key must differ from the offline root key") + if public_raw in seen_public_keys: + raise PublicationBatchError("approval keyring reuses an Ed25519 public key") + seen_public_keys.add(public_raw) + not_before = _parse_time(key["not_before"], "approval key not_before") + not_after = _parse_time(key["not_after"], "approval key not_after") + if not_after <= not_before: + raise PublicationBatchError("approval key validity window is invalid") + validated.append( + { + **key, + "public_key": public_key, + "public_key_raw": public_raw, + "not_before_parsed": not_before, + "not_after_parsed": not_after, + } + ) + return validated + + +def validate_approval_receipt( + manifest: Mapping[str, Any], + receipt: Any, + keyring: Any, + root_public_key: Ed25519PublicKey, +) -> dict[str, Any]: + envelope = _require_keys( + receipt, + "approval receipt", + ("schema_version", "approval_id", "payload", "signature"), + ) + if envelope["schema_version"] != APPROVAL_SCHEMA_VERSION: + raise PublicationBatchError("approval receipt schema is unsupported") + payload = _require_keys( + envelope["payload"], + "approval payload", + ( + "schema_version", "batch_manifest_payload_sha256", "decision", "approved_at", + "expires_at", "key_id", "approver", "asset_payload_sha256s", + "prompt_module_payload_sha256s", "golden_disposition_inventory_sha256", + "evidence_sha256", + ), + ) + if payload["schema_version"] != APPROVAL_PAYLOAD_SCHEMA_VERSION or payload["decision"] != "approved": + raise PublicationBatchError("approval receipt is not an approved RC-06 batch receipt") + if payload["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]: raise PublicationBatchError("approval receipt is not bound to this batch") - _parse_time(row["approved_at"], "approval approved_at") - approver = _require_keys(row["approver"], "approval approver", ("name", "role", "organization", "human_confirmed")) - _require_string(approver["name"], "approval approver name", max_length=256) - _require_string(approver["role"], "approval approver role", max_length=256) - _require_string(approver["organization"], "approval approver organization", max_length=256) - if approver["human_confirmed"] is not True: - raise PublicationBatchError("AI or automated self-approval is forbidden") + approved_at = _parse_time(payload["approved_at"], "approval approved_at") + expires_at = _parse_time(payload["expires_at"], "approval expires_at") + if expires_at <= approved_at or expires_at - approved_at > MAX_APPROVAL_WINDOW: + raise PublicationBatchError("approval validity must be greater than zero and no longer than seven days") + _require_sole_release_reviewer(payload["approver"], "approval approver") expected_assets = { item["template_id"]: item["asset_payload_sha256"] for item in manifest["asset_batch"]["items"] @@ -2377,71 +2573,156 @@ def validate_approval_receipt(manifest: Mapping[str, Any], receipt: Any) -> dict item["key"]: item["module_payload_sha256"] for item in manifest["prompt_module_batch"]["items"] } - if row["asset_payload_sha256s"] != expected_assets: + if payload["asset_payload_sha256s"] != expected_assets: raise PublicationBatchError("approval receipt asset identities drift") - if row["prompt_module_payload_sha256s"] != expected_modules: + if payload["prompt_module_payload_sha256s"] != expected_modules: raise PublicationBatchError("approval receipt Prompt identities drift") - if row["golden_disposition_inventory_sha256"] != manifest["golden_dispositions"]["inventory_sha256"]: + if payload["golden_disposition_inventory_sha256"] != manifest["golden_dispositions"]["inventory_sha256"]: raise PublicationBatchError("approval receipt Golden disposition identity drift") - _require_sha(row["evidence_sha256"], "approval evidence SHA-256") + _require_sha(payload["evidence_sha256"], "approval evidence SHA-256") if not manifest["publication_control"]["golden_dispositions_complete"]: raise PublicationBatchError("approval cannot release a batch with unresolved Golden dispositions") - _assert_no_secret_material(row, "approval receipt") - return row + + trusted_keys = _validate_approval_keyring( + keyring, + root_public_key=root_public_key, + approved_at=approved_at, + ) + key_id = _require_string(payload["key_id"], "approval key_id", max_length=128) + matches = [ + item + for item in trusted_keys + if item["key_id"] == key_id + ] + if len(matches) != 1: + raise PublicationBatchError("approval key must resolve uniquely") + key = matches[0] + if approved_at < key["not_before_parsed"] or expires_at > key["not_after_parsed"]: + raise PublicationBatchError("approval validity is outside the trusted key window") + public_key = key["public_key"] + try: + signature = base64.b64decode( + _require_string(envelope["signature"], "approval signature", max_length=1024), + validate=True, + ) + public_key.verify(signature, APPROVAL_SIGNATURE_DOMAIN + _canonical(payload)) + except Exception as exc: + raise PublicationBatchError("approval signature verification failed") from exc + expected_id = f"rc06-batch-approval-{_sha256_bytes(_canonical(payload) + signature)[:24]}" + if envelope["approval_id"] != expected_id: + raise PublicationBatchError("approval_id does not match its signed material") + _assert_no_secret_material(envelope, "approval receipt") + return envelope def validate_apply_receipt( manifest: Mapping[str, Any], approval: Mapping[str, Any], + approval_keyring: Mapping[str, Any], + approval_root_public_key: Ed25519PublicKey, + apply_service_public_key: Ed25519PublicKey, approval_raw_sha256: str, receipt: Any, ) -> dict[str, Any]: - raise PublicationBatchError( - "RC-06 item-level signed apply receipts and online snapshots are not implemented; " - "release remains blocked" + validate_approval_receipt( + manifest, + approval, + approval_keyring, + approval_root_public_key, ) - # Retained schema code below is intentionally unreachable until apply - # responses are collected from an isolated UAT and cryptographically bound. - row = _require_keys( + envelope = _require_keys( receipt, "apply receipt", + ("schema_version", "receipt_id", "payload", "signature"), + ) + if envelope["schema_version"] != APPLY_SCHEMA_VERSION: + raise PublicationBatchError("apply receipt schema is unsupported") + row = _require_keys( + envelope["payload"], + "apply receipt payload", ( "schema_version", "batch_manifest_payload_sha256", "approval_receipt_sha256", - "environment_id", "applied_at", "applied_by", "asset_online_sha256s", - "prompt_module_online_sha256s", "write_count", "status", + "environment_id", "applied_at", "generated_by", "item_results", "write_count", + "status", ), ) - if row["schema_version"] != APPLY_SCHEMA_VERSION or row["status"] != "applied": + if row["schema_version"] != APPLY_PAYLOAD_SCHEMA_VERSION or row["status"] != "applied_and_verified": raise PublicationBatchError("apply receipt is not a successful RC-06 receipt") if row["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]: raise PublicationBatchError("apply receipt is not bound to this batch") if row["approval_receipt_sha256"] != _require_sha(approval_raw_sha256, "approval raw SHA-256"): raise PublicationBatchError("apply receipt is not bound to the approval receipt") _require_string(row["environment_id"], "apply environment_id", max_length=256) - _parse_time(row["applied_at"], "apply applied_at") - actor = _require_keys(row["applied_by"], "apply actor", ("name", "role", "human_confirmed")) - _require_string(actor["name"], "apply actor name", max_length=256) - _require_string(actor["role"], "apply actor role", max_length=256) - if actor["human_confirmed"] is not True: - raise PublicationBatchError("automated or AI self-asserted apply actor is forbidden") - expected_assets = { - item["template_id"]: item["content_sha256"] + applied_at = _parse_time(row["applied_at"], "apply applied_at") + approval_payload = approval["payload"] + if applied_at < _parse_time(approval_payload["approved_at"], "approval approved_at") or applied_at > _parse_time( + approval_payload["expires_at"], "approval expires_at" + ): + raise PublicationBatchError("apply receipt was generated outside the approval window") + actor = _require_keys(row["generated_by"], "apply generator", ("type", "component")) + if actor["type"] != "system": + raise PublicationBatchError("per-item apply evidence must be system generated") + _require_string(actor["component"], "apply generator component", max_length=256) + expected_results = [ + { + "kind": "asset", + "item_id": item["template_id"], + "expected_content_sha256": item["content_sha256"], + "online_content_sha256": item["content_sha256"], + "status": "applied_and_verified", + } for item in manifest["asset_batch"]["items"] - } - expected_modules = { - item["key"]: item["content_sha256"] + ] + [ + { + "kind": "prompt_module", + "item_id": item["key"], + "expected_content_sha256": item["content_sha256"], + "online_content_sha256": item["content_sha256"], + "status": "applied_and_verified", + } for item in manifest["prompt_module_batch"]["items"] - } - if row["asset_online_sha256s"] != expected_assets: - raise PublicationBatchError("apply receipt asset online hashes drift") - if row["prompt_module_online_sha256s"] != expected_modules: - raise PublicationBatchError("apply receipt Prompt online hashes drift") + ] + expected_results.sort(key=lambda item: (item["kind"], item["item_id"])) + if row["item_results"] != expected_results: + raise PublicationBatchError("apply receipt 74-item online evidence drift") if row["write_count"] != EXPECTED_ASSET_COUNT + EXPECTED_MODULE_COUNT: raise PublicationBatchError("apply receipt write count must be exactly 74") - if approval["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]: + if approval_payload["batch_manifest_payload_sha256"] != manifest["manifest_payload_sha256"]: raise PublicationBatchError("approval/apply batch identity mismatch") - _assert_no_secret_material(row, "apply receipt") - return row + if not HEX64_RE.fullmatch(TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256): + raise PublicationBatchError("apply service public key pin is not provisioned") + service_public_raw = _public_key_raw(apply_service_public_key) + if _sha256_bytes(service_public_raw) != TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256: + raise PublicationBatchError("apply service public key does not match the frozen pin") + if service_public_raw == _public_key_raw(approval_root_public_key): + raise PublicationBatchError("apply service key must differ from the approval root key") + for key in approval_keyring["keys"]: + try: + approval_public_key = serialization.load_pem_public_key( + key["public_key_pem"].encode("ascii") + ) + except (KeyError, AttributeError, UnicodeError, ValueError) as exc: + raise PublicationBatchError("approval keyring public key is invalid") from exc + if isinstance(approval_public_key, Ed25519PublicKey) and service_public_raw == _public_key_raw( + approval_public_key + ): + raise PublicationBatchError("apply service key must differ from reviewer approval keys") + try: + signature = base64.b64decode( + _require_string(envelope["signature"], "apply receipt signature", max_length=1024), + validate=True, + ) + apply_service_public_key.verify( + signature, + APPLY_SIGNATURE_DOMAIN + _canonical(row), + ) + except (TypeError, ValueError, InvalidSignature) as exc: + raise PublicationBatchError("apply service signature verification failed") from exc + expected_id = f"rc06-apply-receipt-{_sha256_bytes(_canonical(row) + signature)[:24]}" + if envelope["receipt_id"] != expected_id: + raise PublicationBatchError("apply receipt_id does not match its signed material") + _assert_no_secret_material(envelope, "apply receipt") + return envelope def _load_manifest(path: Path, expected_sha256: str | None) -> tuple[dict[str, Any], str]: @@ -2592,15 +2873,22 @@ def main(argv: list[str] | None = None) -> int: approval_parser.add_argument("--manifest", type=Path, required=True) approval_parser.add_argument("--manifest-sha256") approval_parser.add_argument("--approval", type=Path, required=True) - approval_parser.add_argument("--approval-sha256") + approval_parser.add_argument("--approval-sha256", required=True) + approval_parser.add_argument("--approval-keyring", type=Path, required=True) + approval_parser.add_argument("--approval-keyring-sha256", required=True) + approval_parser.add_argument("--approval-root-public-key", type=Path, required=True) apply_parser = subparsers.add_parser("validate-apply", help="validate a separate apply receipt") apply_parser.add_argument("--manifest", type=Path, required=True) apply_parser.add_argument("--manifest-sha256") apply_parser.add_argument("--approval", type=Path, required=True) apply_parser.add_argument("--approval-sha256", required=True) + apply_parser.add_argument("--approval-keyring", type=Path, required=True) + apply_parser.add_argument("--approval-keyring-sha256", required=True) + apply_parser.add_argument("--approval-root-public-key", type=Path, required=True) + apply_parser.add_argument("--apply-service-public-key", type=Path, required=True) apply_parser.add_argument("--apply-receipt", type=Path, required=True) - apply_parser.add_argument("--apply-receipt-sha256") + apply_parser.add_argument("--apply-receipt-sha256", required=True) args = parser.parse_args(argv) try: @@ -2620,13 +2908,28 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "validate-approval": manifest, _ = _load_manifest(args.manifest, args.manifest_sha256) approval, raw_sha = secure_read_json(args.approval, "approval receipt", args.approval_sha256) - validate_approval_receipt(manifest, approval) + approval_keyring, _ = secure_read_json( + args.approval_keyring, + "approval keyring", + args.approval_keyring_sha256, + ) + approval_root_public_key = _read_pinned_raw_public_key( + args.approval_root_public_key, + expected_sha256=TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256, + label="approval root public key", + ) + validate_approval_receipt( + manifest, + approval, + approval_keyring, + approval_root_public_key, + ) result = { "structurally_valid": True, "approval_raw_sha256": raw_sha, "evidence_complete": False, "release_ready": False, - "blocked_controls": list(BLOCKED_CONTROLS), + "blocked_controls": list(BLOCKED_CONTROLS[1:]), "service_writes": 0, } else: @@ -2634,17 +2937,45 @@ def main(argv: list[str] | None = None) -> int: approval, approval_raw = secure_read_json( args.approval, "approval receipt", args.approval_sha256 ) - validate_approval_receipt(manifest, approval) + approval_keyring, _ = secure_read_json( + args.approval_keyring, + "approval keyring", + args.approval_keyring_sha256, + ) + approval_root_public_key = _read_pinned_raw_public_key( + args.approval_root_public_key, + expected_sha256=TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256, + label="approval root public key", + ) + apply_service_public_key = _read_pinned_raw_public_key( + args.apply_service_public_key, + expected_sha256=TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256, + label="apply service public key", + ) + validate_approval_receipt( + manifest, + approval, + approval_keyring, + approval_root_public_key, + ) apply_receipt, apply_raw = secure_read_json( args.apply_receipt, "apply receipt", args.apply_receipt_sha256 ) - validate_apply_receipt(manifest, approval, approval_raw, apply_receipt) + validate_apply_receipt( + manifest, + approval, + approval_keyring, + approval_root_public_key, + apply_service_public_key, + approval_raw, + apply_receipt, + ) result = { "structurally_valid": True, "apply_receipt_raw_sha256": apply_raw, "evidence_complete": False, "release_ready": False, - "blocked_controls": list(BLOCKED_CONTROLS), + "blocked_controls": list(BLOCKED_CONTROLS[2:]), "service_writes": 0, } except PublicationBatchError as exc: diff --git a/yuqei-infra/scripts/prepare-policy-legal-approval.py b/yuqei-infra/scripts/prepare-policy-legal-approval.py index f1c04a02..503aa2d3 100644 --- a/yuqei-infra/scripts/prepare-policy-legal-approval.py +++ b/yuqei-infra/scripts/prepare-policy-legal-approval.py @@ -22,18 +22,18 @@ PACKAGE_SCHEMA = "yuqei-public-policy-review-package-v1" PENDING_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-pending-v1" APPROVED_PAYLOAD_SCHEMA = "yuqei-public-policy-approval-payload-v1" APPROVAL_SCHEMA = "yuqei-public-policy-legal-approval-v1" -KEYRING_SCHEMA = "yuqei-public-policy-legal-keyring-v1" -TRUST_REQUEST_SCHEMA = "yuqei-public-policy-key-trust-request-v1" -TRUST_APPROVAL_PAYLOAD_SCHEMA = "yuqei-public-policy-key-trust-approval-payload-v1" -TRUST_APPROVAL_SCHEMA = "yuqei-public-policy-key-trust-approval-v1" +KEYRING_SCHEMA = "yuqei-public-policy-legal-keyring-v2" +TRUST_REQUEST_SCHEMA = "yuqei-public-policy-key-trust-request-v2" +TRUST_APPROVAL_PAYLOAD_SCHEMA = "yuqei-public-policy-key-trust-approval-payload-v2" +TRUST_APPROVAL_SCHEMA = "yuqei-public-policy-key-trust-approval-v2" SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_LEGAL_APPROVAL_V1\0" -TRUST_SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_KEY_TRUST_APPROVAL_V1\0" +TRUST_SIGNATURE_DOMAIN = b"YUQEI_PUBLIC_POLICY_KEY_TRUST_APPROVAL_V2\0" SIGN_CONFIRMATION = "I_APPROVE_THIS_EXACT_POLICY_PAYLOAD" TRUST_SIGN_CONFIRMATION = "I_APPROVE_THIS_EXACT_POLICY_KEY_TRUST_PAYLOAD" TRUST_REVIEW_ATTESTATIONS = [ - "verified_legal_approver_identity_and_authority_out_of_band", + "confirmed_same_named_reviewer_identity_and_authority", "verified_legal_signing_key_fingerprint_out_of_band", - "accepts_independent_security_or_operations_review_responsibility", + "accepts_separate_key_security_or_operations_responsibility", ] EXPECTED_POLICY_IDS = { "user_terms", @@ -47,6 +47,12 @@ MAX_APPROVAL_WINDOW = timedelta(days=7) SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") KEY_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$") PROHIBITED_MARKERS = ("TODO", "TBD", "产品草案", "待补充", "待确认") +SOLE_POLICY_REVIEWER = { + "approver_id": "chen-yuqi", + "reviewer": "陈钰麒", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", +} class PolicyApprovalError(ValueError): @@ -243,6 +249,8 @@ def build_review_package( "role": _nonempty(role, "role"), "organization": _nonempty(organization, "organization"), } + if identity != SOLE_POLICY_REVIEWER: + raise PolicyApprovalError("policy approval must use the configured sole named reviewer") if identity["organization"] != manifest["publisher"]["legal_name"]: raise PolicyApprovalError("approver organization must match the policy publisher") @@ -427,6 +435,7 @@ def verify_approval( request = payload.get("approval_request") if not isinstance(request, dict): raise PolicyApprovalError("policy approval_request is invalid") + _require_sole_policy_reviewer(request, label="policy approval") expected_review_package = { "schema_version": PACKAGE_SCHEMA, "status": "pending_named_legal_review", @@ -470,6 +479,7 @@ def verify_approval( if len(matches) != 1: raise PolicyApprovalError("policy signing key must resolve uniquely") entry = matches[0] + _require_sole_policy_reviewer(entry, label="policy keyring entry") if entry.get("revoked") is not False: raise PolicyApprovalError("policy signing key is revoked") key_not_before = parse_timestamp(_required_text(entry, "not_before"), label="key not_before") @@ -509,7 +519,7 @@ def verify_approval( def verify_trusted_keyring_basis(keyring: dict[str, Any], *, approval: dict[str, Any]) -> None: basis = keyring.get("trust_basis") if not isinstance(basis, dict): - raise PolicyApprovalError("trusted policy keyring lacks an independently signed trust basis") + raise PolicyApprovalError("trusted policy keyring lacks a signed single-reviewer trust basis") if basis.get("policy_activation_authorized") is not False: raise PolicyApprovalError("trusted policy keyring must not authorize policy activation") trust_approval = basis.get("trust_approval") @@ -523,6 +533,8 @@ def verify_trusted_keyring_basis(keyring: dict[str, Any], *, approval: dict[str, legal_approver = payload.get("legal_approver") if not isinstance(decision, dict) or not isinstance(trust_approver, dict) or not isinstance(legal_approver, dict): raise PolicyApprovalError("trusted policy keyring trust bindings are invalid") + _require_sole_policy_reviewer(legal_approver, label="trusted legal approval") + _require_sole_policy_reviewer(trust_approver, label="trusted key confirmation") if decision.get("decision") != "approve_policy_signing_key_trust": raise PolicyApprovalError("trusted policy keyring trust decision is invalid") if decision.get("policy_activation_authorized") is not False: @@ -553,10 +565,18 @@ def verify_trusted_keyring_basis(keyring: dict[str, Any], *, approval: dict[str, if not isinstance(keys, list) or len(keys) != 1 or not isinstance(keys[0], dict): raise PolicyApprovalError("trusted policy keyring candidate key set is invalid") legal_key = keys[0] - if trust_approver.get("approver_id") == legal_key.get("approver_id"): - raise PolicyApprovalError("trusted policy keyring approvers are not independent") + _require_sole_policy_reviewer(legal_key, label="trusted policy keyring entry") + for field in ("approver_id", "reviewer", "role", "organization"): + if legal_approver.get(field) != legal_key.get(field): + raise PolicyApprovalError( + f"trusted policy keyring legal approval identity mismatch: {field}" + ) + if trust_approver.get(field) != legal_key.get(field): + raise PolicyApprovalError( + f"trusted policy keyring does not use the same named reviewer: {field}" + ) if trust_approver.get("public_key_raw_sha256") == legal_key.get("public_key_raw_sha256"): - raise PolicyApprovalError("trusted policy keyring approval keys are not independent") + raise PolicyApprovalError("trusted policy keyring must use a separate trust key") public_pem = basis.get("trust_approver_public_key_pem") if not isinstance(public_pem, str) or not public_pem.strip(): raise PolicyApprovalError("trusted policy keyring trust approver public key is invalid") @@ -605,7 +625,7 @@ def build_trust_request( decision = payload["decision"] return { "schema_version": TRUST_REQUEST_SCHEMA, - "status": "pending_independent_trust_approval", + "status": "pending_single_reviewer_trust_approval", "requested_at": requested_at, "activation_authorized": False, "approval_id": verified["approval_id"], @@ -631,11 +651,11 @@ def build_trust_request( "public_key_raw_sha256", ) }, - "required_independent_actions": [ - "verify_approver_identity_and_authority_out_of_band", + "required_reviewer_actions": [ + "confirm_the_named_reviewer_identity_and_authority", "verify_public_key_fingerprint_out_of_band", - "record_named_security_or_operations_approval", - "install_a_separately_approved_trusted_keyring", + "record_one_named_security_or_operations_confirmation", + "install_a_separate-key_trusted_keyring", "rerun_policy_approval_verification_with_candidate_override_disabled", ], "prohibited_automatic_actions": [ @@ -658,8 +678,8 @@ def validate_trust_request( ) -> dict[str, Any]: if trust_request.get("schema_version") != TRUST_REQUEST_SCHEMA: raise PolicyApprovalError("policy trust request schema_version is invalid") - if trust_request.get("status") != "pending_independent_trust_approval": - raise PolicyApprovalError("policy trust request is not pending independent approval") + if trust_request.get("status") != "pending_single_reviewer_trust_approval": + raise PolicyApprovalError("policy trust request is not pending named-reviewer approval") if trust_request.get("activation_authorized") is not False: raise PolicyApprovalError("policy trust request must not authorize activation") requested_at = _required_text(trust_request, "requested_at") @@ -733,20 +753,25 @@ def build_trust_approval_payload( "role": _nonempty(role, "trust role"), "organization": _nonempty(organization, "trust organization"), } + _require_sole_policy_reviewer(identity, label="policy trust confirmation") responsibility = _nonempty(responsibility, "trust responsibility").lower() if responsibility not in {"security", "operations"}: raise PolicyApprovalError("trust responsibility must be security or operations") legal_key = request.get("key") if not isinstance(legal_key, dict): raise PolicyApprovalError("policy trust request legal key binding is invalid") + _require_sole_policy_reviewer(legal_key, label="policy trust request") public_raw_sha256 = sha256_bytes(public_raw) - if identity["approver_id"] == legal_key.get("approver_id"): - raise PolicyApprovalError("trust approver must differ from the legal approver") + for field in ("approver_id", "reviewer", "role", "organization"): + if identity[field] != legal_key.get(field): + raise PolicyApprovalError( + f"trust confirmation must use the same named reviewer: {field}" + ) if public_raw_sha256 == legal_key.get("public_key_raw_sha256"): raise PolicyApprovalError("trust approver key must differ from the legal approval key") return { "schema_version": TRUST_APPROVAL_PAYLOAD_SCHEMA, - "status": "pending_independent_trust_signature", + "status": "pending_single_reviewer_trust_signature", "trust_request_sha256": sha256_bytes(canonical_json(request)), "policy_approval_id": request["approval_id"], "policy_approval_sha256": request["approval_sha256"], @@ -790,7 +815,7 @@ def sign_trust_approval_payload( raise PolicyApprovalError(f"trust signing requires confirmation {TRUST_SIGN_CONFIRMATION}") if payload.get("schema_version") != TRUST_APPROVAL_PAYLOAD_SCHEMA: raise PolicyApprovalError("trust approval payload schema_version is invalid") - if payload.get("status") != "pending_independent_trust_signature": + if payload.get("status") != "pending_single_reviewer_trust_signature": raise PolicyApprovalError("only a pending trust approval payload can be signed") decision = payload.get("decision") if not isinstance(decision, dict) or decision.get("policy_activation_authorized") is not False: @@ -940,6 +965,18 @@ def _required_text(value: dict[str, Any], key: str) -> str: return _nonempty(value.get(key), key) +def _require_sole_policy_reviewer(value: dict[str, Any], *, label: str) -> dict[str, str]: + identity = { + field: _required_text(value, field) + for field in ("approver_id", "reviewer", "role", "organization") + } + if identity != SOLE_POLICY_REVIEWER: + raise PolicyApprovalError( + f"{label} must use the configured sole named reviewer" + ) + return identity + + def _nonempty(value: Any, label: str) -> str: if not isinstance(value, str) or not value.strip() or value != value.strip(): raise PolicyApprovalError(f"{label} must be a trimmed non-empty string") @@ -1075,7 +1112,7 @@ def _sign_trust_approval_command(args: argparse.Namespace) -> dict[str, Any]: raw = canonical_json(trust_approval) write_atomic(args.output, raw, private=True) return { - "status": "signed_independent_trust_approval", + "status": "signed_single_reviewer_trust_approval", "trust_approval_id": trust_approval["trust_approval_id"], "trust_approval_sha256": sha256_bytes(raw), "activation_authorized": False, @@ -1157,7 +1194,7 @@ def parse_args() -> argparse.Namespace: trust_prepare = commands.add_parser( "prepare-trust-approval", - help="create the exact independent security or operations approval payload", + help="create the exact same-reviewer, separate-key trust confirmation payload", ) trust_prepare.add_argument("--trust-request-file", type=Path, required=True) trust_prepare.add_argument("--approval-file", type=Path, required=True) @@ -1178,7 +1215,7 @@ def parse_args() -> argparse.Namespace: trust_sign = commands.add_parser( "sign-trust-approval", - help="sign an exact trust approval payload with the independent approver key", + help="sign an exact trust confirmation payload with the reviewer's separate trust key", ) trust_sign.add_argument("--payload-file", type=Path, required=True) trust_sign.add_argument("--private-key-file", type=Path, required=True) diff --git a/yuqei-infra/scripts/prepare-provider-legal-approval.py b/yuqei-infra/scripts/prepare-provider-legal-approval.py index b7b74406..8b0c4fe0 100644 --- a/yuqei-infra/scripts/prepare-provider-legal-approval.py +++ b/yuqei-infra/scripts/prepare-provider-legal-approval.py @@ -32,6 +32,7 @@ from yuqei_ai_platform_api.provider_legal_approval import ( # noqa: E402 ProviderLegalApprovalKeyring, ProviderLegalApprovalPayload, ProviderLegalApprovalPublicKey, + SOLE_PROVIDER_LEGAL_REVIEWER, SignedProviderLegalApproval, canonical_legal_approval_payload, load_legal_approval_file, @@ -68,6 +69,25 @@ PHASE13_ROOT = ( / "v1_phase13_legal_knowledge_template_library" ) GENERATION_INVENTORY = PHASE13_ROOT / "inventories" / "generation-golden-sample-inventory.csv" +SOLE_PROVIDER_REVIEWER = SOLE_PROVIDER_LEGAL_REVIEWER + + +def _require_sole_provider_reviewer( + *, + approver_id: str, + reviewer: str, + role: str, + organization: str, + label: str, +) -> None: + identity = { + "approver_id": approver_id, + "reviewer": reviewer, + "role": role, + "organization": organization, + } + if identity != SOLE_PROVIDER_REVIEWER: + raise ValueError(f"{label} must use the configured sole named reviewer") def _strict_json(raw: bytes, *, label: str) -> Any: @@ -404,6 +424,13 @@ def build_approval_payload( approved_at: datetime, expires_at: datetime, ) -> ProviderLegalApprovalPayload: + _require_sole_provider_reviewer( + approver_id=approver_id, + reviewer=reviewer, + role=role, + organization=organization, + label="Provider legal approval", + ) payload = ProviderLegalApprovalPayload( approval_version=LEGAL_APPROVAL_VERSION, algorithm=LEGAL_APPROVAL_ALGORITHM, @@ -456,6 +483,24 @@ def verify_offline_approval( ) approval = load_legal_approval_file(approval_file) keyring = load_legal_keyring_file(keyring_file) + _require_sole_provider_reviewer( + approver_id=approval.payload.approver_id, + reviewer=approval.payload.reviewer, + role=approval.payload.role, + organization=approval.payload.organization, + label="Provider legal approval", + ) + matching_keys = [item for item in keyring.keys if item.key_id == approval.payload.key_id] + if len(matching_keys) != 1: + raise ValueError("Provider legal approval key must resolve uniquely") + trusted_key = matching_keys[0] + _require_sole_provider_reviewer( + approver_id=trusted_key.approver_id, + reviewer=trusted_key.reviewer, + role=trusted_key.role, + organization=trusted_key.organization, + label="Provider legal keyring entry", + ) problems = verify_legal_approval( approval, keyring=keyring, @@ -586,6 +631,13 @@ def _run_prepare(args: argparse.Namespace) -> dict[str, Any]: def _run_keyring(args: argparse.Namespace) -> dict[str, Any]: + _require_sole_provider_reviewer( + approver_id=args.approver_id, + reviewer=args.reviewer, + role=args.role, + organization=args.organization, + label="Provider legal keyring entry", + ) public_raw = _read_bounded(args.public_key_file, label="legal public key", limit=MAX_KEY_BYTES) key = serialization.load_pem_public_key(public_raw) if not isinstance(key, Ed25519PublicKey): diff --git a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py index f167a6e7..8c49950d 100644 --- a/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py +++ b/yuqei-infra/tests/test_first_release_snapshot_restore_planner.py @@ -104,8 +104,9 @@ def _case( snapshot_created_at: str = "2026-09-27T00:00:00Z", database_downgrade_performed: bool = False, successful_release_count: int = 0, - same_approver_person: bool = False, - reused_approval_public_key: bool = False, + approval_approver_id: str = planner.SOLE_REVIEWER_ID, + approval_role: str = "release_owner", + reuse_root_as_approval_key: bool = False, restore_bindings_match: bool = True, empty_image_map: str | None = None, ) -> dict[str, Any]: @@ -415,42 +416,23 @@ def _case( restore_path = _write_json(evidence_root / "independent-restore-test.json", restore_test) root_private = Ed25519PrivateKey.generate() - release_manager_key = Ed25519PrivateKey.generate() - role_keys = { - "release_manager": release_manager_key, - "database_owner": ( - release_manager_key - if reused_approval_public_key - else Ed25519PrivateKey.generate() - ), - } - approver_ids = { - "release_manager": "approver-release-manager", - "database_owner": ( - "approver-release-manager" - if same_approver_person - else "approver-database-owner" - ), - } - key_ids = { - "release_manager": "key-release-manager-1", - "database_owner": "key-database-owner-1", - } - key_records = [] - for role in ("release_manager", "database_owner"): - key_records.append( - { - "key_id": key_ids[role], - "approver_id": approver_ids[role], - "role": role, - "public_key_base64": base64.b64encode( - _raw_public(role_keys[role]) - ).decode("ascii"), - "not_before": "2026-09-26T00:00:00Z", - "not_after": "2026-10-01T00:00:00Z", - "revoked": False, - } - ) + approval_key = ( + root_private if reuse_root_as_approval_key else Ed25519PrivateKey.generate() + ) + key_id = "key-release-owner-1" + key_records = [ + { + "key_id": key_id, + "approver_id": approval_approver_id, + "role": approval_role, + "public_key_base64": base64.b64encode( + _raw_public(approval_key) + ).decode("ascii"), + "not_before": "2026-09-26T00:00:00Z", + "not_after": "2026-10-01T00:00:00Z", + "revoked": False, + } + ] keyring_unsigned = { "schema_version": planner.KEYRING_SCHEMA, "root_key_id": planner.TRUSTED_ROOT_KEY_ID, @@ -515,14 +497,13 @@ def _case( evidence_hash = planner.canonical_sha256(evidence_unsigned) approvals = [ _approval( - approval_id=f"approval-{role}", - approver_id=approver_ids[role], - role=role, - key_id=key_ids[role], - private_key=role_keys[role], + approval_id="approval-release-owner", + approver_id=approval_approver_id, + role=approval_role, + key_id=key_id, + private_key=approval_key, evidence_hash=evidence_hash, ) - for role in ("release_manager", "database_owner") ] evidence = { **evidence_unsigned, @@ -634,12 +615,14 @@ def test_valid_first_release_evidence_emits_only_a_non_writing_plan( "contract_files_volume", "minio_object_store", ] - assert set(item["role"] for item in validated["approvals"]) == { - "release_manager", - "database_owner", + assert {item["role"] for item in validated["approvals"]} == {"release_owner"} + assert len({item["approver_id"] for item in validated["approvals"]}) == 1 + assert len({item["key_id"] for item in validated["approvals"]}) == 1 + assert validated["reviewer"] == { + "approver_id": "chen-yuqi", + "name": "陈钰麒", + "title": "总经理", } - assert len({item["approver_id"] for item in validated["approvals"]}) == 2 - assert len({item["key_id"] for item in validated["approvals"]}) == 2 assert plan["status"] == "signature_verified_against_frozen_pins_plan_only" assert plan["apply_allowed"] is False assert plan["execution_entrypoint_available"] is False @@ -1021,35 +1004,46 @@ def test_restore_test_is_bound_to_the_exact_environment_and_tls_hashes( _validate(case) -def test_approvals_require_two_different_people_and_keys( +def test_approval_rejects_a_different_reviewer_identity( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - case = _case(tmp_path, same_approver_person=True) + case = _case(tmp_path, approval_approver_id="another-reviewer") _provision_test_pins(monkeypatch, case) - with pytest.raises(planner.SnapshotRestorePlanError, match="two roles, people, and keys"): + with pytest.raises(planner.SnapshotRestorePlanError, match="unauthorized reviewer"): _validate(case) -def test_keyring_rejects_public_key_reused_across_approval_identities( +def test_keyring_rejects_approval_key_reused_as_offline_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - case = _case(tmp_path, reused_approval_public_key=True) + case = _case(tmp_path, reuse_root_as_approval_key=True) key_records = case["keyring"]["keys"] - assert len({item["key_id"] for item in key_records}) == 2 - assert len({item["approver_id"] for item in key_records}) == 2 - assert len({item["role"] for item in key_records}) == 2 + assert len({item["key_id"] for item in key_records}) == 1 + assert len({item["approver_id"] for item in key_records}) == 1 + assert len({item["role"] for item in key_records}) == 1 assert len({item["public_key_base64"] for item in key_records}) == 1 _provision_test_pins(monkeypatch, case) with pytest.raises( planner.SnapshotRestorePlanError, - match="reuses an Ed25519 public key across identities", + match="must differ from the offline root key", ): _validate(case) +def test_recovery_evidence_rejects_more_than_one_approval( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + case = _case(tmp_path) + case["evidence"]["approvals"].append(dict(case["evidence"]["approvals"][0])) + _provision_test_pins(monkeypatch, case) + with pytest.raises(planner.SnapshotRestorePlanError, match="exactly one"): + _validate(case) + + def test_cli_has_no_apply_or_caller_supplied_trust_pin() -> None: options = { option diff --git a/yuqei-infra/tests/test_policy_legal_signing_helper.py b/yuqei-infra/tests/test_policy_legal_signing_helper.py index 33d16a86..e223d6cc 100644 --- a/yuqei-infra/tests/test_policy_legal_signing_helper.py +++ b/yuqei-infra/tests/test_policy_legal_signing_helper.py @@ -59,9 +59,9 @@ def prepare(tmp_path: Path, *, policy_dir: Path = POLICY_SOURCE) -> tuple[dict, policy_dir=policy_dir, public_key_file=public_path, key_id="policy-legal-2026q3", - approver_id="cyuqei@gmail.com", + approver_id="chen-yuqi", reviewer="陈钰麒", - role="CEO", + role="总经理", organization="内蒙古钰麒科技有限公司", prepared_at="2026-08-12T12:00:00+08:00", key_not_before="2026-07-01T00:00:00+08:00", @@ -83,6 +83,24 @@ def test_prepare_binds_exact_four_policy_files_and_public_key(tmp_path: Path) -> assert keyring["keys"][0]["not_after"] == "2026-09-30T23:59:59+08:00" +def test_prepare_rejects_any_additional_policy_reviewer(tmp_path: Path) -> None: + _private, public, _password = make_key_files(tmp_path) + + with pytest.raises(policy.PolicyApprovalError, match="sole named reviewer"): + policy.build_review_package( + policy_dir=POLICY_SOURCE, + public_key_file=public, + key_id="policy-legal-2026q3", + approver_id="another.reviewer@example.test", + reviewer="Another Reviewer", + role="总经理", + organization="内蒙古钰麒科技有限公司", + prepared_at="2026-08-12T12:00:00+08:00", + key_not_before="2026-07-01T00:00:00+08:00", + key_not_after="2026-09-30T23:59:59+08:00", + ) + + def test_prepare_rejects_unresolved_or_modified_candidate_marker(tmp_path: Path) -> None: policy_dir = tmp_path / "policies" shutil.copytree(POLICY_SOURCE, policy_dir) @@ -95,9 +113,9 @@ def test_prepare_rejects_unresolved_or_modified_candidate_marker(tmp_path: Path) policy_dir=policy_dir, public_key_file=public, key_id="policy-legal-2026q3", - approver_id="cyuqei@gmail.com", + approver_id="chen-yuqi", reviewer="陈钰麒", - role="CEO", + role="总经理", organization="内蒙古钰麒科技有限公司", prepared_at="2026-08-12T12:00:00+08:00", key_not_before="2026-07-01T00:00:00+08:00", @@ -150,6 +168,50 @@ def test_sign_and_verify_exact_policy_bytes_with_candidate_override(tmp_path: Pa assert verified["approval_id"] == approval["approval_id"] +def test_verify_rejects_a_self_consistent_unconfigured_reviewer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + private, public, password = make_key_files(tmp_path, stem="unconfigured-reviewer") + unconfigured = { + "approver_id": "unconfigured-reviewer", + "reviewer": "未配置审核人", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", + } + with monkeypatch.context() as scoped: + scoped.setattr(policy, "SOLE_POLICY_REVIEWER", unconfigured) + _package, pending, keyring = policy.build_review_package( + policy_dir=POLICY_SOURCE, + public_key_file=public, + key_id="policy-legal-unconfigured", + **unconfigured, + prepared_at="2026-08-12T12:00:00+08:00", + key_not_before="2026-07-01T00:00:00+08:00", + key_not_after="2026-09-30T23:59:59+08:00", + ) + final_payload = policy.finalize_payload( + pending, + approved_at="2026-08-12T13:00:00+08:00", + expires_at="2026-08-19T13:00:00+08:00", + ) + approval = policy.sign_payload( + final_payload, + private_key_file=private, + password_file=password, + confirmation=policy.SIGN_CONFIRMATION, + ) + + with pytest.raises(policy.PolicyApprovalError, match="configured sole named reviewer"): + policy.verify_approval( + approval, + keyring=keyring, + policy_dir=POLICY_SOURCE, + checked_at="2026-08-12T14:00:00+08:00", + allow_candidate_keyring=True, + ) + + def test_verify_rejects_policy_byte_tampering(tmp_path: Path) -> None: policy_dir = tmp_path / "policies" shutil.copytree(POLICY_SOURCE, policy_dir) @@ -252,7 +314,7 @@ def test_trust_request_binds_signed_approval_without_granting_trust(tmp_path: Pa ) assert trust_request["schema_version"] == policy.TRUST_REQUEST_SCHEMA - assert trust_request["status"] == "pending_independent_trust_approval" + assert trust_request["status"] == "pending_single_reviewer_trust_approval" assert trust_request["activation_authorized"] is False assert trust_request["approval_id"] == approval["approval_id"] assert trust_request["approval_sha256"] == policy.sha256_bytes(policy.canonical_json(approval)) @@ -286,7 +348,7 @@ def test_trust_request_rejects_expired_approval_or_non_candidate_keyring(tmp_pat ) trusted = {**keyring, "status": "trusted"} - with pytest.raises(policy.PolicyApprovalError, match="signed trust basis"): + with pytest.raises(policy.PolicyApprovalError, match="single-reviewer trust basis"): policy.verify_approval( approval, keyring=trusted, @@ -330,10 +392,10 @@ def make_signed_trust_chain(tmp_path: Path) -> tuple[dict, dict, dict, dict, Pat policy_dir=POLICY_SOURCE, trust_approver_public_key_file=trust_public, key_id="policy-trust-ops-2026q3", - approver_id="security@example.test", - reviewer="Named Security Reviewer", - role="Security Officer", - organization="Independent Security Operations", + approver_id="chen-yuqi", + reviewer="陈钰麒", + role="总经理", + organization="内蒙古钰麒科技有限公司", responsibility="security", approved_at="2026-08-12T15:00:00+08:00", expires_at="2026-08-19T12:00:00+08:00", @@ -349,7 +411,7 @@ def make_signed_trust_chain(tmp_path: Path) -> tuple[dict, dict, dict, dict, Pat return legal_approval, keyring, trust_request, trust_approval, trust_public -def test_independent_signed_trust_approval_is_required_to_issue_trusted_keyring( +def test_same_reviewer_separate_key_trust_confirmation_issues_trusted_keyring( tmp_path: Path, ) -> None: legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain( @@ -382,8 +444,13 @@ def test_independent_signed_trust_approval_is_required_to_issue_trusted_keyring( assert verified["status"] == "verified" tampered = json.loads(json.dumps(trusted)) - signature = tampered["trust_basis"]["trust_approval"]["signature"] - tampered["trust_basis"]["trust_approval"]["signature"] = "A" + signature[1:] + signature = bytearray( + base64.b64decode(tampered["trust_basis"]["trust_approval"]["signature"]) + ) + signature[0] ^= 1 + tampered["trust_basis"]["trust_approval"]["signature"] = base64.b64encode( + bytes(signature) + ).decode("ascii") with pytest.raises(policy.PolicyApprovalError, match="trust approval hash"): policy.verify_approval( legal_approval, @@ -413,7 +480,38 @@ def test_independent_signed_trust_approval_is_required_to_issue_trusted_keyring( policy.verify_trusted_keyring_basis(trusted, approval=different_approval) -def test_trust_approval_rejects_same_approver_or_same_key(tmp_path: Path) -> None: +def test_trusted_keyring_basis_is_bound_to_the_configured_sole_reviewer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain( + tmp_path + ) + trusted = policy.issue_trusted_keyring( + trust_approval, + trust_request=trust_request, + approval=legal_approval, + candidate_keyring=keyring, + policy_dir=POLICY_SOURCE, + trust_approver_public_key_file=trust_public, + checked_at="2026-08-12T16:00:00+08:00", + ) + with monkeypatch.context() as scoped: + scoped.setattr( + policy, + "SOLE_POLICY_REVIEWER", + { + "approver_id": "different-configured-reviewer", + "reviewer": "另一名审核人", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", + }, + ) + with pytest.raises(policy.PolicyApprovalError, match="configured sole named reviewer"): + policy.verify_trusted_keyring_basis(trusted, approval=legal_approval) + + +def test_trust_confirmation_rejects_another_reviewer_or_same_key(tmp_path: Path) -> None: _package, pending, keyring, legal_private, legal_public, legal_password = prepare(tmp_path) legal_payload = policy.finalize_payload( pending, @@ -438,28 +536,28 @@ def test_trust_approval_rejects_same_approver_or_same_key(tmp_path: Path) -> Non candidate_keyring=keyring, policy_dir=POLICY_SOURCE, key_id="policy-trust-ops-2026q3", - reviewer="Named Reviewer", - role="Security Officer", - organization="Independent Security Operations", + reviewer="陈钰麒", + role="总经理", + organization="内蒙古钰麒科技有限公司", responsibility="security", approved_at="2026-08-12T15:00:00+08:00", expires_at="2026-08-19T12:00:00+08:00", key_not_before="2026-08-01T00:00:00+08:00", key_not_after="2026-09-30T23:59:59+08:00", ) - _other_private, other_public, _other_password = make_key_files(tmp_path, stem="independent") - with pytest.raises(policy.PolicyApprovalError, match="approver must differ"): + _other_private, other_public, _other_password = make_key_files(tmp_path, stem="separate") + with pytest.raises(policy.PolicyApprovalError, match="configured sole named reviewer"): policy.build_trust_approval_payload( trust_request, trust_approver_public_key_file=other_public, - approver_id="cyuqei@gmail.com", + approver_id="security@example.test", **common, ) with pytest.raises(policy.PolicyApprovalError, match="key must differ"): policy.build_trust_approval_payload( trust_request, trust_approver_public_key_file=legal_public, - approver_id="security@example.test", + approver_id="chen-yuqi", **common, ) @@ -482,7 +580,7 @@ def test_issue_trusted_keyring_rejects_tampered_request_and_signature(tmp_path: ) -def test_trust_approval_requires_signed_independent_review_attestations(tmp_path: Path) -> None: +def test_trust_confirmation_requires_signed_separate_key_attestations(tmp_path: Path) -> None: legal_approval, keyring, trust_request, trust_approval, trust_public = make_signed_trust_chain( tmp_path ) @@ -504,7 +602,11 @@ def test_trust_approval_requires_signed_independent_review_attestations(tmp_path ) tampered_approval = json.loads(json.dumps(trust_approval)) - tampered_approval["signature"] = "A" + tampered_approval["signature"][1:] + tampered_signature = bytearray(base64.b64decode(tampered_approval["signature"])) + tampered_signature[0] ^= 1 + tampered_approval["signature"] = base64.b64encode( + bytes(tampered_signature) + ).decode("ascii") with pytest.raises(policy.PolicyApprovalError, match="signature verification failed"): policy.issue_trusted_keyring( tampered_approval, diff --git a/yuqei-infra/tests/test_production_release_tools.py b/yuqei-infra/tests/test_production_release_tools.py index b7d9f411..00b92aaf 100644 --- a/yuqei-infra/tests/test_production_release_tools.py +++ b/yuqei-infra/tests/test_production_release_tools.py @@ -58,6 +58,8 @@ def production_values() -> dict[str, str]: values = preflight.parse_env_file(REPO_ROOT / "yuqei-infra/compose/.env.production.example") replacements = { "RELEASE_ID": "v2.0.0-rc.1", + "YUQEI_RELEASE_ENVIRONMENT_ID": "production-primary", + "YUQEI_RELEASE_HOST_ID": "production-host-01", "CONTRACT_API_IMAGE": f"registry.internal/contract-api@sha256:{'1' * 64}", "CONTRACT_WEB_IMAGE": f"registry.internal/contract-web@sha256:{'c' * 64}", "AI_PLATFORM_API_IMAGE": f"registry.internal/ai-platform-api@sha256:{'2' * 64}", diff --git a/yuqei-infra/tests/test_provider_legal_signing_helper.py b/yuqei-infra/tests/test_provider_legal_signing_helper.py index 3b5fd967..ae607011 100644 --- a/yuqei-infra/tests/test_provider_legal_signing_helper.py +++ b/yuqei-infra/tests/test_provider_legal_signing_helper.py @@ -51,10 +51,10 @@ from yuqei_ai_platform_api.provider_review_package import ( # noqa: E402 NOW = datetime(2026, 8, 11, 8, tzinfo=UTC) IDENTITY = { "key_id": "named-legal-2026q3", - "approver_id": "legal.reviewer@example.test", - "reviewer": "Named Legal Reviewer", - "role": "Senior Legal Counsel", - "organization": "Example Legal", + "approver_id": "chen-yuqi", + "reviewer": "陈钰麒", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", } @@ -193,6 +193,65 @@ def test_prepares_signs_and_verifies_exact_package_and_31_case_suite(tmp_path: P assert evidence["contract_product_golden"]["summary"]["status"] == "needs_legal_review" +def test_final_verifier_rejects_self_consistent_unconfigured_reviewer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + package_file, summary_file = _input_files(tmp_path) + evidence, package = helper.build_review_evidence(package_file, summary_file) + evidence_raw = canonical_json(evidence) + evidence_file = tmp_path / "unconfigured-evidence.json" + evidence_file.write_bytes(evidence_raw) + unconfigured = { + "approver_id": "unconfigured-reviewer", + "reviewer": "未配置审核人", + "role": "总经理", + "organization": "内蒙古钰麒科技有限公司", + } + with monkeypatch.context() as scoped: + scoped.setattr(helper, "SOLE_PROVIDER_REVIEWER", unconfigured) + payload = helper.build_approval_payload( + package=package, + evidence_ref="https://evidence.example.test/provider/legal/review.json", + evidence_raw=evidence_raw, + key_id="unconfigured-legal-key", + approved_at=NOW, + expires_at=NOW + timedelta(days=7), + **unconfigured, + ) + password = b"test-password-not-a-production-secret" + private_pem, public_pem = _key_material(password) + approval = sign_legal_approval(payload, private_pem, private_key_password=password) + keyring = ProviderLegalApprovalKeyring( + version=1, + keys=[ + ProviderLegalApprovalPublicKey( + key_id="unconfigured-legal-key", + algorithm=LEGAL_APPROVAL_ALGORITHM, + public_key_pem=public_pem, + not_before=NOW - timedelta(days=1), + not_after=NOW + timedelta(days=30), + revoked=False, + **unconfigured, + ) + ], + ) + approval_file = tmp_path / "unconfigured-approval.json" + approval_file.write_bytes(canonical_json(approval.model_dump(mode="json"))) + keyring_file = tmp_path / "unconfigured-keyring.json" + keyring_file.write_bytes(canonical_json(keyring.model_dump(mode="json"))) + + with pytest.raises(ValueError, match="configured sole named reviewer"): + helper.verify_offline_approval( + approval_file=approval_file, + keyring_file=keyring_file, + evidence_file=evidence_file, + review_package_file=package_file, + golden_summary_file=summary_file, + checked_at=NOW + timedelta(minutes=1), + ) + + def test_rejects_reformatted_or_tampered_evidence_and_stale_golden_summary(tmp_path: Path) -> None: package_file, summary_file = _input_files(tmp_path) evidence, _package = helper.build_review_evidence(package_file, summary_file) diff --git a/yuqei-infra/tests/test_provider_promotion_two_stage.py b/yuqei-infra/tests/test_provider_promotion_two_stage.py index 59763f63..9ba39a74 100644 --- a/yuqei-infra/tests/test_provider_promotion_two_stage.py +++ b/yuqei-infra/tests/test_provider_promotion_two_stage.py @@ -179,10 +179,10 @@ def test_finalize_verifies_named_signature_package_binding_and_real_evidence(mon key_id="legal-key-1", decision="approved", tenant_id=package.tenant_id, - approver_id="legal.reviewer@example.test", - reviewer="Named Legal Reviewer", - role="Senior Legal Counsel", - organization="Yuqei Legal", + approver_id="chen-yuqi", + reviewer="陈钰麒", + role="总经理", + organization="内蒙古钰麒科技有限公司", approved_at=now, expires_at=now + timedelta(days=7), provider_config_id=package.provider_config_id, diff --git a/yuqei-infra/tests/test_rc06_publication_batch.py b/yuqei-infra/tests/test_rc06_publication_batch.py index 96ea3971..1e85a365 100644 --- a/yuqei-infra/tests/test_rc06_publication_batch.py +++ b/yuqei-infra/tests/test_rc06_publication_batch.py @@ -1,12 +1,15 @@ from __future__ import annotations import copy +import base64 import importlib.util import json import sys from pathlib import Path import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey REPO_ROOT = Path(__file__).resolve().parents[2] @@ -442,12 +445,8 @@ def _resolved_disposition(case: dict) -> dict: **case, "status": "named_legal_approval", "human_confirmed": True, - "decision_source": "external_human_record", - "decision_maker": { - "name": "Fixture Legal Reviewer", - "role": "Legal reviewer", - "organization": "Fixture Organization", - }, + "decision_source": "single_reviewer_batch", + "decision_maker": batch.SOLE_RELEASE_REVIEWER, "decided_at": "2026-09-20T10:00:00+08:00", "evidence_sha256": "9" * 64, "risk_acceptance": None, @@ -547,20 +546,25 @@ def test_manifest_is_canonical_fail_closed_and_rejects_hash_or_duplicate_drift(i batch.validate_manifest(duplicate) -def test_golden_dispositions_require_external_human_record_and_complete_risk_fields(inventory: dict) -> None: +def test_golden_dispositions_require_sole_reviewer_batch_and_complete_risk_fields(inventory: dict) -> None: case = inventory["golden_cases"][0] approval = _resolved_disposition(case) assert batch.validate_disposition(approval, case)["status"] == "named_legal_approval" automated = {**approval, "human_confirmed": False} - with pytest.raises(batch.PublicationBatchError, match="external human record"): + with pytest.raises(batch.PublicationBatchError, match="sole reviewer batch"): batch.validate_disposition(automated, case) + another_reviewer = copy.deepcopy(approval) + another_reviewer["decision_maker"]["approver_id"] = "another-reviewer" + with pytest.raises(batch.PublicationBatchError, match="sole release reviewer"): + batch.validate_disposition(another_reviewer, case) + risk = { **approval, "status": "scoped_internal_risk_acceptance", "risk_acceptance": { - "owner": "Pilot owner", + "owner": "陈钰麒", "scope": "Named internal pilot workspaces only", "expires_at": "2026-10-20T10:00:00+08:00", "controls": ["No public registration", "Manual incident stop"], @@ -574,20 +578,82 @@ def test_golden_dispositions_require_external_human_record_and_complete_risk_fie batch.validate_disposition(missing_controls, case) -def test_legacy_batch_receipts_are_blocked_until_item_signatures_and_snapshots_exist( +def test_one_reviewer_batch_signature_and_system_item_receipts_remove_human_repetition( inventory: dict, + monkeypatch: pytest.MonkeyPatch, ) -> None: manifest = _manifest(inventory, resolved=True) batch.validate_manifest(manifest) - approval = { - "schema_version": batch.APPROVAL_SCHEMA_VERSION, + root_private_key = Ed25519PrivateKey.generate() + private_key = Ed25519PrivateKey.generate() + apply_service_private_key = Ed25519PrivateKey.generate() + root_public_key = root_private_key.public_key() + apply_service_public_key = apply_service_private_key.public_key() + root_public_raw = root_public_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + apply_service_public_raw = apply_service_public_key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + public_pem = private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + keyring_unsigned = { + "schema_version": batch.APPROVAL_KEYRING_SCHEMA_VERSION, + "root_key_id": batch.TRUSTED_APPROVAL_ROOT_KEY_ID, + "epoch": 1, + "issued_at": "2026-09-01T00:00:00+08:00", + "expires_at": "2026-10-31T23:59:59+08:00", + "keys": [ + { + "key_id": "rc06-release-owner-2026q3", + "algorithm": "Ed25519", + "public_key_pem": public_pem, + **batch.SOLE_RELEASE_REVIEWER, + "not_before": "2026-09-01T00:00:00+08:00", + "not_after": "2026-10-31T23:59:59+08:00", + "revoked": False, + } + ], + } + keyring_payload_sha256 = batch._sha256_json(keyring_unsigned) + keyring = { + **keyring_unsigned, + "keyring_payload_sha256": keyring_payload_sha256, + "root_signature_base64": base64.b64encode( + root_private_key.sign( + batch.APPROVAL_KEYRING_SIGNATURE_DOMAIN + batch._canonical(keyring_unsigned) + ) + ).decode("ascii"), + } + monkeypatch.setattr( + batch, + "TRUSTED_APPROVAL_ROOT_PUBLIC_KEY_SHA256", + batch._sha256_bytes(root_public_raw), + ) + monkeypatch.setattr( + batch, + "TRUSTED_APPROVAL_KEYRING_PAYLOAD_SHA256", + keyring_payload_sha256, + ) + monkeypatch.setattr(batch, "MIN_APPROVAL_KEYRING_EPOCH", 1) + monkeypatch.setattr( + batch, + "TRUSTED_APPLY_SERVICE_PUBLIC_KEY_SHA256", + batch._sha256_bytes(apply_service_public_raw), + ) + approval_payload = { + "schema_version": batch.APPROVAL_PAYLOAD_SCHEMA_VERSION, "batch_manifest_payload_sha256": manifest["manifest_payload_sha256"], "decision": "approved", "approved_at": "2026-09-21T09:00:00+08:00", + "expires_at": "2026-09-28T09:00:00+08:00", + "key_id": "rc06-release-owner-2026q3", "approver": { - "name": "Fixture Release Approver", - "role": "Release owner", - "organization": "Fixture Organization", + **batch.SOLE_RELEASE_REVIEWER, "human_confirmed": True, }, "asset_payload_sha256s": { @@ -601,34 +667,131 @@ def test_legacy_batch_receipts_are_blocked_until_item_signatures_and_snapshots_e "golden_disposition_inventory_sha256": manifest["golden_dispositions"]["inventory_sha256"], "evidence_sha256": "8" * 64, } - with pytest.raises(batch.PublicationBatchError, match="item-level signed approval"): - batch.validate_approval_receipt(manifest, approval) + signature = private_key.sign( + batch.APPROVAL_SIGNATURE_DOMAIN + batch._canonical(approval_payload) + ) + approval = { + "schema_version": batch.APPROVAL_SCHEMA_VERSION, + "approval_id": ( + "rc06-batch-approval-" + + batch._sha256_bytes(batch._canonical(approval_payload) + signature)[:24] + ), + "payload": approval_payload, + "signature": base64.b64encode(signature).decode("ascii"), + } + assert batch.validate_approval_receipt( + manifest, + approval, + keyring, + root_public_key, + ) == approval + + untrusted_keyring = copy.deepcopy(keyring) + root_signature = bytearray(base64.b64decode(untrusted_keyring["root_signature_base64"])) + root_signature[0] ^= 1 + untrusted_keyring["root_signature_base64"] = base64.b64encode(bytes(root_signature)).decode( + "ascii" + ) + with pytest.raises(batch.PublicationBatchError, match="root signature is invalid"): + batch.validate_approval_receipt( + manifest, + approval, + untrusted_keyring, + root_public_key, + ) approval_raw_sha = batch._sha256_bytes(batch._canonical(approval)) - apply_receipt = { - "schema_version": batch.APPLY_SCHEMA_VERSION, + item_results = [ + { + "kind": "asset", + "item_id": item["template_id"], + "expected_content_sha256": item["content_sha256"], + "online_content_sha256": item["content_sha256"], + "status": "applied_and_verified", + } + for item in manifest["asset_batch"]["items"] + ] + [ + { + "kind": "prompt_module", + "item_id": item["key"], + "expected_content_sha256": item["content_sha256"], + "online_content_sha256": item["content_sha256"], + "status": "applied_and_verified", + } + for item in manifest["prompt_module_batch"]["items"] + ] + item_results.sort(key=lambda item: (item["kind"], item["item_id"])) + apply_payload = { + "schema_version": batch.APPLY_PAYLOAD_SCHEMA_VERSION, "batch_manifest_payload_sha256": manifest["manifest_payload_sha256"], "approval_receipt_sha256": approval_raw_sha, "environment_id": "isolated-uat-fixture", "applied_at": "2026-09-21T10:00:00+08:00", - "applied_by": { - "name": "Fixture Operator", - "role": "Release operator", - "human_confirmed": True, - }, - "asset_online_sha256s": { - item["template_id"]: item["content_sha256"] - for item in manifest["asset_batch"]["items"] - }, - "prompt_module_online_sha256s": { - item["key"]: item["content_sha256"] - for item in manifest["prompt_module_batch"]["items"] - }, + "generated_by": {"type": "system", "component": "rc06-publication-service"}, + "item_results": item_results, "write_count": 74, - "status": "applied", + "status": "applied_and_verified", } - with pytest.raises(batch.PublicationBatchError, match="item-level signed apply"): - batch.validate_apply_receipt(manifest, approval, approval_raw_sha, apply_receipt) + apply_signature = apply_service_private_key.sign( + batch.APPLY_SIGNATURE_DOMAIN + batch._canonical(apply_payload) + ) + apply_receipt = { + "schema_version": batch.APPLY_SCHEMA_VERSION, + "receipt_id": ( + "rc06-apply-receipt-" + + batch._sha256_bytes(batch._canonical(apply_payload) + apply_signature)[:24] + ), + "payload": apply_payload, + "signature": base64.b64encode(apply_signature).decode("ascii"), + } + assert batch.validate_apply_receipt( + manifest, + approval, + keyring, + root_public_key, + apply_service_public_key, + approval_raw_sha, + apply_receipt, + ) == apply_receipt + + another_reviewer = copy.deepcopy(approval) + another_reviewer["payload"]["approver"]["approver_id"] = "another-reviewer" + with pytest.raises(batch.PublicationBatchError, match="sole release reviewer"): + batch.validate_approval_receipt( + manifest, + another_reviewer, + keyring, + root_public_key, + ) + + tampered_apply = copy.deepcopy(apply_receipt) + tampered_apply["payload"]["item_results"][0]["online_content_sha256"] = "7" * 64 + with pytest.raises(batch.PublicationBatchError, match="74-item online evidence drift"): + batch.validate_apply_receipt( + manifest, + approval, + keyring, + root_public_key, + apply_service_public_key, + approval_raw_sha, + tampered_apply, + ) + + forged_apply = copy.deepcopy(apply_receipt) + attacker = Ed25519PrivateKey.generate() + forged_apply["signature"] = base64.b64encode( + attacker.sign(batch.APPLY_SIGNATURE_DOMAIN + batch._canonical(forged_apply["payload"])) + ).decode("ascii") + with pytest.raises(batch.PublicationBatchError, match="service signature verification failed"): + batch.validate_apply_receipt( + manifest, + approval, + keyring, + root_public_key, + apply_service_public_key, + approval_raw_sha, + forged_apply, + ) assert manifest["publication_control"]["blocked_controls"] == list(batch.BLOCKED_CONTROLS) assert manifest["publication_control"]["release_ready"] is False diff --git a/yuqei-infra/tests/test_release_package_executor.py b/yuqei-infra/tests/test_release_package_executor.py index 6b23ad67..b0e7c7b0 100644 --- a/yuqei-infra/tests/test_release_package_executor.py +++ b/yuqei-infra/tests/test_release_package_executor.py @@ -1959,7 +1959,7 @@ def test_v2_first_release_readiness_is_revalidated_before_injected_apply( snapshot_planner = release_package._load_snapshot_restore_planner_module() daemon_identity = preplan["docker_daemon_identity"] readiness = { - "schema_version": "yuqei.first-release-recovery-readiness-receipt.v1", + "schema_version": "yuqei.first-release-recovery-readiness-receipt.v2", "result": release_package.RECOVERY_READINESS_RESULT, "purpose": "release_package_install_apply_gate", "install_apply_gate": True, diff --git a/yuqei-infra/tests/test_rollback_rehearsal_planner.py b/yuqei-infra/tests/test_rollback_rehearsal_planner.py index 3ba32946..9d9ba506 100644 --- a/yuqei-infra/tests/test_rollback_rehearsal_planner.py +++ b/yuqei-infra/tests/test_rollback_rehearsal_planner.py @@ -176,7 +176,7 @@ def _resign_evidence( *, expires_at: str = "2026-09-21T12:00:00Z", signed_at: str = "2026-09-21T05:45:00Z", - approval_ids: tuple[str, str] = ("approval-1", "approval-2"), + approval_id: str = "approval-1", ) -> None: payload = { key: evidence[key] @@ -190,32 +190,25 @@ def _resign_evidence( ) } payload_hash = planner.canonical_sha256(payload) - identities = ( - ("release-key-1", "release-admin-1", "release_manager"), - ("database-key-1", "database-admin-1", "database_owner"), - ) - approvals = [] - for private, (key_id, approver_id, role), approval_id in zip( - approver_keys, identities, approval_ids, strict=True - ): - unsigned = { - "schema_version": planner.APPROVAL_SCHEMA, - "approval_id": approval_id, - "approver_id": approver_id, - "role": role, - "key_id": key_id, - "signed_at": signed_at, - "expires_at": expires_at, - "evidence_payload_sha256": payload_hash, + assert len(approver_keys) == 1 + unsigned = { + "schema_version": planner.APPROVAL_SCHEMA, + "approval_id": approval_id, + "approver_id": planner.SOLE_REVIEWER_ID, + "role": "release_owner", + "key_id": "release-owner-key-1", + "signed_at": signed_at, + "expires_at": expires_at, + "evidence_payload_sha256": payload_hash, + } + approvals = [ + { + **unsigned, + "signature_base64": base64.b64encode( + approver_keys[0].sign(planner.canonical_json_bytes(unsigned)) + ).decode("ascii"), } - approvals.append( - { - **unsigned, - "signature_base64": base64.b64encode( - private.sign(planner.canonical_json_bytes(unsigned)) - ).decode("ascii"), - } - ) + ] evidence["evidence_payload_sha256"] = payload_hash evidence["approvals"] = approvals @@ -232,11 +225,8 @@ def _context(tmp_path: Path, monkeypatch) -> dict[str, Any]: "TRUSTED_ROOT_PUBLIC_KEY_SHA256", hashlib.sha256(root_raw).hexdigest(), ) - approver_keys = [Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()] - identities = ( - ("release-key-1", "release-admin-1", "release_manager"), - ("database-key-1", "database-admin-1", "database_owner"), - ) + approver_keys = [Ed25519PrivateKey.generate()] + identities = (("release-owner-key-1", planner.SOLE_REVIEWER_ID, "release_owner"),) key_items = [] for private, (key_id, approver_id, role) in zip( approver_keys, identities, strict=True @@ -489,7 +479,7 @@ def _rewrite_artifact(context: dict[str, Any], kind: str, mutate) -> None: _resign_evidence(context["evidence"], context["approver_keys"]) -def test_root_signed_two_person_evidence_unlocks_plan_generation_only( +def test_root_signed_single_reviewer_evidence_unlocks_plan_generation_only( tmp_path: Path, monkeypatch ) -> None: context = _context(tmp_path, monkeypatch) @@ -516,6 +506,14 @@ def test_root_signed_two_person_evidence_unlocks_plan_generation_only( assert plan["keyring_payload_sha256"] == context["keyring"][ "keyring_payload_sha256" ] + assert len({item["approver_id"] for item in validated["approvals"]}) == 1 + assert {item["role"] for item in validated["approvals"]} == {"release_owner"} + assert len({item["key_id"] for item in validated["approvals"]}) == 1 + assert plan["reviewer"] == { + "approver_id": "chen-yuqi", + "name": "陈钰麒", + "title": "总经理", + } assert plan["compose_input_sha256"] == context["runtime"][ "compose_input_sha256" ] @@ -576,6 +574,21 @@ def test_old_unexpired_keyring_is_rejected_after_new_revocation_state( new_keyring = copy.deepcopy(old_keyring) new_keyring["epoch"] = 2 new_keyring["keys"][0]["revoked"] = True + rotated_key = Ed25519PrivateKey.generate() + new_keyring["keys"].append( + { + "key_id": "release-owner-key-2", + "approver_id": planner.SOLE_REVIEWER_ID, + "role": "release_owner", + "algorithm": "ed25519", + "public_key_base64": base64.b64encode( + _raw_public_key(rotated_key) + ).decode("ascii"), + "not_before": "2026-09-20T00:00:00Z", + "not_after": "2026-09-22T00:00:00Z", + "revoked": False, + } + ) _root_sign_keyring(new_keyring, context["root_private"]) monkeypatch.setattr( planner, @@ -589,8 +602,8 @@ def test_old_unexpired_keyring_is_rejected_after_new_revocation_state( now=NOW, trusted_root_public_key=root, ) - assert "release-key-1" not in trusted - assert "database-key-1" in trusted + assert "release-owner-key-1" not in trusted + assert "release-owner-key-2" in trusted with pytest.raises(planner.RehearsalProofError, match="older than the trusted minimum"): planner._validate_keyring(old_keyring, now=NOW, trusted_root_public_key=root) @@ -602,18 +615,46 @@ def test_keyring_forged_by_untrusted_root_is_rejected(tmp_path: Path, monkeypatc _validate(context) -def test_keyring_rejects_reused_public_key_for_two_people(tmp_path: Path, monkeypatch) -> None: +def test_keyring_rejects_approval_key_reused_as_offline_root( + tmp_path: Path, monkeypatch +) -> None: context = _context(tmp_path, monkeypatch) - context["keyring"]["keys"][1]["public_key_base64"] = context["keyring"]["keys"][0][ - "public_key_base64" - ] + context["keyring"]["keys"][0]["public_key_base64"] = base64.b64encode( + context["root_file"].read_bytes() + ).decode("ascii") _root_sign_keyring(context["keyring"], context["root_private"]) monkeypatch.setattr( planner, "TRUSTED_CURRENT_KEYRING_PAYLOAD_SHA256", context["keyring"]["keyring_payload_sha256"], ) - with pytest.raises(planner.RehearsalProofError, match="must not reuse one public key"): + with pytest.raises(planner.RehearsalProofError, match="must differ from the offline root"): + _validate(context) + + +def test_keyring_rejects_another_reviewer_identity( + tmp_path: Path, monkeypatch +) -> None: + context = _context(tmp_path, monkeypatch) + context["keyring"]["keys"][0]["approver_id"] = "another-reviewer" + _root_sign_keyring(context["keyring"], context["root_private"]) + monkeypatch.setattr( + planner, + "TRUSTED_CURRENT_KEYRING_PAYLOAD_SHA256", + context["keyring"]["keyring_payload_sha256"], + ) + with pytest.raises(planner.RehearsalProofError, match="unauthorized reviewer"): + _validate(context) + + +def test_rollback_approval_rejects_more_than_one_signature( + tmp_path: Path, monkeypatch +) -> None: + context = _context(tmp_path, monkeypatch) + context["evidence"]["approvals"].append( + copy.deepcopy(context["evidence"]["approvals"][0]) + ) + with pytest.raises(planner.RehearsalProofError, match="Exactly one"): _validate(context) @@ -626,7 +667,7 @@ def test_keyring_rejects_reused_public_key_for_two_people(tmp_path: Path, monkey ("wrong_image", "does not match the immutable"), ("wrong_schema", "Unsupported rollback rehearsal evidence schema"), ("wrong_signature", "signature verification failed"), - ("duplicate_approval", "approval_id values must be unique"), + ("extra_approval", "Exactly one"), ("runtime", "not bound to this exact env"), ), ) @@ -657,12 +698,8 @@ def test_untrusted_or_incomplete_signed_evidence_is_rejected( _resign_evidence(evidence, context["approver_keys"]) elif case == "wrong_signature": evidence["approvals"][0]["signature_base64"] = base64.b64encode(b"0" * 64).decode() - elif case == "duplicate_approval": - _resign_evidence( - evidence, - context["approver_keys"], - approval_ids=("approval-same", "approval-same"), - ) + elif case == "extra_approval": + evidence["approvals"].append(copy.deepcopy(evidence["approvals"][0])) else: evidence["runtime"]["env_file_sha256"] = "0" * 64 _resign_evidence(evidence, context["approver_keys"])