Zum Inhalt springen
Deutsch

Schlüsselrotation

Für Object-Storage-Verschlüsselung muss der Master-Key regelmäßig rotieren (Security-Policy, Kompromittierung, Compliance). Das Framework unterstützt Online-Rotation — null Downtime, alte Daten durchgängig lesbar.

Der Ablauf:

1. Neuen Key erzeugen. Als `active` in den Keyring; den alten unter `retired` behalten.
2. Ausrollen. Neue Writes verschlüsseln unter `active`; alte Payloads entschlüsseln via `retired`.
3. Re-Encryption-Sweep laufen lassen. Alte Payloads → der aktive Key.
4. Rollback-Fenster abwarten (~7 Tage) — Backups referenzieren evtl. noch den alten Key.
5. Den `retired`-Eintrag aus dem Keyring entfernen. Cleanup.

Diese Seite behandelt die Store-seitige Mechanik; den operativen Durchlauf siehe Master-Key-Rotation (Operations).

Verschlüsselungs-Keys leben in einem MasterKeyRing — ein active-Key für neue Writes plus alle retired-Keys, die noch zum Entschlüsseln älterer Payloads gebraucht werden. Jeder Eintrag paart eine numerische version (im Payload-Manifest eingebettet) mit dem 32-Byte-AES-256-Key:

import type { MasterKeyRing } from 'actor-ts/persistence';
const keyring: MasterKeyRing = {
active: { version: 2, key: newKey }, // neue Writes nutzen diesen
retired: [{ version: 1, key: oldKey }], // entschlüsselt weiterhin alte Payloads
};

Versionen laufen von 0 bis 255 — ein Byte des Payload-Manifests trägt eine, und mehr hat ein Leser nicht, um den Key zu bestimmen. Zwei Einträge dürfen deshalb nie dieselbe Version beanspruchen. Ein Ring, der das verletzt, wird abgelehnt: bei der Plugin-Registrierung, am Store und beim Start eines Sweeps.

// abgelehnt: active und retired[0] beanspruchen beide Version 1
const ambiguous: MasterKeyRing = {
active: { version: 1, key: newKey },
retired: [{ version: 1, key: oldKey }],
};

Ohne die Prüfung löst der Lookup die Kollision über die Reihenfolge auf — active wird zuerst geprüft. Payloads, die unter dem älteren Key geschrieben wurden, werden dann mit dem neueren entschlüsselt und scheitern an einem Auth-Tag-Fehler, der die Ursache nicht nennt. Einen Key zu promoten, ohne ihn umzunummerieren, reicht dafür aus — bei der zweiten Rotation genauso wie bei der zweihundertsten.

Die 32-Byte-Key-Länge wird im selben Durchgang geprüft, aus demselben Grund: ein zu kurzer retired-Key fällt sonst erst auf, wenn irgendwann ein Payload dieser Version gelesen wird.

Die Zahl begrenzt, wie viele Versionen gleichzeitig in einem Bestand leben dürfen, nicht wie oft du rotieren darfst. Ein abgeschlossener Sweep bringt jedes Payload auf die active-Version; die retired-Einträge fallen danach weg und jede andere Nummer ist wieder frei.

Ab Version 240 loggt die Registrierung eine Warnung, die genau das sagt — Sweep über jeden Prefix laufen lassen, die retired-Einträge entfernen, bei 0 neu nummerieren. Ein breiteres Versionsfeld gibt es bewusst nicht: das Ein-Byte-Manifest bleibt, weil der Sweep die einzige Situation, in der ein breiteres helfen würde, ohnehin auflöst.

import { reEncryptObjectStorage } from 'actor-ts/persistence';
const result = await reEncryptObjectStorage(backend, {
keyPrefix: 'snapshots/', // welche Keys verarbeitet werden
keyring, // active + retired Keys
info: 'acme/prod/snapshot/v1', // der HKDF-Kontext des Stores
skip: (key) => key.endsWith('.manifest'), // optional: Keys ausschließen
onProgress: (e) => console.log(`${e.index}/${e.total} ${e.key}`),
});
console.log(`re-encrypted ${result.rewrote} of ${result.scanned}`);

info ist Pflicht und muss exakt dem EncryptionConfig.info des schreibenden Stores entsprechen — es ist die Hälfte der Subkey-Ableitung, eine Abweichung lässt jede Entschlüsselung im Sweep fehlschlagen.

Der Sweep listet jedes Objekt unter keyPrefix und für jedes:

  1. Liest es; ist es bereits auf der active-Version, wird es übersprungen (der idempotente Fast-Path — kein PUT).
  2. Andernfalls entschlüsselt er mit dem passenden retired-Key und schreibt unter dem active-Key neu.

