From 7b730adce0dd239b57dda695307f4c34927e1e14 Mon Sep 17 00:00:00 2001 From: John Wang Date: Sat, 24 Oct 2026 07:28:24 -0700 Subject: [PATCH 1/5] fix(local)!: authenticate the keyring's rollback high-water mark The highest keyring generation seen was kept in plain JSON beside the keyring, and a missing file was read as "nothing seen". Anyone able to write the keys directory could put back an older, still validly sealed keyring and delete or edit that file, and Open accepted the older keyring without AcceptRollback, bringing back key versions that had been destroyed or undoing a rotation. The backup state file is now authenticated with HMAC-SHA-256 under a key derived from the root key with HKDF, and bound to the keyring's file name. Open refuses: - a state file that does not authenticate (ErrCorruptBackupState); - a missing state file beside a keyring past generation 1 (ErrBackupStateMissing); generation 1 is still accepted, since a crash during Create leaves exactly that; - a state file in the previous unauthenticated format (ErrUnauthenticatedBackupState). Each can be accepted with AcceptRollback, which rewrites the state authenticated and keeps any existing high-water mark and backup acknowledgement. Root key rotation authenticates the state under both the old and the new root key before replacing the keyring, so a crash on either side leaves state the surviving keyring's root key opens. BREAKING CHANGE: the first Open after upgrading fails with ErrUnauthenticatedBackupState until it is done once with AcceptRollback. Opening a keyring past generation 1 without its backup state file, for example after moving it to a new host, also needs AcceptRollback. Co-Authored-By: Claude Opus 5.5 --- docs/operations.md | 16 +++ docs/threat-model.md | 7 +- embedded/embedded.go | 7 +- provider/local/backup.go | 134 ++++++++++++++++++++++--- provider/local/backup_test.go | 183 ++++++++++++++++++++++++++++++++++ provider/local/crash_test.go | 5 +- provider/local/local.go | 67 +++++++++++-- provider/local/rootkey.go | 13 +++ 8 files changed, 407 insertions(+), 25 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 6220f15..c649382 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -134,6 +134,22 @@ in-memory copy until it restarts. Without it, a keyring older than one this host is refused with `ErrKeyringRolledBack`, naming both generations, so that a stale copy is never used by mistake. +The highest generation this host has seen is kept beside the keyring in +`keyring.kf.backup`, authenticated under a key derived from the root key, so it +cannot be edited or forged without the root key. Removing it does not switch +the check off: a keyring past generation 1 with no such file is refused with +`ErrBackupStateMissing`, and one that does not authenticate with +`ErrCorruptBackupState`. Moving a keyring to a new host without that file, or +replacing a damaged one, therefore also needs `AcceptRollback: true` once. The +check cannot tell an older keyring restored together with its own older state +file from the real thing; keep the keys directory writable only by the service. + +**Upgrading from a version that did not authenticate the state file.** The +first open after the upgrade fails with `ErrUnauthenticatedBackupState`. Confirm +the keyring on disk is the one you expect (`keyforge keyring inspect`), then open +once with `AcceptRollback: true`; the existing high-water mark and backup +acknowledgement are kept and the file is rewritten authenticated. + **What a restore loses.** The registry holds data keys. Any data key created after the registry backup is not in it, and data encrypted under such a key cannot be decrypted. Back up the registry as often as you can afford to lose diff --git a/docs/threat-model.md b/docs/threat-model.md index 61fa9a5..afad514 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -27,7 +27,7 @@ the data keys protect, and the ability to destroy keys. | Asset | Threat | Mitigation | |---|---|---| | Key-encryption key material | Read from disk or backup | Sealed under a root key held apart from the keyring; keyring mode 0600, group- or other-readable refused; a root key file beside the keyring refused | -| Key-encryption key material | Wrong or stale keyring used | A wrong root key is refused before decryption; a keyring older than one seen is refused unless the restore is accepted | +| Key-encryption key material | Wrong or stale keyring used | A wrong root key is refused before decryption; a keyring older than one seen is refused unless the restore is accepted; the high-water mark is authenticated under the root key and a missing one is refused past generation 1 | | Data key material | Memory read | Bounded cache with a TTL; best-effort zeroing | | Ciphertext | Moved between purposes, subjects or records | AAD binds the purpose and the caller's context; the wrapped key is bound to its purpose, subject and identity | | Ciphertext | Tampering | AES-256-GCM authentication | @@ -67,6 +67,11 @@ the set and never widen it. The code stays valid under restoring an older registry resurrects the subject's keys, and a keyring backup holds destroyed versions' material. Erasure is complete for the live system only. +- **A matched restore of keyring and state file is not detected.** The + high-water mark lives beside the keyring. Someone who can write the keys + directory and holds an older copy of both the keyring and its state file can + put them back together; detecting that needs state kept somewhere they cannot + write. - **A restore under a running process.** An older keyring copy put in place while the process runs is refused at the next write and at the next open, but the process keeps using its in-memory copy until restarted. Stop the process diff --git a/embedded/embedded.go b/embedded/embedded.go index 441de67..48b2fff 100644 --- a/embedded/embedded.go +++ b/embedded/embedded.go @@ -94,8 +94,11 @@ type Options struct { // AcceptRollback opens a keyring that is older than one this host has // seen, which is what restoring from backup produces. Without it, Open // fails with [local.ErrKeyringRolledBack] naming both generations, so a - // stale copy is never used by mistake. Set it only after confirming that - // the restore was intended. + // stale copy is never used by mistake. It is also needed once when the + // keyring's backup state file is missing past generation 1 or was written + // by an older version that did not authenticate it + // ([local.ErrBackupStateMissing], [local.ErrUnauthenticatedBackupState]). + // Set it only after confirming that the restore was intended. AcceptRollback bool // Purposes lists the purposes to manage. Each gets a KEK on first start. // Required. diff --git a/provider/local/backup.go b/provider/local/backup.go index 0c16608..da58bce 100644 --- a/provider/local/backup.go +++ b/provider/local/backup.go @@ -2,10 +2,14 @@ package local import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "os" + "path/filepath" "time" ) @@ -22,9 +26,15 @@ import ( // - the generation the operator last acknowledged as safely backed up, so // status can report a keyring that is ahead of its backup. // -// The file is not authenticated. Someone who can write beside the keyring can -// hide staleness, but they can already replace the keyring; its purpose is to -// catch mistakes, not an attacker with the host. +// The state is authenticated with HMAC-SHA-256 under a key derived from the +// root key, and bound to the keyring's file name, so it cannot be edited or +// forged without the root key. A missing state file is accepted only beside a +// keyring at generation 1, which is what a crash between creating the keyring +// and writing its state leaves; for any later generation it is refused, so +// deleting the file does not switch rollback detection off. A copy of an +// older, genuine state file restored together with the matching older keyring +// is not detected: protecting against that needs state kept outside the +// keyring's directory. // Typed errors for backup state. var ( @@ -39,8 +49,21 @@ var ( // the one already acknowledged. ErrAckRegressed = errors.New("local: cannot acknowledge an older generation than the last acknowledgement") // ErrCorruptBackupState is returned when the backup state file cannot be - // read. Remove it to start fresh, which also forgets the high-water mark. + // read or does not authenticate under the root key. Remove it and open + // with AcceptRollback to start fresh, which also forgets the high-water + // mark. ErrCorruptBackupState = errors.New("local: the backup state file is unreadable") + // ErrBackupStateMissing is returned when a keyring past generation 1 has + // no backup state file, so whether it is older than one this host has + // seen cannot be checked. Open with AcceptRollback once the keyring is + // confirmed to be the intended one, for example after moving it to a new + // host. + ErrBackupStateMissing = errors.New("local: the backup state file is missing") + // ErrUnauthenticatedBackupState is returned for a backup state file + // written by a version of keyforge that did not authenticate it. Open + // with AcceptRollback once, after confirming the keyring is the intended + // one, to keep its contents and authenticate it. + ErrUnauthenticatedBackupState = errors.New("local: the backup state file is not authenticated") ) // RollbackError describes a keyring found older than one already seen. It @@ -88,36 +111,123 @@ func (s BackupStatus) Behind() uint64 { func (s BackupStatus) UpToDate() bool { return s.Acknowledged >= s.Generation } type backupFile struct { - Format int `json:"format"` + Format int `json:"format,omitempty"` // set in files written before the state was authenticated Highest uint64 `json:"highestGeneration"` Acknowledged uint64 `json:"acknowledgedGeneration"` At time.Time `json:"acknowledgedAt,omitzero"` Note string `json:"note,omitempty"` } -const backupFormat = 1 +// backupEnvelope is the backup state file: the state and its MACs, one per +// root key it is authenticated under, keyed by the root key identifier in hex. +// There is one MAC except while the root key is being rotated, when the state +// carries one for the old key and one for the new, so that a crash on either +// side of the keyring write leaves state the keyring's root key opens. +type backupEnvelope struct { + Format int `json:"format"` + State json.RawMessage `json:"state"` + MACs map[string][]byte `json:"macs"` +} + +const ( + legacyBackupFormat = 1 // unauthenticated + backupFormat = 2 + infoBackupStateMAC = "keyforge/keyring/backup-state-mac/v1" +) + +// stateKey is a root key identifier and the backup state MAC key derived from +// that root key. +type stateKey struct { + id [rootKeyIDSize]byte + mac []byte +} func (p *Provider) backupPath() string { return p.path + ".backup" } -// loadBackup reads the backup state. A missing file is empty state. -func (p *Provider) loadBackup() (backupFile, error) { +// stateMAC authenticates the encoded state under key, bound to the keyring's +// file name so that the state of one keyring cannot stand in for another's +// under the same root key. +func (p *Provider) stateMAC(key, state []byte) []byte { + m := hmac.New(sha256.New, key) + m.Write([]byte(infoBackupStateMAC)) + m.Write([]byte{0}) + m.Write([]byte(filepath.Base(p.path))) + m.Write([]byte{0}) + m.Write(state) + return m.Sum(nil) +} + +// errNoBackupState reports a missing backup state file to loadBackup's +// callers that treat it differently from empty state. +var errNoBackupState = errors.New("local: no backup state file") + +// readBackup reads and authenticates the backup state under the root key in +// use. It returns errNoBackupState if there is no file, and +// ErrUnauthenticatedBackupState, with the file's unverified contents, for a +// file in the older unauthenticated format. +func (p *Provider) readBackup() (backupFile, error) { b, err := os.ReadFile(p.backupPath()) if errors.Is(err, os.ErrNotExist) { - return backupFile{Format: backupFormat}, nil + return backupFile{}, errNoBackupState } if err != nil { return backupFile{}, fmt.Errorf("local: %w", err) } + var env backupEnvelope + if err := json.Unmarshal(b, &env); err != nil { + return backupFile{}, ErrCorruptBackupState + } + if env.Format == legacyBackupFormat { + var f backupFile + if err := json.Unmarshal(b, &f); err != nil { + return backupFile{}, ErrCorruptBackupState + } + f.Format = 0 + return f, ErrUnauthenticatedBackupState + } + if env.Format != backupFormat || p.macKey == nil { + return backupFile{}, ErrCorruptBackupState + } + mac, ok := env.MACs[hex.EncodeToString(p.rootID[:])] + if !ok || !hmac.Equal(mac, p.stateMAC(p.macKey, env.State)) { + return backupFile{}, fmt.Errorf("%w: it does not authenticate under the root key", ErrCorruptBackupState) + } var f backupFile - if err := json.Unmarshal(b, &f); err != nil || f.Format != backupFormat { + if err := json.Unmarshal(env.State, &f); err != nil { return backupFile{}, ErrCorruptBackupState } return f, nil } +// loadBackup reads the backup state. A missing file is empty state. +func (p *Provider) loadBackup() (backupFile, error) { + f, err := p.readBackup() + if errors.Is(err, errNoBackupState) { + return backupFile{}, nil + } + return f, err +} + +// saveBackup writes the backup state authenticated under the root key in use. func (p *Provider) saveBackup(f backupFile) error { - f.Format = backupFormat - b, err := json.Marshal(f) + return p.saveBackupUnder(f, stateKey{id: p.rootID, mac: p.macKey}) +} + +// saveBackupUnder writes the backup state authenticated under each of keys. +func (p *Provider) saveBackupUnder(f backupFile, keys ...stateKey) error { + f.Format = 0 + state, err := json.Marshal(f) + if err != nil { + return fmt.Errorf("local: %w", err) + } + env := backupEnvelope{Format: backupFormat, State: state, MACs: make(map[string][]byte, len(keys))} + for _, k := range keys { + if k.mac == nil { + return ErrClosed + } + env.MACs[hex.EncodeToString(k.id[:])] = p.stateMAC(k.mac, state) + } + b, err := json.Marshal(env) if err != nil { return fmt.Errorf("local: %w", err) } diff --git a/provider/local/backup_test.go b/provider/local/backup_test.go index 2113ecc..a4a045f 100644 --- a/provider/local/backup_test.go +++ b/provider/local/backup_test.go @@ -2,8 +2,10 @@ package local import ( "bytes" + "encoding/json" "errors" "os" + "path/filepath" "slices" "testing" "time" @@ -303,3 +305,184 @@ func TestOlderKeyringPutInPlaceWhileRunningIsRefused(t *testing.T) { t.Fatalf("Open = %v, want ErrKeyringRolledBack", err) } } + +// staleCopy returns a keyring at generation 2 and leaves the live keyring at +// generation 3, with the provider closed: the setup for a restore of a copy +// taken before a rotation. +func staleCopy(t *testing.T, opts Options) []byte { + t.Helper() + p := mustCreate(t, opts) + ref := newKey(t, p) + old, err := os.ReadFile(opts.Path) // generation 2 + if err != nil { + t.Fatal(err) + } + if _, err := p.Rotate(bg, ref); err != nil { // generation 3 + t.Fatal(err) + } + p.Close() + return old +} + +func restore(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { //nolint:gosec // G703: the path is a test temp directory + t.Fatal(err) + } +} + +// Deleting the backup state must not switch rollback detection off. +func TestDeletedBackupStateDoesNotHideARollback(t *testing.T) { + opts := newOpts(t, 1) + old := staleCopy(t, opts) + restore(t, opts.Path, old) + if err := os.Remove(opts.Path + ".backup"); err != nil { + t.Fatal(err) + } + + if _, err := Open(bg, opts); !errors.Is(err, ErrBackupStateMissing) { + t.Fatalf("Open = %v, want ErrBackupStateMissing", err) + } + + // An operator who intended it says so; the state is written again and the + // next open needs no acknowledgement. + accept := opts + accept.AcceptRollback = true + r, err := Open(bg, accept) + if err != nil { + t.Fatalf("Open with AcceptRollback: %v", err) + } + r.Close() + r, err = Open(bg, opts) + if err != nil { + t.Fatalf("Open after the state was rewritten: %v", err) + } + r.Close() +} + +// A crash between creating a keyring and writing its state leaves a keyring at +// generation 1 and no state; that must still open. +func TestMissingBackupStateAtGenerationOneOpens(t *testing.T) { + opts := newOpts(t, 1) + p := mustCreate(t, opts) + p.Close() + if err := os.Remove(opts.Path + ".backup"); err != nil { + t.Fatal(err) + } + r, err := Open(bg, opts) + if err != nil { + t.Fatalf("Open = %v", err) + } + r.Close() +} + +// Editing the high-water mark down must not make an older keyring acceptable. +func TestEditedBackupStateIsRefused(t *testing.T) { + opts := newOpts(t, 1) + old := staleCopy(t, opts) + restore(t, opts.Path, old) + + b, err := os.ReadFile(opts.Path + ".backup") + if err != nil { + t.Fatal(err) + } + var env backupEnvelope + if err := json.Unmarshal(b, &env); err != nil { + t.Fatal(err) + } + env.State = bytes.Replace(env.State, []byte(`"highestGeneration":3`), []byte(`"highestGeneration":2`), 1) + if b, err = json.Marshal(env); err != nil { + t.Fatal(err) + } + restore(t, opts.Path+".backup", b) + + if _, err := Open(bg, opts); !errors.Is(err, ErrCorruptBackupState) { + t.Fatalf("Open = %v, want ErrCorruptBackupState", err) + } +} + +// A state file in the unauthenticated format, forged or left by an older +// version, is not trusted without an acknowledgement. +func TestUnauthenticatedBackupStateNeedsAcknowledgement(t *testing.T) { + opts := newOpts(t, 1) + old := staleCopy(t, opts) + restore(t, opts.Path, old) + legacy := []byte(`{"format":1,"highestGeneration":2,"acknowledgedGeneration":2,"note":"offsite"}`) + restore(t, opts.Path+".backup", legacy) + + if _, err := Open(bg, opts); !errors.Is(err, ErrUnauthenticatedBackupState) { + t.Fatalf("Open = %v, want ErrUnauthenticatedBackupState", err) + } + + accept := opts + accept.AcceptRollback = true + r, err := Open(bg, accept) + if err != nil { + t.Fatalf("Open with AcceptRollback: %v", err) + } + st, err := r.BackupStatus(bg) + if err != nil { + t.Fatal(err) + } + if st.Acknowledged != 2 || st.Note != "offsite" { + t.Fatalf("status = %+v, want the acknowledgement kept", st) + } + r.Close() + + r, err = Open(bg, opts) + if err != nil { + t.Fatalf("Open after the state was authenticated: %v", err) + } + r.Close() +} + +// The state of one keyring cannot stand in for another's under the same root +// key. +func TestBackupStateIsBoundToItsKeyring(t *testing.T) { + opts := newOpts(t, 1) + staleCopy(t, opts) // generation 3, state at 3 + + other := opts + other.Path = filepath.Join(filepath.Dir(opts.Path), "other.kf") + o := mustCreate(t, other) // generation 1, state at 1 + o.Close() + + b, err := os.ReadFile(other.Path + ".backup") + if err != nil { + t.Fatal(err) + } + restore(t, opts.Path+".backup", b) + if _, err := Open(bg, opts); !errors.Is(err, ErrCorruptBackupState) { + t.Fatalf("Open = %v, want ErrCorruptBackupState", err) + } +} + +// A crash during root key rotation leaves state that the root key of +// whichever keyring survived authenticates. +func TestRootKeyRotationCrashKeepsStateReadable(t *testing.T) { + for _, tc := range []struct { + stage string + root RootKeySource + }{ + {"temp-synced", testRoot(1)}, // before the rename: the old keyring remains + {"renamed", testRoot(50)}, // after it: the new keyring is in place + } { + t.Run(tc.stage, func(t *testing.T) { + opts := newOpts(t, 1) + p := mustCreate(t, opts) + newKey(t, p) // generation 2, so a missing or unreadable state would be refused + p.hook = crashAt(tc.stage) + if err := p.RotateRootKey(bg, testRoot(50)); !errors.Is(err, errSimulatedCrash) { + t.Fatalf("RotateRootKey = %v, want the simulated crash", err) + } + p.Close() + + opts.Root = tc.root + r, err := Open(bg, opts) + if err != nil { + t.Fatalf("Open after a crash at %s: %v", tc.stage, err) + } + r.Close() + }) + } +} diff --git a/provider/local/crash_test.go b/provider/local/crash_test.go index e9a8f05..676a04c 100644 --- a/provider/local/crash_test.go +++ b/provider/local/crash_test.go @@ -167,6 +167,10 @@ func TestCrashBetweenKeyringAndBackupStateIsNotARollback(t *testing.T) { opts := newOpts(t, 1) p := mustCreate(t, opts) ref := newKey(t, p) + data, err := os.ReadFile(opts.Path + ".backup") // backup state at 2 + if err != nil { + t.Fatal(err) + } if _, err := p.Rotate(bg, ref); err != nil { // generation 3, backup state at 3 t.Fatal(err) } @@ -174,7 +178,6 @@ func TestCrashBetweenKeyringAndBackupStateIsNotARollback(t *testing.T) { // Put the backup state back to what it would be had the crash happened // after the keyring write and before the state write. - data := []byte(`{"format":1,"highestGeneration":2,"acknowledgedGeneration":0}`) if err := os.WriteFile(opts.Path+".backup", data, 0o600); err != nil { //nolint:gosec // G703: the path is a test temp directory t.Fatal(err) } diff --git a/provider/local/local.go b/provider/local/local.go index b7362fd..20f2838 100644 --- a/provider/local/local.go +++ b/provider/local/local.go @@ -63,9 +63,12 @@ type Options struct { // directory. Leave it false outside tests. AllowRootKeyBesideKeyring bool // AcceptRollback opens a keyring that is older than one this host has - // seen, which is what restoring from backup produces. Set it only after - // confirming that the restore was intended; new writes continue from the - // highest generation seen, so generations are never reused. + // seen, which is what restoring from backup produces. It also opens a + // keyring whose backup state is missing (past generation 1) or in the + // older unauthenticated format, and rewrites that state authenticated. + // Set it only after confirming that the restore was intended; new writes + // continue from the highest generation seen, so generations are never + // reused. AcceptRollback bool } @@ -80,6 +83,7 @@ type Provider struct { mu sync.Mutex root RootKeySource aead cipher.AEAD // seals and opens the file under the key derived from the root key + macKey []byte // authenticates the backup state, derived from the root key rootID [rootKeyIDSize]byte st *state gen uint64 @@ -131,7 +135,11 @@ func (p *Provider) useRoot(ctx context.Context, src RootKeySource) error { if err != nil { return err } - p.aead, p.rootID, p.root = aead, id, src + macKey, err := deriveStateMACKey(root) + if err != nil { + return err + } + p.aead, p.macKey, p.rootID, p.root = aead, macKey, id, src return nil } @@ -202,14 +210,32 @@ func Open(ctx context.Context, opts Options) (*Provider, error) { // checkRollback compares the generation just read with the highest this host // has seen. It refuses an older keyring unless the caller has accepted the // rollback, and records a higher generation when the file is ahead. +// +// Without accept it also refuses when the high-water mark cannot be trusted: +// a keyring past generation 1 with no backup state, or state in the older +// unauthenticated format. With accept, an unauthenticated state's contents +// are kept and the state is rewritten authenticated. func (p *Provider) checkRollback(ctx context.Context, gen uint64, accept bool) error { unlock, err := lockFile(ctx, p.lockPath) if err != nil { return err } defer unlock() - f, err := p.loadBackup() - if err != nil { + f, err := p.readBackup() + trusted := err == nil + switch { + case err == nil: + case errors.Is(err, errNoBackupState): + // Create writes the keyring at generation 1 before its state, so a + // crash between the two leaves exactly this. + if gen > 1 && !accept { + return fmt.Errorf("%w: the keyring is at generation %d", ErrBackupStateMissing, gen) + } + case errors.Is(err, ErrUnauthenticatedBackupState): + if !accept { + return err + } + default: return err } if gen < f.Highest { @@ -222,6 +248,12 @@ func (p *Provider) checkRollback(ctx context.Context, gen uint64, accept bool) e f.Acknowledged = min(f.Acknowledged, gen) return p.saveBackup(f) } + if !trusted { + // Accepted state that could not be trusted is written again, + // authenticated, so the next open does not need the acknowledgement. + f.Highest = max(f.Highest, gen) + return p.saveBackup(f) + } return p.notePersisted(gen) } @@ -377,6 +409,8 @@ func (p *Provider) Close() error { p.st = newState() } p.aead = nil + clear(p.macKey) + p.macKey = nil return nil } @@ -610,6 +644,10 @@ func (p *Provider) RotateRootKey(ctx context.Context, next RootKeySource) error if err != nil { return err } + newMACKey, err := deriveStateMACKey(root) + if err != nil { + return err + } p.mu.Lock() defer p.mu.Unlock() @@ -629,23 +667,34 @@ func (p *Provider) RotateRootKey(ctx context.Context, next RootKeySource) error return fmt.Errorf("local: encoding the keyring: %w", err) } defer clear(payload) - nextGen, err := p.nextGeneration(gen) + f, err := p.loadBackup() if err != nil { st.wipe() return err } + nextGen := max(gen, f.Highest) + 1 + // Authenticate the backup state under both root keys before the keyring + // changes hands, so that whichever keyring a crash leaves, its root key + // opens the state. + oldKey, newKey := stateKey{id: p.rootID, mac: p.macKey}, stateKey{id: id, mac: newMACKey} + if err := p.saveBackupUnder(f, oldKey, newKey); err != nil { + st.wipe() + return err + } data := seal(newAEAD, header{rootKeyID: id, generation: nextGen}, payload) if err := atomicWrite(p.path, data, p.hook); err != nil { st.wipe() return err } - if err := p.notePersisted(nextGen); err != nil { + f.Highest = max(f.Highest, nextGen) + if err := p.saveBackupUnder(f, newKey); err != nil { st.wipe() return err } p.st.wipe() p.st, p.gen = st, nextGen - p.aead, p.rootID, p.root = newAEAD, id, next + clear(p.macKey) + p.aead, p.macKey, p.rootID, p.root = newAEAD, newMACKey, id, next return nil } diff --git a/provider/local/rootkey.go b/provider/local/rootkey.go index 752fb9d..6da3682 100644 --- a/provider/local/rootkey.go +++ b/provider/local/rootkey.go @@ -153,6 +153,19 @@ const ( derivedKeyBytes = 32 ) +// deriveStateMACKey returns the key that authenticates the backup state, +// derived with HKDF-SHA-256 under its own context string. +func deriveStateMACKey(root []byte) ([]byte, error) { + if len(root) != RootKeySize { + return nil, ErrBadRootKey + } + k, err := hkdf.Key(sha256.New, root, nil, infoBackupStateMAC, derivedKeyBytes) + if err != nil { + return nil, fmt.Errorf("local: deriving the backup state key: %w", err) + } + return k, nil +} + // deriveKeys returns the key that encrypts the keyring and the non-secret // identifier of the root key, both derived with HKDF-SHA-256 under distinct // context strings so neither reveals the other or the root key. From e3169bfcef5f367bd33c8fc4cb24f130fb01faf0 Mon Sep 17 00:00:00 2001 From: John Wang Date: Sat, 24 Oct 2026 09:24:07 -0700 Subject: [PATCH 2/5] fix(policy)!: keep at least the active and previous key versions Policy validation accepted a retained-versions floor of 1, which let a deployment retire the previous key version and lose the rollback margin. Validate now rejects RetainVersions below 2, for the default and for every per-purpose override, so the active version and the previous one are always kept and only N-2 and older can be retired. A compromised version is still not counted by the floor and can be retired and destroyed at once. Tests that retired version 1 under a floor of 1 now rotate once more so that version 1 is N-2. BREAKING CHANGE: a policy with RetainVersions below 2, in the default or in a per-purpose override, is now rejected by Policy.Validate and Set.Validate, and so by rotate.New. Set RetainVersions to 2 or more, or leave it zero to inherit the default of 2. Co-Authored-By: Claude Opus 5.5 --- cli/cli_test.go | 7 ++++-- docs/operations.md | 2 +- .../initiatives/INIT-KEYFORGE-001/TRD.md | 18 ++++++++------- jobs/jobs_test.go | 22 ++++++++++++++----- policy/policy.go | 9 +++++--- policy/policy_test.go | 11 +++++++++- rotate/emergency_test.go | 7 +++++- service/service_test.go | 21 ++++++++++++++---- status/status_test.go | 17 +++++++++----- 9 files changed, 84 insertions(+), 30 deletions(-) diff --git a/cli/cli_test.go b/cli/cli_test.go index ada2593..90058c4 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -83,7 +83,7 @@ func newWorld(t *testing.T, g guard.Guard) *world { if err != nil { t.Fatal(err) } - pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetainVersions: 1, RetirementDelay: 7 * day}} + pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetainVersions: 2, RetirementDelay: 7 * day}} rot, err := rotate.New(rotate.Options{ Store: w.store, Providers: []provider.Provider{prov}, Policy: pol, Clock: w.clock, Audit: &audit.Recorder{}, Guard: g, Epochs: w.client, @@ -267,6 +267,9 @@ func TestRetirementCommands(t *testing.T) { } return o } + // Two rotations make version 1 N-2, outside the floor that keeps the + // active and previous versions. + mustRun("rotate", "orders") mustRun("rotate", "orders") mustRun("rewrap", "orders") @@ -295,7 +298,7 @@ func TestADenialIsShownAndExitsNonZero(t *testing.T) { w := newWorld(t, g) w.seed(4) kek := w.kek.ID.String() - for _, a := range [][]string{{"rotate", "orders"}, {"rewrap", "orders"}, {"retire", "request", kek, "1"}} { + for _, a := range [][]string{{"rotate", "orders"}, {"rotate", "orders"}, {"rewrap", "orders"}, {"retire", "request", kek, "1"}} { if _, err := run(t, inProcess(w), a...); err != nil { t.Fatalf("%v: %v", a, err) } diff --git a/docs/operations.md b/docs/operations.md index c649382..7ba62f3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -33,7 +33,7 @@ Policy is set per purpose through `embedded.Options.Policy`: | `DEKMaxAge` | 90 days | How long a data key epoch takes writes | | `DEKMaxEncryptions` | 2³⁰ | Encryptions per data key | | `RetirementDelay` | 30 days | Wait between asking to retire a version and doing it | -| `RetainVersions` | 2 | Versions never retired: the active one and the newest decrypt-only | +| `RetainVersions` | 2 | Versions never retired: the active one and the newest decrypt-only. At least 2, so the previous version remains as the rollback margin | | `RequireBackup` | off | Rotation waits for the keyring backup to be acknowledged | The retirement delay must be at least ten times the client cache TTL, so that a diff --git a/docs/specs/initiatives/INIT-KEYFORGE-001/TRD.md b/docs/specs/initiatives/INIT-KEYFORGE-001/TRD.md index cd12cb7..45d2588 100644 --- a/docs/specs/initiatives/INIT-KEYFORGE-001/TRD.md +++ b/docs/specs/initiatives/INIT-KEYFORGE-001/TRD.md @@ -500,7 +500,7 @@ per-purpose overrides; a field a purpose leaves unset inherits. | `dekMaxAge` | 90 days | How long a data key epoch is current | | `dekMaxEncryptions` | 2³⁰ | Cap on encryptions per data key | | `retirementDelay` | 30 days | Wait between a request to retire a version and its retirement | -| `retainVersions` | 2 | Floor on versions kept: the active one and the most recent decrypt-only ones | +| `retainVersions` | 2 | Floor on versions kept: the active one and the most recent decrypt-only ones. At least 2; a lower value is rejected | | `requireBackup` | false | Rotation waits until the keyring backup is acknowledged as current | - **A purpose can only tighten `requireBackup`**: it can ask for a backup the @@ -552,13 +552,15 @@ per-purpose overrides; a field a purpose leaves unset inherits. ### Retained versions -- **Policy keeps the last two versions.** `retain_versions` defaults to 2: the - active version and the most recent `decrypt-only` version are never - retired, even when nothing references the latter. It stays usable for - rollback: if the active version turns out to be bad (a provider fault, a - wrong configuration), the previous one is reactivated (see - [Transitions](#transitions)) and nothing needs recovering. Counting by state - rather than by number keeps the rule well defined after a rollback. +- **Policy keeps the last two versions.** `retain_versions` defaults to 2, + and validation rejects anything lower: the active version and the most + recent `decrypt-only` version are never retired, even when nothing + references the latter. It stays usable for rollback: if the active version + turns out to be bad (a provider fault, a wrong configuration), the previous + one is reactivated (see [Transitions](#transitions)) and nothing needs + recovering. Counting by state rather than by number keeps the rule well + defined after a rollback. A `compromised` version is not counted: the + emergency path retires it whatever the floor. - **Older versions (N-2 and below) become eligible for retirement only when all of these hold:** no DEK record or tracked object references them; the retirement delay has passed; no hold covers data they protect; and the diff --git a/jobs/jobs_test.go b/jobs/jobs_test.go index 354973b..55e459b 100644 --- a/jobs/jobs_test.go +++ b/jobs/jobs_test.go @@ -329,7 +329,7 @@ func TestReplicasStartTheSameRun(t *testing.T) { } func TestRetirementAndDestructionRunOverTicks(t *testing.T) { - pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetirementDelay: 7 * day, RetainVersions: 1}} + pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetirementDelay: 7 * day, RetainVersions: 2}} w := newWorld(t, pol, guard.AllowAll()) w.seed(4) e := w.embedded("", nil) @@ -354,7 +354,14 @@ func TestRetirementAndDestructionRunOverTicks(t *testing.T) { w.clock.Advance(31 * day) step(jobs.RotateKEK) - step(jobs.RequestRetirement) // version 1 is outside a floor of one, and drained + // Version 1 is now the previous version, the rollback margin, which the + // floor of two keeps: nothing is due until the next rotation. + if res := mustTick(t, e); len(res.Started) != 0 { + t.Fatalf("a tick with version 1 inside the floor started %v", kinds(res)) + } + w.clock.Advance(31 * day) + step(jobs.RotateKEK) + step(jobs.RequestRetirement) // version 1 is now N-2, outside the floor, and drained if got := w.states()[1]; got != registry.VersionRetireRequested { t.Fatalf("version 1 is %s", got) } @@ -378,7 +385,7 @@ func TestRetirementAndDestructionRunOverTicks(t *testing.T) { } func TestAPendingGuardIsAskedAgainOnlyAfterTheRecheck(t *testing.T) { - pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetirementDelay: 7 * day, RetainVersions: 1}} + pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetirementDelay: 7 * day, RetainVersions: 2}} asked := 0 answers := []guard.Decision{guard.AwaitApproval("ticket 41"), guard.AwaitApproval("ticket 41"), guard.Allow(), guard.Allow()} g := guard.Func(func(context.Context, guard.Request) (guard.Decision, error) { @@ -391,14 +398,19 @@ func TestAPendingGuardIsAskedAgainOnlyAfterTheRecheck(t *testing.T) { e := w.embedded("", func(o *jobs.EmbeddedOptions) { o.Recheck = 10 * time.Minute }) w.run(e) + // Rotate twice so version 1 is N-2, outside the floor of two, then request + // its retirement. + w.clock.Advance(31 * day) + res := mustTick(t, e) + settle(t, e, res.Started) w.clock.Advance(31 * day) for range 2 { // rotate, then request retirement - res := mustTick(t, e) + res = mustTick(t, e) settle(t, e, res.Started) } w.clock.Advance(8 * day) - res := mustTick(t, e) + res = mustTick(t, e) settle(t, e, res.Started) if asked != 1 || w.states()[1] != registry.VersionRetireRequested { t.Fatalf("after the first pass: asked %d, version 1 is %s", asked, w.states()[1]) diff --git a/policy/policy.go b/policy/policy.go index 96040ae..60acc5e 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -33,6 +33,7 @@ const ( DefaultRetirementDelay = 30 * 24 * time.Hour // DefaultRetainVersions is how many versions are never retired: the active // one and the most recent decrypt-only one, which is the rollback margin. + // It is also the minimum [Policy.Validate] accepts. DefaultRetainVersions = 2 ) @@ -58,7 +59,9 @@ type Policy struct { // RetainVersions is the floor on how many versions are kept: the active // version and the most recent decrypt-only ones up to this count are never // retired, even when nothing references them. It is a floor on retention, - // never a trigger to delete. + // never a trigger to delete. It must be at least 2, so the previous version + // always remains as the rollback margin; only N-2 and older are retired. + // A compromised version is not counted and can be retired at any time. RetainVersions int // RequireBackup makes rotation wait until the keyring's backup has been // acknowledged as current, so that restoring it cannot lose data wrapped @@ -92,8 +95,8 @@ func (p Policy) Validate() error { if p.RetirementDelay <= 0 { errs = append(errs, errors.New("retirement delay must be positive")) } - if p.RetainVersions < 1 { - errs = append(errs, errors.New("retained versions must be at least 1")) + if p.RetainVersions < DefaultRetainVersions { + errs = append(errs, fmt.Errorf("retained versions must be at least %d: the active version and the previous one", DefaultRetainVersions)) } return errors.Join(errs...) } diff --git a/policy/policy_test.go b/policy/policy_test.go index badec90..7cd2512 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -37,7 +37,9 @@ func TestValidate(t *testing.T) { {"cap above the limit", func(p *policy.Policy) { p.DEKMaxEncryptions = keyforge.MaxEncryptionCap + 1 }, "encryption cap"}, {"no delay", func(p *policy.Policy) { p.RetirementDelay = 0 }, "retirement delay"}, {"retain nothing", func(p *policy.Policy) { p.RetainVersions = 0 }, "retained versions"}, - {"retain one", func(p *policy.Policy) { p.RetainVersions = 1 }, ""}, + {"retain only the active version", func(p *policy.Policy) { p.RetainVersions = 1 }, "at least 2"}, + {"retain the active and previous versions", func(p *policy.Policy) { p.RetainVersions = 2 }, ""}, + {"retain three", func(p *policy.Policy) { p.RetainVersions = 3 }, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -106,6 +108,13 @@ func TestSetValidate(t *testing.T) { } } + // A per-purpose override cannot lower the floor below the previous version. + lowered := policy.Set{Purposes: map[keyforge.Purpose]policy.Policy{"orders": {RetainVersions: 1}}} + if err := lowered.Validate(0); err == nil || !strings.Contains(err.Error(), `purpose "orders"`) || + !strings.Contains(err.Error(), "retained versions must be at least 2") { + t.Fatalf("Validate = %v, want the override of 1 refused", err) + } + // With the cache TTL unknown the delay check is skipped. short := policy.Set{Purposes: map[keyforge.Purpose]policy.Policy{"quick": {RetirementDelay: time.Minute}}} if err := short.Validate(0); err != nil { diff --git a/rotate/emergency_test.go b/rotate/emergency_test.go index adee22d..c1f8b70 100644 --- a/rotate/emergency_test.go +++ b/rotate/emergency_test.go @@ -157,7 +157,9 @@ func TestEmergencyDoesNotWaitForABackup(t *testing.T) { } func TestCompromisedOlderVersionIsMovedWithoutAnotherRotation(t *testing.T) { - w := newWorld(t, setup{}) + // The floor keeps the active and previous versions, but a compromised + // previous version is not held by it: it is retired and destroyed at once. + w := newWorld(t, setup{policy: policy.Set{Default: policy.Policy{RetainVersions: 2}}}) w.seed(w.client(small), 6) // three data keys under version 1 first, err := w.rot.RotateKEK(bg, purpose) if err != nil { @@ -177,6 +179,9 @@ func TestCompromisedOlderVersionIsMovedWithoutAnotherRotation(t *testing.T) { if kv, _ := w.store.ActiveKEK(bg, purpose); kv.Version != first.To { t.Fatalf("the active version changed to %d, want %d", kv.Version, first.To) } + if got := w.versions()[1]; got != registry.VersionDestroyed { + t.Fatalf("the compromised previous version is %s, want destroyed despite the floor of 2", got) + } } func TestEmergencyResumesAfterAFailedRewrap(t *testing.T) { diff --git a/service/service_test.go b/service/service_test.go index 8d075f6..a3152e2 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -401,16 +401,23 @@ func TestRollbackWithNothingToReturnTo(t *testing.T) { } func TestRetirementOverHTTP(t *testing.T) { - pol := policy.Set{Default: policy.Policy{RetainVersions: 1, RetirementDelay: 7 * day}} + pol := policy.Set{Default: policy.Policy{RetainVersions: 2, RetirementDelay: 7 * day}} f := newFixture(t, fixtureOpts{pol: pol, g: guard.AllowAll()}) f.seed(4) + // Two rotations make version 1 N-2, outside the floor that keeps the + // active and previous versions. + f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rewrap", nil) // A version that is not decrypt-only cannot be put forward. - if r := f.do("POST", f.kekPath(2, "retire"), nil); r.code != 409 || errCode(r) != "invalid_state" { + if r := f.do("POST", f.kekPath(3, "retire"), nil); r.code != 409 || errCode(r) != "invalid_state" { t.Fatalf("retiring the active version = %d %q", r.code, errCode(r)) } + // The previous version is the rollback margin and stays within the floor. + if r := f.do("POST", f.kekPath(2, "retire"), nil); r.code != 409 || errCode(r) != "not_eligible" { + t.Fatalf("retiring the previous version = %d %q", r.code, errCode(r)) + } if r := f.do("POST", f.kekPath(1, "retire"), nil); r.code != 200 || r.body["state"] != "retire-requested" { t.Fatalf("request = %d %v", r.code, r.body) } @@ -430,7 +437,7 @@ func TestRetirementOverHTTP(t *testing.T) { } func TestGuardAnswersMapToStatuses(t *testing.T) { - pol := policy.Set{Default: policy.Policy{RetainVersions: 1, RetirementDelay: 7 * day}} + pol := policy.Set{Default: policy.Policy{RetainVersions: 2, RetirementDelay: 7 * day}} answers := []guard.Decision{guard.AwaitApproval("ticket 7"), guard.Deny("legal hold")} i := 0 g := guard.Func(func(context.Context, guard.Request) (guard.Decision, error) { @@ -440,6 +447,9 @@ func TestGuardAnswersMapToStatuses(t *testing.T) { }) f := newFixture(t, fixtureOpts{pol: pol, g: g}) f.seed(4) + // Two rotations make version 1 N-2, outside the floor that keeps the + // active and previous versions. + f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rewrap", nil) f.do("POST", f.kekPath(1, "retire"), nil) @@ -456,9 +466,12 @@ func TestGuardAnswersMapToStatuses(t *testing.T) { } func TestCancelRetirement(t *testing.T) { - pol := policy.Set{Default: policy.Policy{RetainVersions: 1}} + pol := policy.Set{Default: policy.Policy{RetainVersions: 2}} f := newFixture(t, fixtureOpts{pol: pol, g: guard.AllowAll()}) f.seed(2) + // Two rotations make version 1 N-2, outside the floor that keeps the + // active and previous versions. + f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rotate", nil) f.do("POST", "/v1/purposes/orders/rewrap", nil) f.do("POST", f.kekPath(1, "retire"), nil) diff --git a/status/status_test.go b/status/status_test.go index 4ae29e8..7e3d975 100644 --- a/status/status_test.go +++ b/status/status_test.go @@ -184,26 +184,33 @@ func TestOverdueRotationIsAProblem(t *testing.T) { } func TestVersionsShowRetirability(t *testing.T) { - pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetainVersions: 1}} + pol := policy.Set{Default: policy.Policy{KEKInterval: 30 * day, RetainVersions: 2}} w := newWorld(t, providermemory.NewKeyring(), pol) w.seed(4) rot := w.rotator() - if _, err := rot.RotateKEK(bg, purpose); err != nil { - t.Fatal(err) + // Two rotations make version 1 N-2, outside the floor of the active and + // previous versions. + for range 2 { + if _, err := rot.RotateKEK(bg, purpose); err != nil { + t.Fatal(err) + } } v := w.reporter(nil).Report(bg).Purposes[0].Versions - if len(v) != 2 || v[0].State != "decrypt-only" || v[0].References != 2 || v[0].Retirable { + if len(v) != 3 || v[0].State != "decrypt-only" || v[0].References != 2 || v[0].Retirable { t.Fatalf("Versions = %+v, want version 1 referenced and not retirable", v) } if len(v[0].Reasons) == 0 || strings.Contains(strings.Join(v[0].Reasons, " "), "wrapped under it") == false { t.Fatalf("Reasons = %v, want the reference named", v[0].Reasons) } + if v[1].State != "decrypt-only" || v[1].Retirable || !strings.Contains(strings.Join(v[1].Reasons, " "), "floor") { + t.Fatalf("version 2 = %+v, want it held by the floor as the rollback margin", v[1]) + } if _, err := rot.Rewrap(bg, w.kek.ID, 1, rotate.RewrapOptions{}); err != nil { t.Fatal(err) } v = w.reporter(nil).Report(bg).Purposes[0].Versions - if !v[0].Retirable || v[0].References != 0 || v[1].State != "active" || v[1].ActivatedAt.IsZero() { + if !v[0].Retirable || v[0].References != 0 || v[1].Retirable || v[2].State != "active" || v[2].ActivatedAt.IsZero() { t.Fatalf("after the re-wrap: %+v", v) } } From c3987231297af71d5332dfdf1f1bf941889805b6 Mon Sep 17 00:00:00 2001 From: John Wang Date: Sun, 25 Oct 2026 06:40:09 -0700 Subject: [PATCH 3/5] docs(changelog): add v0.2.0 entry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.json | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.json b/CHANGELOG.json index cb97bea..de77e05 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,66 @@ "plexusone" ], "releases": [ + { + "version": "v0.2.0", + "date": "2026-10-25", + "highlights": [ + { + "description": "The keyring's rollback high-water mark is now authenticated under the root key, so an older keyring can no longer be put back by editing or deleting the state file beside it" + }, + { + "description": "Policies must keep at least the active and the previous key version, so a rotation always leaves a rollback margin" + }, + { + "description": "Two breaking changes: the first `Open` after upgrading needs `AcceptRollback` once, and `RetainVersions` below 2 is rejected" + } + ], + "security": [ + { + "description": "The local provider kept the highest keyring generation seen in plain JSON beside the keyring (`.backup`) and read a missing file as \"nothing seen\". Anyone able to write the keys directory could restore an older, still validly sealed keyring and delete or edit that file, and `Open` accepted it without `AcceptRollback`, bringing back destroyed key versions or undoing a rotation. The state file is now authenticated with HMAC-SHA-256 under a key derived from the root key with HKDF and bound to the keyring's file name; `Open` refuses a state file that does not authenticate, a missing state file beside a keyring past generation 1, and a state file in the previous unauthenticated format" + }, + { + "description": "Policy validation accepted a retained-versions floor of 1, which let a deployment retire the previous key version and lose the rollback margin. `RetainVersions` must now be at least 2, in the default and in every per-purpose override; a compromised version is still not counted by the floor and can be retired and destroyed at once" + } + ], + "added": [ + { + "description": "`local.ErrCorruptBackupState`, `local.ErrBackupStateMissing` and `local.ErrUnauthenticatedBackupState`, returned by `Open` for a backup state file that does not authenticate, is missing beside a keyring past generation 1, or is in the previous unauthenticated format" + } + ], + "changed": [ + { + "description": "Root key rotation authenticates the backup state under both the old and the new root key before replacing the keyring, so a crash on either side leaves state that the surviving keyring's root key opens" + }, + { + "description": "Opening with `AcceptRollback` after one of the new backup state errors rewrites the state authenticated and keeps any existing high-water mark and backup acknowledgement" + } + ], + "breaking": [ + { + "description": "The first `Open` of an existing keyring after upgrading fails with `ErrUnauthenticatedBackupState` until it is done once with `AcceptRollback` (`local.Options` or `embedded.Options`)" + }, + { + "description": "Opening a keyring past generation 1 without its backup state file, for example after moving it to a new host, fails with `ErrBackupStateMissing` and needs `AcceptRollback` once" + }, + { + "description": "A policy with `RetainVersions` below 2, in the default or in a per-purpose override, is rejected by `Policy.Validate` and `Set.Validate`, and so by `rotate.New`" + } + ], + "upgradeGuide": [ + { + "description": "Stop the process, confirm the keyring on disk is the one you expect with `keyforge keyring inspect`, then open it once with `AcceptRollback: true` in `local.Options` or `embedded.Options`; remove the option after that first successful open" + }, + { + "description": "Set `RetainVersions` to 2 or more, or leave it zero to inherit the default of 2, in the default policy and in every per-purpose override" + } + ], + "documentation": [ + { + "description": "Operations guide and threat model describe the authenticated backup state file, the new errors and the upgrade step; the policy table states the retained-versions floor of 2" + } + ] + }, { "version": "v0.1.0", "date": "2026-10-12", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c56a02..3df3d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,43 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.2.0] - 2026-10-25 + +### Highlights + +- The keyring's rollback high-water mark is now authenticated under the root key, so an older keyring can no longer be put back by editing or deleting the state file beside it +- Policies must keep at least the active and the previous key version, so a rotation always leaves a rollback margin +- Two breaking changes: the first `Open` after upgrading needs `AcceptRollback` once, and `RetainVersions` below 2 is rejected + +### Breaking + +- The first `Open` of an existing keyring after upgrading fails with `ErrUnauthenticatedBackupState` until it is done once with `AcceptRollback` (`local.Options` or `embedded.Options`) +- Opening a keyring past generation 1 without its backup state file, for example after moving it to a new host, fails with `ErrBackupStateMissing` and needs `AcceptRollback` once +- A policy with `RetainVersions` below 2, in the default or in a per-purpose override, is rejected by `Policy.Validate` and `Set.Validate`, and so by `rotate.New` + +### Upgrade Guide + +- Stop the process, confirm the keyring on disk is the one you expect with `keyforge keyring inspect`, then open it once with `AcceptRollback: true` in `local.Options` or `embedded.Options`; remove the option after that first successful open +- Set `RetainVersions` to 2 or more, or leave it zero to inherit the default of 2, in the default policy and in every per-purpose override + +### Security + +- The local provider kept the highest keyring generation seen in plain JSON beside the keyring (`.backup`) and read a missing file as "nothing seen". Anyone able to write the keys directory could restore an older, still validly sealed keyring and delete or edit that file, and `Open` accepted it without `AcceptRollback`, bringing back destroyed key versions or undoing a rotation. The state file is now authenticated with HMAC-SHA-256 under a key derived from the root key with HKDF and bound to the keyring's file name; `Open` refuses a state file that does not authenticate, a missing state file beside a keyring past generation 1, and a state file in the previous unauthenticated format +- Policy validation accepted a retained-versions floor of 1, which let a deployment retire the previous key version and lose the rollback margin. `RetainVersions` must now be at least 2, in the default and in every per-purpose override; a compromised version is still not counted by the floor and can be retired and destroyed at once + +### Added + +- `local.ErrCorruptBackupState`, `local.ErrBackupStateMissing` and `local.ErrUnauthenticatedBackupState`, returned by `Open` for a backup state file that does not authenticate, is missing beside a keyring past generation 1, or is in the previous unauthenticated format + +### Changed + +- Root key rotation authenticates the backup state under both the old and the new root key before replacing the keyring, so a crash on either side leaves state that the surviving keyring's root key opens +- Opening with `AcceptRollback` after one of the new backup state errors rewrites the state authenticated and keeps any existing high-water mark and backup acknowledgement + +### Documentation + +- Operations guide and threat model describe the authenticated backup state file, the new errors and the upgrade step; the policy table states the retained-versions floor of 2 + ## [v0.1.0] - 2026-10-12 ### Highlights @@ -39,5 +76,6 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - Documentation: getting started, operations, envelope format, threat model, and the product and technical requirements - Import-boundary test: the packages that touch key material import only the standard library and oscompat, a dependency-free module for operating-system differences -[unreleased]: https://github.com/plexusone/keyforge/compare/v0.1.0...HEAD +[unreleased]: https://github.com/plexusone/keyforge/compare/v0.2.0...HEAD +[v0.2.0]: https://github.com/plexusone/keyforge/compare/v0.1.0...v0.2.0 [v0.1.0]: https://github.com/plexusone/keyforge/releases/tag/v0.1.0 From bf4707be9be65d70b17c31889fd227a7401ec59f Mon Sep 17 00:00:00 2001 From: John Wang Date: Sun, 25 Oct 2026 06:42:30 -0700 Subject: [PATCH 4/5] docs(release): add v0.2.0 release notes Co-Authored-By: Claude Opus 5.5 --- docs/releases/v0.2.0.md | 139 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 140 insertions(+) create mode 100644 docs/releases/v0.2.0.md diff --git a/docs/releases/v0.2.0.md b/docs/releases/v0.2.0.md new file mode 100644 index 0000000..ec2cf70 --- /dev/null +++ b/docs/releases/v0.2.0.md @@ -0,0 +1,139 @@ +# v0.2.0 + +**Release Date:** 2026-10-25 + +## Highlights + +- **The rollback check can no longer be switched off from the keys + directory.** The highest keyring generation a host has seen is now + authenticated under the root key, so an older keyring cannot be put back by + editing or deleting the state file beside it. +- **A rollback margin is always kept.** A policy must retain at least the + active key version and the previous one. +- **Two breaking changes.** The first open after upgrading needs + `AcceptRollback` once, and `RetainVersions` below 2 is rejected. See + [Upgrading](#upgrading). + +## Status + +keyforge v0.2.0 has **not been externally audited** and is not yet recommended +for production use. The envelope format and the keyring file format are +unchanged from v0.1.0. See the [threat model](../threat-model.md) for what it +defends and where it stops. + +## Security fixes + +### Authenticated rollback high-water mark + +The local provider keeps the highest keyring generation it has seen in a state +file beside the keyring (`.backup`, `keyring.kf.backup` under +`embedded`). Before this release that file was plain JSON, and a missing file +was read as "nothing seen". Anyone able to write the keys directory could put +back an older, still validly sealed keyring and delete or edit the state file, +and `Open` accepted the older keyring without `AcceptRollback`, bringing back +key versions that had been destroyed or undoing a rotation. + +The state file is now authenticated with HMAC-SHA-256 under a key derived from +the root key with HKDF, and bound to the keyring's file name. `Open` refuses: + +| Condition | Error | +|-----------|-------| +| A state file that does not authenticate | `local.ErrCorruptBackupState` | +| No state file beside a keyring past generation 1 | `local.ErrBackupStateMissing` | +| A state file in the previous, unauthenticated format | `local.ErrUnauthenticatedBackupState` | + +Generation 1 without a state file is still accepted, since a crash during +`Create` leaves exactly that. Each refusal can be accepted with +`AcceptRollback`, which rewrites the state authenticated and keeps any existing +high-water mark and backup acknowledgement. Root key rotation authenticates the +state under both the old and the new root key before replacing the keyring, so +a crash on either side leaves state that the surviving keyring's root key +opens. + +The check still cannot tell an older keyring restored together with its own +older state file from the real thing; keep the keys directory writable only by +the service. + +### Retained-versions floor of 2 + +Policy validation accepted `RetainVersions: 1`, which let a deployment retire +the previous key version as soon as a rotation completed and lose the rollback +margin. `Policy.Validate` and `Set.Validate`, and so `rotate.New`, now reject a +value below 2 in the default and in every per-purpose override. The active +version and the previous one are always kept, and only N-2 and older can be +retired. A compromised version is still not counted by the floor and can be +retired and destroyed at once. + +## Upgrading + +### 1. Open each existing keyring once with `AcceptRollback` + +The first `Open` of a keyring written by v0.1.0 fails with +`ErrUnauthenticatedBackupState`. For each keyring: + +1. Stop the process. +2. Confirm the keyring on disk is the one you expect: + + ```bash + keyforge keyring inspect + ``` + +3. Open it once with `AcceptRollback: true`: + + ```go + sys, err := embedded.Open(ctx, embedded.Options{ + Dir: "/var/lib/myapp/keyforge", + Root: local.FileSource("/etc/keyforge/root.key"), + AcceptRollback: true, // once, for the upgrade + Purposes: purposes, + }) + ``` + + With the local provider directly, set `AcceptRollback: true` in + `local.Options`. + +4. Remove `AcceptRollback` again and restart. Leaving it set accepts a rolled + back keyring on every open. + +The existing high-water mark and backup acknowledgement are kept; the state +file is rewritten in the authenticated format. + +The same one-time step is needed when a keyring past generation 1 is opened +without its state file, for example after moving it to a new host +(`ErrBackupStateMissing`). + +### 2. Keep at least two versions + +If any policy sets `RetainVersions` to 1, `rotate.New` and policy validation +now return an error. Set it to 2 or more, or leave it zero to inherit the +default of 2, in the default policy and in every per-purpose override: + +```go +policy.Policy{ + RetainVersions: 2, // or 0 to inherit the default +} +``` + +A deployment that relied on retiring the previous version straight after a +rotation now retires it one rotation later. + +### 3. Update the module + +```bash +go get github.com/plexusone/keyforge@v0.2.0 +go install github.com/plexusone/keyforge/cmd/keyforge@v0.2.0 +``` + +Requires Go 1.26. + +## Documentation + +- [Operations](../operations.md) describes the authenticated backup state file, + the new errors and the upgrade step, and states the retained-versions floor. +- The [threat model](../threat-model.md) covers the authenticated high-water + mark and its remaining limit. + +## Known limitations + +The limitations listed for [v0.1.0](v0.1.0.md#known-limitations) still apply. +The complete list is in the [threat model](../threat-model.md#known-limitations). diff --git a/mkdocs.yml b/mkdocs.yml index f10b3d4..216c7da 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - Plan: specs/initiatives/INIT-KEYFORGE-001/PLAN.md - Roadmap: specs/initiatives/INIT-KEYFORGE-001/ROADMAP.md - Releases: + - v0.2.0: releases/v0.2.0.md - v0.1.0: releases/v0.1.0.md extra: From 8a277e9f9947f31bcb9b0522127f6398bf33b39b Mon Sep 17 00:00:00 2001 From: John Wang Date: Sun, 25 Oct 2026 06:42:31 -0700 Subject: [PATCH 5/5] docs(readme): point to the v0.2.0 release notes Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 160f930..f69985a 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Encryption and signing key lifecycle for Go applications: envelope encryption, automatic rotation without re-encrypting stored data, and controlled retirement and destruction. -> **Status:** pre-release (v0.1.0 in preparation), not externally audited, and -> not ready for production use. The envelope format is not frozen until v0.1.0. +> **Status:** pre-release (v0.2.0), not externally audited, and not ready for +> production use. The envelope format is frozen from v0.1.0. > See the [threat model](docs/threat-model.md) for what it defends and where it > stops. @@ -105,7 +105,7 @@ operating-system differences, and a test enforces it. - [Product requirements](docs/specs/initiatives/INIT-KEYFORGE-001/PRD.md) - [Technical requirements](docs/specs/initiatives/INIT-KEYFORGE-001/TRD.md) - [Plan](docs/specs/initiatives/INIT-KEYFORGE-001/PLAN.md) and [roadmap](docs/specs/initiatives/INIT-KEYFORGE-001/ROADMAP.md) -- [Release notes](docs/releases/v0.1.0.md) and [changelog](CHANGELOG.md) +- [Release notes](docs/releases/v0.2.0.md) and [changelog](CHANGELOG.md) - [Security policy](SECURITY.md) ## License