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';
// 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';
// `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`);

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.

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.

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.