Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions CHANGELOG.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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",
Expand Down
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<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
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 17 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions docs/releases/v0.2.0.md
Original file line number Diff line number Diff line change
@@ -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 (`<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).
Loading
Loading