Zum Inhalt springen
Deutsch

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.

Drei Auslöser:

  1. Geplante Rotation — eine Security-Policy (alle 90 Tage, jährlich).
  2. Vermutete Kompromittierung — geleaktes Schlüsselmaterial; sofort rotieren.
  3. Compliance — regulatorische Anforderungen schreiben periodische Rotation vor.

Auch ohne spezifischen Auslöser ist periodische Rotation gute Praxis — begrenzt den Blast Radius eines unentdeckten Leaks.

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.

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.

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.

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 — ein ReEncryptProgressStore (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, solange integrity gesetzt 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.

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.

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.

Der keyRing liefert kein Key-Storage-Backend mit. Übliche Muster:

QuelleMuster
Env-Varsprocess.env.MASTER_KEY_V2 — einfachste Variante, fein für Tests.
K8s-SecretsAls Files gemountet; beim Start gelesen.
HashiCorp VaultDynamisch beim Start ziehen; periodisch erneuern.
AWS KMS / GCP KMS / Azure Key VaultCloud-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.

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.