コンテンツにスキップ
日本語

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

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

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';
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';
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.

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

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.

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.