Key rotation
このコンテンツはまだ日本語訳がありません。
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/persistence';
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/persistence';
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/persistence';
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.
Sweeping an integrity-protected corpus
Section titled “Sweeping an integrity-protected corpus”A bucket with the integrity HMAC turned on needs that key here too. The tag covers the manifest bytes, so the sweep has to verify it to read a body at all and recompute it to write one back:
await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1', integrity: { mode: 'hmac-sha256', integrityKey },});integrity takes the same shape the store takes — a flat config, or the
same per-persistenceId resolver — so a deployment that keys integrity
per tenant resolves the same key here that its store does.
Leave it out on a tagged corpus and the sweep refuses before the first
write, naming an offending key. Tags are re-applied only where the body
already carried one, so an untagged body is never promoted; a corpus
that is still mid-integrity-migration adds allowUntaggedBodies: true,
the sweep-side counterpart of the store option of the same name.
The sweep does not rotate the integrity key itself, and no migration
window lets you either. Nothing on the wire records which integrity key
sealed a body, so a half-swept corpus would be indistinguishable from a
tampered one — and allowUntaggedBodies is not the way round it, because
it re-admits bodies carrying no tag, while a body tagged under the old
key still fails the HMAC comparison before that branch is reached. What
does work is the per-call PersistenceOptions.integrity override, read
and write independently, one persistenceId at a time: see integrity
and master-key
rotation,
and #1354 for the
shape a real roll would need.
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 objects it would rewrite 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. The same pass answers the integrity
question: a sampled body carrying a tag that integrity resolves
no key for stops the sweep here rather than in the middle of the
corpus. Disable with verifyKeyringCompleteness: false only if
you have independent assurance the keyring is complete.
Both halves sample rather than enumerate, so both are a footgun
catcher and not a proof — a straggler past sampleSize still
aborts mid-corpus.
Which keys the sweep can process
Section titled “Which keys the sweep can process”The sweep derives its HKDF salt from the object’s key and then rewrites the body, so a key it cannot read reliably is a body it must not touch. Two rules follow, and both are enforced:
- No control characters. Every object-storage backend rejects
them on
put, so a key this framework writes is always a key the sweep accepts. Reads and deletes stay permissive, so an object written by an older version is still reachable. - Exactly
<keyPrefix><persistenceId>/<leaf>. That is the layout both built-in stores use —<pid>/<seq>.jsonfor snapshots,<pid>/state.jsonfor durable state. A key with an extra level is refused rather than read as its first segment: apersistenceIdcontaining/, or akeyPrefixshorter than the store’s ownprefix, would otherwise yield a plausible but wrong salt for the whole corpus. A different layout supplies its ownpidFromKey.
Set keyPrefix to the store’s prefix exactly. Sweeping
'snapshots/' when the store writes under 'snapshots/prod/' is
the second case above, and the sweep refuses it.
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}That equation leaves out skippedMalformedKey on purpose: a
returning sweep always has it at zero. A pass that met such a key
finishes rotating everything else and then throws
ReEncryptIncompleteError instead of returning, because those
objects are still under the key you were about to retire. The error
carries the full result plus a sample of the offending keys.
import { ReEncryptIncompleteError } from 'actor-ts/persistence';
try { await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info });} catch (thrown) { if (thrown instanceof ReEncryptIncompleteError) { console.error(thrown.message); // names the keys, says not to drop the key console.error(thrown.malformedKeys); // a bounded sample console.error(thrown.result.rewrote); // what did get rotated } throw thrown;}Fix those keys, or — if they are not this framework’s objects —
exclude them with the skip predicate above and re-run. 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.
