Prototype signed catalog fallback (T-101)

This commit is contained in:
ila
2026-07-16 16:13:18 +08:00
parent 45c242ecec
commit 0d6ed05d7e
15 changed files with 959 additions and 10 deletions
+166
View File
@@ -0,0 +1,166 @@
package catalog
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"unicode/utf8"
)
var integerJSONNumber = regexp.MustCompile(`^-?(0|[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)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
value, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
if _, err := decoder.Token(); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
}
return nil, fmt.Errorf("%w: trailing data: %v", ErrInvalidDocument, err)
}
return value, nil
}
func decodeJSONValue(decoder *json.Decoder) (any, error) {
token, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidDocument, err)
}
switch value := token.(type) {
case json.Delim:
switch value {
case '{':
object := make(map[string]any)
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("%w: object key: %v", ErrInvalidDocument, err)
}
key, ok := keyToken.(string)
if !ok {
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
}
if _, exists := object[key]; exists {
return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key)
}
child, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
object[key] = child
}
end, err := decoder.Token()
if err != nil || end != json.Delim('}') {
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
}
return object, nil
case '[':
var array []any
for decoder.More() {
child, err := decodeJSONValue(decoder)
if err != nil {
return nil, err
}
array = append(array, child)
}
end, err := decoder.Token()
if err != nil || end != json.Delim(']') {
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
}
return array, nil
default:
return nil, fmt.Errorf("%w: unexpected delimiter %q", ErrInvalidDocument, value)
}
case json.Number:
if !integerJSONNumber.MatchString(string(value)) {
return nil, fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
}
return value, nil
case string, bool, nil:
return value, nil
default:
return nil, fmt.Errorf("%w: unsupported token %T", ErrInvalidDocument, token)
}
}
func canonicalJSON(value any) ([]byte, error) {
var buffer bytes.Buffer
if err := appendCanonicalJSON(&buffer, value); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
switch value := value.(type) {
case nil:
buffer.WriteString("null")
case bool:
if value {
buffer.WriteString("true")
} else {
buffer.WriteString("false")
}
case string:
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%w: encode string: %v", ErrInvalidDocument, err)
}
buffer.Write(encoded)
case json.Number:
if !integerJSONNumber.MatchString(string(value)) {
return fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
}
buffer.WriteString(string(value))
case []any:
buffer.WriteByte('[')
for index, child := range value {
if index > 0 {
buffer.WriteByte(',')
}
if err := appendCanonicalJSON(buffer, child); err != nil {
return err
}
}
buffer.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
buffer.WriteByte('{')
for index, key := range keys {
if index > 0 {
buffer.WriteByte(',')
}
encodedKey, err := json.Marshal(key)
if err != nil {
return fmt.Errorf("%w: encode key: %v", ErrInvalidDocument, err)
}
buffer.Write(encodedKey)
buffer.WriteByte(':')
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
return err
}
}
buffer.WriteByte('}')
default:
return fmt.Errorf("%w: unsupported value %T", ErrInvalidDocument, value)
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package catalog
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var ErrCachePathEmpty = errors.New("catalog cache path is empty")
// FileCache stores a signed Catalog document and a crash-recovery backup.
type FileCache struct {
path string
mu sync.Mutex
}
func NewFileCache(path string) *FileCache {
return &FileCache{path: path}
}
func (cache *FileCache) Load() ([]byte, error) {
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.path == "" {
return nil, ErrCachePathEmpty
}
document, err := os.ReadFile(cache.path)
if err == nil {
return document, nil
}
if !os.IsNotExist(err) {
return nil, err
}
return os.ReadFile(cache.backupPath())
}
func (cache *FileCache) Store(document []byte) error {
cache.mu.Lock()
defer cache.mu.Unlock()
if cache.path == "" {
return ErrCachePathEmpty
}
directory := filepath.Dir(cache.path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("create catalog cache directory: %w", err)
}
temporary, err := os.CreateTemp(directory, ".catalog-*.tmp")
if err != nil {
return fmt.Errorf("create catalog cache temp file: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("protect catalog cache temp file: %w", err)
}
if _, err := temporary.Write(document); err != nil {
temporary.Close()
return fmt.Errorf("write catalog cache temp file: %w", err)
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return fmt.Errorf("sync catalog cache temp file: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close catalog cache temp file: %w", err)
}
backupPath := cache.backupPath()
movedCurrent := false
if _, err := os.Stat(cache.path); err == nil {
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale catalog cache backup: %w", err)
}
if err := os.Rename(cache.path, backupPath); err != nil {
return fmt.Errorf("backup current catalog cache: %w", err)
}
movedCurrent = true
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect current catalog cache: %w", err)
}
if err := os.Rename(temporaryPath, cache.path); err != nil {
if movedCurrent {
_ = os.Rename(backupPath, cache.path)
}
return fmt.Errorf("activate catalog cache: %w", err)
}
if movedCurrent {
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove catalog cache backup: %w", err)
}
}
return nil
}
func (cache *FileCache) backupPath() string {
return cache.path + ".backup"
}
+49
View File
@@ -0,0 +1,49 @@
package catalog
import (
"os"
"path/filepath"
"testing"
)
func TestFileCacheStoreAndUpdate(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache", "manifest.json")
cache := NewFileCache(path)
if err := cache.Store([]byte("first")); err != nil {
t.Fatalf("Store(first) error = %v", err)
}
if err := cache.Store([]byte("second")); err != nil {
t.Fatalf("Store(second) error = %v", err)
}
got, err := cache.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if string(got) != "second" {
t.Fatalf("Load() = %q, want %q", got, "second")
}
if _, err := os.Stat(path + ".backup"); !os.IsNotExist(err) {
t.Fatalf("backup should be removed after successful update, stat error = %v", err)
}
}
func TestFileCacheLoadsBackupAfterInterruptedSwitch(t *testing.T) {
path := filepath.Join(t.TempDir(), "manifest.json")
cache := NewFileCache(path)
if err := cache.Store([]byte("verified")); err != nil {
t.Fatalf("Store() error = %v", err)
}
if err := os.Rename(path, path+".backup"); err != nil {
t.Fatalf("simulate interrupted switch: %v", err)
}
got, err := cache.Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if string(got) != "verified" {
t.Fatalf("Load() = %q, want %q", got, "verified")
}
}
+106
View File
@@ -0,0 +1,106 @@
package catalog
import (
"context"
"errors"
"fmt"
)
var ErrNoValidCatalog = errors.New("no valid catalog available")
// Fetcher obtains a signed Catalog document from a remote or test source.
type Fetcher interface {
Fetch(context.Context) ([]byte, error)
}
// FetchFunc adapts a function to Fetcher.
type FetchFunc func(context.Context) ([]byte, error)
func (function FetchFunc) Fetch(ctx context.Context) ([]byte, error) {
return function(ctx)
}
// Cache stores the last verified signed document.
type Cache interface {
Load() ([]byte, error)
Store([]byte) error
}
// LoadSource describes where a verified result came from.
type LoadSource string
const (
SourceRemote LoadSource = "remote"
SourceCache LoadSource = "cache"
)
// LoadResult returns verified bytes and a non-fatal refresh/cache warning.
type LoadResult struct {
Document VerifiedDocument
Source LoadSource
Warning error
}
// LoadError preserves both the refresh and cache failure.
type LoadError struct {
Refresh error
Cache error
}
func (err *LoadError) Error() string {
return fmt.Sprintf("%s: refresh=%v; cache=%v", ErrNoValidCatalog, err.Refresh, err.Cache)
}
func (err *LoadError) Unwrap() error {
return ErrNoValidCatalog
}
// Loader verifies remote data before storing it and re-verifies cache fallback.
type Loader struct {
verifier Verifier
fetcher Fetcher
cache Cache
}
func NewLoader(verifier Verifier, fetcher Fetcher, cache Cache) *Loader {
return &Loader{
verifier: verifier,
fetcher: fetcher,
cache: cache,
}
}
// Load prefers a verified remote document and falls back to verified cache.
func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
remoteBytes, refreshErr := loader.fetcher.Fetch(ctx)
if refreshErr == nil {
verified, verifyErr := loader.verifier.Verify(remoteBytes)
if verifyErr == nil {
storeErr := loader.cache.Store(verified.Bytes)
return LoadResult{
Document: verified,
Source: SourceRemote,
Warning: storeErr,
}, nil
}
refreshErr = verifyErr
}
cachedBytes, cacheErr := loader.cache.Load()
if cacheErr == nil {
var verified VerifiedDocument
verified, cacheErr = loader.verifier.Verify(cachedBytes)
if cacheErr == nil {
return LoadResult{
Document: verified,
Source: SourceCache,
Warning: refreshErr,
}, nil
}
}
return LoadResult{}, &LoadError{
Refresh: refreshErr,
Cache: cacheErr,
}
}
+148
View File
@@ -0,0 +1,148 @@
package catalog
import (
"context"
"errors"
"testing"
)
func TestLoaderUsesVerifiedRemoteAndStoresCache(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
cache := &memoryCache{}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return validDocument, nil
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceRemote {
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
}
if string(cache.document) != string(validDocument) {
t.Fatal("verified remote document was not stored")
}
}
func TestLoaderFallsBackToVerifiedCache(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
offline := errors.New("offline")
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return nil, offline
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceCache {
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
}
if !errors.Is(result.Warning, offline) {
t.Fatalf("Warning = %v, want %v", result.Warning, offline)
}
}
func TestLoaderRejectsRemoteWithoutOverwritingCache(t *testing.T) {
verifier, validDocument, tamperedDocument := loaderTestDocuments(t)
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return tamperedDocument, nil
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceCache {
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
}
if !errors.Is(result.Warning, ErrSignatureInvalid) {
t.Fatalf("Warning = %v, want %v", result.Warning, ErrSignatureInvalid)
}
if cache.storeCalls != 0 {
t.Fatalf("Store() called %d times for invalid remote", cache.storeCalls)
}
if string(cache.document) != string(validDocument) {
t.Fatal("invalid remote changed cached document")
}
}
func TestLoaderRejectsInvalidCache(t *testing.T) {
verifier, _, tamperedDocument := loaderTestDocuments(t)
offline := errors.New("offline")
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return nil, offline
}), &memoryCache{document: tamperedDocument})
_, err := loader.Load(context.Background())
if !errors.Is(err, ErrNoValidCatalog) {
t.Fatalf("Load() error = %v, want %v", err, ErrNoValidCatalog)
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("Load() error type = %T, want *LoadError", err)
}
if !errors.Is(loadErr.Cache, ErrSignatureInvalid) {
t.Fatalf("cache error = %v, want %v", loadErr.Cache, ErrSignatureInvalid)
}
}
func TestLoaderReturnsCacheStoreWarning(t *testing.T) {
verifier, validDocument, _ := loaderTestDocuments(t)
storeFailure := errors.New("disk full")
cache := &memoryCache{storeErr: storeFailure}
loader := NewLoader(verifier, FetchFunc(func(context.Context) ([]byte, error) {
return validDocument, nil
}), cache)
result, err := loader.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceRemote {
t.Fatalf("Source = %q, want %q", result.Source, SourceRemote)
}
if !errors.Is(result.Warning, storeFailure) {
t.Fatalf("Warning = %v, want %v", result.Warning, storeFailure)
}
}
type memoryCache struct {
document []byte
loadErr error
storeErr error
storeCalls int
}
func (cache *memoryCache) Load() ([]byte, error) {
if cache.loadErr != nil {
return nil, cache.loadErr
}
return append([]byte(nil), cache.document...), nil
}
func (cache *memoryCache) Store(document []byte) error {
cache.storeCalls++
if cache.storeErr != nil {
return cache.storeErr
}
cache.document = append([]byte(nil), document...)
return nil
}
func loaderTestDocuments(t *testing.T) (Verifier, []byte, []byte) {
t.Helper()
publicKey, privateKey := catalogTestKey()
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
validDocument, signature := signCatalogPayload(t, validPayload, privateKey)
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
tamperedDocument := attachCatalogSignature(t, tamperedPayload, signature)
return verifier, validDocument, tamperedDocument
}
+89
View File
@@ -0,0 +1,89 @@
package catalog
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
)
var (
ErrInvalidDocument = errors.New("invalid catalog document")
ErrDuplicateField = errors.New("duplicate catalog field")
ErrUnsupportedNumber = errors.New("unsupported catalog number")
ErrSignatureMissing = errors.New("catalog signature missing")
ErrSignatureInvalid = errors.New("catalog signature invalid")
ErrPublicKeyInvalid = errors.New("catalog public key invalid")
)
// VerifiedDocument contains the original signed document and its signing bytes.
type VerifiedDocument struct {
Bytes []byte
SignedPayload []byte
}
// Verifier validates signed Catalog JSON documents with one Ed25519 public key.
type Verifier struct {
publicKey ed25519.PublicKey
}
// NewVerifier copies and validates the public key.
func NewVerifier(publicKey []byte) (Verifier, error) {
if len(publicKey) != ed25519.PublicKeySize {
return Verifier{}, fmt.Errorf(
"%w: got %d bytes, want %d",
ErrPublicKeyInvalid,
len(publicKey),
ed25519.PublicKeySize,
)
}
keyCopy := append(ed25519.PublicKey(nil), publicKey...)
return Verifier{publicKey: keyCopy}, nil
}
// Verify rejects ambiguous JSON and validates the top-level signature.
func (verifier Verifier) Verify(document []byte) (VerifiedDocument, error) {
rootValue, err := parseRestrictedJSON(document)
if err != nil {
return VerifiedDocument{}, err
}
root, ok := rootValue.(map[string]any)
if !ok {
return VerifiedDocument{}, fmt.Errorf("%w: root must be an object", ErrInvalidDocument)
}
signatureValue, exists := root["signature"]
if !exists {
return VerifiedDocument{}, ErrSignatureMissing
}
signatureText, ok := signatureValue.(string)
if !ok {
return VerifiedDocument{}, fmt.Errorf("%w: signature must be a string", ErrSignatureInvalid)
}
delete(root, "signature")
signedPayload, err := canonicalJSON(root)
if err != nil {
return VerifiedDocument{}, err
}
signature, err := base64.StdEncoding.Strict().DecodeString(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,
)
}
if !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
return VerifiedDocument{}, ErrSignatureInvalid
}
return VerifiedDocument{
Bytes: append([]byte(nil), document...),
SignedPayload: append([]byte(nil), signedPayload...),
}, nil
}
+136
View File
@@ -0,0 +1,136 @@
package catalog
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func TestVerifierFixtures(t *testing.T) {
publicKey, privateKey := catalogTestKey()
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
validDocument, validSignature := signCatalogPayload(t, validPayload, privateKey)
tamperedPayload := readCatalogFixture(t, "manifest-tampered-payload.json")
tamperedDocument := attachCatalogSignature(t, tamperedPayload, validSignature)
forgedDocument := readCatalogFixture(t, "manifest-forged.json")
tests := []struct {
name string
document []byte
wantErr error
}{
{name: "valid", document: validDocument},
{name: "tampered", document: tamperedDocument, wantErr: ErrSignatureInvalid},
{name: "forged", document: forgedDocument, wantErr: ErrSignatureInvalid},
{
name: "duplicate field",
document: []byte(`{"channel":"modern","channel":"win7","signature":"x"}`),
wantErr: ErrDuplicateField,
},
{
name: "fractional number",
document: []byte(`{"schema_version":1.5,"signature":"x"}`),
wantErr: ErrUnsupportedNumber,
},
{
name: "exponent number",
document: []byte(`{"schema_version":1e2,"signature":"x"}`),
wantErr: ErrUnsupportedNumber,
},
{
name: "missing signature",
document: validPayload,
wantErr: ErrSignatureMissing,
},
{
name: "trailing value",
document: append(append([]byte(nil), validDocument...), []byte(` {}`)...),
wantErr: ErrInvalidDocument,
},
{
name: "invalid UTF-8",
document: []byte{0xff, 0xfe},
wantErr: ErrInvalidDocument,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
verified, err := verifier.Verify(test.document)
if test.wantErr == nil {
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
if len(verified.SignedPayload) == 0 {
t.Fatal("Verify() returned empty signed payload")
}
return
}
if !errors.Is(err, test.wantErr) {
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
}
})
}
}
func catalogTestKey() (ed25519.PublicKey, ed25519.PrivateKey) {
seed := sha256.Sum256([]byte("SoftBox catalog verifier test key - never use in production"))
privateKey := ed25519.NewKeyFromSeed(seed[:])
return privateKey.Public().(ed25519.PublicKey), privateKey
}
func readCatalogFixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("..", "..", "testdata", "catalog", name)
document, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return document
}
func signCatalogPayload(t *testing.T, payload []byte, privateKey ed25519.PrivateKey) ([]byte, string) {
t.Helper()
value, err := parseRestrictedJSON(payload)
if err != nil {
t.Fatalf("parse payload: %v", err)
}
root, ok := value.(map[string]any)
if !ok {
t.Fatal("payload root is not an object")
}
canonical, err := canonicalJSON(root)
if err != nil {
t.Fatalf("canonicalize payload: %v", err)
}
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
return attachCatalogSignature(t, payload, signature), signature
}
func attachCatalogSignature(t *testing.T, payload []byte, signature string) []byte {
t.Helper()
value, err := parseRestrictedJSON(payload)
if err != nil {
t.Fatalf("parse payload: %v", err)
}
root, ok := value.(map[string]any)
if !ok {
t.Fatal("payload root is not an object")
}
root["signature"] = signature
document, err := json.MarshalIndent(root, "", " ")
if err != nil {
t.Fatalf("encode signed document: %v", err)
}
return document
}
+1
View File
@@ -103,6 +103,7 @@ soft_quay/
- 软件主键是永久稳定的 `id`(小写英文/数字/短横线),不用名称;下架用 `status`,不用名称前缀。
- 清单验签失败时**拒绝**,回退到最后一次验证成功的缓存,绝不接受未验证的新内容。
- Phase 1 清单签名原型会拒绝重复字段、尾随 JSON 与非整数数字;签名域为移除顶层 `signature` 后的受限规范 JSON,细节见 [api.md](api.md)。
- channel 分 `modern` / `win7`,更新器必须校验 channel + min_os,禁止交叉升级。
### 4.2 本地动态数据(JSON + 原子写入)
+11
View File
@@ -58,6 +58,17 @@
- 下架:显式 `status: deprecated | hidden`,不用名称前缀。
- 过滤:按 `min_os` 与 `architectures` 过滤;不兼容软件可见说明但不可下载。
### 1.1 Phase 1 签名域原型
T-101 验证采用以下签名域,供客户端与后续发布器实现对齐:
1. 输入必须是单个 UTF-8 JSON object;重复字段、尾随 JSON、浮点/指数数字直接拒绝。
2. 读取顶层 `signature`(标准 Base64 编码的 64 字节 Ed25519 签名),然后从对象中移除该字段。
3. 对剩余值递归规范化:对象键按 Unicode 字符串升序排列;数组保持原顺序;字符串按 JSON 转义;数字仅允许 JSON 整数并保持其合法十进制写法;不保留无意义空白。
4. Ed25519 直接签名/验证上述规范 JSON 字节。
这是 Phase 1 风险原型结论。T-201 正式接入时必须与 `softbox-catalog` 发布端做跨实现向量测试,再冻结 Schema、密钥 ID/轮换字段和版本兼容策略;在此之前不得另造签名域。
## 2. 标准软件包协议 v1(ZIP)
```text
+10 -10
View File
@@ -13,36 +13,36 @@
## 当前快照
- 日期:2026-07-16
- 阶段:M1 已完成(Phase 0 工程骨架、状态/事件模型、双 Gio 窗口、平台 stub 与双目标验证闸门全部落地)
- 阶段:M2 进行中(Phase 1 的 T-101 清单验签与缓存回退原型已完成)
- 技术栈:根 Go workspace 纳入 core/app-modern/app-win7 三模块;`app-win7/go.work` 隔离 Go 1.20.14 构建;modern Gio v0.10.1 与 win7 Gio v0.6.0 已实际接入
- 生产代码:core 已有状态模型与事件 runtime;modern/win7 均可打开最小 AppShell,并有平台接口、Windows 实现与非 Windows stub
- 测试:core 覆盖状态/事件 runtime;两个 app 覆盖 AppShell 布局尺寸与平台 stub 契约;Phase 0 脚本统一执行全部验证
- 数据:无;Catalog 清单与测试样例待建
- 生产代码:core 已有状态/事件 runtime 与 Catalog Ed25519 验签、受限规范 JSON、验证后缓存回退原型;modern/win7 均可打开最小 AppShell
- 测试:core 覆盖状态/事件、Catalog 篡改/伪造/歧义输入、缓存回退和文件缓存恢复;两个 app 覆盖 AppShell 与平台 stub
- 数据:`testdata/catalog/` 已有公开虚构的合法 payload、篡改 payload 与伪造签名样例
- 标准启动路径:`./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-101
- 当前 blocker:无;下一步按路线图落成并领取 T-102
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
| `docs/tasks/` | 已有 | 任务目录;T-001 已完成,T-002 待按路线图落成 |
| `docs/tasks/` | 已有 | Phase 0 四项与 T-101 已完成;T-102 待按路线图落成 |
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
| `core/` | 已建 | Go 1.20 兼容共享模块;已有 domain 状态模型与 application 事件 runtime |
| `core/` | 已建 | Go 1.20 兼容共享模块;已有状态/事件与 Catalog 验签/缓存原型 |
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;可打开 Modern AppShell |
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;可打开带 Legacy 标识的 AppShell |
| `schemas/` | 待建 | 协议 JSON Schema(T-201) |
| `testdata/` | 待建 | 假数据测试样例(T-101 起) |
| `testdata/` | 已建 | 当前包含 Catalog 假数据与恶意样例;后续任务继续扩展 |
## 任务状态
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
- 已完成:Phase 0 的 `T-001`、`T-002`、`T-003`、`T-004`。
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`。
- 正在进行:无。
- 下一个可领取任务:按路线图落成并领取 `T-101 签名清单验签与缓存回退原型`。
- 下一个可领取任务:按路线图落成并领取 `T-102 ZIP 安全解压原型`。
## 当前可运行内容
+65
View File
@@ -0,0 +1,65 @@
---
id: T-101
title: 签名 Catalog 验签与缓存回退原型
phase: 1
deps: [T-002]
status: DONE
created: 2026-07-16
issue: null
context_ref: 45c242ecec153aa2d409a46883da9845503f185c
claim_branch: null
work_branch: agent/codex/T-101
write_paths:
- docs/tasks/T-101.md
- core/catalog/
- testdata/catalog/
- testdata/README.md
- docs/api.md
- docs/04-architecture.md
- docs/current-state.md
---
## 问题 / 背景
Catalog 是后续下载与执行链路的信任根。当前协议只约定 Ed25519 和顶层 `signature`,尚未验证签名域、JSON 歧义处理、缓存替换与断网回退行为。若这里错误,篡改清单可能进入安装链路。
## 方案
1. 在 `core/catalog` 建立 Ed25519 verifier,签名域为“移除顶层 `signature` 后的受限规范 JSON”。
2. 解析时拒绝重复字段、尾随 JSON、非对象根、非整数数字、缺失/非法长度签名,避免不同解析器对同一文档产生歧义。
3. 建立 Fetcher/Cache 接口与 Loader:远端验签成功后才写缓存;远端获取或验签失败时只使用再次验签通过的缓存。
4. 提供文件缓存原型,使用同目录临时文件 + current/backup 改名,并能读取中断后遗留的 backup。
5. 在 `testdata/catalog` 放置假数据与恶意样例;测试使用明确标注的专用测试密钥,不提交生产私钥。
6. 将签名域与原型结论同步到 `docs/api.md`、`docs/04-architecture.md`。
## 验收要点
- 合法测试清单验签通过。
- 篡改内容复用合法签名、伪造签名、重复字段、浮点/指数数字均被拒绝。
- 远端验签失败不会覆盖最后有效缓存;断网可返回再次验签通过的缓存。
- 缓存自身被篡改时拒绝,不得当作离线可信数据。
- 文件缓存更新与 backup 恢复测试通过。
- `cd core && go vet ./... && go test -count=1 ./...` 通过,Phase 0 双目标闸门继续通过。
## 边界(不改什么)
- 不实现 HTTPS 客户端、Catalog 字段 Schema、OS/架构过滤或 UI 接入(T-201)。
- 不定稿密钥轮换、撤销名单和 package 独立签名域。
- 不引入第三方 JSON canonicalization 或加密依赖。
- 不提交生产私钥、真实 URL 或真实签名。
## 协作约束
未启用 Gitea;本任务在 `agent/codex/T-101` 分支串行执行。签名格式结论标记为 Phase 1 原型,T-201 正式接入时再与发布端共同冻结。
## 执行记录
- 2026-07-16:在 `core/catalog` 建立受限 JSON 解析/规范化与 Ed25519 verifier;拒绝重复字段、尾随值、无效 UTF-8、非对象根、非整数数字和非法签名编码/长度。
- 2026-07-16:签名域确定为移除顶层 `signature` 后的受限规范 JSON;对象键排序、数组保序、字符串标准 JSON 转义、数字仅允许整数。该结论已同步 `docs/api.md` 与 `docs/04-architecture.md`,并明确为 T-201 前的 Phase 1 原型。
- 2026-07-16:建立 Fetcher/Cache/Loader;远端只有验签成功才写缓存,获取失败或验签失败时只返回再次验签成功的缓存;缓存写失败作为非致命 warning 暴露。
- 2026-07-16:建立 FileCache 临时文件 + current/backup 切换原型,并支持 current 缺失时读取中断遗留 backup。
- 2026-07-16:新增公开虚构的 Catalog testdata;测试密钥由测试代码中的明确测试 seed 运行时派生,仓库不含生产私钥。
- 定向测试通过:合法、篡改、伪造、重复字段、浮点、指数、缺签名、尾随 JSON、无效 UTF-8、断网回退、坏缓存、缓存写失败与中断 backup 恢复。
- 验证通过:Go 1.20.14 `go vet ./...`、`go test -count=1 ./...`。
- 验证通过:`./scripts/verify_phase0.ps1`,包含 core 边界/版本检查和 modern/win7 双目标构建。
- 验证通过:`python scripts/validate_harness_governance.py`。
+7
View File
@@ -0,0 +1,7 @@
# 测试数据
本目录只保存公开、虚构、不可用于生产的数据与攻击样例。
- 不放生产私钥、真实注册码、真实机器标识或真实下载地址。
- 测试若需要签名,使用测试代码中明确标注的专用测试密钥。
- 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。
+8
View File
@@ -0,0 +1,8 @@
{
"schema_version": 1,
"channel": "modern",
"generated_at": "2026-07-16T00:00:00Z",
"min_box_version": "1.0.0",
"apps": [],
"signature": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
+29
View File
@@ -0,0 +1,29 @@
{
"schema_version": 1,
"channel": "modern",
"generated_at": "2026-07-16T00:00:00Z",
"min_box_version": "1.0.0",
"apps": [
{
"id": "json-parser",
"name": "JSON解析工具",
"description": "攻击者修改了版本但复用了旧签名",
"version": "9.9.9",
"channel": "stable",
"status": "active",
"tags": ["工具", "JSON"],
"min_os": "windows-10",
"architectures": ["amd64"],
"entry_exe": "JsonParser.exe",
"requires_admin": false,
"packages": {
"amd64": {
"url": "https://download.invalid/forged.zip",
"size": 42,
"sha256": "forged-sha256-placeholder",
"signature": "forged-package-signature-placeholder"
}
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"schema_version": 1,
"channel": "modern",
"generated_at": "2026-07-16T00:00:00Z",
"min_box_version": "1.0.0",
"apps": [
{
"id": "json-parser",
"name": "JSON解析工具",
"description": "仅用于测试的清单项",
"version": "1.2.0",
"channel": "stable",
"status": "active",
"tags": ["工具", "JSON"],
"min_os": "windows-10",
"architectures": ["amd64"],
"entry_exe": "JsonParser.exe",
"requires_admin": false,
"packages": {
"amd64": {
"url": "https://download.invalid/json-parser.zip",
"size": 42,
"sha256": "test-sha256-placeholder",
"signature": "test-package-signature-placeholder"
}
}
}
]
}