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';
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';
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';
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.

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 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.

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.

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.