diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e177361b..87b2feb2 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-13): +- D-307/ADR-0399(已接受):物理 Edge release archive Gate 使用外部 Ed25519 签发、QingLong verify-only 的两阶段协议。`prepare` exact 重建 owner-private 统一物理报告,要求 direct release service start 已通过,并把 repository、40 位 Git revision、设备/boot、物理报告、release archive、实机 artifact tree/metadata/entrypoint 与 Node digest/version 编入无换行 canonical payload;私钥始终位于 HSM/KMS/离线 operator。`finalize` 以 operator-pinned SPKI 公钥重算 fingerprint,稳定读取并复核所有输入后验证 64-byte detached signature,任一 source/archive/evidence 漂移均失败关闭,输出 `0600` no-replace envelope。通过只把 `release_archive_signature` 替换为 `release_archive_signature_or_attestation`,`supported:false` 与其余 firmware、整机 flash、migration、断电、固定实机采集和 Cluster 容量 Gate 全部保留。基础 importer 同时把 Edge、SQLite 与 Plugin Package 三个 workload 的 platform/architecture 精确绑定到统一物理观测,release verifier 再要求 observed/Edge/SQLite 为完整 recorder shape 且 Node identity 相同,拒绝跨主机拼接与重算外层摘要后的最小伪造。实现不新增 package、依赖、daemon、listener、timer、watcher 或设备常驻负担;18-package clean build/test 退出 0,backend 1,208 项为 1,206 pass/2 条件 skip/0 fail,package/dependency/Edge/service bridge/Cluster deployment 审计零 finding。 - D-303/ADR-0391(已接受) Cluster operator context 在离线 `validate` 之后增加显式、只读的 `ql3-cluster-admin context probe`。probe 先完整预检全部 context entry,任何晚出现的配置错误都会在首个网络请求前失败关闭;随后才按固定 catalog 顺序,以 production TLS 1.3、CA、 @@ -5898,6 +5899,10 @@ pnpm evidence:physical-edge-idle -- --manifest=/absolute/idle.json --output=/abs pnpm evidence:physical-edge-fault -- --manifest=/absolute/fault.json --output=/absolute/fault-evidence.json --json # 在真实数据文件系统中通过正式 TaskDefinition Repository 写入并扫描三档规模 pnpm evidence:physical-edge-task-scale -- --manifest=/absolute/task-scale.json --data-path=/absolute/scratch --output=/absolute/task-scale-evidence.json --json +# 统一物理报告通过且包含 direct release start 后,生成外部签名 payload;私钥不由 QingLong 持有 +pnpm evidence:physical-edge-release -- prepare --physical-report=/absolute/physical.json --release-archive=/absolute/qinglong3-edge.tar.gz --repository=https://github.com/whyour/qinglong.git --revision=<40-lowercase-git-revision> --payload=/absolute/release-payload.json --json +# operator 外部签署 exact payload 后,以固定 Ed25519 公钥完成 verify-only envelope +pnpm evidence:physical-edge-release -- finalize --physical-report=/absolute/physical.json --release-archive=/absolute/qinglong3-edge.tar.gz --payload=/absolute/release-payload.json --signature=/absolute/release-payload.sig --trusted-public-key=/absolute/release-ed25519.pub --expected-repository=https://github.com/whyour/qinglong.git --expected-revision=<40-lowercase-git-revision> --output=/absolute/physical-release-evidence.json --json ``` Edge 基准记录 runtime 模块加载 RSS 增量、单个空任务、10000 行流式输出和取消时延;Node SQLite 基准记录 transaction p95、批次 stall、RSS 与 integrity;Local Workflow 门直接运行真实两步 product vertical、记录进程 peak RSS,以正式 Repository 测量每 Workflow 单写事务,并运行 admission 与 conclusive-stop/control-terminal 两组各 16 点、共 32 点 `SIGKILL` crash/reopen/replay。Local AI Prompt 门直接运行正式 install/materialize/publication、active composition、execute 与 exact replay,记录进程 peak RSS、SQLite logical/allocated growth、content-free 与 provider exactly-once,并分别运行 ModelInvocation start/completion 14 点和 Prompt admission/finalization 外层事务 20 点 `SIGKILL` 矩阵;它仍明确不证明物理断电。Cluster 基准记录控制面模块加载和 disabled activation,并强制数据库打开与 runtime assembly 次数为零。 @@ -5912,7 +5917,7 @@ ADR-0088 将 Linux CI 拆为三个不能互相替代的档位: 三档都固定 Node `24.18.0` 和 Debian slim,只在原生 Linux x64/arm64 runner 上执行。`scripts/ql3-linux-resource-gate.cjs` 必须从容器内部证明 cgroup v2 的 memory/swap/cpu/PID 精确限额、工作前后零 `max`/OOM 事件、非 root、只读根与 workspace、有界可写 `/tmp`、seccomp 和 `NoNewPrivs`;Docker CLI 参数、`os.totalmem()`、QEMU 或一次成功退出均不能替代这些事实。Workflow/Prompt crash report 永久保持 `physicalPowerLossProven=false`;固定设备受控断电前,它只能证明进程崩溃恢复。Docker tmpfs 上的 logical/allocated growth 也不能推导闪存 FTL 写放大。 -当前 Edge 产品候选仍是 1 CPU、256 MiB RAM、1 GiB 可用持久空间,推荐 512 MiB,但正式支持阈值必须来自固定物理设备。`scripts/ql3-physical-edge-evidence.cjs` 已提供 fail-closed candidate recorder:绑定 exact-shape 设备 manifest、实际架构/内存/kernel/libc/文件系统/容量、Edge 与真实数据盘 Node SQLite 基准,拒绝容器/VM 指示、symlink/路径漂移和覆盖已有输出,以 `0600` + fsync 发布 SHA-256 绑定报告,并永久输出 `supported=false`。D-87 又增加了同设备同 boot 的进程 idle sampler 与专用文件系统 fault probe;D-88 增加了通过正式 TaskDefinition Repository 在 fresh v14 SQLite 上写入并完整扫描 100/1000/10000 三档的规模记录器,同时记录 RSS、耗时与数据库 logical/allocated bytes。D-306B2/ADR-0398 进一步把真实 Plugin Package 失败升级加入基础 workload:在同一 scratch 数据盘的 fresh production migration SQLite 上先激活 generation 1,再以正式 recovery coordinator、候选物化 prerequisite 和 SQLite repositories 恢复含循环 Workflow 的 generation 2;只有 v2 进入 `failed(activation_fact_conflict)`、旧 active digest 双重保留、publisher 零调用、候选 materialized revision 零行、`integrity_check=ok` 且耗时/RSS/logical 与 allocated 增长均未越界才通过。该 workload 也进入 128/256 MiB cgroup 门,但容器结果仍只属于 stress;统一 physical recorder 在无虚拟化固定设备上采集的总报告才属于物理候选。基础记录器只能导入私有、摘要有效且 scope 精确的同设备同 boot 报告。规模报告不证明 scheduler 吞吐,也不替代 2.x adopted migration 的时间与磁盘峰值;失败升级报告也永久声明 `physical_power_loss_not_proven`。物理支持仍需补齐冷启动/首次 ready、整机指标、adopted migration、application recovery、断电和 release signature;规模与失败升级协议虽已实现,仍待固定实机采集。Cluster 必须另行记录副本数、PostgreSQL/连接池、claim/ACK/completion 吞吐、重复率、队列深度和 failover;不得从 512 MiB 空载门禁推导生产规格。 +当前 Edge 产品候选仍是 1 CPU、256 MiB RAM、1 GiB 可用持久空间,推荐 512 MiB,但正式支持阈值必须来自固定物理设备。`scripts/ql3-physical-edge-evidence.cjs` 已提供 fail-closed candidate recorder:绑定 exact-shape 设备 manifest、实际架构/内存/kernel/libc/文件系统/容量、Edge 与真实数据盘 Node SQLite 基准,拒绝容器/VM 指示、symlink/路径漂移和覆盖已有输出,以 `0600` + fsync 发布 SHA-256 绑定报告,并永久输出 `supported=false`。D-87 又增加了同设备同 boot 的进程 idle sampler 与专用文件系统 fault probe;D-88 增加了通过正式 TaskDefinition Repository 在 fresh v14 SQLite 上写入并完整扫描 100/1000/10000 三档的规模记录器,同时记录 RSS、耗时与数据库 logical/allocated bytes。D-306B2/ADR-0398 进一步把真实 Plugin Package 失败升级加入基础 workload:在同一 scratch 数据盘的 fresh production migration SQLite 上先激活 generation 1,再以正式 recovery coordinator、候选物化 prerequisite 和 SQLite repositories 恢复含循环 Workflow 的 generation 2;只有 v2 进入 `failed(activation_fact_conflict)`、旧 active digest 双重保留、publisher 零调用、候选 materialized revision 零行、`integrity_check=ok` 且耗时/RSS/logical 与 allocated 增长均未越界才通过。该 workload 也进入 128/256 MiB cgroup 门,但容器结果仍只属于 stress;统一 physical recorder 在无虚拟化固定设备上采集的总报告才属于物理候选。基础记录器只能导入私有、摘要有效且 scope 精确的同设备同 boot 报告,且三个基础 workload 的 platform/architecture 必须与统一设备观测相同。D-307/ADR-0399 增加 verify-only 外部 release attestation:operator 在 QingLong 之外签署绑定 source revision、archive、物理报告、实机 artifact 与 Node identity 的 canonical payload;验签成功只关闭 `release_archive_signature`,不会改变 `supported:false` 或其它 Gate。规模报告不证明 scheduler 吞吐,也不替代 2.x adopted migration 的时间与磁盘峰值;失败升级报告也永久声明 `physical_power_loss_not_proven`。物理支持仍需补齐冷启动/首次 ready、整机指标、adopted migration、application recovery、断电、真实 release ceremony 和固定设备采集;现有协议实现不等于现场证据。Cluster 必须另行记录副本数、PostgreSQL/连接池、claim/ACK/completion 吞吐、重复率、队列深度和 failover;不得从 512 MiB 空载门禁推导生产规格。 冷启动/首次 ready 只能对最终 application artifact 与冻结的 readiness contract 计时,不能用 Executor benchmark 或模块 import 代替。TaskDefinition schema、独立 execution revision digest、版本化 Trigger schema、Repository 与读取路径现已由 local SQLite capability v16 冻结,规模记录器只能调用正式端口;它生成的仍是物理 candidate,固定设备报告采集前不形成支持结论。fresh schema migration 计时只属于该规模报告的局部度量,2.x adopted database 仍须单独记录 migration 时间、替表额外磁盘峰值与采样精度。对应证据未完成前,candidate report 保留其余 required evidence。 diff --git a/docs/adr/ADR-0088-linux-resource-envelopes-and-physical-device-evidence.md b/docs/adr/ADR-0088-linux-resource-envelopes-and-physical-device-evidence.md index 61e8228f..7c522ff7 100644 --- a/docs/adr/ADR-0088-linux-resource-envelopes-and-physical-device-evidence.md +++ b/docs/adr/ADR-0088-linux-resource-envelopes-and-physical-device-evidence.md @@ -400,8 +400,41 @@ recorder 以 `--direct-service-start-evidence=` 导入;通过 移除 `direct_release_unit_without_evidence_wrapper`,firmware/bootloader、 whole-device 写放大、断电、签名和其它未采集门不变。 +### Release archive 外部签名证明 + +ADR-0399 将 release archive Gate 设计为 QingLong verify-only 的两阶段协议。基础物理 +报告必须已导入通过的 direct release start evidence;`prepare` 把 source repository、 +40 位 Git revision、物理设备/boot、release archive digest、实机 artifact tree 与 Node +identity 写入 canonical signing payload,operator 在 QingLong 外部使用 Ed25519 私钥签署, +`finalize` 再用固定 SPKI 公钥复算 fingerprint 并验证 detached signature: + +```sh +pnpm evidence:physical-edge-release -- prepare \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --repository=https://github.com/whyour/qinglong.git \ + --revision=<40-lowercase-git-revision> \ + --payload=/opt/qinglong/evidence/release-payload.json + +# operator 外部签署 exact payload bytes + +pnpm evidence:physical-edge-release -- finalize \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --payload=/opt/qinglong/evidence/release-payload.json \ + --signature=/opt/qinglong/evidence/release-payload.sig \ + --trusted-public-key=/etc/qinglong/release-ed25519.pub \ + --expected-repository=https://github.com/whyour/qinglong.git \ + --expected-revision=<40-lowercase-git-revision> \ + --output=/opt/qinglong/evidence/physical-release-evidence.json +``` + +验签通过只关闭 `release_archive_signature`。公钥分发/撤销、真实签发 ceremony、固定设备 +采集、firmware/bootloader、whole-device flash、adopted migration 与断电证据继续独立; +输出永久保持 `supported=false`。私钥不得进入 QingLong 仓库、设备数据目录、环境变量或命令。 + ## 后续约束 -物理设备 candidate recorder、进程 idle sampler、专用文件系统 fault probe、正式 TaskDefinition 规模记录、Compose storage、warm-Node native application start、wrapper init-managed 与 direct release init-managed 跨启动候选协议已实现;真实 Local Workflow 也已进入 128/256 MiB CI 资源门,但下一切片仍需补齐 firmware/bootloader clock、整机 wakeup/FTL 写放大、2.x adopted migration、application recovery、受控突发断电和 release archive signature,并在固定设备采集现有协议报告,而不是继续降低容器内存数字。只有固定 x64/arm64 设备矩阵完成后,才能把某一档位从 `ci_*` 提升为产品支持证据;任何阈值调整都必须说明硬件、内核、文件系统、工作负载和历史基线,Cluster 扩容结论继续走独立容量测试。 +物理设备 candidate recorder、进程 idle sampler、专用文件系统 fault probe、正式 TaskDefinition 规模记录、Compose storage、warm-Node native application start、wrapper init-managed、direct release init-managed 与外部 release attestation 候选协议已实现;真实 Local Workflow 也已进入 128/256 MiB CI 资源门,但下一切片仍需补齐 firmware/bootloader clock、整机 wakeup/FTL 写放大、2.x adopted migration、application recovery、受控突发断电,并在固定设备执行现有 recorder 与真实 signing ceremony,而不是继续降低容器内存数字。只有固定 x64/arm64 设备矩阵完成后,才能把某一档位从 `ci_*` 提升为产品支持证据;任何阈值调整都必须说明硬件、内核、文件系统、工作负载和历史基线,Cluster 扩容结论继续走独立容量测试。 首次 active recorder 已绑定最终可发布 application artifact 与正式 readiness contract;wrapper init-managed recorder 把 Node 前 kernel uptime、systemd/OpenRC 和 live process tree 纳入同一 boot;direct recorder 再以生产 startup receipt 关闭 wrapper 与最终 release unit 的结构差异。但 Linux uptime 仍不含 firmware,当前所有开发机/Docker 结果也未替代固定物理设备报告,所以仍不能替代完整 power-on Gate。当前 Executor benchmark 或一次模块 import同样不能替代。TaskDefinition 持久化 schema、Repository 与读取路径现已由 ADR-0089 冻结,100/1000/10000 规模记录器也已接入基础报告,但尚未采集固定设备结果。其 fresh schema migration 计时不得替代 2.x adopted database migration,后者仍须记录可审计的额外磁盘峰值采样精度。未完成的边界继续保留在 remaining evidence。 diff --git a/docs/adr/ADR-0399-external-release-attestation-for-physical-edge-evidence.md b/docs/adr/ADR-0399-external-release-attestation-for-physical-edge-evidence.md new file mode 100644 index 00000000..502dc3dc --- /dev/null +++ b/docs/adr/ADR-0399-external-release-attestation-for-physical-edge-evidence.md @@ -0,0 +1,118 @@ +# ADR-0399:物理 Edge 证据的外部发布归档证明 + +- 状态:Accepted(协议、exact recorder 重建与本地离线验签已实现;18-package/backend/边界门通过,固定物理设备和真实 release ceremony 待执行) +- 日期:2026-08-14 +- 关联 RFC:QL-RFC-0001 D-05、D-06、D-86、D-196、D-307 +- 关联 ADR:ADR-0088、ADR-0206、ADR-0363、ADR-0398 + +## 上下文 + +物理 Edge 聚合报告已经能绑定设备、数据文件系统、同 boot workload,以及最终 +systemd/OpenRC unit 直接运行的 Node、production application artifact 和 startup +receipt;但它仍把 `release_archive_signature` 保留为发布 Gate。仅记录 Git revision +或归档 SHA-256 不足以证明归档、源码 revision、设备实际运行 artifact 与 Node 是同一 +次发布决策;让 QingLong 自己生成并保存发布私钥,又会把构建、签发和验证 authority +混入低配设备工具。 + +基础 importer 还存在一个较窄的身份绑定缺口:Edge Executor、Node SQLite 和 Plugin +Package recovery 三个子 workload 虽在 recorder 中由同一进程启动,离线重建时没有逐项 +复验其 platform/architecture。攻击者可以在重算外层摘要后把其他主机报告注入统一报告。 + +## 决策 + +新增非 workspace-package、无第三方依赖、无常驻进程的 +`scripts/ql3-physical-edge-release-evidence.cjs`,根命令为 +`evidence:physical-edge-release`。它只承担确定性载荷生成与验签,不持有或调用私钥。 + +协议分两阶段: + +1. `prepare` 读取当前 UID 所有、`0600`、无 symlink 的统一物理报告,完整重建其 manifest、 + 三个基础 workload、全部 supplemental evidence、qualification 和 SHA-256;必须已有通过的 + direct release service start evidence,并且 `release_archive_signature` 恰好仍缺一次。 +2. 工具以稳定 descriptor 流式读取不超过 64 MiB、不可被 group/other 写入的 release archive, + 生成不带换行的 canonical JSON signing payload。payload 精确绑定 repository、40 位小写 + Git revision、物理报告摘要、设备/boot、归档摘要/字节数,以及实机 direct report 中的 + artifact tree、metadata、entrypoint、Node digest/version。 +3. 发布 operator 在工具之外通过 HSM、KMS、离线机或 OpenSSL 使用 Ed25519 私钥签署 payload + 原始字节。私钥不进入 QingLong data path、配置、环境变量、报告或进程参数。 +4. `finalize` 从调用方固定的 Ed25519 SPKI 公钥重新计算 SHA-256 fingerprint,重新读取并 + 计算全部输入,要求 payload 是 exact canonical bytes,再验证 64-byte detached signature。 + source revision、archive 或物理报告任一漂移都失败关闭;输出以 `0600`、no-replace、fsync + 发布。 + +通过的最终 envelope 只把 `release_archive_signature` 替换为 +`release_archive_signature_or_attestation`。它永久保持 `supported:false`,保留原报告中的 +firmware/bootloader、whole-device flash、migration、断电等所有未完成证据。 + +基础 recorder 同时收紧 workload identity:Edge report 的 `host.platform/architecture`、 +SQLite report 的 `platform/arch`、Plugin Package report 的 +`identity.platform/architecture` 必须全部等于统一物理观测值。该检查不增加设备工作量, +只拒绝跨主机拼接。 + +## Operator ceremony + +```sh +pnpm evidence:physical-edge-release -- prepare \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --repository=https://github.com/whyour/qinglong.git \ + --revision=<40-lowercase-git-revision> \ + --payload=/opt/qinglong/evidence/release-signing-payload.json \ + --json + +# 在 QingLong 工具之外签署 exact payload bytes;下面只是一种 operator 实现。 +openssl pkeyutl -sign -rawin \ + -inkey /offline/release-ed25519.key \ + -in /opt/qinglong/evidence/release-signing-payload.json \ + -out /opt/qinglong/evidence/release-signing-payload.sig +chmod 600 /opt/qinglong/evidence/release-signing-payload.sig + +pnpm evidence:physical-edge-release -- finalize \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --payload=/opt/qinglong/evidence/release-signing-payload.json \ + --signature=/opt/qinglong/evidence/release-signing-payload.sig \ + --trusted-public-key=/etc/qinglong/release-ed25519.pub \ + --expected-repository=https://github.com/whyour/qinglong.git \ + --expected-revision=<40-lowercase-git-revision> \ + --output=/opt/qinglong/evidence/physical-release-evidence.json \ + --json +``` + +public key 与 archive 必须是 canonical regular file、无 symlink,且 group/other 不可写; +physical report、payload、signature 必须为当前 UID 所有的 `0600` 文件。所有输入在读取前后 +复验 device/inode/size/mtime/ctime,输出拒绝覆盖已有文件。 + +## 不证明的内容 + +- verifier 不展开 archive,也不自行复现 build;“archive、source 与 runtime artifact 属于同一 + 发布”的关系由被信任 operator 的签名证明。 +- 公钥分发、轮换、撤销、透明日志和 signer 组织流程仍由 release authority 负责。 +- 签名不证明固定设备已经采集、firmware/bootloader 起点、exclusive cold cache、整机 CPU + wakeup、NAND/FTL 写放大、突发断电、adopted migration 或 Cluster 容量。 +- 本地生成的测试 key 和合成物理 fixture 只验证协议,不可作为正式 release evidence。 + +## 被否决的方案 + +1. **把私钥放入仓库或 recorder**:混合签发与验证 authority,并扩大低配设备 Secret 面,拒绝。 +2. **只签 archive digest**:无法绑定 source revision、实机报告和实际运行 artifact,拒绝。 +3. **只信任外层物理报告摘要**:无法阻止可重算摘要下的跨 platform workload 拼接,拒绝。 +4. **验签成功即设置 `supported:true`**:签名只关闭一个 Gate,不能替代剩余物理证据,拒绝。 +5. **为一次性工具新建 workspace package**:没有独立制品、依赖或常驻 authority,违反 package + 边界原则,拒绝。 + +## 验收 + +- exact payload、Ed25519 成功验签、公钥 fingerprint、归档篡改、source mismatch、私有文件 + mode、canonical path、no-replace 和 remaining evidence 由 backend contract test 覆盖。 +- 跨 platform Plugin Package workload 必须使基础物理 candidate 失败。 +- 工具不得新增 workspace package、production dependency、daemon、listener、timer、watcher、 + 数据库 migration 或 Edge 常驻导入。 +- 阶段合并前必须通过相关定向测试、18-package clean build/test、完整 backend、package 边界、 + dependency/import 审计和 GitNexus staged change 审计。 + +当前实现完成后,18-package clean build/test 退出 0;完整 backend 为 1,208 项、1,206 +通过、2 条 Linux 条件跳过、0 失败。package boundary 仍为 18 个 package, +`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Cluster dependency、Edge import、 +service bridge import 与 Cluster deployment 审计均无 finding。以上只验证协议和回归, +本地合成 fixture 与测试 key 不替代固定设备报告或正式 release signing ceremony。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 98b58391..a6c7bea8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -402,6 +402,7 @@ | [ADR-0396](./ADR-0396-generation-transition-plugin-package-secret-binding.md) | 按 Package Generation 切换 Plugin Package Secret Binding | Proposed | | [ADR-0397](./ADR-0397-explicit-cluster-secret-action-manual-recovery.md) | Cluster Secret Action 显式人工恢复 | Accepted(实现、单节点 PostgreSQL、完整 workspace/后端/边界与 physical HA 门完成) | | [ADR-0398](./ADR-0398-pre-activation-plugin-package-candidate-qualification.md) | Plugin Package 激活前候选资格校验与自动保留旧版本 | Proposed(v2 私有报告/离线审计已编排、全量/HA 门完成;远端 Kubernetes 成功记录与物理低配门待闭合) | +| [ADR-0399](./ADR-0399-external-release-attestation-for-physical-edge-evidence.md) | 物理 Edge 证据的外部发布归档证明 | Accepted(exact recorder 重建与离线验签已实现;固定实机 ceremony 待执行) | ## 规则 diff --git a/docs/operations/ql3-local-deployment.md b/docs/operations/ql3-local-deployment.md index 89aa488a..a136b8f3 100644 --- a/docs/operations/ql3-local-deployment.md +++ b/docs/operations/ql3-local-deployment.md @@ -1258,6 +1258,45 @@ disable/descriptor removal、突然断电、firmware shutdown、whole-device fla release signature。报告成功保存并完成聚合后,operator 才可按受审流程 disable,删除 exact descriptor、root handoff 与 scratch deployment;不要使用通配符。 +### 绑定物理报告、Release Archive 与源码 Revision + +统一 physical report 已通过且包含 direct release start 后,才能执行 release +attestation。QingLong 只生成和验证 payload;发布私钥必须留在外部 HSM、KMS 或离线 +签名环境。先以最终待发布 archive 和 exact 40 位小写 Git revision 生成 payload: + +```sh +pnpm evidence:physical-edge-release -- prepare \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --repository=https://github.com/whyour/qinglong.git \ + --revision=<40-lowercase-git-revision> \ + --payload=/opt/qinglong/evidence/release-payload.json \ + --json +``` + +必须签署 `release-payload.json` 的原始字节,不能重新格式化或追加换行。将 64-byte +Ed25519 detached signature 安全传回设备并设为当前 UID `0600`;固定公钥可以只读,但 +不得 group/other writable。随后重新指定期望 source,完成 verify-only finalization: + +```sh +pnpm evidence:physical-edge-release -- finalize \ + --physical-report=/opt/qinglong/evidence/physical.json \ + --release-archive=/opt/qinglong/releases/qinglong3-edge.tar.gz \ + --payload=/opt/qinglong/evidence/release-payload.json \ + --signature=/opt/qinglong/evidence/release-payload.sig \ + --trusted-public-key=/etc/qinglong/release-ed25519.pub \ + --expected-repository=https://github.com/whyour/qinglong.git \ + --expected-revision=<40-lowercase-git-revision> \ + --output=/opt/qinglong/evidence/physical-release-evidence.json \ + --json +``` + +archive、payload、报告或 source revision 任一改变都必须重新 prepare 和外部签名,不能 +复用旧 signature。验签成功只增加 +`release_archive_signature_or_attestation`;输出仍是 `supported=false`,不得据此跳过 +firmware/bootloader、整机写入、migration、断电或固定设备矩阵。公钥轮换/撤销与签名 +透明记录由发布流程单独管理。 + ## 7. 后续 Owner ceremony deployment prepare 只创建存储、Owner pepper 主备和 Local Secret keyring,不会 diff --git a/package.json b/package.json index 77eb9aab..a8705693 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "evidence:physical-edge-service-start": "node scripts/ql3-physical-edge-service-start.cjs", "evidence:physical-edge-direct-service-start": "node scripts/ql3-physical-edge-direct-service-start.cjs", "evidence:physical-edge-direct-service-stop": "node scripts/ql3-physical-edge-direct-service-stop.cjs", + "evidence:physical-edge-release": "node scripts/ql3-physical-edge-release-evidence.cjs", "benchmark:db:node-sqlite": "node scripts/ql3-node-sqlite-benchmark.cjs", "audit:schema:ql3": "pnpm --filter @qinglong/local-owner-cli run readiness", "audit:legacy-schema:ql3": "node scripts/ql3-schema-audit.cjs", diff --git a/scripts/ql3-physical-edge-evidence.cjs b/scripts/ql3-physical-edge-evidence.cjs index 2f314186..c36e1a5a 100644 --- a/scripts/ql3-physical-edge-evidence.cjs +++ b/scripts/ql3-physical-edge-evidence.cjs @@ -731,7 +731,7 @@ function runEvidenceWorkloads(root, dataPath) { } } -function validateEvidenceWorkloads(workloads) { +function validateEvidenceWorkloads(workloads, observed) { if (!Array.isArray(workloads) || workloads.length !== 3) { return Object.freeze(['physical evidence workloads are incomplete']); } @@ -754,6 +754,8 @@ function validateEvidenceWorkloads(workloads) { if ( edge?.schemaVersion !== 1 || edge?.profile !== 'edge' || + edge?.host?.platform !== observed?.platform || + edge?.host?.architecture !== observed?.architecture || edge?.gates?.passed !== true || !Array.isArray(edge?.gates?.violations) || edge.gates.violations.length !== 0 @@ -762,6 +764,8 @@ function validateEvidenceWorkloads(workloads) { } const sqlite = workloads[1].report; if ( + sqlite?.platform !== observed?.platform || + sqlite?.arch !== observed?.architecture || sqlite?.journalMode !== 'delete' || sqlite?.synchronous !== 'full' || sqlite?.integrityCheck !== 'ok' || @@ -776,6 +780,14 @@ function validateEvidenceWorkloads(workloads) { (violation) => `Plugin Package recovery workload: ${violation}`, ), ); + if ( + workloads[2].report?.identity?.platform !== observed?.platform || + workloads[2].report?.identity?.architecture !== observed?.architecture + ) { + violations.push( + 'Plugin Package recovery workload runtime identity did not match', + ); + } return Object.freeze(violations); } @@ -1467,7 +1479,7 @@ function buildEvidenceReport({ }) { const violations = [ ...validateObservedPlatform(manifest, observed), - ...validateEvidenceWorkloads(workloads), + ...validateEvidenceWorkloads(workloads, observed), ]; const idleEvidence = supplementalEvidence.find( ({ evidenceClass }) => evidenceClass === 'physical_edge_idle_candidate', diff --git a/scripts/ql3-physical-edge-release-evidence.cjs b/scripts/ql3-physical-edge-release-evidence.cjs new file mode 100644 index 00000000..a77223d0 --- /dev/null +++ b/scripts/ql3-physical-edge-release-evidence.cjs @@ -0,0 +1,1019 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + QingLong3PhysicalEvidenceError, + buildEvidenceReport, + canonicalDigest, + normalizeManifest, + validateAdoptionScaleEvidenceReport, + validateApplicationStartEvidenceReport, + validateComposeStorageEvidenceReport, + validateDirectServiceStartEvidenceReport, + validateDirectServiceStopEvidenceReport, + validateEvidenceWorkloads, + validateFaultEvidenceReport, + validateIdleEvidenceReport, + validateObservedPlatform, + validateServiceStartEvidenceReport, + validateTaskScaleEvidenceReport, + writeNoReplace, +} = require('./ql3-physical-edge-evidence.cjs'); + +const MAX_PHYSICAL_REPORT_BYTES = 4 * 1024 * 1024; +const MAX_RELEASE_ARCHIVE_BYTES = 64 * 1024 * 1024; +const MAX_PAYLOAD_BYTES = 16 * 1024; +const MAX_PUBLIC_KEY_BYTES = 8 * 1024; +const ED25519_SIGNATURE_BYTES = 64; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const REVISION_PATTERN = /^[a-f0-9]{40}$/; +const RELEASE_SCOPE = + 'operator_attests_archive_source_and_runtime_artifact_identity'; +const PHYSICAL_REPORT_KEYS = Object.freeze([ + 'evidenceClass', + 'generatedAt', + 'manifest', + 'observed', + 'qualification', + 'schemaVersion', + 'sha256', + 'supplementalEvidence', + 'supported', + 'workloads', +]); +const QUALIFICATION_KEYS = Object.freeze([ + 'collectedEvidence', + 'physicalCandidate', + 'remainingRequiredEvidence', + 'violations', +]); +const OBSERVED_KEYS = Object.freeze([ + 'architecture', + 'bootId', + 'cpuCount', + 'cpuModel', + 'dataAvailableBytes', + 'dataBytes', + 'dataFilesystem', + 'dataMountOptions', + 'dataPath', + 'distribution', + 'kernel', + 'libc', + 'node', + 'observedModel', + 'platform', + 'totalMemoryBytes', + 'virtualizationIndicators', +]); +const EDGE_REPORT_KEYS = Object.freeze([ + 'cancellation', + 'cases', + 'gates', + 'generatedAt', + 'host', + 'moduleLoad', + 'profile', + 'schemaVersion', +]); +const EDGE_CASE_KEYS = Object.freeze([ + 'baselineRssBytes', + 'durationMs', + 'exitCode', + 'name', + 'outcome', + 'peakRssBytes', + 'peakRssDeltaBytes', +]); +const SQLITE_REPORT_KEYS = Object.freeze([ + 'arch', + 'batchSize', + 'databaseBytes', + 'integrityCheck', + 'iterations', + 'journalMode', + 'maxBatchStallMs', + 'node', + 'platform', + 'rssDeltaMb', + 'synchronous', + 'transactionMs', +]); +const SUPPLEMENTAL_CLASSES = Object.freeze([ + 'physical_edge_idle_candidate', + 'physical_edge_fault_candidate', + 'physical_edge_task_scale_candidate', + 'physical_edge_adoption_scale_candidate', + 'physical_edge_compose_storage_candidate', + 'physical_edge_application_start_candidate', + 'physical_edge_service_start_candidate', + 'physical_edge_direct_service_start_candidate', + 'physical_edge_direct_service_stop_candidate', +]); +const LIMITATIONS = Object.freeze([ + 'the trusted operator attests the archive, source revision and runtime artifact relationship; this verifier does not unpack or independently reproduce the archive', + 'public-key distribution, rotation, revocation and transparency remain release-operator responsibilities', + 'the signature does not prove firmware or bootloader time, whole-device flash behavior, physical power loss, migration capacity or cluster capacity', + 'the final report remains a physical Edge candidate and never changes supported status', +]); + +class QingLong3PhysicalEdgeReleaseEvidenceError extends Error { + constructor(message) { + super(`QingLong 3.0 physical Edge release evidence failed: ${message}`); + this.name = 'QingLong3PhysicalEdgeReleaseEvidenceError'; + } +} + +function hasExactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === JSON.stringify(expected) + ); +} + +function parseArguments(argv) { + const options = { json: false }; + for (const argument of argv) { + if (argument === '--') continue; + if (argument === '--json') { + options.json = true; + continue; + } + if (!options.phase && ['prepare', 'finalize'].includes(argument)) { + options.phase = argument; + continue; + } + const separator = argument.indexOf('='); + if (separator < 1) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `unsupported argument ${argument}`, + ); + } + const name = argument.slice(0, separator); + const value = argument.slice(separator + 1); + if (name === '--physical-report') options.physicalReportPath = value; + else if (name === '--release-archive') options.releaseArchivePath = value; + else if (name === '--repository') options.repository = value; + else if (name === '--revision') options.revision = value; + else if (name === '--payload') options.payloadPath = value; + else if (name === '--signature') options.signaturePath = value; + else if (name === '--trusted-public-key') options.publicKeyPath = value; + else if (name === '--expected-repository') + options.expectedRepository = value; + else if (name === '--expected-revision') options.expectedRevision = value; + else if (name === '--output') options.outputPath = value; + else { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `unsupported argument ${name}`, + ); + } + } + if (!['prepare', 'finalize'].includes(options.phase)) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'phase must be prepare or finalize', + ); + } + const commonPaths = [ + ['physicalReportPath', options.physicalReportPath], + ['releaseArchivePath', options.releaseArchivePath], + ['payloadPath', options.payloadPath], + ]; + const phasePaths = + options.phase === 'finalize' + ? [ + ['signaturePath', options.signaturePath], + ['publicKeyPath', options.publicKeyPath], + ['outputPath', options.outputPath], + ] + : []; + for (const [label, value] of [...commonPaths, ...phasePaths]) { + if (!path.isAbsolute(value ?? '')) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must be absolute`, + ); + } + } + if (options.phase === 'prepare') { + validateRepository(options.repository, 'repository'); + validateRevision(options.revision, 'revision'); + const invalid = [ + options.signaturePath, + options.publicKeyPath, + options.expectedRepository, + options.expectedRevision, + options.outputPath, + ].some((value) => value !== undefined); + if (invalid) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'prepare received a finalize-only option', + ); + } + } else { + validateRepository(options.expectedRepository, 'expectedRepository'); + validateRevision(options.expectedRevision, 'expectedRevision'); + if (options.repository !== undefined || options.revision !== undefined) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'finalize received a prepare-only option', + ); + } + } + return Object.freeze(options); +} + +function validateRepository(value, label) { + if ( + typeof value !== 'string' || + value.length < 3 || + value.length > 512 || + !/^[A-Za-z0-9][A-Za-z0-9._:/@+~-]+$/.test(value) + ) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError(`${label} is invalid`); + } + return value; +} + +function validateRevision(value, label) { + if (!REVISION_PATTERN.test(value ?? '')) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must be a 40-character lowercase Git revision`, + ); + } + return value; +} + +function stableStatIdentity(stat) { + return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':'); +} + +function openStableInput(filePath, label, maximumBytes, privateInput) { + if (!path.isAbsolute(filePath) || fs.realpathSync(filePath) !== filePath) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must use a canonical absolute path`, + ); + } + const before = fs.lstatSync(filePath, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink()) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must be a regular file without symlinks`, + ); + } + const mode = Number(before.mode & 0o777n); + const effectiveUserId = process.geteuid?.(); + if ( + privateInput && + (!Number.isSafeInteger(effectiveUserId) || + before.uid !== BigInt(effectiveUserId) || + mode !== 0o600) + ) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must be a current-user-owned 0600 file`, + ); + } + if (!privateInput && (mode & 0o022) !== 0) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} must not be group- or other-writable`, + ); + } + if (before.size < 1n || before.size > BigInt(maximumBytes)) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} exceeds its bounded size`, + ); + } + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor, { bigint: true }); + if (stableStatIdentity(before) !== stableStatIdentity(opened)) { + fs.closeSync(descriptor); + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} changed while it was opened`, + ); + } + return Object.freeze({ descriptor, before }); +} + +function finishStableInput(filePath, label, descriptor, before) { + const afterDescriptor = fs.fstatSync(descriptor, { bigint: true }); + const afterPath = fs.lstatSync(filePath, { bigint: true }); + if ( + stableStatIdentity(before) !== stableStatIdentity(afterDescriptor) || + stableStatIdentity(before) !== stableStatIdentity(afterPath) + ) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} changed while it was read`, + ); + } +} + +function readStableBuffer(filePath, label, maximumBytes, privateInput) { + const { descriptor, before } = openStableInput( + filePath, + label, + maximumBytes, + privateInput, + ); + try { + const contents = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < contents.length) { + const count = fs.readSync( + descriptor, + contents, + offset, + contents.length - offset, + offset, + ); + if (count === 0) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} ended before its declared size`, + ); + } + offset += count; + } + finishStableInput(filePath, label, descriptor, before); + return contents; + } finally { + fs.closeSync(descriptor); + } +} + +function hashStableFile(filePath, label, maximumBytes) { + const { descriptor, before } = openStableInput( + filePath, + label, + maximumBytes, + false, + ); + try { + const digest = crypto.createHash('sha256'); + const buffer = Buffer.alloc(64 * 1024); + let offset = 0; + while (offset < Number(before.size)) { + const count = fs.readSync( + descriptor, + buffer, + 0, + Math.min(buffer.length, Number(before.size) - offset), + offset, + ); + if (count === 0) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} ended before its declared size`, + ); + } + digest.update(buffer.subarray(0, count)); + offset += count; + } + finishStableInput(filePath, label, descriptor, before); + return Object.freeze({ + sha256: digest.digest('hex'), + bytes: Number(before.size), + }); + } finally { + fs.closeSync(descriptor); + } +} + +function parsePrivateJson(filePath, label, maximumBytes) { + const contents = readStableBuffer(filePath, label, maximumBytes, true); + try { + return JSON.parse(contents.toString('utf8')); + } catch (error) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `${label} is not valid JSON: ${error.message}`, + ); + } +} + +function validateExactObserved(observed) { + if ( + !hasExactKeys(observed, OBSERVED_KEYS) || + !hasExactKeys(observed?.distribution, ['id', 'versionId']) || + observed.platform !== 'linux' || + !['arm64', 'x64', 'arm'].includes(observed.architecture) || + !/^v24\.[0-9]+\.[0-9]+$/.test(observed.node ?? '') || + !/^[a-f0-9-]{36}$/.test(observed.bootId ?? '') || + !path.isAbsolute(observed.dataPath ?? '') || + !Number.isSafeInteger(observed.cpuCount) || + observed.cpuCount < 1 || + !Number.isSafeInteger(observed.totalMemoryBytes) || + observed.totalMemoryBytes < 1 || + !Number.isSafeInteger(observed.dataBytes) || + observed.dataBytes < 1 || + !Number.isSafeInteger(observed.dataAvailableBytes) || + observed.dataAvailableBytes < 0 || + observed.dataAvailableBytes > observed.dataBytes || + !Array.isArray(observed.dataMountOptions) || + observed.dataMountOptions.length < 1 || + observed.dataMountOptions.some( + (option) => typeof option !== 'string' || option.length > 128, + ) || + !Array.isArray(observed.virtualizationIndicators) || + ![ + observed.cpuModel, + observed.observedModel, + observed.kernel, + observed.libc, + observed.dataFilesystem, + observed.distribution.id, + observed.distribution.versionId, + ].every( + (value) => + typeof value === 'string' && value.length >= 1 && value.length <= 384, + ) + ) { + return Object.freeze(['physical observed platform shape is invalid']); + } + return Object.freeze([]); +} + +function validRssMeasurement(value) { + return ( + hasExactKeys(value, ['rssAfterBytes', 'rssBeforeBytes', 'rssDeltaBytes']) && + Number.isSafeInteger(value.rssBeforeBytes) && + value.rssBeforeBytes > 0 && + Number.isSafeInteger(value.rssAfterBytes) && + value.rssAfterBytes > 0 && + Number.isSafeInteger(value.rssDeltaBytes) && + value.rssDeltaBytes === + Math.max(0, value.rssAfterBytes - value.rssBeforeBytes) + ); +} + +function validEdgeCase(value, expectedName, outputRequired) { + const expectedKeys = outputRequired + ? [...EDGE_CASE_KEYS, 'output'].sort() + : EDGE_CASE_KEYS; + if ( + !hasExactKeys(value, expectedKeys) || + value.name !== expectedName || + value.outcome !== 'succeeded' || + value.exitCode !== 0 || + !Number.isFinite(value.durationMs) || + value.durationMs < 0 || + !Number.isSafeInteger(value.baselineRssBytes) || + value.baselineRssBytes < 1 || + !Number.isSafeInteger(value.peakRssBytes) || + value.peakRssBytes < value.baselineRssBytes || + value.peakRssDeltaBytes !== + Math.max(0, value.peakRssBytes - value.baselineRssBytes) + ) { + return false; + } + return ( + !outputRequired || + (hasExactKeys(value.output, ['bytes', 'lines', 'writes']) && + Number.isSafeInteger(value.output.bytes) && + value.output.bytes > 0 && + value.output.lines === 10_000 && + Number.isSafeInteger(value.output.writes) && + value.output.writes > 0) + ); +} + +function validateExactBaseWorkloads(workloads, observed) { + const violations = []; + if (!Array.isArray(workloads) || workloads.length !== 3) { + return Object.freeze(['physical evidence workloads are incomplete']); + } + const edge = workloads[0]?.report; + if ( + !hasExactKeys(edge, EDGE_REPORT_KEYS) || + !hasExactKeys(edge?.host, [ + 'architecture', + 'cpuCount', + 'node', + 'platform', + 'totalMemoryBytes', + ]) || + edge.host.platform !== observed.platform || + edge.host.architecture !== observed.architecture || + edge.host.node !== observed.node || + !Number.isSafeInteger(edge.host.cpuCount) || + edge.host.cpuCount < 1 || + !Number.isSafeInteger(edge.host.totalMemoryBytes) || + edge.host.totalMemoryBytes < 1 || + !Number.isFinite(Date.parse(edge.generatedAt ?? '')) || + !validRssMeasurement(edge.moduleLoad) || + !Array.isArray(edge.cases) || + edge.cases.length !== 2 || + !validEdgeCase(edge.cases[0], 'single_noop', false) || + !validEdgeCase(edge.cases[1], 'stdout_10000_lines', true) || + !hasExactKeys(edge.cancellation, [ + 'durationMs', + 'killSignalSent', + 'outcome', + 'termSignalSent', + ]) || + !Number.isFinite(edge.cancellation.durationMs) || + edge.cancellation.durationMs < 0 || + edge.cancellation.outcome !== 'cancelled' || + typeof edge.cancellation.termSignalSent !== 'boolean' || + typeof edge.cancellation.killSignalSent !== 'boolean' || + !hasExactKeys(edge.gates, [ + 'maxCancelMs', + 'maxRssDeltaMb', + 'passed', + 'violations', + ]) || + edge.gates.maxCancelMs !== 5000 || + edge.gates.maxRssDeltaMb !== 96 || + edge.gates.passed !== true || + !Array.isArray(edge.gates.violations) || + edge.gates.violations.length !== 0 + ) { + violations.push('Edge executor report is not an exact recorder result'); + } + const sqlite = workloads[1]?.report; + if ( + !hasExactKeys(sqlite, SQLITE_REPORT_KEYS) || + sqlite.platform !== observed.platform || + sqlite.arch !== observed.architecture || + sqlite.node !== observed.node || + sqlite.iterations !== 250 || + sqlite.batchSize !== 10 || + sqlite.journalMode !== 'delete' || + sqlite.synchronous !== 'full' || + sqlite.integrityCheck !== 'ok' || + !Number.isSafeInteger(sqlite.databaseBytes) || + sqlite.databaseBytes < 1 || + !Number.isFinite(sqlite.maxBatchStallMs) || + sqlite.maxBatchStallMs < 0 || + !Number.isFinite(sqlite.rssDeltaMb) || + sqlite.rssDeltaMb < 0 || + !hasExactKeys(sqlite.transactionMs, ['max', 'p50', 'p95', 'p99']) || + ![ + sqlite.transactionMs.p50, + sqlite.transactionMs.p95, + sqlite.transactionMs.p99, + sqlite.transactionMs.max, + ].every((value) => Number.isFinite(value) && value >= 0) || + sqlite.transactionMs.p50 > sqlite.transactionMs.p95 || + sqlite.transactionMs.p95 > sqlite.transactionMs.p99 || + sqlite.transactionMs.p99 > sqlite.transactionMs.max + ) { + violations.push('Node SQLite report is not an exact recorder result'); + } + if (workloads[2]?.report?.identity?.node !== observed.node) { + violations.push('Plugin Package recovery Node identity did not match'); + } + return Object.freeze(violations); +} + +function validateSupplementalEvidence(report, manifest, observed) { + const violations = []; + const counts = new Map(); + const directStart = report.supplementalEvidence.find( + ({ evidenceClass }) => + evidenceClass === 'physical_edge_direct_service_start_candidate', + ); + for (const evidence of report.supplementalEvidence) { + const evidenceClass = evidence?.evidenceClass; + if (!SUPPLEMENTAL_CLASSES.includes(evidenceClass)) { + violations.push(`unknown supplemental evidence class ${evidenceClass}`); + continue; + } + const discriminator = + evidenceClass === 'physical_edge_fault_candidate' + ? `${evidenceClass}:${evidence.manifest?.fault}` + : evidenceClass; + counts.set(discriminator, (counts.get(discriminator) ?? 0) + 1); + if (counts.get(discriminator) > 1) { + violations.push(`duplicate supplemental evidence ${discriminator}`); + continue; + } + let evidenceViolations; + if (evidenceClass === 'physical_edge_idle_candidate') { + evidenceViolations = validateIdleEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_fault_candidate') { + evidenceViolations = validateFaultEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_task_scale_candidate') { + evidenceViolations = validateTaskScaleEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_adoption_scale_candidate') { + evidenceViolations = validateAdoptionScaleEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_compose_storage_candidate') { + evidenceViolations = validateComposeStorageEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_application_start_candidate') { + evidenceViolations = validateApplicationStartEvidenceReport( + evidence, + manifest, + observed, + ); + } else if (evidenceClass === 'physical_edge_service_start_candidate') { + evidenceViolations = validateServiceStartEvidenceReport( + evidence, + manifest, + observed, + ); + } else if ( + evidenceClass === 'physical_edge_direct_service_start_candidate' + ) { + evidenceViolations = validateDirectServiceStartEvidenceReport( + evidence, + manifest, + observed, + ); + } else { + evidenceViolations = validateDirectServiceStopEvidenceReport( + evidence, + manifest, + observed, + directStart, + ); + } + violations.push( + ...evidenceViolations.map( + (violation) => `${evidenceClass}: ${violation}`, + ), + ); + } + return Object.freeze(violations); +} + +function validatePhysicalEvidenceReport(report) { + const violations = []; + if (!hasExactKeys(report, PHYSICAL_REPORT_KEYS)) { + return Object.freeze(['physical evidence report shape is invalid']); + } + if (!hasExactKeys(report.qualification, QUALIFICATION_KEYS)) { + return Object.freeze(['physical evidence qualification shape is invalid']); + } + let manifest; + try { + manifest = normalizeManifest(report.manifest); + } catch (error) { + return Object.freeze([ + `physical evidence manifest is invalid: ${error.message}`, + ]); + } + if ( + report.schemaVersion !== 1 || + report.evidenceClass !== 'physical_edge_candidate' || + report.supported !== false || + !Number.isFinite(Date.parse(report.generatedAt ?? '')) || + !Array.isArray(report.workloads) || + !Array.isArray(report.supplementalEvidence) + ) { + violations.push('physical evidence identity is invalid'); + } + violations.push( + ...validateExactObserved(report.observed), + ...validateObservedPlatform(manifest, report.observed), + ...validateEvidenceWorkloads(report.workloads, report.observed), + ...validateExactBaseWorkloads(report.workloads, report.observed), + ); + if (Array.isArray(report.supplementalEvidence)) { + violations.push( + ...validateSupplementalEvidence(report, manifest, report.observed), + ); + } + let rebuilt; + try { + rebuilt = buildEvidenceReport({ + manifest, + observed: report.observed, + workloads: report.workloads, + supplementalEvidence: report.supplementalEvidence, + generatedAt: report.generatedAt, + }); + } catch (error) { + violations.push(`physical evidence could not be rebuilt: ${error.message}`); + } + if (rebuilt && JSON.stringify(rebuilt) !== JSON.stringify(report)) { + violations.push('physical evidence report was not an exact reconstruction'); + } + if ( + report.qualification.physicalCandidate !== true || + report.qualification.violations.length !== 0 + ) { + violations.push('physical evidence candidate did not pass'); + } + const directStart = report.supplementalEvidence.find?.( + ({ evidenceClass }) => + evidenceClass === 'physical_edge_direct_service_start_candidate', + ); + if (!directStart) { + violations.push('direct release service start evidence is required'); + } + const releaseRequirementCount = + report.qualification.remainingRequiredEvidence.filter?.( + (item) => item === 'release_archive_signature', + ).length ?? 0; + if (releaseRequirementCount !== 1) { + violations.push('release archive signature requirement is not exact'); + } + return Object.freeze(violations); +} + +function requirePhysicalEvidenceReport(report) { + const violations = validatePhysicalEvidenceReport(report); + if (violations.length > 0) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `physical evidence rejected: ${violations.join('; ')}`, + ); + } + return Object.freeze(report); +} + +function buildSigningPayload({ + physicalReport, + releaseArchive, + repository, + revision, + signedAt, +}) { + validateRepository(repository, 'repository'); + validateRevision(revision, 'revision'); + if (!Number.isFinite(Date.parse(signedAt ?? ''))) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'signedAt must be an ISO timestamp', + ); + } + if ( + !SHA256_PATTERN.test(releaseArchive?.sha256 ?? '') || + !Number.isSafeInteger(releaseArchive?.bytes) || + releaseArchive.bytes < 1 || + releaseArchive.bytes > MAX_RELEASE_ARCHIVE_BYTES + ) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'release archive identity is invalid', + ); + } + const directStart = physicalReport.supplementalEvidence.find( + ({ evidenceClass }) => + evidenceClass === 'physical_edge_direct_service_start_candidate', + ); + const artifact = directStart.observed.artifact; + const after = directStart.observed.after; + return Object.freeze({ + schemaVersion: 1, + attestationClass: 'physical_edge_release_archive_binding', + algorithm: 'Ed25519', + repository, + revision, + signedAt, + scope: RELEASE_SCOPE, + device: Object.freeze({ + deviceId: physicalReport.manifest.deviceId, + deviceModel: physicalReport.manifest.deviceModel, + soc: physicalReport.manifest.soc, + architecture: physicalReport.observed.architecture, + filesystem: physicalReport.observed.dataFilesystem, + storageMedium: physicalReport.manifest.storageMedium, + bootId: physicalReport.observed.bootId, + }), + physicalEvidence: Object.freeze({ + sha256: physicalReport.sha256, + generatedAt: physicalReport.generatedAt, + }), + release: Object.freeze({ + archiveSha256: releaseArchive.sha256, + archiveBytes: releaseArchive.bytes, + profile: 'edge', + artifactSha256: artifact.artifactSha256, + artifactMetadataSha256: artifact.artifactMetadataSha256, + artifactFiles: artifact.artifactFiles, + artifactBytes: artifact.artifactBytes, + entrypointSha256: artifact.entrypointSha256, + nodeSha256: after.nodeSha256, + nodeVersion: after.nodeVersion, + }), + }); +} + +function readExactPayload(filePath) { + const contents = readStableBuffer( + filePath, + 'signing payload', + MAX_PAYLOAD_BYTES, + true, + ); + let payload; + try { + payload = JSON.parse(contents.toString('utf8')); + } catch (error) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `signing payload is not valid JSON: ${error.message}`, + ); + } + const canonical = Buffer.from(JSON.stringify(payload), 'utf8'); + if ( + contents.length !== canonical.length || + !crypto.timingSafeEqual(contents, canonical) + ) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'signing payload must contain exact canonical JSON without a newline', + ); + } + return Object.freeze({ payload, contents }); +} + +function readTrustedPublicKey(filePath) { + const pem = readStableBuffer( + filePath, + 'trusted public key', + MAX_PUBLIC_KEY_BYTES, + false, + ); + let key; + try { + key = crypto.createPublicKey(pem); + } catch (error) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + `trusted public key is invalid: ${error.message}`, + ); + } + if (key.asymmetricKeyType !== 'ed25519') { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'trusted public key must be Ed25519', + ); + } + const der = key.export({ type: 'spki', format: 'der' }); + return Object.freeze({ + key, + fingerprintSha256: crypto.createHash('sha256').update(der).digest('hex'), + }); +} + +function buildFinalReport({ physicalReport, payload, signature, fingerprint }) { + const remainingRequiredEvidence = + physicalReport.qualification.remainingRequiredEvidence.filter( + (item) => item !== 'release_archive_signature', + ); + const collectedEvidence = [ + ...physicalReport.qualification.collectedEvidence, + 'release_archive_signature_or_attestation', + ]; + const body = { + schemaVersion: 1, + evidenceClass: 'physical_edge_release_candidate', + supported: false, + generatedAt: new Date().toISOString(), + payload, + trust: { + algorithm: 'Ed25519', + publicKeyFingerprintSha256: fingerprint, + }, + signature: { + encoding: 'base64', + value: signature.toString('base64'), + }, + qualification: { + passed: true, + violations: [], + collectedEvidence, + remainingRequiredEvidence, + }, + limitations: LIMITATIONS, + }; + return Object.freeze({ ...body, sha256: canonicalDigest(body) }); +} + +function preparePhase(options) { + const physicalReport = requirePhysicalEvidenceReport( + parsePrivateJson( + options.physicalReportPath, + 'physical evidence report', + MAX_PHYSICAL_REPORT_BYTES, + ), + ); + const releaseArchive = hashStableFile( + options.releaseArchivePath, + 'release archive', + MAX_RELEASE_ARCHIVE_BYTES, + ); + const payload = buildSigningPayload({ + physicalReport, + releaseArchive, + repository: options.repository, + revision: options.revision, + signedAt: new Date().toISOString(), + }); + const serialized = JSON.stringify(payload); + writeNoReplace(options.payloadPath, serialized); + process.stdout.write( + `${JSON.stringify(payload, null, options.json ? 0 : 2)}\n`, + ); +} + +function finalizePhase(options) { + const physicalReport = requirePhysicalEvidenceReport( + parsePrivateJson( + options.physicalReportPath, + 'physical evidence report', + MAX_PHYSICAL_REPORT_BYTES, + ), + ); + const releaseArchive = hashStableFile( + options.releaseArchivePath, + 'release archive', + MAX_RELEASE_ARCHIVE_BYTES, + ); + const { payload, contents } = readExactPayload(options.payloadPath); + const expectedPayload = buildSigningPayload({ + physicalReport, + releaseArchive, + repository: options.expectedRepository, + revision: options.expectedRevision, + signedAt: payload.signedAt, + }); + if (JSON.stringify(payload) !== JSON.stringify(expectedPayload)) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'signing payload did not match the expected source, archive and evidence', + ); + } + const signature = readStableBuffer( + options.signaturePath, + 'detached signature', + ED25519_SIGNATURE_BYTES, + true, + ); + if (signature.length !== ED25519_SIGNATURE_BYTES) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'detached Ed25519 signature must be exactly 64 bytes', + ); + } + const trustedKey = readTrustedPublicKey(options.publicKeyPath); + if (!crypto.verify(null, contents, trustedKey.key, signature)) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'detached signature verification failed', + ); + } + const report = buildFinalReport({ + physicalReport, + payload, + signature, + fingerprint: trustedKey.fingerprintSha256, + }); + const serialized = `${JSON.stringify(report, null, options.json ? 0 : 2)}\n`; + writeNoReplace(options.outputPath, serialized); + process.stdout.write(serialized); +} + +function main() { + if (Number(process.versions.node.split('.')[0]) < 24) { + throw new QingLong3PhysicalEdgeReleaseEvidenceError( + 'Node.js 24 or newer is required', + ); + } + const options = parseArguments(process.argv.slice(2)); + if (options.phase === 'prepare') preparePhase(options); + else finalizePhase(options); +} + +module.exports = { + ED25519_SIGNATURE_BYTES, + LIMITATIONS, + MAX_RELEASE_ARCHIVE_BYTES, + QingLong3PhysicalEdgeReleaseEvidenceError, + buildFinalReport, + buildSigningPayload, + hashStableFile, + parseArguments, + readExactPayload, + readTrustedPublicKey, + validatePhysicalEvidenceReport, + validateExactBaseWorkloads, + validateExactObserved, +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + const message = + error instanceof QingLong3PhysicalEvidenceError || error instanceof Error + ? error.message + : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + } +} diff --git a/test/back/ql3PhysicalEdgeEvidence.test.cjs b/test/back/ql3PhysicalEdgeEvidence.test.cjs index 66a9274e..e53567bb 100644 --- a/test/back/ql3PhysicalEdgeEvidence.test.cjs +++ b/test/back/ql3PhysicalEdgeEvidence.test.cjs @@ -183,12 +183,15 @@ test('produces digest-bound candidate evidence but never supported status', asyn report: { schemaVersion: 1, profile: 'edge', + host: { platform: 'linux', architecture: 'arm64' }, gates: { passed: true, violations: [] }, }, }, { name: 'node-sqlite-on-device-storage', report: { + platform: 'linux', + arch: 'arm64', journalMode: 'delete', synchronous: 'full', integrityCheck: 'ok', @@ -198,7 +201,14 @@ test('produces digest-bound candidate evidence but never supported status', asyn }, { name: 'plugin-package-failed-upgrade', - report: pluginPackageRecovery, + report: { + ...pluginPackageRecovery, + identity: { + ...pluginPackageRecovery.identity, + platform: 'linux', + architecture: 'arm64', + }, + }, }, ]; const report = buildEvidenceReport({ @@ -215,6 +225,30 @@ test('produces digest-bound candidate evidence but never supported status', asyn ), ); assert.equal(report.sha256.length, 64); + const foreignWorkload = buildEvidenceReport({ + manifest: manifest(), + observed: observed(), + workloads: [ + workloads[0], + workloads[1], + { + ...workloads[2], + report: { + ...workloads[2].report, + identity: { + ...workloads[2].report.identity, + platform: 'darwin', + }, + }, + }, + ], + generatedAt: '2026-07-22T00:00:00.000Z', + }); + assert.equal(foreignWorkload.qualification.physicalCandidate, false); + assert.match( + foreignWorkload.qualification.violations.join('; '), + /runtime identity did not match/, + ); const changed = buildEvidenceReport({ manifest: manifest({ deviceId: 'router-a2' }), observed: observed(), diff --git a/test/back/ql3PhysicalEdgeReleaseEvidence.test.cjs b/test/back/ql3PhysicalEdgeReleaseEvidence.test.cjs new file mode 100644 index 00000000..a5526d07 --- /dev/null +++ b/test/back/ql3PhysicalEdgeReleaseEvidence.test.cjs @@ -0,0 +1,634 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const { + buildDirectServiceStartReport, + normalizeDirectServiceStartManifest, + normalizeSession, + parseStartupReceipt, +} = require('../../scripts/ql3-physical-edge-direct-service-start.cjs'); +const { + buildEvidenceReport, + canonicalDigest, + normalizeManifest, + writeNoReplace, +} = require('../../scripts/ql3-physical-edge-evidence.cjs'); +const { + buildFinalReport, + buildSigningPayload, + parseArguments, + validatePhysicalEvidenceReport, +} = require('../../scripts/ql3-physical-edge-release-evidence.cjs'); +const { + runBenchmark: runPluginPackageRecoveryEdgeBenchmark, +} = require('../../scripts/ql3-plugin-package-recovery-edge-benchmark.cjs'); + +const SCRIPT_PATH = path.resolve( + __dirname, + '../../scripts/ql3-physical-edge-release-evidence.cjs', +); +const REPOSITORY = 'https://github.com/whyour/qinglong.git'; +const REVISION = 'a'.repeat(40); +const PACKAGES = [ + '@qinglong/local-admin', + '@qinglong/local-application', + '@qinglong/local-command-file', + '@qinglong/local-execution', + '@qinglong/local-process', + '@qinglong/local-secret', + '@qinglong/local-sqlite', + '@qinglong/runtime-core', + 'croner', + 'semver', +]; + +function artifact() { + return { + artifactSha256: '1'.repeat(64), + artifactMetadataSha256: '2'.repeat(64), + artifactFiles: 627, + artifactBytes: 5_045_360, + entrypointSha256: '3'.repeat(64), + packages: PACKAGES, + }; +} + +function directManifest() { + return normalizeDirectServiceStartManifest({ + schemaVersion: 1, + evidenceClass: 'physical_edge_direct_service_start_candidate', + profile: 'edge', + deviceId: 'router-a1', + serviceManager: 'systemd', + expectedArchitecture: 'arm64', + expectedFilesystem: 'ext4', + expectedArtifactSha256: '1'.repeat(64), + expectedArtifactFiles: 627, + expectedArtifactBytes: 5_045_360, + expectedNodeSha256: '4'.repeat(64), + maximumBootToActiveMs: 180_000, + maximumServiceStartBootAgeMs: 60_000, + maximumServiceStartToActiveMs: 30_000, + }); +} + +function physicalManifest() { + return normalizeManifest({ + schemaVersion: 1, + evidenceClass: 'physical_edge_candidate', + profile: 'edge', + deviceId: 'router-a1', + deviceModel: 'Example Router A1', + soc: 'Example SoC', + storageMedium: 'emmc', + expectedArchitecture: 'arm64', + memoryBytes: { + minimum: 128 * 1024 * 1024, + maximum: 512 * 1024 * 1024, + }, + expectedFilesystem: 'ext4', + }); +} + +function environment(bootId) { + return { + platform: 'linux', + architecture: 'arm64', + bootId, + bootAgeMs: 15_000, + dataFilesystem: 'ext4', + nodeExecutable: '/usr/bin/node', + nodeSha256: '4'.repeat(64), + nodeVersion: 'v24.18.0', + virtualizationIndicators: [], + }; +} + +function physicalObserved(bootId) { + return { + platform: 'linux', + architecture: 'arm64', + node: 'v24.18.0', + bootId, + kernel: '6.6.0', + distribution: { id: 'openwrt', versionId: '24.10' }, + libc: 'musl-ld-musl-aarch64.so.1', + cpuModel: 'Example CPU', + cpuCount: 1, + totalMemoryBytes: 256 * 1024 * 1024, + observedModel: 'Example Router A1', + dataPath: '/mnt/ql3-evidence', + dataFilesystem: 'ext4', + dataMountOptions: ['rw', 'noatime'], + dataBytes: 4 * 1024 * 1024 * 1024, + dataAvailableBytes: 2 * 1024 * 1024 * 1024, + virtualizationIndicators: [], + }; +} + +function edgeWorkloadReport() { + return { + schemaVersion: 1, + profile: 'edge', + generatedAt: '2026-08-14T08:01:30.000Z', + host: { + platform: 'linux', + architecture: 'arm64', + node: 'v24.18.0', + cpuCount: 1, + totalMemoryBytes: 256 * 1024 * 1024, + }, + moduleLoad: { + rssBeforeBytes: 50_000_000, + rssAfterBytes: 55_000_000, + rssDeltaBytes: 5_000_000, + }, + cases: [ + { + name: 'single_noop', + durationMs: 15, + baselineRssBytes: 55_000_000, + peakRssBytes: 56_000_000, + peakRssDeltaBytes: 1_000_000, + outcome: 'succeeded', + exitCode: 0, + }, + { + name: 'stdout_10000_lines', + durationMs: 45, + baselineRssBytes: 56_000_000, + peakRssBytes: 58_000_000, + peakRssDeltaBytes: 2_000_000, + outcome: 'succeeded', + exitCode: 0, + output: { bytes: 270_000, lines: 10_000, writes: 20 }, + }, + ], + cancellation: { + durationMs: 20, + outcome: 'cancelled', + termSignalSent: true, + killSignalSent: false, + }, + gates: { + maxRssDeltaMb: 96, + maxCancelMs: 5000, + passed: true, + violations: [], + }, + }; +} + +function sqliteWorkloadReport() { + return { + node: 'v24.18.0', + arch: 'arm64', + platform: 'linux', + iterations: 250, + batchSize: 10, + journalMode: 'delete', + synchronous: 'full', + transactionMs: { p50: 0.1, p95: 0.2, p99: 0.3, max: 0.4 }, + maxBatchStallMs: 2, + rssDeltaMb: 1, + databaseBytes: 16_384, + integrityCheck: 'ok', + }; +} + +function directSession() { + const manifest = directManifest(); + const sessionId = '019f0000-0000-4000-8000-000000000030'; + const dataPath = '/mnt/ql3-evidence'; + const deploymentRoot = `${dataPath}/.ql3-direct-service-start-deployment-${sessionId}`; + const descriptorSource = `${deploymentRoot}/service/qinglong3.service`; + const body = { + schemaVersion: 1, + evidenceClass: 'physical_edge_direct_service_start_session', + sessionId, + manifestDigest: canonicalDigest(manifest), + uid: 1000, + preparedAt: '2026-08-14T08:00:00.000Z', + artifact: artifact(), + environment: environment('019f0000-0000-4000-8000-000000000001'), + bridge: { + actionId: sessionId, + controllerRoot: '/var/lib/qinglong3-service-bridge', + intentDigest: '5'.repeat(64), + intentPath: `${deploymentRoot}/service/service-manager-intents/${sessionId}.json`, + outcomePath: `${deploymentRoot}/service/service-manager-outcomes/${sessionId}.json`, + }, + paths: { + dataPath, + deploymentRoot, + artifactRoot: '/opt/qinglong3-release', + applicationEntrypoint: + '/opt/qinglong3-release/node_modules/@qinglong/local-application/dist/cli.js', + applicationConfig: `${deploymentRoot}/local-application.json`, + startupReceipt: `${deploymentRoot}/local-application.json.active.json`, + }, + service: { + kind: 'systemd', + serviceName: 'qinglong3', + managerExecutable: '/usr/bin/systemctl', + managerSha256: '6'.repeat(64), + enableExecutable: '/usr/bin/systemctl', + enableSha256: '6'.repeat(64), + supervisorExecutable: null, + supervisorSha256: null, + descriptorSource, + descriptorDestination: '/etc/systemd/system/qinglong3.service', + descriptorMode: 0o644, + descriptorSha256: '7'.repeat(64), + applicationConfigSha256: '8'.repeat(64), + installArguments: [ + '-o', + 'root', + '-g', + 'root', + '-m', + '644', + descriptorSource, + '/etc/systemd/system/qinglong3.service', + ], + enableArguments: ['enable', 'qinglong3'], + }, + }; + return normalizeSession({ ...body, sha256: canonicalDigest(body) }); +} + +function startupReceipt() { + const body = { + schemaVersion: 1, + schema: 'qinglong/local-application-startup-receipt@v1', + instanceId: 'physical-direct-019f0000', + profile: 'edge', + aiStatus: 'deployment_excluded', + bootId: '019f0000-0000-4000-8000-000000000002', + activeBootAgeMs: 13_250, + processId: 101, + processStartTicks: '1200', + nodeExecutable: '/usr/bin/node', + nodeVersion: 'v24.18.0', + }; + const sha256 = crypto + .createHash('sha256') + .update('qinglong.local-application-startup-receipt.v1\0', 'utf8') + .update(JSON.stringify(body), 'utf8') + .digest('hex'); + return parseStartupReceipt(JSON.stringify({ ...body, sha256 })); +} + +function directReport() { + const session = directSession(); + const receipt = startupReceipt(); + const after = environment(receipt.bootId); + return buildDirectServiceStartReport({ + manifest: directManifest(), + session, + observed: { + after, + artifact: artifact(), + bridge: { + actionId: session.bridge.actionId, + intentDigest: session.bridge.intentDigest, + outcomeDigest: '9'.repeat(64), + observationDigest: 'a'.repeat(64), + state: 'active', + }, + process: { + bootId: after.bootId, + nodePid: 101, + nodeParentPid: 1, + nodeStartTicks: 1200, + clockTicksPerSecond: 100, + }, + receipt, + service: { + kind: 'systemd', + serviceName: 'qinglong3', + managerExecutable: '/usr/bin/systemctl', + managerSha256: '6'.repeat(64), + descriptorSha256: '7'.repeat(64), + mainPid: 101, + mainStartMonotonicUs: 12_000_000, + }, + }, + measurements: { + serviceStartBootAgeMs: 12_000, + activeBootAgeMs: 13_250, + bootToActiveMs: 13_250, + serviceStartToActiveMs: 1_250, + }, + outcomes: { + aiStatus: 'deployment_excluded', + descriptorInstalled: true, + initSupervisionMatched: true, + managerStartMonotonicMatched: true, + nodeProcessIdentityMatched: true, + ownerBridgeOutcomeVerified: true, + serviceActive: true, + serviceEnabled: true, + startupReceiptValidated: true, + }, + generatedAt: '2026-08-14T08:01:00.000Z', + }); +} + +async function physicalReport() { + const plugin = await runPluginPackageRecoveryEdgeBenchmark({ + maxDatabaseGrowthBytes: 4 * 1024 * 1024, + maxDurationMs: 10_000, + maxRssDeltaBytes: 96 * 1024 * 1024, + }); + const direct = directReport(); + const observed = physicalObserved(direct.observed.after.bootId); + return buildEvidenceReport({ + manifest: physicalManifest(), + observed, + workloads: [ + { + name: 'edge-executor', + report: edgeWorkloadReport(), + }, + { + name: 'node-sqlite-on-device-storage', + report: sqliteWorkloadReport(), + }, + { + name: 'plugin-package-failed-upgrade', + report: { + ...plugin, + identity: { + ...plugin.identity, + platform: 'linux', + architecture: 'arm64', + }, + }, + }, + ], + supplementalEvidence: [direct], + generatedAt: '2026-08-14T08:02:00.000Z', + }); +} + +function writeFile(filePath, contents, mode) { + fs.writeFileSync(filePath, contents, { mode }); + fs.chmodSync(filePath, mode); +} + +test('requires exact phase-specific source and authority inputs', () => { + assert.equal( + parseArguments([ + 'prepare', + '--physical-report=/tmp/physical.json', + '--release-archive=/tmp/release.tar.gz', + `--repository=${REPOSITORY}`, + `--revision=${REVISION}`, + '--payload=/tmp/payload.json', + ]).phase, + 'prepare', + ); + assert.equal( + parseArguments([ + 'finalize', + '--physical-report=/tmp/physical.json', + '--release-archive=/tmp/release.tar.gz', + '--payload=/tmp/payload.json', + '--signature=/tmp/payload.sig', + '--trusted-public-key=/tmp/release.pub', + `--expected-repository=${REPOSITORY}`, + `--expected-revision=${REVISION}`, + '--output=/tmp/release-evidence.json', + ]).phase, + 'finalize', + ); + assert.throws( + () => + parseArguments([ + 'prepare', + '--physical-report=physical.json', + '--release-archive=/tmp/release.tar.gz', + `--repository=${REPOSITORY}`, + `--revision=${REVISION}`, + '--payload=/tmp/payload.json', + ]), + /physicalReportPath must be absolute/, + ); +}); + +test('reconstructs a passed physical report and retains unsupported status', async () => { + const report = await physicalReport(); + assert.equal(report.qualification.physicalCandidate, true); + assert.deepEqual(validatePhysicalEvidenceReport(report), []); + const minimalForgery = buildEvidenceReport({ + manifest: report.manifest, + observed: report.observed, + workloads: [ + { + name: 'edge-executor', + report: { + schemaVersion: 1, + profile: 'edge', + host: { + platform: 'linux', + architecture: 'arm64', + }, + gates: { passed: true, violations: [] }, + }, + }, + report.workloads[1], + report.workloads[2], + ], + supplementalEvidence: report.supplementalEvidence, + generatedAt: report.generatedAt, + }); + assert.equal(minimalForgery.qualification.physicalCandidate, true); + assert.match( + validatePhysicalEvidenceReport(minimalForgery).join('; '), + /not an exact recorder result/, + ); + const withoutDirect = buildEvidenceReport({ + manifest: report.manifest, + observed: report.observed, + workloads: report.workloads, + generatedAt: report.generatedAt, + }); + assert.match( + validatePhysicalEvidenceReport(withoutDirect).join('; '), + /direct release service start evidence is required/, + ); + const payload = buildSigningPayload({ + physicalReport: report, + releaseArchive: { sha256: 'b'.repeat(64), bytes: 1024 }, + repository: REPOSITORY, + revision: REVISION, + signedAt: '2026-08-14T08:03:00.000Z', + }); + const final = buildFinalReport({ + physicalReport: report, + payload, + signature: Buffer.alloc(64, 1), + fingerprint: 'c'.repeat(64), + }); + assert.equal(final.supported, false); + assert.equal( + final.qualification.remainingRequiredEvidence.includes( + 'release_archive_signature', + ), + false, + ); + assert.ok( + final.qualification.remainingRequiredEvidence.includes( + 'power_loss_restart', + ), + ); +}); + +test('prepares and verifies one externally signed release binding', async (t) => { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-evidence-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const paths = { + physical: path.join(directory, 'physical.json'), + archive: path.join(directory, 'release.tar.gz'), + payload: path.join(directory, 'payload.json'), + signature: path.join(directory, 'payload.sig'), + publicKey: path.join(directory, 'release.pub'), + output: path.join(directory, 'release-evidence.json'), + mismatchOutput: path.join(directory, 'mismatch-evidence.json'), + tamperedOutput: path.join(directory, 'tampered-evidence.json'), + }; + writeNoReplace( + paths.physical, + `${JSON.stringify(await physicalReport(), null, 2)}\n`, + ); + writeFile(paths.archive, Buffer.from('bounded release archive'), 0o444); + + const prepared = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + 'prepare', + `--physical-report=${paths.physical}`, + `--release-archive=${paths.archive}`, + `--repository=${REPOSITORY}`, + `--revision=${REVISION}`, + `--payload=${paths.payload}`, + '--json', + ], + { encoding: 'utf8' }, + ); + assert.equal(prepared.status, 0, prepared.stderr); + assert.equal(fs.statSync(paths.payload).mode & 0o777, 0o600); + assert.equal(fs.readFileSync(paths.payload, 'utf8').endsWith('\n'), false); + + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + const signature = crypto.sign( + null, + fs.readFileSync(paths.payload), + privateKey, + ); + writeFile(paths.signature, signature, 0o600); + writeFile( + paths.publicKey, + publicKey.export({ type: 'spki', format: 'pem' }), + 0o444, + ); + const finalized = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + 'finalize', + `--physical-report=${paths.physical}`, + `--release-archive=${paths.archive}`, + `--payload=${paths.payload}`, + `--signature=${paths.signature}`, + `--trusted-public-key=${paths.publicKey}`, + `--expected-repository=${REPOSITORY}`, + `--expected-revision=${REVISION}`, + `--output=${paths.output}`, + '--json', + ], + { encoding: 'utf8' }, + ); + assert.equal(finalized.status, 0, finalized.stderr); + const report = JSON.parse(fs.readFileSync(paths.output, 'utf8')); + assert.equal(report.supported, false); + assert.equal(report.qualification.passed, true); + assert.equal(report.payload.release.archiveBytes, 23); + assert.ok( + report.qualification.collectedEvidence.includes( + 'release_archive_signature_or_attestation', + ), + ); + assert.match(report.trust.publicKeyFingerprintSha256, /^[a-f0-9]{64}$/); + + const sourceMismatch = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + 'finalize', + `--physical-report=${paths.physical}`, + `--release-archive=${paths.archive}`, + `--payload=${paths.payload}`, + `--signature=${paths.signature}`, + `--trusted-public-key=${paths.publicKey}`, + `--expected-repository=${REPOSITORY}`, + `--expected-revision=${'b'.repeat(40)}`, + `--output=${paths.mismatchOutput}`, + ], + { encoding: 'utf8' }, + ); + assert.notEqual(sourceMismatch.status, 0); + assert.match(sourceMismatch.stderr, /payload did not match/); + assert.equal(fs.existsSync(paths.mismatchOutput), false); + + const noReplace = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + 'finalize', + `--physical-report=${paths.physical}`, + `--release-archive=${paths.archive}`, + `--payload=${paths.payload}`, + `--signature=${paths.signature}`, + `--trusted-public-key=${paths.publicKey}`, + `--expected-repository=${REPOSITORY}`, + `--expected-revision=${REVISION}`, + `--output=${paths.output}`, + ], + { encoding: 'utf8' }, + ); + assert.notEqual(noReplace.status, 0); + assert.match(noReplace.stderr, /EEXIST/); + + fs.chmodSync(paths.archive, 0o644); + fs.appendFileSync(paths.archive, 'tampered'); + fs.chmodSync(paths.archive, 0o444); + const tampered = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + 'finalize', + `--physical-report=${paths.physical}`, + `--release-archive=${paths.archive}`, + `--payload=${paths.payload}`, + `--signature=${paths.signature}`, + `--trusted-public-key=${paths.publicKey}`, + `--expected-repository=${REPOSITORY}`, + `--expected-revision=${REVISION}`, + `--output=${paths.tamperedOutput}`, + ], + { encoding: 'utf8' }, + ); + assert.notEqual(tampered.status, 0); + assert.match(tampered.stderr, /payload did not match/); + assert.equal(fs.existsSync(paths.tamperedOutput), false); +});