mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): attest physical edge release archives
This commit is contained in:
@@ -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。
|
||||
|
||||
|
||||
@@ -400,8 +400,41 @@ recorder 以 `--direct-service-start-evidence=<absolute-report>` 导入;通过
|
||||
移除 `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。
|
||||
|
||||
@@ -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。
|
||||
@@ -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 待执行) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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,不会
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user