From 0d6ed05d7e69f4a8e2a20e93051e1fe48a868940 Mon Sep 17 00:00:00 2001 From: ila Date: Thu, 16 Jul 2026 16:13:18 +0800 Subject: [PATCH] Prototype signed catalog fallback (T-101) --- core/catalog/canonical.go | 166 ++++++++++++++++++ core/catalog/file_cache.go | 105 +++++++++++ core/catalog/file_cache_test.go | 49 ++++++ core/catalog/loader.go | 106 +++++++++++ core/catalog/loader_test.go | 148 ++++++++++++++++ core/catalog/verifier.go | 89 ++++++++++ core/catalog/verifier_test.go | 136 ++++++++++++++ docs/04-architecture.md | 1 + docs/api.md | 11 ++ docs/current-state.md | 20 +-- docs/tasks/T-101.md | 65 +++++++ testdata/README.md | 7 + testdata/catalog/manifest-forged.json | 8 + .../catalog/manifest-tampered-payload.json | 29 +++ testdata/catalog/manifest-valid-payload.json | 29 +++ 15 files changed, 959 insertions(+), 10 deletions(-) create mode 100644 core/catalog/canonical.go create mode 100644 core/catalog/file_cache.go create mode 100644 core/catalog/file_cache_test.go create mode 100644 core/catalog/loader.go create mode 100644 core/catalog/loader_test.go create mode 100644 core/catalog/verifier.go create mode 100644 core/catalog/verifier_test.go create mode 100644 docs/tasks/T-101.md create mode 100644 testdata/README.md create mode 100644 testdata/catalog/manifest-forged.json create mode 100644 testdata/catalog/manifest-tampered-payload.json create mode 100644 testdata/catalog/manifest-valid-payload.json diff --git a/core/catalog/canonical.go b/core/catalog/canonical.go new file mode 100644 index 0000000..34abe60 --- /dev/null +++ b/core/catalog/canonical.go @@ -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 +} diff --git a/core/catalog/file_cache.go b/core/catalog/file_cache.go new file mode 100644 index 0000000..4b222aa --- /dev/null +++ b/core/catalog/file_cache.go @@ -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" +} diff --git a/core/catalog/file_cache_test.go b/core/catalog/file_cache_test.go new file mode 100644 index 0000000..3c6c98d --- /dev/null +++ b/core/catalog/file_cache_test.go @@ -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") + } +} diff --git a/core/catalog/loader.go b/core/catalog/loader.go new file mode 100644 index 0000000..c841056 --- /dev/null +++ b/core/catalog/loader.go @@ -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, + } +} diff --git a/core/catalog/loader_test.go b/core/catalog/loader_test.go new file mode 100644 index 0000000..624d0fc --- /dev/null +++ b/core/catalog/loader_test.go @@ -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 +} diff --git a/core/catalog/verifier.go b/core/catalog/verifier.go new file mode 100644 index 0000000..97dcd0d --- /dev/null +++ b/core/catalog/verifier.go @@ -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 +} diff --git a/core/catalog/verifier_test.go b/core/catalog/verifier_test.go new file mode 100644 index 0000000..41812fe --- /dev/null +++ b/core/catalog/verifier_test.go @@ -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 +} diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8934ef8..cbb738b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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 + 原子写入) diff --git a/docs/api.md b/docs/api.md index 2d1bb04..8dd2075 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/current-state.md b/docs/current-state.md index c6c44ae..c7b9cd2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 安全解压原型`。 ## 当前可运行内容 diff --git a/docs/tasks/T-101.md b/docs/tasks/T-101.md new file mode 100644 index 0000000..ca9a09a --- /dev/null +++ b/docs/tasks/T-101.md @@ -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`。 diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..2974bbf --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,7 @@ +# 测试数据 + +本目录只保存公开、虚构、不可用于生产的数据与攻击样例。 + +- 不放生产私钥、真实注册码、真实机器标识或真实下载地址。 +- 测试若需要签名,使用测试代码中明确标注的专用测试密钥。 +- 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。 diff --git a/testdata/catalog/manifest-forged.json b/testdata/catalog/manifest-forged.json new file mode 100644 index 0000000..2f79a05 --- /dev/null +++ b/testdata/catalog/manifest-forged.json @@ -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==" +} diff --git a/testdata/catalog/manifest-tampered-payload.json b/testdata/catalog/manifest-tampered-payload.json new file mode 100644 index 0000000..81ac5be --- /dev/null +++ b/testdata/catalog/manifest-tampered-payload.json @@ -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" + } + } + } + ] +} diff --git a/testdata/catalog/manifest-valid-payload.json b/testdata/catalog/manifest-valid-payload.json new file mode 100644 index 0000000..de507b0 --- /dev/null +++ b/testdata/catalog/manifest-valid-payload.json @@ -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" + } + } + } + ] +}