Er gibt ein ReEncryptResult zurück: { scanned, rewrote, skippedCurrent, skippedUnencrypted, skippedNonAts1 }.

Ein erneuter Lauf ist sicher: ein Objekt, das bereits auf der active-Version ist, wird ohne Write übersprungen. Ein einfacher Re-Run listet und prüft jeden Key erneut (okay für kleine Buckets). Für Millionen-Objekt-Stores übergib einen Progress-Store, damit ein abgestürzter Sweep nahe der Abbruchstelle fortsetzt statt von vorn zu scannen:

import { InMemoryReEncryptProgressStore } from 'actor-ts/persistence';
await reEncryptObjectStorage(backend, {
keyPrefix: 'snapshots/',
keyring,
info: 'acme/prod/snapshot/v1',
progress: new InMemoryReEncryptProgressStore(), // oder ein dauerhafter Store
});

Der Master-Key ist ein Eingang der Subkey-Ableitung, der HKDF-Kontext info ist der andere. Ihn zu rotieren — etwa ein gemeinsames 'actor-ts/snapshot/v1' in Kontexte pro Umgebung aufzuteilen — läuft über denselben Sweep mit newInfo: Bodies werden unter info entschlüsselt und unter newInfo neu geschrieben.

await reEncryptObjectStorage(backend, {
keyPrefix: 'snapshots/',
keyring,
info: 'actor-ts/snapshot/v1', // worunter der Bestand geschrieben wurde
newInfo: 'acme/prod/snapshot/v1', // worunter er ab jetzt liegen soll
});

Beide Achsen sind unabhängig: rotiere den Key, den Kontext oder beides in einem Durchlauf. Roll das neue info in der EncryptionConfig der Anwendung erst nach dem Sweep aus — bis dahin liegt der Bestand noch unter dem alten Kontext.

Eine Konsequenz solltest du vorher kennen. Die Schlüsselversion steht im Body-Manifest, der Kontext nicht — der Sweep kann einen umgeschriebenen Body also nicht am Header von einem noch offenen unterscheiden. Solange newInfo von info abweicht, ist der Versions-Fast-Path deshalb abgeschaltet und jedes Objekt wird entschlüsselt, um das herauszufinden: der Sweep kostet einen vollen Lesedurchlauf über den Prefix statt eines Header-Scans. (Ohne das würde eine reine Kontext-Rotation jedes Objekt als skipped-current melden und nichts ändern.)

Ein erneuter Lauf bleibt sicher: ein Objekt, das sich unter info nicht entschlüsseln lässt, wird unter newInfo erneut versucht; gelingt das, zählt es als skippedCurrent. Lässt es sich unter keinem von beiden entschlüsseln, wird der ursprüngliche Entschlüsselungsfehler geworfen — ein wirklich defekter Bestand wird also nicht stillschweigend übersprungen.

Ein Bucket mit aktiviertem Integritäts-HMAC braucht diesen Schlüssel auch hier. Das Tag deckt die Manifest-Bytes ab; der Sweep muss es also prüfen, um einen Body überhaupt zu lesen, und neu berechnen, um einen zurückzuschreiben:

await reEncryptObjectStorage(backend, {
keyPrefix: 'state/',
keyring,
info: 'acme/prod/durable-state/v1',
integrity: { mode: 'hmac-sha256', integrityKey },
});

integrity nimmt dieselbe Form an wie der Store — eine flache Config oder denselben Resolver pro persistenceId —, sodass ein Deployment mit Integrität pro Tenant hier denselben Schlüssel auflöst wie sein Store.

Lässt du es bei einem getaggten Korpus weg, verweigert der Sweep den Start vor dem ersten Write und nennt einen der betroffenen Keys. Tags werden nur dort neu gesetzt, wo der Body schon eines trug — ein ungetaggter Body wird also nie befördert; ein Korpus mitten in der Integritätsmigration ergänzt allowUntaggedBodies: true, das Sweep-seitige Gegenstück zur gleichnamigen Store-Option.

Den Integritätsschlüssel selbst rotiert der Sweep nicht, und es gibt auch kein Migrationsfenster, das es dir erlaubte. Auf der Leitung ist nirgends festgehalten, welcher Integritätsschlüssel einen Body versiegelt hat; ein halb gesweepter Korpus wäre also nicht von einem manipulierten zu unterscheiden — und allowUntaggedBodies ist nicht der Weg daran vorbei, denn es lässt Bodies wieder zu, die gar kein Tag tragen, während ein unter dem alten Schlüssel getaggter Body weiterhin am HMAC-Vergleich scheitert, bevor dieser Zweig erreicht wird. Was funktioniert, ist das integrity-Override pro Aufruf, unabhängig für Lesen und Schreiben, eine persistenceId nach der anderen: siehe Integrität und Master-Key-Rotation sowie #1354 für die Form, die ein echter Roll bräuchte.

