Prototype signed catalog fallback (T-101)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 安全解压原型`。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -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`。
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# 测试数据
|
||||
|
||||
本目录只保存公开、虚构、不可用于生产的数据与攻击样例。
|
||||
|
||||
- 不放生产私钥、真实注册码、真实机器标识或真实下载地址。
|
||||
- 测试若需要签名,使用测试代码中明确标注的专用测试密钥。
|
||||
- 恶意样例用于证明解析器和安全边界会拒绝输入,不得被发布流程消费。
|
||||
+8
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user