Master-Key-Rotation
Für At-rest-Verschlüsselung
(Object-Storage-Verschlüsselung,
Durable-DD-Verschlüsselung) leben die Schlüssel des Frameworks in
einem MasterKeyRing — einem versionierten Satz von Schlüsseln:
ein active-Schlüssel plus beliebig viele retired-Schlüssel,
jeweils mit einer numerischen Version (ein einzelnes Byte, 0-255)
getaggt. Rotation ist online: Neue Writes laufen unter dem
active-Schlüssel; alte Reads funktionieren weiter unter der Version,
mit der sie verschlüsselt wurden; ein Hintergrund-Sweep verschlüsselt
ältere Daten irgendwann neu.
import type { MasterKeyRing } from 'actor-ts/persistence';
// Jeder Schlüssel ist 32 rohe Bytes (AES-256) — dekodiert aus einer// base64-Env-Var, einem gemounteten Secret oder einem KMS-Unwrap// (siehe "Speicherung der Master-Keys" unten).const keyRing: MasterKeyRing = { active: { version: 2, key: Buffer.from(process.env.MASTER_KEY_V2!, 'base64') }, // neu — aktuell retired: [{ version: 1, key: Buffer.from(process.env.MASTER_KEY_V1!, 'base64') }],};MasterKeyRing ist ein Typ, keine Klasse — du baust das obige
einfache Objekt; es gibt nichts zu new. active ist der Schlüssel,
der für neue Writes genutzt wird. Reads dispatchen anhand des
Version-Bytes im Manifest jedes Blobs und matchen es gegen active
oder einen der retired-Einträge.
Weil dieses Byte der einzige Anhaltspunkt des Lesers ist, braucht jeder Eintrag seine eigene Versionsnummer. Ein Ring mit derselben Version auf zwei Einträgen wird abgelehnt — bei der Registrierung, am Store und beim Start eines Sweeps — statt über die Lookup-Reihenfolge aufgelöst zu werden. Siehe jede Version kommt genau einmal vor.
Das Ein-Byte-Feld begrenzt, wie viele Versionen gleichzeitig
leben dürfen, nicht wie oft du rotieren darfst: sobald ein Sweep
den ganzen Bestand auf die active-Version gezogen hat, fallen die
retired-Einträge weg und ihre Nummern werden wieder nutzbar. Ab
Version 240 warnt die Registrierung, damit dafür noch Zeit bleibt.
Wann rotieren
Abschnitt betitelt „Wann rotieren“Drei Auslöser:
- Geplante Rotation — eine Security-Policy (alle 90 Tage, jährlich).
- Vermutete Kompromittierung — geleaktes Schlüsselmaterial; sofort rotieren.
- Compliance — regulatorische Anforderungen schreiben periodische Rotation vor.
Auch ohne spezifischen Auslöser ist periodische Rotation gute Praxis — begrenzt den Blast Radius eines unentdeckten Leaks.
Der Rotations-Ablauf
Abschnitt betitelt „Der Rotations-Ablauf“ 1. Frischen Schlüssel mit der nächsten Versionsnummer erzeugen. Zu keyRing.retired hinzufügen; NOCH NICHT zu active promoten. 2. Den aktualisierten Ring auf allen Nodes ausrollen. Verifizieren, dass Reads funktionieren — alte Daten entschlüsseln weiter, Writes nutzen weiter den aktuellen active-Schlüssel. 3. Den frischen Schlüssel zu active promoten (den bisherigen active nach retired verschieben). Ausrollen. Neue Writes nutzen den neuen Schlüssel; alte Daten weiter lesbar. 4. Den Re-Encryption-Sweep laufen lassen. Alte Daten werden gelesen, entschlüsselt, unter dem active-Schlüssel neu verschlüsselt. 5. Sobald der Sweep durch ist, den alten Schlüssel aus retired droppen. Ein Rollback-Fenster von ~7 Tagen halten, in dem der alte Schlüssel noch verfügbar ist; danach droppen.Das Framework unterstützt jeden dieser Schritte ohne Downtime.
Schritt 1 — den neuen Schlüssel hinzufügen
Abschnitt betitelt „Schritt 1 — den neuen Schlüssel hinzufügen“const keyRing: MasterKeyRing = { active: { version: 1, key: Buffer.from(process.env.MASTER_KEY_V1!, 'base64') }, // weiter v1 — active unverändert retired: [{ version: 2, key: Buffer.from(process.env.MASTER_KEY_V2!, 'base64') }], // v2 zum Ring hinzugefügt, noch nicht active};Diese Config auf jeden Node ausrollen. Reads von v1-verschlüsselten Daten funktionieren weiter; Writes nutzen weiter v1 — aber jeder Node hält jetzt v2 und kann darunter entschlüsseln.
Dieser Schritt ist sicher und umkehrbar — wenn v2 doch nicht
gebraucht wird, einfach aus retired entfernen.
Schritt 2 — den neuen Schlüssel promoten
Abschnitt betitelt „Schritt 2 — den neuen Schlüssel promoten“const keyRing: MasterKeyRing = { active: { version: 2, key: Buffer.from(process.env.MASTER_KEY_V2!, 'base64') }, // ← jetzt v2 retired: [{ version: 1, key: Buffer.from(process.env.MASTER_KEY_V1!, 'base64') }],};Ausrollen. Neue Writes laufen unter v2. Reads konsultieren den Ring und finden den richtigen Schlüssel (v1 oder v2) anhand des Version-Bytes im Manifest des gespeicherten Blobs.
Nach diesem Schritt sammeln sich neue Daten unter v2 an, sobald der Workload schreibt. Alte Daten bleiben unter v1, bis sie neu verschlüsselt werden.
Schritt 3 — Re-Encryption-Sweep
Abschnitt betitelt „Schritt 3 — Re-Encryption-Sweep“import { reEncryptObjectStorage } from 'actor-ts/persistence';
// `backend` ist das ObjectStorageBackend, durch das dein Store// schreibt (ein FilesystemObjectStorageBackend, S3ObjectStorageBackend, …).const result = await reEncryptObjectStorage(backend, { keyPrefix: 'snapshots/', // welche Objekte gesweept werden keyring: keyRing, // active = v2, retired = [v1] info: 'acme/prod/snapshot/v1', // der HKDF-Kontext des Stores});
console.log(`re-encrypted ${result.rewrote} of ${result.scanned} objects`);console.log(`skipped, still under the old key: ${result.skippedMalformedKey}`);keyPrefix muss exakt der prefix des Stores sein. Ein kürzerer
verschiebt bei jedem Key das persistenceId-Segment um eine Ebene,
und der Sweep verweigert dann lieber den gesamten Korpus, als auf
dem falschen Segment zu salzen.
info ist Pflicht und muss exakt der Wert sein, den der
schreibende Store verwendet. Es ist der HKDF-Kontext, also die
Hälfte dessen, woraus der Subkey abgeleitet wird — ein falscher
Wert lässt jede Entschlüsselung fehlschlagen, statt still
falsche Ausgaben zu erzeugen.
Der Sweep listet jedes Objekt unter keyPrefix und entschlüsselt +
verschlüsselt jedes, das noch nicht auf der active-Version des Rings
ist, unter active neu. Objekte, die bereits aktuell sind, werden
ohne Write übersprungen.
Nützliche Optionen:
skip— ein(key) => boolean-Prädikat; matchende Keys bleiben unangetastet (Nicht-Body-Objekte ausschließen oder eine partielle Rotation eingrenzen).onProgress— Per-Objekt-Callback fürs Logging oder ein Operator-Dashboard bei langen Sweeps.progress— einReEncryptProgressStore(z. B.InMemoryReEncryptProgressStore) für Crash-Resume sehr großer Sweeps.verifyKeyringCompleteness— standardmäßig an: sampelt einige Blobs und verweigert den Start, wenn eines eine Version referenziert, die im Ring fehlt.newInfo— rotiert den HKDF-Kontext statt (oder zusätzlich zu) dem Schlüssel; siehe HKDF-Kontext rotieren.integrity— die Integritätskonfiguration, unter der der Korpus geschrieben wurde, in derselben Form, die auch der Store nimmt. Pflicht bei einem Bucket mit aktiviertem Integritäts-HMAC: Das Tag deckt die Manifest-Bytes ab, der Sweep prüft es beim Lesen und berechnet es beim Schreiben neu — ohne den Schlüssel verweigert er vor dem ersten Write, statt mitten im Korpus abzubrechen. Siehe Einen integritätsgeschützten Korpus sweepen.allowUntaggedBodies— lässt ungetaggte Bodies zu, solangeintegritygesetzt ist, für ein Bucket mitten in der Umstellung auf Integrität. Standardmäßig aus, und das mit Absicht: Von der Position des Sweeps aus sehen ein nie geschriebenes und ein entferntes Tag gleich aus.
Der Sweep ist idempotent + wiederaufnehmbar — ein Objekt, das
bereits auf der active-Version ist, wird ohne Write übersprungen,
sodass ein erneuter Lauf nach einer Unterbrechung sicher ist.
Standardmäßig re-listet und re-checkt ein wiederaufgenommener Lauf
jeden Key; übergib einen progress-Store, um direkt an den bereits
erledigten Objekten vorbeizuspringen.
Ein zurückkehrender Sweep ist das Zertifikat
Abschnitt betitelt „Ein zurückkehrender Sweep ist das Zertifikat“Der Sweep gibt kein Ergebnis zurück, das du erst auditieren musst.
Konnte der Key eines Objekts keine brauchbare persistenceId
liefern, rotiert der Lauf alles Übrige fertig und wirft dann
ReEncryptIncompleteError — denn jedes dieser Objekte liegt noch
unter genau dem Schlüssel, den Schritt 4 gleich droppen will, und
ein Zähler, an dessen Lektüre man sich erinnern muss, ist keine
Absicherung.
import { ReEncryptIncompleteError } from 'actor-ts/persistence';
try { const result = await reEncryptObjectStorage(backend, { /* … */ }); // Reaching here means skippedMalformedKey === 0. Step 4 is cleared.} catch (thrown) { if (thrown instanceof ReEncryptIncompleteError) { console.error(thrown.malformedKeys); // a bounded sample of the offenders console.error(thrown.result); // what the pass did manage to rotate } throw thrown; // do NOT proceed to Step 4}Landest du in diesem catch, hat das eine von drei Ursachen: ein
out-of-band geschriebener Key, ein Key aus einer Version vor der
Einführung der Control-Character-Prüfung auf put, oder ein
keyPrefix, der nicht zum prefix des Stores passt. Korrigiere die
Keys — oder schließe wirklich fremde Objekte mit skip aus — und
lass den Sweep erneut laufen.
Schritt 4 — den alten Schlüssel ausmustern
Abschnitt betitelt „Schritt 4 — den alten Schlüssel ausmustern“Vorbedingung: der Sweep aus Schritt 3 ist zurückgekehrt. Er
wirft, statt zurückzukehren, solange noch ein Objekt unter dem
ausgemusterten Schlüssel liegt — nur ein regulär beendeter Sweep
gibt diesen Schritt also frei. Prüfst du stattdessen ein
Ergebnisobjekt, ist das Feld skippedMalformedKey, und es muss 0
sein.
Nachdem der Sweep durch ist (jedes Item unter v2 verschlüsselt):
const keyRing: MasterKeyRing = { active: { version: 2, key: Buffer.from(process.env.MASTER_KEY_V2!, 'base64') }, // retired gedroppt — v1 ist weg};v1 komplett droppen. Daten, die weiter unter v1 verschlüsselt sind (z. B. Backups, die nicht neu verschlüsselt wurden), sind jetzt nicht mehr lesbar.
Warte ein Rollback-Fenster vor dem Droppen. ~7 Tage lassen dich davon erholen, falls “ach nein, der Sweep hat doch nicht alle Backups erfasst”. Nach bestätigter Migration v1 droppen.
Speicherung der Master-Keys
Abschnitt betitelt „Speicherung der Master-Keys“Der keyRing liefert kein Key-Storage-Backend mit. Übliche Muster:
| Quelle | Muster |
|---|---|
| Env-Vars | process.env.MASTER_KEY_V2 — einfachste Variante, fein für Tests. |
| K8s-Secrets | Als Files gemountet; beim Start gelesen. |
| HashiCorp Vault | Dynamisch beim Start ziehen; periodisch erneuern. |
| AWS KMS / GCP KMS / Azure Key Vault | Cloud-KMS-APIs. Decrypt-on-Load via die KMS-Encryption-Keys. |
Für Produktion ist KMS die richtige Antwort — Schlüssel verlassen die sichere Grenze nie im Klartext.
Multi-Cluster-Aspekte
Abschnitt betitelt „Multi-Cluster-Aspekte“Wenn mehrere Cluster denselben verschlüsselten Store teilen (z. B. eine DR-Replica, die die Backups der Primary liest), brauchen alle Cluster denselben keyRing. Gemeinsam rotieren; nicht zulassen, dass ein Cluster bei Schlüssel-Generationen zurückfällt.
Failure-Modes
Abschnitt betitelt „Failure-Modes“Wohin als nächstes
Abschnitt betitelt „Wohin als nächstes“- Object-Storage-Verschlüsselung — was die Master-Keys nutzt.
- Object-Storage-Key-Rotation — die Storage-seitigen Mechaniken.
- Cluster-Sicherheit — das In-Transit-Pendant zur At-rest-Verschlüsselung.
- Operations-Überblick — vollständige Security-Checkliste.
