Pular para o conteúdo
Português (BR)

Key rotation

Este conteúdo não está disponível em sua língua ainda.

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).

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
};

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 1
const 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.

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.

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:

  1. Reads it; if it’s already at the active version, skips it (the idempotent fast-path — no PUT).
  2. Otherwise decrypts it with the matching retired key and re-writes it under the active key.

It returns a ReEncryptResult: { scanned, rewrote, skippedCurrent, skippedUnencrypted, skippedNonAts1 }.

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
});

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.

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.

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.

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.

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>.json for snapshots, <pid>/state.json for durable state. A key with an extra level is refused rather than read as its first segment: a persistenceId containing /, or a keyPrefix shorter than the store’s own prefix, would otherwise yield a plausible but wrong salt for the whole corpus. A different layout supplies its own pidFromKey.

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.

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.

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.