Files

232 lines
22 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 09:42:30 +08:00
# 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 自述。
2026-07-20 10:28:08 +08:00
## Codex 复核补充与整改建议
### 对原结论的判断
原审查对 T-501~T-503 的密码学、输入验证、隐私边界和 fail-closed 授权路径的正面结论成立。复核时已重新执行 `go -C core vet ./...`、`go -C core test -count=1 ./...`、`go -C app-modern test -count=1 ./...`、`go -C app-win7 test -count=1 ./...`,并完成 modern/Win7 Windows amd64 主程序交叉构建,均通过。该证据不替代 T-601 的真实 Windows/Win7 环境验证。
O1、O3、O4 均为合理建议;O2 是当前最需要先裁定的明确 MVP 文档冲突。以下补充修正其优先级、边界和遗漏项。
### O1 细化 · 明确离线连续性策略,而非默认“永久离线”
缺失、无效或超过宽限的撤销名单会使 `AuthorizationService` 返回 `unavailable`,因此在**未来接入该授权 composition 后**,所有通过当前启动门禁保护的软件都不能启动。名单有效期最多 31 天、宽限最多 7 天,故从 `generated_at` 起的最大连续离线窗口为约 38 天;若签发的 `expires_at` 更早,窗口会更短。
这与当前“离线可验证”的实现一致,但 `01-vision.md` 只承诺“离线可验证”,并未承诺“永久许可证可无限期离线使用”。因此该项应由产品显式定义为可接受的离线连续性上限和首次导入分发方式,而不应把“永久使用与更新权分离”视为已经写入的既定事实。若决定放宽,必须先重新设计威胁模型和授权协议,不能在客户端为缺失名单增加 allow-all 回退。
### O2 细化 · 先消除 P0 冲突,试用协议不要悄然改写 License v1
`02-requirements.md` 将试用列为 P0 用户能力和 MVP 验收,T-503/API/架构则明确 `perpetual:false` 只展示为非永久状态、不会产生本地到期或试用授权;现状确实不能满足“试用与正式状态在盒子和子软件中一致”。该冲突应优先于后续发布装配关闭。
产品应二选一:
1. 若试用仍为 MVP,先新建试用/到期授权任务,并定义可跨盒子与子软件验证的签名期限、时钟和撤销策略;由于 License v1 已冻结九字段,不应静默修改 v1,宜定义兼容的后续协议版本或独立签名授权对象。
2. 若试用不再属于 MVP,将其从 `02-requirements.md` 的用户角色、P0 清单和验收标准同步移到后续版本,并把 `perpetual:false` 的产品语义写清,避免被误解为会自动到期。
### O3 细化 · 分离信任根是降低泄露爆炸半径,不是跨协议解析漏洞
License v1 与 Revocation List v1 的精确字段形状不同,因此共用密钥不会导致一份文档被另一验证器误解析。风险在于同一私钥泄露后,攻击者可以同时伪造许可证和替换撤销名单。发布端应记录这一信任边界,并与 `key_id`、轮换和紧急吊销设计一起评估独立的许可证/撤销签名密钥;现有 core 的显式注入边界可支持不同公钥,无需为此放宽验证逻辑。
### O4 细化 · 将 production composition 写入 T-603 的可验收闭环
双端 production `cmd` 目前故意注入 `UnconfiguredAuthorizationLoader`,所以这不是已部署能力。T-603 的正式任务规格应明确包含:受控生产 trust root、机器摘要采集、许可证文件选择、签名撤销名单获取/刷新、失败诊断、以及干净机器上的导入→授权→启动→撤销验证;不得以 testdata、环境变量、未签名本地文件或 allow-all verifier 代替。M4 在这些端到端证据和子软件侧证据具备前不能宣称完成。
### O5 · 已签名撤销名单的回滚/replay 风险(新增安全项)
当前 `LicenseStore.StoreRevocations` 对任意仍能通过签名和格式验证的名单直接原子替换;它不比较现有缓存的 `generated_at`、也没有已签名递增 revision 或受保护的最高水位。攻击者若能重放一份仍在有效期内的旧签名名单,便可能抹去较新名单中的撤销记录;把时钟调回旧名单的有效区间时,现有 `now.Before(generated_at)` 检查也无法识别这种“旧名单 + 旧时钟”的组合。
这不否定当前针对“同一名单时钟回拨”的 fail-closed 检查,但不能将其表述为完整的时钟/回滚防御。发布协议应增加单调 revision(或等价的受签名序列语义),客户端至少拒绝比已接受名单更旧的远端更新;同时明确:仅依赖可被本机完全恢复的本地文件,无法抵抗拥有本机存储写权限的攻击者,完全防回滚需要可信在线或硬件/系统级锚点。该项应在 T-603 的撤销获取设计前裁定并覆盖重放测试。
### O6 · 子软件独立验权仍缺跨仓库交付证据(新增闭环项)
`02-requirements.md` 要求子软件独立启动时也能识别授权;本仓库不含子软件源码是既定边界,不构成当前 core 缺陷。但在生产 trust root、许可证存储位置、撤销刷新和试用语义尚未对外形成可消费契约前,无法证明子软件与盒子得到一致结论。T-603 或其前置跨仓库任务应冻结该消费契约,并在干净机器上用至少一个真实子软件完成“有效、跨机复制、撤销、过期/宽限(如保留)”的一致性验证。
### 建议处理顺序(复核后)
1. **产品裁定:O2**。决定试用是否仍为 MVP,并同步需求、API、架构、任务与验收标准。
2. **协议/威胁模型:O1 + O5**。定义离线连续性、首次名单分发、名单单调性与回滚威胁边界;没有结论前不接入生产撤销源。
3. **发布安全设计:O3**。冻结 trust root、密钥用途、`key_id`/轮换和紧急吊销方案。
4. **端到端交付:O4 + O6**。将 production composition 和子软件跨仓库验证写入 T-603 及其验收,完成后再更新 M4/`current-state.md` 的完成表述。
## 交叉复核裁定(定稿)
> 复核视角:Claude 全栈开发工程师,对 Codex 复核补充逐条核验后裁定。
> 核验方式:grep `01-vision.md` 原文核对引用;读 `core/storage/license_store.go:StoreRevocations` 与全代码 `GeneratedAt` 比较点核验 O5;因 WSL 无 Go,未执行测试。
> 日期:2026-07-20
### 承认原审核错误
本轮 Codex 复核比原审核更严谨。原审核有**一处文档误引**与**一处表述过宽**,应更正:
- **误引 `01-vision.md`——原审核错。** 原 O1 写"这与 `01-vision.md` 的…'永久使用与更新权分离'的直观预期存在张力"。核实:`01-vision.md:35` 只写"离线授权 | Ed25519 签名许可证 + 机器绑定,**离线可验证**,支持换绑与恢复",**全文无"永久使用与更新权分离"**。该表述出自最初的 Obsidian 方案笔记,非本仓库愿景文档。愿景只承诺"离线可验证",而当前实现**满足**该承诺。O1 的实质结论(缺名单即全面拒绝、约 38 天上限、需分发方案与产品裁定)仍成立,但**不应以愿景冲突立论**。
> 方法教训:这是原审核第二次凭记忆引用文档(Phase 2 原 P3 为第一次)。后续引用文档必须先 grep 原文,不得凭记忆。
- **"时钟回拨防御"表述过宽——应限定范围。** 该检查仅覆盖"**同一份名单** + 时钟回拨";对"**更旧的名单** + 相应回拨的时钟"无效,因为此时 `now` 晚于旧名单的 `generated_at`,`now.Before(GeneratedAt)` 不触发。原审核不应将其表述为完整的时钟/回滚防御。
### 已确认的事实(可作为后续任务前提)
| 项 | 结论 | 证据 |
| --- | --- | --- |
| 愿景未承诺永久离线 | 属实(原审核误引) | `01-vision.md:35` 仅"离线可验证";无"永久使用与更新权分离" |
| 撤销名单可被重放/回滚 | **属实(原审核漏报)** | `core/storage/license_store.go:StoreRevocations` 验签后**直接 `writeReplacementDocument` 覆盖**;全代码仅有 `revocation.go:117` 与 `authorization.go:199` 两处 `now.Before(GeneratedAt)` 新鲜度检查,**无任何跨名单单调性/高水位比较** |
| O1 影响以生产装配为前提 | 属实 | 双端 cmd 现注入 `UnconfiguredAuthorizationLoader`,尚非已部署能力 |
| License v1 九字段已冻结 | 属实 | `verifier.go` 精确 9 字段形状校验;试用不得静默改 v1 |
| 共用密钥非跨协议解析漏洞 | 属实 | License v1 与 Revocation v1 精确字段形状不同,不会互相误解析;风险为泄露爆炸半径 |
### 采纳
- **接受 O5(新增安全项)** 及其定性:当前仅有"同名单时钟回拨"防御,缺单调性;并接受其诚实边界——仅靠本地可恢复文件无法抵抗有本机写权限的攻击者,完全防回滚需可信在线或硬件/系统级锚点。
- **接受 O6**(子软件跨仓库消费契约与干净机器一致性验证纳入 T-603 前置)。
- **接受四条精修**:O1 以生产装配为前提且**不得为缺名单加 allow-all 回退**;O2 不得静默修改冻结的 License v1,应定兼容后续协议版本或独立签名授权对象;O3 澄清为爆炸半径而非解析漏洞;O4 将 production composition 写入 T-603 可验收闭环、M4 在此前不得宣称完成。
### 补充:O5 的威胁边界与定级理由
Codex 已正确指出本地攻击者可绕过。补充三点以明确**为何仍必须实现客户端单调性检查**:
1. **挡不住本地攻击者,但挡得住分发渠道重放。** 单调性检查若置于 `StoreRevocations`,有本机写权限者可直接覆盖缓存文件绕开;但它能拦截**被污染的 CDN 缓存、恶意镜像或中间代理回放旧名单**——这是它的真实价值域。
2. **无需时钟控制即存在约 38 天的撤销回滚窗口。** 旧名单只要仍在自身 `31 天有效期 + 7 天宽限`内即判为 Current/Grace,因此**不碰时钟**、仅回放一份 38 天内的旧名单即可抹掉该期间新增的撤销;回滚更久才需配合时钟。该"无需时钟即可利用"路径使其优先级高于纯理论攻击。
3. **良性陈旧同样触发静默回滚。** 因无单调比较,一次拿到过期 CDN/代理缓存的刷新会把较新名单**覆盖成更旧的**。故单调性不只是安全加固,而是**撤销可靠投递的正确性要求**。
因此建议把"**客户端至少拒绝比已接受名单更旧的更新**"作为最低要求项落地,不仅列为 T-603 设计前裁定。
### 最终处理顺序(定稿)
1. **[产品裁定]** O2:裁定试用归属;若保留 MVP,新协议版本或独立签名对象承载试用/到期,**不改冻结的 License v1**;同步需求/API/架构/任务/验收。
2. **[协议与威胁模型]** O1 + O5:定义离线连续性上限、首次名单分发方式、**名单单调 revision 语义**与回滚威胁边界;客户端落地"拒绝更旧名单"最低要求;结论前不接入生产撤销源;**任何情况下不得为缺名单加 allow-all 回退**。
3. **[发布安全设计]** O3:冻结 trust root、密钥用途、`key_id`/轮换与紧急吊销;记录 License/Revocation 单密钥现状并评估分离。
4. **[端到端交付]** O4 + O6:production composition 与子软件跨仓库一致性验证写入 T-603 验收;完成前不更新 M4/`current-state.md` 的完成表述。
> 裁定:Codex 本轮补充全部成立——纠正原审核一处文档误引与一处表述过宽,新增 O5(真实安全项,原审核漏报)与 O6,四条精修均改进了原结论。Phase 5 的密码学与隐私正面结论(无验签绕过、无跨机复制通过、无原始标识泄露)双方一致,不需回滚或返工;O5 作为新增安全项进入协议/威胁模型处理顺序。