Key rotation
Esta página aún no está disponible en tu idioma.
For object-storage encryption, the master key needs to rotate periodically (security policy, compromise response, compliance). The framework supports online rotation — zero downtime, old data readable throughout.
The flow:
1. Generate a new key. Add it to the keyring as `active`; keep the old one under `retired`. 2. Roll out. New writes encrypt under `active`; old payloads decrypt via `retired`. 3. Run the re-encryption sweep. Old payloads → the active key. 4. Wait the rollback window (~7 days) — backups may still reference the old key. 5. Drop the `retired` entry from the keyring. Cleanup.This page covers the store-side mechanics; for the operational walkthrough see Master key rotation (operations).
The keyring
Section titled “The keyring”Encryption keys live in a MasterKeyRing — an active key for
new writes plus any retired keys still needed to decrypt older
payloads. Each entry pairs a numeric version (embedded in the
payload manifest) with the 32-byte AES-256 key:
import type { MasterKeyRing } from 'actor-ts';
const keyring: MasterKeyRing = { active: { version: 2, key: newKey }, // new writes use this retired: [{ version: 1, key: oldKey }], // still decrypts old payloads};Every version is used once
Section titled “Every version is used once”Versions run 0…255 — one byte of the payload manifest holds
one, and that byte is all a reader has to identify the key with.
So no two entries may claim the same version. A ring that breaks
that is refused, at plugin registration, at the store, and at the
start of a sweep:
// rejected: active and retired[0] both claim version 1const ambiguous: MasterKeyRing = { active: { version: 1, key: newKey }, retired: [{ version: 1, key: oldKey }],};Without the check, the lookup resolves the collision by
precedence — active is matched first — so payloads written
under the older key are decrypted with the newer one and fail
with an authentication-tag error that names nothing. Promoting a
key without renumbering it is enough to produce that, on the
second rotation as easily as the two-hundredth.
The 32-byte key length is checked in the same pass, for the same
reason: a short retired key is otherwise invisible until some
payload at that version is finally read.
255 is not a lifetime budget
Section titled “255 is not a lifetime budget”It bounds how many versions may be live in one corpus at once,
not how often you may rotate. A completed sweep puts every
payload on the active version; the retired entries then go away
and every other number is free to use again.
From version 240 on, registration logs a warning that says so —
run the sweep over every prefix, drop the retired entries, and
restart numbering from 0. There is no wider version field to
switch to, by design: the one-byte manifest stays, because the
sweep already resolves the only situation a wider one would help
with.
The sweep
Section titled “The sweep”import { reEncryptObjectStorage } from 'actor-ts';
const result = await reEncryptObjectStorage(backend, { keyPrefix: 'snapshots/', // which keys to process keyring, // active + retired keys info: 'acme/prod/snapshot/v1', // the store's HKDF context skip: (key) => key.endsWith('.manifest'), // optional: exclude keys onProgress: (e) => console.log(`${e.index}/${e.total} ${e.key}`),});console.log(`re-encrypted ${result.rewrote} of ${result.scanned}`);info is required and must match the encrypting store’s
EncryptionConfig.info
exactly — it is half of the subkey derivation, so a mismatch
fails every decrypt in the sweep.
The sweep lists every object under keyPrefix, and for each one:
- Reads it; if it’s already at the active version, skips it (the idempotent fast-path — no PUT).
- Otherwise decrypts it with the matching
retiredkey and re-writes it under theactivekey.
It returns a ReEncryptResult: { scanned, rewrote, skippedCurrent, skippedUnencrypted, skippedNonAts1 }.
Idempotent + resumable
Section titled “Idempotent + resumable”Re-running is safe: an object already at the active version is skipped without a write. A plain re-run re-lists and re-checks every key (fine for small buckets). For million-object stores, pass a progress store so a crashed sweep resumes near where it stopped instead of re-scanning from the start:
import { InMemoryReEncryptProgressStore } from 'actor-ts';
await reEncryptObjectStorage(backend, { keyPrefix: 'snapshots/', keyring, info: 'acme/prod/snapshot/v1', progress: new InMemoryReEncryptProgressStore(), // or a durable store});Rotating the HKDF context
Section titled “Rotating the HKDF context”The master key is one input to the subkey; the HKDF
info
context is another. Rotating it — say, splitting a shared
'actor-ts/snapshot/v1' into per-environment contexts — uses the
same sweep with newInfo: bodies are decrypted under info and
re-written under newInfo.
await reEncryptObjectStorage(backend, { keyPrefix: 'snapshots/', keyring, info: 'actor-ts/snapshot/v1', // what the corpus was written under newInfo: 'acme/prod/snapshot/v1', // what it should be written under now});The two axes are independent: rotate the key, the context, or
both in one pass. Roll the new info out to the application’s
EncryptionConfig after the sweep finishes — until then, the
corpus is still under the old context.
One consequence is worth knowing before you start. The key
version lives in the body manifest, but the context does not,
so the sweep cannot tell a converted body from an unconverted one
by reading the header. While newInfo differs from info the
version fast-path is therefore switched off and every object is
decrypted to find out — the sweep costs a full read pass over the
prefix rather than a header scan. (Without that, a context-only
rotation would report every object as skipped-current and
change nothing.)
Re-running is still safe: an object that fails to decrypt under
info is retried under newInfo, and one that succeeds there is
counted as skippedCurrent. An object that decrypts under
neither raises the original decrypt error, so a genuinely broken
corpus is not quietly skipped.
Partial rotation with skip
Section titled “Partial rotation with skip”skip(key) => boolean excludes matching keys — the inverse of
a filter. Process a subset by skipping everything else:
await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1', skip: (key) => !key.startsWith('state/account-'), // only account-*});Useful for per-tenant, per-actor-type, or phased rotations.
Keyring-completeness pre-check
Section titled “Keyring-completeness pre-check”By default the sweep samples the first encrypted objects and
refuses to start if any payload’s key version is missing from
the keyring — catching the “operator dropped the retired key too
soon” footgun before a mid-sweep decrypt failure leaves the
corpus half-rewritten. Disable with verifyKeyringCompleteness: false only if you have independent assurance the keyring is
complete.
Confirming completion
Section titled “Confirming completion”Read the returned counts — a complete sweep has scanned === rewrote + skippedCurrent + skippedUnencrypted + skippedNonAts1,
and a second run reports rewrote === 0:
const result = await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1',});if (result.rewrote === 0) { // everything is already at the active key — safe to plan retiring the old one}There is no separate “list stragglers” helper; the counts are the source of truth.
After the sweep
Section titled “After the sweep”Once every payload is re-encrypted and the rollback window has
passed, drop the retired entry:
const keyringAfterRotation: MasterKeyRing = { active: { version: 2, key: newKey }, // retired[] removed};Wait the rollback window (typically 7 days) first — backups may still reference the old key.
Where to next
Section titled “Where to next”- Object storage overview — the bigger picture.
- Encryption — the at-rest encryption setup.
- Master key rotation (ops) — the operations-side walkthrough.
