diff --git a/internal/state/keyid.go b/internal/state/keyid.go new file mode 100644 index 0000000..8fa288b --- /dev/null +++ b/internal/state/keyid.go @@ -0,0 +1,82 @@ +package state + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// hashKeyIDMetadataKey names the metadata row holding the derived identifier +// of hash.key (PLAN.md Section 8.1), so replacement can be detected without +// storing the key itself. +const hashKeyIDMetadataKey = "hash_key_id" + +// metadataValueSQL reads one metadata value by key. +const metadataValueSQL = "SELECT value FROM metadata WHERE key = ?" + +// metadataUpsertSQL writes one metadata value, replacing a prior value. +const metadataUpsertSQL = ` +INSERT INTO metadata (key, value) VALUES (?, ?) +ON CONFLICT(key) DO UPDATE SET value = excluded.value` + +// KeyIDForKey derives the domain-separated identifier that names a 32-byte +// hash key without exposing it. +func KeyIDForKey(key [32]byte) deployment.Digest { + return deployment.HashKeyIdentifier(key) +} + +// HashKeyID reads the committed hash-key identifier, or returns a missing +// error when no identifier has been committed yet. +func (store *Store) HashKeyID() (deployment.Digest, error) { + var raw []byte + err := store.database.conn.QueryRow(metadataValueSQL, hashKeyIDMetadataKey).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return deployment.Digest{}, hashKeyIDMissingError{} + } + if err != nil { + return deployment.Digest{}, fmt.Errorf("state: read hash key identifier: %w", err) + } + return decodeDigest(raw) +} + +// commitHashKeyID writes the identifier in its own short transaction. Callers +// that must commit the identifier with a baseline row use metadataUpsertSQL +// inside that transaction instead. +func (store *Store) commitHashKeyID(id deployment.Digest) error { + transaction, err := store.database.conn.Begin() + if err != nil { + return err + } + if err := execIn(transaction, metadataUpsertSQL, hashKeyIDMetadataKey, id[:]); err != nil { + return err + } + if err := transaction.Commit(); err != nil { + return err + } + return nil +} + +// decodeDigest validates and copies a stored 32-byte BLOB into a Digest. +func decodeDigest(raw []byte) (deployment.Digest, error) { + var digest deployment.Digest + if len(raw) != len(digest) { + return deployment.Digest{}, fmt.Errorf("state: stored digest has length %d, want %d", len(raw), len(digest)) + } + copy(digest[:], raw) + return digest, nil +} + +// hashKeyIDMissingError signals an absent hash_key_id metadata row. Its Is +// method lets recovery detect the missing case regardless of the path. +type hashKeyIDMissingError struct{} + +func (hashKeyIDMissingError) Error() string { + return "state: hash key identifier is missing" +} + +func (hashKeyIDMissingError) Is(target error) bool { + _, matched := target.(hashKeyIDMissingError) + return matched +} diff --git a/internal/state/keyid_test.go b/internal/state/keyid_test.go new file mode 100644 index 0000000..a8c9082 --- /dev/null +++ b/internal/state/keyid_test.go @@ -0,0 +1,172 @@ +package state + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestHashKeyIdentity(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"identifier is deterministic per key", testKeyIDDeterministic}, + {"metadata read reports missing before any commit", testKeyIDMissingMetadata}, + {"commit persists the identifier", testKeyIDCommitted}, + {"replacement key produces a different identifier", testKeyIDReplacement}, + {"stored identifier length is validated", testKeyIDInvalidLength}, + {"errors never expose key material", testKeyIDNoKeyMaterial}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// sampleKey returns a deterministic 32-byte key filled with value. +func sampleKey(value byte) [32]byte { + var key [32]byte + for index := range key { + key[index] = value + } + return key +} + +// keyPathFor resolves the hash.key path beneath the store's cattery directory. +func keyPathFor(t *testing.T, deps Dependencies) string { + t.Helper() + return filepath.Join(catteryDirFor(t, deps), stateKeyFileName) +} + +// writeKeyFile seeds the hash key file with the given key. +func writeKeyFile(t *testing.T, deps Dependencies, key [32]byte) { + t.Helper() + if err := os.WriteFile(keyPathFor(t, deps), key[:], stateFileMode); err != nil { + t.Fatalf("seed hash key: %v", err) + } +} + +// writeRawKeyFile seeds the hash key file with raw bytes, including torn or +// malformed content. +func writeRawKeyFile(t *testing.T, deps Dependencies, contents []byte) { + t.Helper() + if err := os.WriteFile(keyPathFor(t, deps), contents, stateFileMode); err != nil { + t.Fatalf("seed hash key: %v", err) + } +} + +// keyIDHex renders the stored identifier for SQL injection as a BLOB literal. +func keyIDHex(id deployment.Digest) string { + return fmt.Sprintf("X'%x'", id[:]) +} + +func testKeyIDDeterministic(t *testing.T) { + first := KeyIDForKey(sampleKey(7)) + second := KeyIDForKey(sampleKey(7)) + if first != second { + t.Fatal("identifier differs across calls for the same key") + } + if KeyIDForKey(sampleKey(8)) == first { + t.Fatal("identifier collides across different keys") + } +} + +func testKeyIDMissingMetadata(t *testing.T) { + store := openStore(t, tempDependencies(t)) + if _, err := store.HashKeyID(); err == nil { + t.Fatal("HashKeyID succeeded with no metadata row") + } +} + +func testKeyIDCommitted(t *testing.T) { + store := openStore(t, tempDependencies(t)) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(1)) + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + stored, err := store.HashKeyID() + if err != nil { + t.Fatalf("HashKeyID after recovery: %v", err) + } + if stored != KeyIDForKey(sampleKey(1)) { + t.Fatal("stored identifier differs from the derived identifier") + } +} + +func testKeyIDReplacement(t *testing.T) { + store := openStore(t, tempDependencies(t)) + deps := storeDependenciesFor(store) + writeKeyFile(t, deps, sampleKey(1)) + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("initial recovery: %v", err) + } + original, err := store.HashKeyID() + if err != nil { + t.Fatalf("HashKeyID: %v", err) + } + writeKeyFile(t, deps, sampleKey(2)) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey accepted a replaced key") + } + kept, err := store.HashKeyID() + if err != nil { + t.Fatalf("HashKeyID after replacement: %v", err) + } + if kept != original { + t.Fatal("replacement changed the stored identifier") + } +} + +func testKeyIDInvalidLength(t *testing.T) { + store := openStore(t, tempDependencies(t)) + execOn(t, store.Database().conn, + fmt.Sprintf("INSERT INTO metadata (key, value) VALUES ('%s', X'010203')", hashKeyIDMetadataKey)) + if _, err := store.HashKeyID(); err == nil { + t.Fatal("HashKeyID accepted a 3-byte stored identifier") + } +} + +func testKeyIDNoKeyMaterial(t *testing.T) { + store := openStore(t, tempDependencies(t)) + deps := storeDependenciesFor(store) + writeKeyFile(t, deps, sampleKey(9)) + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + writeKeyFile(t, deps, sampleKey(10)) + _, mismatchErr := store.RecoverHashKey() + if mismatchErr == nil { + t.Fatal("RecoverHashKey accepted a replaced key") + } + if containsAnyKey(mismatchErr.Error(), sampleKey(9), sampleKey(10)) { + t.Fatalf("diagnostic %q exposed key material", mismatchErr.Error()) + } +} + +// containsAnyKey reports whether the diagnostic contains any of the keys. +func containsAnyKey(diagnostic string, keys ...[32]byte) bool { + for _, key := range keys { + if containsKey(diagnostic, key) { + return true + } + } + return false +} + +func containsKey(diagnostic string, key [32]byte) bool { + for offset := 0; offset <= len(diagnostic)-len(key); offset++ { + if diagnostic[offset:offset+len(key)] == string(key[:]) { + return true + } + } + return false +} + +// storeDependenciesFor recovers the dependencies of an opened store so tests +// can seed paths beneath its state home. +func storeDependenciesFor(store *Store) Dependencies { + return Dependencies{StateHome: store.stateHome} +} diff --git a/internal/state/recovery.go b/internal/state/recovery.go new file mode 100644 index 0000000..504f933 --- /dev/null +++ b/internal/state/recovery.go @@ -0,0 +1,95 @@ +package state + +import ( + "errors" + "fmt" + "path/filepath" +) + +// secretBaselinesSQL counts every stored secret file row regardless of status, +// because retired secrets still need the key for diagnostics. +const secretBaselinesSQL = "SELECT COUNT(*) FROM files WHERE source_kind = 'secret'" + +// RecoverHashKey guarantees a usable 32-byte hash key exists and matches its +// committed identifier (PLAN.md Section 8.1), returning the key. It fails +// safely whenever the stored key cannot be proven correct: it never guesses, +// and it never silently replaces a key that old baselines depend on. +func (store *Store) RecoverHashKey() ([32]byte, error) { + key, readErr := store.keyFile().Read() + identifier, idErr := store.HashKeyID() + if readErr == nil && idErr == nil { + if KeyIDForKey(key) != identifier { + return [32]byte{}, errKeyMismatch(store.keyPath()) + } + return key, nil + } + hasSecrets, err := store.secretBaselinesExist() + if err != nil { + return [32]byte{}, err + } + if hasSecrets { + return [32]byte{}, errKeyRecoveryBlocked(store.keyPath(), readErr, idErr) + } + return store.recoverWithoutBaselines(key, readErr, idErr) +} + +// recoverWithoutBaselines applies the no-secret-row rules: commit the derived +// identifier of an orphaned valid key, create a key only when both are absent, +// and fail on any other mismatch that requires explicit cleanup. +func (store *Store) recoverWithoutBaselines(key [32]byte, readErr, idErr error) ([32]byte, error) { + switch { + case readErr == nil && errors.Is(idErr, hashKeyIDMissingError{}): + if err := store.commitHashKeyID(KeyIDForKey(key)); err != nil { + return [32]byte{}, err + } + return key, nil + case errors.Is(readErr, keyMissingError{}) && errors.Is(idErr, hashKeyIDMissingError{}): + key, err := store.keyFile().Create() + if err != nil { + return [32]byte{}, err + } + return key, nil + default: + return [32]byte{}, errKeyRecoveryBlocked(store.keyPath(), readErr, idErr) + } +} + +// secretBaselinesExist reports whether any secret file row is stored. +func (store *Store) secretBaselinesExist() (bool, error) { + var count int64 + if err := store.database.conn.QueryRow(secretBaselinesSQL).Scan(&count); err != nil { + return false, fmt.Errorf("state: count secret baselines: %w", err) + } + return count > 0, nil +} + +// keyPath returns the hash.key path beside the open database. +func (store *Store) keyPath() string { + return filepath.Join(filepath.Dir(store.database.path), stateKeyFileName) +} + +// keyFile returns a KeyFile bound to the store's hash.key path. +func (store *Store) keyFile() *KeyFile { + return NewKeyFile(store.keyPath()) +} + +// errKeyMismatch reports a key file whose identifier contradicts the +// committed one, without exposing either. +func errKeyMismatch(path string) error { + return fmt.Errorf( + "state: hash key %q does not match its stored identifier; restore the matching hash.key or reset state", + path) +} + +// errKeyRecoveryBlocked reports a key state that cannot be repaired +// automatically. It names paths and statuses only, never key material. +func errKeyRecoveryBlocked(path string, keyErr, idErr error) error { + return fmt.Errorf( + "state: hash key %q cannot be used; restore the matching hash.key or reset state (key: %v, identifier: %v)", + path, keyErr, idErr) +} + +// errKeyMissing reports that no usable key exists, helping operators recover. +func errKeyMissing(path string) error { + return fmt.Errorf("state: hash key %q is missing; restore it or reset state", path) +} diff --git a/internal/state/recovery_test.go b/internal/state/recovery_test.go new file mode 100644 index 0000000..5f6f8e5 --- /dev/null +++ b/internal/state/recovery_test.go @@ -0,0 +1,201 @@ +package state + +import ( + "fmt" + "os" + "testing" +) + +func TestHashKeyRecovery(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"reuses a valid key with a matching identifier", testRecoveryReuseValid}, + {"commits the identifier for an orphaned valid key", testRecoveryOrphanedKey}, + {"creates a key when both are absent", testRecoveryCreatesKey}, + {"defers the identifier until the first secret baseline", testRecoveryDefersID}, + {"fails on a stale identifier without a key", testRecoveryStaleIdentifier}, + {"fails on an identifier mismatch", testRecoveryMismatch}, + {"fails on a malformed key file", testRecoveryMalformedKey}, + {"fails safely when secret rows exist and the key is missing", testRecoveryRowsWithoutKey}, + {"reuses the key when secret rows exist and everything matches", testRecoveryRowsMatched}, + {"fails when secret rows exist and the identifier mismatches", testRecoveryRowsMismatch}, + {"accepts a fresh key after explicit reset", testRecoveryAfterReset}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// seedKeyID commits the given identifier directly, as an interrupted recovery +// or a migrated store would have. +func seedKeyID(t *testing.T, store *Store, idHex string) { + t.Helper() + execOn(t, store.Database().conn, + fmt.Sprintf("INSERT INTO metadata (key, value) VALUES ('%s', %s)", hashKeyIDMetadataKey, idHex)) +} + +// baselineSeed names the pair and target of a directly inserted secret row. +type baselineSeed struct { + root, home, target string +} + +// insertSecretBaseline inserts one secret file row for the registered pair. +func insertSecretBaseline(t *testing.T, store *Store, seed baselineSeed) { + t.Helper() + repository, err := store.RegisterRepository(seed.root, seed.home) + if err != nil { + t.Fatalf("RegisterRepository: %v", err) + } + execOn(t, store.Database().conn, fmt.Sprintf( + "INSERT INTO files (repository_id, target_path, group_name, source_path, source_kind, layer, baseline_content_hash, baseline_source_hash, executable_bits, status, applied_at) VALUES (%d, '%s', '', 'secrets/%s', 'secret', 'base', X'0101010101010101010101010101010101010101010101010101010101010101', X'0202020202020202020202020202020202020202020202020202020202020202', 384, 'active', '2026-01-02T03:04:05Z')", + repository.ID, seed.target, seed.target)) +} + +func testRecoveryReuseValid(t *testing.T) { + store := openStore(t, tempDependencies(t)) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(3)) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(3)))) + key, err := store.RecoverHashKey() + if err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + if key != sampleKey(3) { + t.Fatal("recovery returned a different key") + } +} + +func testRecoveryOrphanedKey(t *testing.T) { + store := openStore(t, tempDependencies(t)) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(4)) + key, err := store.RecoverHashKey() + if err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + if key != sampleKey(4) { + t.Fatal("recovery returned a different key") + } + stored, err := store.HashKeyID() + if err != nil { + t.Fatalf("HashKeyID after recovery: %v", err) + } + if stored != KeyIDForKey(sampleKey(4)) { + t.Fatal("recovery committed the wrong identifier") + } +} + +func testRecoveryCreatesKey(t *testing.T) { + store := openStore(t, tempDependencies(t)) + key, err := store.RecoverHashKey() + if err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + if key == [32]byte{} { + t.Fatal("recovery returned an empty key") + } + info, err := os.Lstat(keyPathFor(t, storeDependenciesFor(store))) + if err != nil { + t.Fatalf("created key file: %v", err) + } + if info.Mode().Perm() != stateFileMode { + t.Fatalf("key file mode = %v, want %v", info.Mode().Perm(), stateFileMode) + } +} + +func testRecoveryDefersID(t *testing.T) { + store := openStore(t, tempDependencies(t)) + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("RecoverHashKey: %v", err) + } + if _, err := store.HashKeyID(); err == nil { + t.Fatal("recovery committed the identifier before any secret baseline") + } +} + +func testRecoveryStaleIdentifier(t *testing.T) { + store := openStore(t, tempDependencies(t)) + deps := storeDependenciesFor(store) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(5)))) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey succeeded with a stale identifier and no key") + } + if _, err := os.Lstat(keyPathFor(t, deps)); !os.IsNotExist(err) { + t.Fatal("recovery created a key for a stale identifier") + } +} + +func testRecoveryMismatch(t *testing.T) { + store := openStore(t, tempDependencies(t)) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(1)) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(2)))) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey accepted a mismatched key") + } +} + +func testRecoveryMalformedKey(t *testing.T) { + store := openStore(t, tempDependencies(t)) + writeRawKeyFile(t, storeDependenciesFor(store), []byte("torn")) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey accepted a malformed key") + } +} + +func testRecoveryRowsWithoutKey(t *testing.T) { + store := openStore(t, tempDependencies(t)) + root := t.TempDir() + home := t.TempDir() + insertSecretBaseline(t, store, baselineSeed{root: root, home: home, target: ".secret"}) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey succeeded with secret rows and no key") + } + if _, err := os.Lstat(keyPathFor(t, storeDependenciesFor(store))); !os.IsNotExist(err) { + t.Fatal("recovery created a key while secret rows existed") + } +} + +func testRecoveryRowsMatched(t *testing.T) { + store := openStore(t, tempDependencies(t)) + root := t.TempDir() + home := t.TempDir() + insertSecretBaseline(t, store, baselineSeed{root: root, home: home, target: ".secret"}) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(6)) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(6)))) + key, err := store.RecoverHashKey() + if err != nil { + t.Fatalf("RecoverHashKey with matched rows: %v", err) + } + if key != sampleKey(6) { + t.Fatal("recovery returned a different key") + } +} + +func testRecoveryRowsMismatch(t *testing.T) { + store := openStore(t, tempDependencies(t)) + root := t.TempDir() + home := t.TempDir() + insertSecretBaseline(t, store, baselineSeed{root: root, home: home, target: ".secret"}) + writeKeyFile(t, storeDependenciesFor(store), sampleKey(1)) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(2)))) + if _, err := store.RecoverHashKey(); err == nil { + t.Fatal("RecoverHashKey accepted a mismatched key with secret rows") + } +} + +func testRecoveryAfterReset(t *testing.T) { + store := openStore(t, tempDependencies(t)) + deps := storeDependenciesFor(store) + writeKeyFile(t, deps, sampleKey(1)) + seedKeyID(t, store, keyIDHex(KeyIDForKey(sampleKey(1)))) + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("initial recovery: %v", err) + } + if err := os.Remove(keyPathFor(t, deps)); err != nil { + t.Fatalf("remove key: %v", err) + } + execOn(t, store.Database().conn, "DELETE FROM metadata WHERE key = '"+hashKeyIDMetadataKey+"'") + if _, err := store.RecoverHashKey(); err != nil { + t.Fatalf("recovery after reset: %v", err) + } +}