skip(key) => boolean schließt passende Keys aus — die Umkehrung eines Filters. Verarbeite eine Teilmenge, indem du alles andere überspringst:

await reEncryptObjectStorage(backend, {
keyPrefix: 'state/',
keyring,
info: 'acme/prod/durable-state/v1',
skip: (key) => !key.startsWith('state/account-'), // nur account-*
});

Nützlich für Rotation pro Tenant, pro Actor-Typ oder in Phasen.

Standardmäßig sampelt der Sweep die ersten Objekte, die er umschreiben würde, und verweigert den Start, wenn die Key-Version eines Payloads im Keyring fehlt — das fängt den „Operator hat den retired-Key zu früh entfernt”-Fallstrick ab, bevor ein Mid-Sweep-Decrypt-Fehler den Korpus halb umgeschrieben zurücklässt. Derselbe Durchgang beantwortet die Integritätsfrage: Ein gesampeltes Objekt mit Tag, für das integrity keinen Schlüssel auflöst, stoppt den Sweep hier statt mitten im Korpus. Mit verifyKeyringCompleteness: false abschalten — nur, wenn du unabhängig sicher bist, dass der Keyring vollständig ist.

Beide Hälften sampeln, statt aufzuzählen; beide sind also ein Fallstrick-Fänger und kein Beweis — ein Nachzügler jenseits von sampleSize bricht den Sweep weiterhin mitten im Korpus ab.

Der Sweep leitet sein HKDF-Salt aus dem Key des Objekts ab und schreibt den Body anschließend neu — ein Key, den er nicht verlässlich lesen kann, ist also ein Body, den er nicht anfassen darf. Daraus folgen zwei Regeln, beide werden erzwungen:

  • Keine Control-Characters. Jedes Object-Storage-Backend weist sie bei put zurück; ein Key, den dieses Framework schreibt, ist damit immer ein Key, den der Sweep akzeptiert. Lesen und Löschen bleiben permissiv, sodass ein von einer älteren Version geschriebenes Objekt weiterhin erreichbar ist.
  • Exakt <keyPrefix><persistenceId>/<leaf>. Das ist das Layout beider eingebauter Stores — <pid>/<seq>.json für Snapshots, <pid>/state.json für Durable State. Ein Key mit einer zusätzlichen Ebene wird verweigert statt auf sein erstes Segment reduziert: eine persistenceId mit / darin oder ein keyPrefix, der kürzer ist als der prefix des Stores, ergäbe sonst ein plausibles, aber falsches Salt für den gesamten Korpus. Ein abweichendes Layout bringt sein eigenes pidFromKey mit.

Setze keyPrefix exakt auf den prefix des Stores. 'snapshots/' zu sweepen, während der Store unter 'snapshots/prod/' schreibt, ist genau der zweite Fall — und der Sweep verweigert ihn.

Lies die zurückgegebenen Zähler — ein vollständiger Sweep hat scanned === rewrote + skippedCurrent + skippedUnencrypted + skippedNonAts1, und ein zweiter Lauf meldet rewrote === 0:

const result = await reEncryptObjectStorage(backend, {
keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1',
});
if (result.rewrote === 0) {
// alles ist bereits auf dem active-Key — das Retiren des alten kann geplant werden
}

Diese Gleichung lässt skippedMalformedKey bewusst weg: ein zurückkehrender Sweep hat den Zähler immer auf null. Ein Lauf, der auf so einen Key trifft, rotiert alles Übrige fertig und wirft dann ReEncryptIncompleteError, statt zurückzukehren — denn diese Objekte liegen noch unter dem Schlüssel, den du gleich ausmustern wolltest. Der Fehler trägt das vollständige result plus eine Stichprobe der betroffenen 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;
}

Korrigiere diese Keys — oder schließe sie, falls es gar keine Objekte dieses Frameworks sind, mit dem skip-Prädikat oben aus — und lass den Sweep erneut laufen. Es gibt keinen separaten „Stragglers auflisten”-Helper; die Zähler sind die Wahrheit.

Sobald jedes Payload re-verschlüsselt ist und das Rollback-Fenster verstrichen ist, entferne den retired-Eintrag:

const keyringAfterRotation: MasterKeyRing = {
active: { version: 2, key: newKey }, // retired[] entfernt
};

Warte zuerst das Rollback-Fenster ab (typischerweise 7 Tage) — Backups referenzieren evtl. noch den alten Key.