diff --git a/core/catalog/canonical.go b/core/catalog/canonical.go index 34abe60..7fc874c 100644 --- a/core/catalog/canonical.go +++ b/core/catalog/canonical.go @@ -10,12 +10,15 @@ import ( "unicode/utf8" ) -var integerJSONNumber = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`) +var integerJSONNumber = regexp.MustCompile(`^(0|[1-9][0-9]*|-[1-9][0-9]*)$`) func parseRestrictedJSON(data []byte) (any, error) { if !utf8.Valid(data) { return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument) } + if err := validateJSONStringSurrogates(data); err != nil { + return nil, err + } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() @@ -33,6 +36,78 @@ func parseRestrictedJSON(data []byte) (any, error) { return value, nil } +func validateJSONStringSurrogates(data []byte) error { + for index := 0; index < len(data); index++ { + if data[index] != '"' { + continue + } + next, err := scanJSONStringSurrogates(data, index) + if err != nil { + return err + } + index = next - 1 + } + return nil +} + +func scanJSONStringSurrogates(data []byte, start int) (int, error) { + for index := start + 1; index < len(data); index++ { + switch data[index] { + case '"': + return index + 1, nil + case '\\': + if index+1 >= len(data) { + return 0, fmt.Errorf("%w: incomplete string escape", ErrInvalidDocument) + } + if data[index+1] != 'u' { + index++ + continue + } + codeUnit, ok := decodeJSONHexCodeUnit(data, index+2) + if !ok { + return 0, fmt.Errorf("%w: invalid unicode escape", ErrInvalidDocument) + } + switch { + case codeUnit >= 0xd800 && codeUnit <= 0xdbff: + if index+7 >= len(data) || data[index+6] != '\\' || data[index+7] != 'u' { + return 0, fmt.Errorf("%w: high surrogate is not paired", ErrInvalidDocument) + } + lowSurrogate, ok := decodeJSONHexCodeUnit(data, index+8) + if !ok || lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff { + return 0, fmt.Errorf("%w: high surrogate is not followed by a low surrogate", ErrInvalidDocument) + } + index += 11 + case codeUnit >= 0xdc00 && codeUnit <= 0xdfff: + return 0, fmt.Errorf("%w: low surrogate has no high surrogate", ErrInvalidDocument) + default: + index += 5 + } + } + } + return 0, fmt.Errorf("%w: unterminated string", ErrInvalidDocument) +} + +func decodeJSONHexCodeUnit(data []byte, start int) (uint16, bool) { + if start+4 > len(data) { + return 0, false + } + var value uint16 + for _, digit := range data[start : start+4] { + value <<= 4 + switch { + case digit >= '0' && digit <= '9': + value |= uint16(digit - '0') + case digit >= 'a' && digit <= 'f': + value |= uint16(digit-'a') + 10 + case digit >= 'A' && digit <= 'F': + value |= uint16(digit-'A') + 10 + default: + return 0, false + } + } + return value, true +} + func decodeJSONValue(decoder *json.Decoder) (any, error) { token, err := decoder.Token() if err != nil { diff --git a/core/catalog/canonical_vectors_test.go b/core/catalog/canonical_vectors_test.go new file mode 100644 index 0000000..4be13c4 --- /dev/null +++ b/core/catalog/canonical_vectors_test.go @@ -0,0 +1,171 @@ +package catalog + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +type canonicalVectorCorpus struct { + SchemaVersion int `json:"schema_version"` + PublicKeyBase64 string `json:"public_key_base64"` + Vectors []canonicalVector `json:"vectors"` +} + +type canonicalVector struct { + Name string `json:"name"` + Document string `json:"document"` + SignedPayloadBase64 string `json:"signed_payload_base64"` + Signature string `json:"signature"` + WantError string `json:"want_error"` +} + +func TestVerifierCanonicalVectors(t *testing.T) { + corpus := readCanonicalVectorCorpus(t) + publicKey, err := base64.StdEncoding.DecodeString(corpus.PublicKeyBase64) + if err != nil { + t.Fatalf("decode corpus public key: %v", err) + } + verifier, err := NewVerifier(publicKey) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + + for _, vector := range corpus.Vectors { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + verified, err := verifier.Verify([]byte(vector.Document)) + if vector.WantError != "" { + want := canonicalVectorError(t, vector.WantError) + if !errors.Is(err, want) { + t.Fatalf("Verify() error = %v, want %v", err, want) + } + return + } + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + + expectedPayload, err := base64.StdEncoding.DecodeString(vector.SignedPayloadBase64) + if err != nil { + t.Fatalf("decode static signed payload: %v", err) + } + if !bytes.Equal(verified.SignedPayload, expectedPayload) { + t.Fatalf( + "SignedPayload = %q, want static vector %q", + verified.SignedPayload, + expectedPayload, + ) + } + + signature, err := base64.StdEncoding.DecodeString(vector.Signature) + if err != nil { + t.Fatalf("decode static signature: %v", err) + } + if !ed25519.Verify(ed25519.PublicKey(publicKey), expectedPayload, signature) { + t.Fatal("static signature does not verify the static signed payload") + } + if got := vectorDocumentSignature(t, vector.Document); got != vector.Signature { + t.Fatalf("document signature = %q, want static vector %q", got, vector.Signature) + } + }) + } +} + +func TestParserRejectsNonCanonicalSignatureVectorText(t *testing.T) { + corpus := readCanonicalVectorCorpus(t) + for _, vector := range corpus.Vectors { + if vector.WantError != "signature_invalid" { + continue + } + vector := vector + t.Run(vector.Name, func(t *testing.T) { + if err := validateSignature(vectorDocumentSignature(t, vector.Document)); err == nil { + t.Fatal("validateSignature() accepted a non-canonical signature text") + } + }) + } +} + +func TestParserRejectsNonCanonicalPackageSignatureVectors(t *testing.T) { + corpus := readCanonicalVectorCorpus(t) + for _, vector := range corpus.Vectors { + if vector.WantError != "signature_invalid" { + continue + } + vector := vector + t.Run(vector.Name, func(t *testing.T) { + manifest := validManifestForTest() + publishedPackage := manifest.Apps[0].Packages[ArchitectureAMD64] + publishedPackage.Signature = vectorDocumentSignature(t, vector.Document) + manifest.Apps[0].Packages[ArchitectureAMD64] = publishedPackage + + _, err := parseSignedManifestForTest(t, manifest, ChannelModern) + if !errors.Is(err, ErrInvalidManifest) { + t.Fatalf("Parse() error = %v, want %v", err, ErrInvalidManifest) + } + }) + } +} + +func readCanonicalVectorCorpus(t *testing.T) canonicalVectorCorpus { + t.Helper() + path := filepath.Join("..", "..", "testdata", "catalog", "canonical-vectors.json") + file, err := os.Open(path) + if err != nil { + t.Fatalf("open canonical vector corpus: %v", err) + } + defer file.Close() + + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + var corpus canonicalVectorCorpus + if err := decoder.Decode(&corpus); err != nil { + t.Fatalf("decode canonical vector corpus: %v", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + t.Fatalf("canonical vector corpus has trailing data: %v", err) + } + if corpus.SchemaVersion != 1 { + t.Fatalf("corpus schema_version = %d, want 1", corpus.SchemaVersion) + } + if len(corpus.Vectors) == 0 { + t.Fatal("corpus has no vectors") + } + return corpus +} + +func canonicalVectorError(t *testing.T, value string) error { + t.Helper() + switch value { + case "invalid_document": + return ErrInvalidDocument + case "unsupported_number": + return ErrUnsupportedNumber + case "signature_invalid": + return ErrSignatureInvalid + default: + t.Fatalf("unsupported corpus want_error %q", value) + return nil + } +} + +func vectorDocumentSignature(t *testing.T, document string) string { + t.Helper() + var root struct { + Signature string `json:"signature"` + } + if err := json.Unmarshal([]byte(document), &root); err != nil { + t.Fatalf("decode vector document signature: %v", err) + } + if root.Signature == "" { + t.Fatal("vector document has no signature") + } + return root.Signature +} diff --git a/core/catalog/parser.go b/core/catalog/parser.go index cc05b21..2f5f782 100644 --- a/core/catalog/parser.go +++ b/core/catalog/parser.go @@ -2,7 +2,6 @@ package catalog import ( "bytes" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -231,12 +230,9 @@ func validatePackage(architecture Architecture, publishedPackage Package) error } func validateSignature(value string) error { - signature, err := base64.StdEncoding.Strict().DecodeString(value) + _, err := decodeCanonicalSignature(value) if err != nil { - return fmt.Errorf("must be strict Base64: %v", err) - } - if len(signature) != 64 { - return fmt.Errorf("must decode to 64 bytes") + return fmt.Errorf("must be canonical padded Base64 for 64 bytes: %v", err) } return nil } diff --git a/core/catalog/verifier.go b/core/catalog/verifier.go index 97dcd0d..91f8e96 100644 --- a/core/catalog/verifier.go +++ b/core/catalog/verifier.go @@ -66,17 +66,9 @@ func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) { if err != nil { return VerifiedDocument{}, err } - signature, err := base64.StdEncoding.Strict().DecodeString(signatureText) + signature, err := decodeCanonicalSignature(signatureText) if err != nil { - return VerifiedDocument{}, fmt.Errorf("%w: base64: %v", ErrSignatureInvalid, err) - } - if len(signature) != ed25519.SignatureSize { - return VerifiedDocument{}, fmt.Errorf( - "%w: got %d signature bytes, want %d", - ErrSignatureInvalid, - len(signature), - ed25519.SignatureSize, - ) + return VerifiedDocument{}, fmt.Errorf("%w: %v", ErrSignatureInvalid, err) } if !ed25519.Verify(verifier.publicKey, signedPayload, signature) { return VerifiedDocument{}, ErrSignatureInvalid @@ -87,3 +79,21 @@ func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) { SignedPayload: append([]byte(nil), signedPayload...), }, nil } + +func decodeCanonicalSignature(value string) ([]byte, error) { + signature, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil { + return nil, fmt.Errorf("invalid standard Base64: %w", err) + } + if base64.StdEncoding.EncodeToString(signature) != value { + return nil, errors.New("signature must use canonical padded Base64") + } + if len(signature) != ed25519.SignatureSize { + return nil, fmt.Errorf( + "got %d signature bytes, want %d", + len(signature), + ed25519.SignatureSize, + ) + } + return signature, nil +} diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index c8d34ad..28c38c7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, ## 当前阶段 -当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-613`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描以及安装文件/目录/journal 的代码层耐久顺序均已关闭;`T-614` 是当前唯一任务,用于冻结 Catalog canonicalization/签名跨实现向量。物理断电、文件锁与杀毒软件干扰验证仍后置到 T-302/T-601,T-302 暂后置。 +当前项目已完成 Phase 0~2、T-301 与审核整改 `T-604`~`T-614`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序以及 Catalog canonicalization/签名静态 corpus 均已关闭;下一步正式落成 T-302。物理断电、文件锁与杀毒软件干扰验证仍后置到 T-302/T-601。 优先路径: @@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端, 2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。 3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。 4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。 -5. 已完成 T-606~T-613:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描和安装耐久顺序;下一步执行 T-614 的独立签名向量任务,再继续 T-302/T-303 与 Phase 4-6。T-302/T-601 仍须补真实 Windows 环境的断电/干扰注入。 +5. 已完成 T-606~T-614:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序和 Catalog 静态签名向量;下一步正式落成并执行 T-302,再继续 T-303 与 Phase 4-6。T-302/T-601 仍须补真实 Windows 环境的断电/干扰注入。 ## 领取任务规则 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 914fe93..64bba72 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,7 +42,7 @@ #### Phase 1 交叉审核加固 -Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612 与 T-613 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,物理断电验证仍后置到 T-302/T-601。T-614 冻结 Catalog canonicalization/签名跨实现 testdata corpus 与客户端拒绝规则;完成后才进入 T-302。 +Phase 1 安全整改按 `docs/review/phase1-security-review.md` 的交叉复核定稿顺序串行落成。T-605、T-612、T-613 与 T-614 已关闭;T-613 建立文件、目录和 journal 的代码层耐久顺序,T-614 冻结 Catalog canonicalization/签名静态 corpus 与客户端拒绝规则。下一步按路线图正式落成并执行 T-302;物理断电验证仍后置到 T-302/T-601。 | ID | 任务 | 依赖 | 验收要点 | | --- | --- | --- | --- | diff --git a/docs/current-state.md b/docs/current-state.md index beeed88..55228b2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -13,22 +13,22 @@ ## 当前快照 - 日期:2026-07-18 -- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-613 已完成,T-614 是当前唯一 TODO,T-302 继续暂后置 +- 阶段:Phase 2 已完成(T-201~T-204);Phase 3 的 T-301 可恢复下载队列已完成;审核整改 T-604~T-614 已完成,下一步应正式落成并执行 T-302 - 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析 -- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数,每个 payload Sync/Close 后同步 staging tree 及父目录;transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 -- 测试:core 覆盖 Catalog、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 -- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 +- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型(Extractor 在同一普通文件句柄上核对 expected Catalog size 后有界预扫 EOCD/ZIP64、中央目录与条目数,每个 payload Sync/Close 后同步 staging tree 及父目录;transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件 +- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过 +- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵 - 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令) - 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1` - 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交 -- 当前 blocker:无;T-613 已建立 payload、staging tree、journal 与目录 rename/remove 的 fail-closed 耐久顺序,并由 Windows 原生目录 `FlushFileBuffers` 用例验证。T-614 将冻结 Catalog canonicalization/签名静态跨实现向量与 surrogate/Base64/`-0` 拒绝边界;完成前 T-302 继续后置。物理断电、文件锁/杀毒软件干扰仍需 T-302/T-601 的目标 Windows VM/真机故障注入 +- 当前 blocker:无;T-614 已以静态 corpus 冻结客户端 Catalog canonicalization/签名行为,外部 `softbox-catalog` 消费 corpus 的 CI 证据仍需跨仓库协调,但不阻止落成 T-302。物理断电、文件锁/杀毒软件干扰仍需 T-302/T-601 的目标 Windows VM/真机故障注入 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | harness coding 文档集(本次初始化完成) | -| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-613 已完成;T-614 是当前唯一 TODO 的 Phase 1 签名向量整改,T-302 暂后置 | +| `docs/tasks/` | 已有 | Phase 0~2、T-301 与 T-604~T-614 已完成;下一步按路线图落成 T-302 安装流程整合任务 | | `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 | | `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 | | `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 | @@ -40,9 +40,9 @@ 任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要: -- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-613`。 +- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204`;Phase 3 的 `T-301`;审核整改 `T-604`~`T-614`。 - 正在进行:无。 -- 下一个可领取任务:`T-614` — 冻结 Catalog canonicalization/签名静态跨实现向量,收紧 surrogate、`-0` 与 Base64 文本边界;完成后才可领取 T-302。T-302/T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 +- 下一个可领取任务:暂无;应按 Phase 3 路线图先将 T-302 安装流程整合正式落成任务文件,再领取。T-302/T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。 ## 当前可运行内容 diff --git a/docs/review/phase1-security-review.md b/docs/review/phase1-security-review.md index fe1b006..763e61f 100644 --- a/docs/review/phase1-security-review.md +++ b/docs/review/phase1-security-review.md @@ -202,7 +202,7 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 1. **[阻断 T-302 · 最高 · T-605]** 修 Windows 尾部空格/点、DOS 设备名与路径别名:建逐段 Windows 安全路径校验器(各层共用)+ 提取时 destination 包含性兜底检查;补 Win7/10/11 真实文件系统用例。关闭前不得把 Extractor 描述为"无路径穿越"。 2. **[已关闭 · T-612]** 已在构造 `zip.Reader` 前增加同句柄包大小与中央目录/EOCD(含 ZIP64)预扫描边界;Extractor 强制接收 expected Catalog `size`,核对打开文件长度并限制原始包、中央目录与声明条目数,不只依赖 `len(archive.File)`。T-302 仍须把已验签 Catalog、完成下载文件和 SHA-256 编排为真实安装调用链。 3. **[代码顺序已关闭 · T-613;发布前环境验证仍必做]** payload 文件 Sync/Close → staging 子目录、根与父目录元数据栅栏 → journal 临时文件 Sync/Close 与 root 栅栏 → 每次 rename 后 root 栅栏 → committed 耐久后才删 backup/journal。Windows 已使用 Win7 可用的目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`,失败 fail closed;fake 覆盖 payload/tree/journal/rename/rollback/recovery/cleanup 失败,且原生 Windows 用例验证目录 fence 可执行。T-302/T-601 仍必须在 VM/真机进行真实断电、文件锁/杀毒干扰注入,不能以单元测试替代。 -4. **[协议冻结前 · T-614]** `softbox-catalog` 与客户端独立 canonicalization/签名测试向量:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate 拒绝策略、`-0`/大整数/嵌套 signature、base64 padding/CR/LF、同语义不同表示同签名字节。T-614 先在本仓库冻结 corpus 和客户端拒绝规则;外部发布端消费同一 corpus 的 CI 证据仍须单独取得。 +4. **[客户端协议已关闭 · T-614;外部发布端验证待协调]** 已冻结静态 corpus 与客户端 canonicalization/签名拒绝规则:Unicode 键序、转义、`<>&`、U+2028/2029、合法/非法 surrogate、`-0`/大整数/嵌套 signature、Base64 padding/CR/LF/空白、同语义不同表示同 signing bytes。`softbox-catalog` 外部仓库仍必须消费相同 corpus 并提供独立 CI 证据;本仓库没有其源码,不能将客户端回归测试表述为发布端集成已完成。 5. **[整合时复核]** 按真实包采样调解压硬上限,保留硬边界,不降级为软信号。 6. **[实现时重点审]** T-402/T-403 真实健康检查:防"进程短暂启动即判健康"、错误工作目录/错误二进制被探活。 7. **[低优先]** 固定解压落盘 mode,可执行入口由已验证 app.json/Catalog 定义。 @@ -222,3 +222,9 @@ ZIP mode 决定 `0o600`/`0o700` 在目标 Windows 上基本不构成安全问题 - transaction、Switcher、rollback、Recovery 和受控删除统一经内部耐久接口:journal 临时文件先同步内容,所有 journal/目录 rename 或删除后同步 app root,未完成栅栏不得推进测试步骤或下一 phase。清理路径同时保留 `ErrRecoveryRequired` 与底层耐久错误,以便调用方可判定恢复状态。 - Windows 原生目录测试已验证读写目录句柄 `CreateFile(FILE_FLAG_BACKUP_SEMANTICS)` + `FlushFileBuffers`;Go 1.20 标准 `syscall` 路径不引入 Win10+ API 或第三方依赖。fake 覆盖 payload/tree、journal、switch、rollback/recovery 与 committed 清理的错误传播和后续 Recover 不变式。 - 关闭的是代码中的耐久顺序与 fail-closed 策略;T-302/T-601 的 Windows VM/真机物理断电、文件锁与杀毒软件故障注入仍为发布前环境验证,不宣称当前单元测试提供硬件级保证。 + +### T-614 完成记录(2026-07-18) + +- 新增 `testdata/catalog/canonical-vectors.json` v1:公开 RFC 8032 测试公钥、固定原始 document、固定 canonical signing bytes 和 Ed25519 signature 均为静态数据。测试先比较 bytes,再用静态公钥验证静态 signature,不调用客户端 canonicalizer 或运行时私钥生成期望值。 +- corpus 覆盖 Unicode 键排序/转义、`<>&`、U+2028/U+2029、合法 pair 和三类非法 surrogate、`-0`、大整数、嵌套 `signature`、标准 padding、CR/LF/space/tab、padding 缺失/额外以及两种语义相同 JSON 表示。 +- 客户端现在在 JSON decoder 前拒绝孤立/不匹配 surrogate,拒绝 `-0`;Verifier 和 Parser 共享标准 padded Base64 的 decode→reencode 相等检查。关闭的是客户端协议契约;外部 `softbox-catalog` 消费 corpus 的实现/CI 证据仍需跨仓库协调。 diff --git a/docs/tasks/T-614.md b/docs/tasks/T-614.md index 7da9c89..9fec211 100644 --- a/docs/tasks/T-614.md +++ b/docs/tasks/T-614.md @@ -3,12 +3,12 @@ id: T-614 title: 冻结 Catalog 规范化与签名跨实现测试向量 phase: 1 deps: [T-613] -status: TODO +status: DONE created: 2026-07-18 issue: null -context_ref: null +context_ref: 9e5f3f4840b03dc90342a9bde923c39d6178b029 claim_branch: null -work_branch: null +work_branch: agent/codex/T-614 write_paths: - docs/tasks/T-614.md - core/catalog/ @@ -69,3 +69,6 @@ T-101 的 Catalog verifier 已能拒绝重复字段、尾随 JSON 和非整数 - 2026-07-18:根据 `docs/review/phase1-security-review.md` 最终处理顺序第 4 项落成;T-613 已完成,因此 T-614 成为下一项唯一任务。 - 2026-07-18:前置检索确认现有合法签名测试由同一客户端 canonicalizer/测试私钥生成,Go JSON decoder 会将非法 surrogate 替换为 U+FFFD,且标准库 strict Base64 仍容忍 CR/LF。任务据此冻结拒绝策略和静态跨实现 corpus,不把客户端自举测试误作发布端互操作证明。 +- 2026-07-18:新增 `testdata/catalog/canonical-vectors.json` v1 和客户端 corpus 测试。有效向量固定断言 canonical bytes、顶层签名和 RFC 8032 公开测试公钥验证结果,不调用 `canonicalJSON` 或测试期私钥生成预期;拒绝向量覆盖孤立/不匹配 surrogate、`-0`、Base64 CR/LF/space/tab 与 padding 缺失/额外。两种 key order/空白不同的 JSON 固定映射到同一 bytes/signature。 +- 2026-07-18:在受限 JSON 解析器增加 surrogate 扫描,拒绝 Go decoder 原会替换的非法 pair;整数 token 只接受 `0`、正整数或负非零整数。Verifier 和 Parser 共享 Base64 decode→reencode 相等校验,因此非唯一文本不再被接受;嵌套 `signature` 仍在 signed payload。 +- 2026-07-18:验证通过:`GOWORK=off go -C core vet ./catalog`、`GOWORK=off go -C core test -count=10 ./catalog`、`./scripts/verify_phase0.ps1`、`python scripts/validate_agent_context.py`、`python scripts/validate_harness_governance.py` 与 `git diff --check`;完整验证同时覆盖 Go 1.20.14 core、现代版与 Win7 版构建/测试。外部 `softbox-catalog` 的 corpus 消费和 CI 证据不在本仓库,已作为跨仓库待协调项如实保留,无当前代码 blocker。 diff --git a/testdata/README.md b/testdata/README.md index 8130ae8..4856c65 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -7,3 +7,11 @@ - 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。 - `catalog/manifest-valid-payload.json` 同时作为 manifest v1 强类型解析与目标过滤的公开虚构样例;包哈希与签名只保证格式合法,不对应真实下载物。 - `download/`:T-301 在运行时生成 HTTPS Range、断连、并发和恢复样例,不保存真实下载包。 + +## Catalog canonicalization vectors + +`catalog/canonical-vectors.json` is the versioned, cross-implementation signing corpus for the Catalog signature domain. It contains only a public RFC 8032 test key and fixed synthetic documents, canonical signing bytes and Ed25519 signatures; it never contains a production private key, real Catalog URL or release signature. + +Each `document` is the raw UTF-8 JSON text passed to the verifier. A successful vector provides the exact `signed_payload_base64` and top-level `signature`; a rejecting vector provides `want_error` (`invalid_document`, `unsupported_number` or `signature_invalid`). Both the client and the external `softbox-catalog` publisher must consume these static values verbatim. They must not recreate expected bytes or signatures by calling their own canonicalizer or signing helper. + +The corpus freezes Unicode key sorting/escaping, U+2028/U+2029, valid and invalid surrogate behavior, `-0` and large integers, nested `signature`, canonical padded Base64 and semantic-equivalent JSON representations. Extending it is a protocol change: increment `schema_version` when its interpretation changes and update `docs/api.md` together with client and publisher tests. diff --git a/testdata/catalog/canonical-vectors.json b/testdata/catalog/canonical-vectors.json new file mode 100644 index 0000000..796d2b6 --- /dev/null +++ b/testdata/catalog/canonical-vectors.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "public_key_base64": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", + "vectors": [ + { + "name": "unicode-escapes-sorted-keys-and-nested-signature", + "document": "{\"z\":\"\\u003c\\u003e\\u0026\\u2028\\u2029\",\"nested\":{\"signature\":\"inner\",\"n\":9007199254740993},\"emoji\":\"\\uD83D\\uDE00\",\"\\u00e9\":\"\\u00e9\",\"a\":\"line\\u000aquote\\u0022slash\\u005c\",\"signature\":\"JS8or6xTEfGtGOoxPbephpnYbB15fcRL1VnEcoQ0IZIHDx6URuRs3SciDIubEqxVCqy+GVOuyD2lRAOLm11OBQ==\"}", + "signed_payload_base64": "eyJhIjoibGluZVxucXVvdGVcInNsYXNoXFwiLCJlbW9qaSI6IvCfmIAiLCJuZXN0ZWQiOnsibiI6OTAwNzE5OTI1NDc0MDk5Mywic2lnbmF0dXJlIjoiaW5uZXIifSwieiI6Ilx1MDAzY1x1MDAzZVx1MDAyNlx1MjAyOFx1MjAyOSIsIsOpIjoiw6kifQ==", + "signature": "JS8or6xTEfGtGOoxPbephpnYbB15fcRL1VnEcoQ0IZIHDx6URuRs3SciDIubEqxVCqy+GVOuyD2lRAOLm11OBQ==" + }, + { + "name": "equivalent-object-order-a", + "document": "{\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\",\"a\":0}", + "signed_payload_base64": "eyJhIjowLCJiIjpbdHJ1ZSxudWxsLCJ4Il19", + "signature": "ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==" + }, + { + "name": "equivalent-object-order-b", + "document": " { \"a\" : 0 , \"b\" : [ true , null , \"x\" ] , \"signature\" : \"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\" } ", + "signed_payload_base64": "eyJhIjowLCJiIjpbdHJ1ZSxudWxsLCJ4Il19", + "signature": "ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==" + }, + { + "name": "reject-isolated-high-surrogate", + "document": "{\"value\":\"\\uD800\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}", + "want_error": "invalid_document" + }, + { + "name": "reject-isolated-low-surrogate", + "document": "{\"value\":\"\\uDC00\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}", + "want_error": "invalid_document" + }, + { + "name": "reject-mismatched-surrogate-pair", + "document": "{\"value\":\"\\uD800\\u0041\",\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}", + "want_error": "invalid_document" + }, + { + "name": "reject-negative-zero", + "document": "{\"n\":-0,\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\"}", + "want_error": "unsupported_number" + }, + { + "name": "reject-signature-crlf", + "document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59\\r\\nAdULCg==\"}", + "want_error": "signature_invalid" + }, + { + "name": "reject-signature-space", + "document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg== \"}", + "want_error": "signature_invalid" + }, + { + "name": "reject-signature-tab", + "document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg==\\t\"}", + "want_error": "signature_invalid" + }, + { + "name": "reject-signature-missing-padding", + "document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg\"}", + "want_error": "signature_invalid" + }, + { + "name": "reject-signature-extra-padding", + "document": "{\"a\":0,\"b\":[true,null,\"x\"],\"signature\":\"ozK/qFxWS+WX2Ic//QJMP87iDQqm1jXNv+1BE45/eddfx92zcq20x74Xo8VjO6zAOirKTj5OilL5JL59AdULCg===\"}", + "want_error": "signature_invalid" + } + ] +}