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).
Der Keyring
Abschnitt betitelt „Der Keyring“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';
const keyring: MasterKeyRing = { active: { version: 2, key: newKey }, // neue Writes nutzen diesen retired: [{ version: 1, key: oldKey }], // entschlüsselt weiterhin alte Payloads};Jede Version kommt genau einmal vor
Abschnitt betitelt „Jede Version kommt genau einmal vor“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 1const 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.
255 ist kein Lebenszeit-Budget
Abschnitt betitelt „255 ist kein Lebenszeit-Budget“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.
Der Sweep
Abschnitt betitelt „Der Sweep“import { reEncryptObjectStorage } from 'actor-ts';
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:
- Liest es; ist es bereits auf der active-Version, wird es übersprungen (der idempotente Fast-Path — kein PUT).
- Andernfalls entschlüsselt er mit dem passenden
retired-Key und schreibt unter demactive-Key neu.
Er gibt ein ReEncryptResult zurück: { scanned, rewrote, skippedCurrent, skippedUnencrypted, skippedNonAts1 }.
Idempotent + fortsetzbar
Abschnitt betitelt „Idempotent + fortsetzbar“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';
await reEncryptObjectStorage(backend, { keyPrefix: 'snapshots/', keyring, info: 'acme/prod/snapshot/v1', progress: new InMemoryReEncryptProgressStore(), // oder ein dauerhafter Store});HKDF-Kontext rotieren
Abschnitt betitelt „HKDF-Kontext rotieren“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.
Teilrotation mit skip
Abschnitt betitelt „Teilrotation mit skip“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.
Keyring-Vollständigkeits-Vorabprüfung
Abschnitt betitelt „Keyring-Vollständigkeits-Vorabprüfung“Standardmäßig sampelt der Sweep die ersten verschlüsselten
Objekte 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. Mit verifyKeyringCompleteness: false abschalten —
nur, wenn du unabhängig sicher bist, dass der Keyring vollständig
ist.
Abschluss bestätigen
Abschnitt betitelt „Abschluss bestätigen“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}Es gibt keinen separaten „Stragglers auflisten”-Helper; die Zähler sind die Wahrheit.
Nach dem Sweep
Abschnitt betitelt „Nach dem Sweep“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.
Wie geht es weiter
Abschnitt betitelt „Wie geht es weiter“- Object-Storage im Überblick — das große Ganze.
- Verschlüsselung — das At-Rest-Verschlüsselungs-Setup.
- Master-Key-Rotation (Ops) — der Operations-seitige Durchlauf.
