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 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 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 6220f15..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 @@ -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/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/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/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/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/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: 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/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. 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) } }