From 5db6031e4b54e151f26dff51faa1e67c04ca81d8 Mon Sep 17 00:00:00 2001 From: ila Date: Mon, 20 Jul 2026 09:42:30 +0800 Subject: [PATCH] Add Phase 5 licensing review (T-501~T-503) Code-level audit of the authorization chain: domain-separated machine hash with no raw identifiers in errors or storage, strict License v1 verification (base64 re-encode round-trip, exact 9-field shape, timestamp round-trip, machine-hash binding), and revocation state with clock-rollback defense and revoked-before-expiry ordering. Snapshots to the UI carry no license ID, signature, path or raw Windows identifiers; defaults fail closed. Catalog/License/Revocation share one canonical JSON implementation. No security defect found. Records two product decisions needed: the revocation list is a hard dependency for any authorization (missing or >38-day-old list blocks all launches, in tension with the offline licensing vision and lacking a distribution plan), and trial is a P0 requirement plus MVP acceptance criterion that is deliberately not implemented, leaving 02-requirements in conflict with the architecture and task docs. Co-Authored-By: Claude Fable 5 --- docs/review/phase5-review.md | 134 +++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/review/phase5-review.md diff --git a/docs/review/phase5-review.md b/docs/review/phase5-review.md new file mode 100644 index 0000000..c6fb853 --- /dev/null +++ b/docs/review/phase5-review.md @@ -0,0 +1,134 @@ +# Phase 5 审查(T-501 ~ T-503:机器指纹 / 许可证验签 / 授权界面与撤销) + +> 审查范围:`8d81276`(T-501 机器指纹 machine_hash)、`cd76f7f`(T-502 Ed25519 离线许可证验证)、`76f6108`(T-503 授权界面、离线导入与撤销状态)。 +> 审查视角:全栈开发工程师 + 安全审计。 +> 审查方式:静态代码审计 + 攻击路径推演(伪造许可证、跨机复制、时钟操纵、隐私泄露)。因 WSL 无 Go,未执行测试。 +> 日期:2026-07-20 + +## 总体判断 + +**Phase 5 的密码学与隐私实现质量高,未发现验签绕过、跨机复制通过或原始硬件标识泄露的路径。** 三条链(指纹派生 → 许可证验签 → 撤销状态)都 fail-closed,且 `core/internal/canonicaljson` 被 Catalog / License / Revocation **共用一套实现**,避免了第二套 canonicalizer 漂移——这是很好的纪律。 + +但本轮发现**两个需要产品裁定的问题**(均非代码缺陷,代码与架构文档一致): + +1. **撤销名单是授权的硬依赖**——没有名单或名单过期超过宽限,**所有软件都无法启动**,与"离线授权 / 永久使用"的愿景存在张力,且缺分发方案。 +2. **"试用"是 `02-requirements` 的 P0 需求与验收项,但未实现**,且 `02-requirements` 与架构/任务文档口径不一致。 + +## 逐模块核验 + +### T-501 · 机器指纹(licensing/machine_hash + platform/windows) + +- **域分隔 + 定长拼接**:`"softbox.machine-hash.v1\x00" + 规范化 GUID + "\x00" + %08x 卷序列号` → SHA-256 → 64 位小写 hex。域前缀防跨协议碰撞,NUL 分隔防拼接歧义。 +- **严格规范化**:GUID 必须恰好 36 字符、纯 ASCII、位置 8/13/18/23 为 `-`、其余为 hex,统一小写;不合法即 `ErrInvalidMachineGUID`。 +- **隐私正确**:错误值"intentionally contains no source value";平台层"only for the duration of this call"读取 `MachineGuid`(注册表)与 Windows 目录所在卷序列号;**不使用 MAC**,原始 GUID / 序列号不落盘、不进事件/UI/日志。 +- **平台隔离**:`machine_hash_windows.go`(`//go:build windows`)+ `machine_hash_stub.go`(非 Windows fail-closed)。 + +**关于"严格双来源"的取舍**:实现为 2-of-2,任一来源变化即指纹变化。早期架构风险表曾写"多标识加权 + 容错",现已同步更新为"v1 固定双来源、缺失即拒绝;**不在客户端做部分匹配**,后续按签发端 rebind/申诉流程处理"(`04-architecture.md`)。**文档与实现一致,且该取舍更安全**——客户端部分匹配会削弱绑定强度。代价是硬件/系统变动必然需要签发端换绑,故换绑流程从"可选"变为**必需的运营依赖**。 + +### T-502 · 许可证验签(licensing/verifier + internal/canonicaljson) + +验签链顺序正确且严格: + +1. 校验注入公钥 32 字节(复制持有)、`expectedMachineHash` 为 64 位小写 hex。 +2. `canonicaljson.Parse`(共用受限解析:拒绝重复字段、非整数数字等)→ 取顶层 `signature` → 删除 → `canonicaljson.Marshal` 得签名字节 → `ed25519.Verify`。 +3. **`decodeCanonicalSignature` 做 base64 重编码回比**(`EncodeToString(sig) != value` 即拒),彻底排除 CR/LF、空白、padding 变体等非唯一表示——这正是 Phase 1 对 Catalog 提出的收紧,在此已正确落地。 +4. `hasExactLicenseShape`:**恰好 9 个字段**、类型逐一匹配,多一个少一个都拒。 +5. `decodeLicense`:`DisallowUnknownFields` + `UseNumber` + 尾随数据拒绝。 +6. `validateLicense`:`schema_version==1`、license_id/machine_hash/policy 正则、**`issued_at` 时间戳格式回比**(非规范写法拒绝)、products 非空 + 正则 + **去重**。 +7. **`license.MachineHash != expectedMachineHash` → `ErrMachineMismatch`**:跨机复制被拒。 + +所有错误均为无内容 sentinel,不回显文档;core 不含任何私钥,公钥由调用方显式注入。 + +### T-503 · 撤销状态与授权服务(licensing/revocation + application/authorization) + +**撤销验签**与 License 同构(共用 canonical、精确 5 字段、签名回比、时间戳回比、ID 正则 + 去重),并强制 `MaxRevocationValidity = 31 天` 的签名有效期上限。 + +**`StateFor` 状态机(安全关键)**: + +- **时钟回拨防御**:`now.Before(GeneratedAt)` → `Unavailable`。把系统时钟调到名单生成之前**不会**让过期名单重新"变新",而是 fail closed。 +- **有效期边界在求值时二次校验**(不只解析时)。 +- **`Revoked` 判定先于过期判定**:已撤销的许可证在任何新鲜度状态下都是 `Revoked`。 +- 未过期 → `Current`;过期后 7 天内 → `Grace`;之后 → `Unavailable`。 + +**授权服务**: + +- **快照净化正确**:`AuthorizationSnapshot{State, MachineHash, Products}`,`AuthorizedProduct` 明确"contains no license ID, source path, signature or source document"——UI 拿不到 license ID、签名、JSON、文件路径或原始 Windows 标识;`MachineHash` 为派生值,可安全展示(用户申请换绑需要)。 +- **仅 `Current` / `Grace` 授权**;`Revoked` / `Unavailable` 一律不授权。 +- **`IsAuthorized(productID)` 按 product ID 而非 app ID** 判定,与 T-503 把启动门禁改为 product 维度一致。 +- **fail-closed 默认**:`UnconfiguredAuthorizationLoader` 发布 `unconfigured` + 空 Products;bootstrap 对未配置发 `Unconfigured`、对其他错误发 `Unavailable`,两者都不授权;导入失败发 `ImportFailed` + 空 Products。 +- **UI 纪律**:点击只入队,文件选择/读取/验签/写盘全在后台,经 relay 回 UI goroutine。 + +## 需优化项(按优先级) + +### O1 · 撤销名单是授权硬依赖,与"离线授权"愿景存在张力(需产品裁定 + 分发方案) + +**现状(已核实):** + +```go +list, found, err := service.store.LoadRevocations(service.revocationVerifier) +if err != nil || !found { + return unavailableAuthorizationSnapshot(...), ErrAuthorizationUnavailable +} +``` + +**没有撤销名单(`!found`)或名单验签失败 → `Unavailable` → 空 Products → `IsAuthorized` 对一切返回 false → 所有软件都无法启动。** 叠加新鲜度上限:一份名单最多覆盖 `31 天有效期 + 7 天宽限 = 38 天`(自 `generated_at` 起)。 + +**影响:** + +- **首次授权必须已有撤销名单**:用户导入了合法许可证,但从未获得撤销名单 → 一个软件也启动不了。 +- **纯离线用户约 38 天后全部失效**:必须周期性获得新签名名单,否则授权中断。 +- 这与 `01-vision.md` 的"**离线授权**…离线可验证"和"永久使用与更新权分离"的直观预期存在张力:**许可证确实可离线验签,但整体授权并非可无限期离线**。 + +代码与 `04-architecture.md`("unavailable/revoked 一律不可授权")**一致**,故非缺陷;但这是一个尚未被显式裁定的产品后果。 + +**建议:** + +1. 产品层显式裁定:"离线超过约 38 天即停止授权"是否可接受;若不可接受,考虑对**永久许可证**放宽(如缺名单时降级为"仅允许已装软件启动、不允许新装/更新"),而非一刀切拒绝。 +2. 无论如何裁定,都必须有**分发方案**:撤销名单随许可证文件一并交付 / 安装包内置初始名单 / 首次联网自动获取,否则首次离线授权不可能成立。 +3. 把结论写进 `01-vision.md` 与 `02-requirements.md`,避免"离线授权"被误读为"无限期离线可用"。 + +### O2 · "试用"是 P0 需求与验收项,但未实现(文档口径冲突,需裁定) + +**现状(已核实当前文档,非记忆):** + +- `02-requirements.md:35`(功能清单,**P0**):"机器绑定许可证 | 导入许可证,离线验证,**区分正式版/试用版**/授权错误" +- `02-requirements.md:67`(MVP 验收):"**试用与正式状态在盒子和子软件中一致**" +- `02-requirements.md:17`(用户角色):普通用户可"**试用**" +- 但 `docs/tasks/T-503.md`:"本任务**不实现**本地试用计时、到期、绕过授权或自助换绑";`04-architecture.md:193`:"试用/到期/自助换绑/申诉仍不在客户端实现" +- 代码核实:`supports_trial` 仅作为元数据从 app.json 透传到 `installed-app.json`,**无任何试用计时/到期逻辑**(`authorization.go` 注释亦承认 "trial expiration which License v1 does not carry");无许可证时 `IsAuthorized` 恒 false,**不存在试用启动路径**。 + +**影响:** 需求文档声明 P0 且列入 MVP 验收,实现与架构/任务文档明确排除——**两侧文档互相冲突**,按现状 MVP 验收无法通过"试用与正式状态一致"这一条。 + +**建议:** 二选一并同步文档: + +- (a) 补一个试用任务(需先在 `api.md` 定义 License v1 如何表达试用/到期,或定义无许可证时的本地试用计时与防回拨策略);或 +- (b) 把试用从 MVP P0 降级到 V1.1/V2,同步修改 `02-requirements.md` 的功能清单与验收标准。 + +**不要保留当前的文档冲突状态。** + +### O3 · License 与 Revocation 共用同一授权公钥 + +**现状:** `RevocationVerifier` 注释说明"Callers may inject the same controlled authorization key used for License v1, but no fallback key exists"。 + +**影响:** 单密钥泄露同时意味着可伪造许可证**与**伪造/替换撤销名单(后者可用于抹掉撤销)。v1 简化可接受,但两类文档的信任影响不同。 + +**建议:** 后续协议升级(与 `key ID / rotation` 一并)时评估**签名密钥分离**;当前至少在发布端设计文档中记录该单点。 + +### O4 · 无生产装配(与 Phase 3/4 一致) + +**现状:** 双端 `cmd/softbox/main.go` 均注入 `UnconfiguredAuthorizationLoader`;无真实信任根、许可证文件来源、撤销获取与文件选择 composition。 + +**影响:** 与 Phase 3/4 结论一致——**无头 core 的授权边界已完成;端到端授权闭环未接通**。不注入 allow-all 是好纪律。M4(自更新 + 授权完成,MVP 验收)仍需端到端装配。 + +**建议:** 随发布配置(信任根公钥、许可证/撤销分发)一并落地,并在 `current-state.md` 保持准确表述。 + +## 结论 + +Phase 5 的密码学实现可作为授权基线:指纹派生域分隔且隐私正确、许可证与撤销验签严格(含 base64 回比、字段精确匹配、时间戳回比)、时钟回拨防御到位、快照净化与 fail-closed 默认正确、canonical JSON 单一实现共用。建议处理顺序: + +1. **[产品裁定 · 最高]** O1:裁定离线授权上限与降级策略,并确定撤销名单的分发方案(随许可证交付 / 内置初始名单 / 首次联网获取);结论回写愿景与需求。 +2. **[消除文档冲突]** O2:裁定试用归属(补任务 或 降级出 MVP),同步 `02-requirements.md` 与架构/任务文档。 +3. **[发布端记录]** O3:在 softbox-catalog 设计中记录 License/Revocation 单密钥现状,随 key ID/轮换一并评估分离。 +4. **[装配]** O4:随发布配置落地端到端授权,保持"无头 core 已完成、端到端未接通"的准确表述。 + +> 声明:本报告为静态审计与攻击路径推演,逐项核了实现(域分隔哈希与 GUID 规范化、base64 重编码回比、9 字段精确形状、`ErrMachineMismatch` 跨机拒绝、`StateFor` 时钟回拨与 revoked 优先、快照净化字段、`IsAuthorized` 状态门禁、双端 `UnconfiguredAuthorizationLoader` 装配);O1/O2 的事实均来自当前文档与代码重读,非记忆推断。因 WSL 无 Go 未执行测试,测试通过为 Codex 自述。