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/persistence';
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/persistence';
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/persistence';
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.
Einen integritätsgeschützten Korpus sweepen
Abschnitt betitelt „Einen integritätsgeschützten Korpus sweepen“Ein Bucket mit aktiviertem Integritäts-HMAC braucht diesen Schlüssel auch hier. Das Tag deckt die Manifest-Bytes ab; der Sweep muss es also prüfen, um einen Body überhaupt zu lesen, und neu berechnen, um einen zurückzuschreiben:
await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1', integrity: { mode: 'hmac-sha256', integrityKey },});integrity nimmt dieselbe Form an wie der Store — eine flache
Config oder denselben Resolver pro persistenceId —, sodass ein
Deployment mit Integrität pro Tenant hier denselben Schlüssel
auflöst wie sein Store.
Lässt du es bei einem getaggten Korpus weg, verweigert der Sweep
den Start vor dem ersten Write und nennt einen der betroffenen
Keys. Tags werden nur dort neu gesetzt, wo der Body schon eines
trug — ein ungetaggter Body wird also nie befördert; ein Korpus
mitten in der Integritätsmigration ergänzt allowUntaggedBodies: true, das Sweep-seitige Gegenstück zur gleichnamigen
Store-Option.
Den Integritätsschlüssel selbst rotiert der Sweep nicht, und es
gibt auch kein Migrationsfenster, das es dir erlaubte. Auf der
Leitung ist nirgends festgehalten, welcher Integritätsschlüssel einen
Body versiegelt hat; ein halb gesweepter Korpus wäre also nicht von
einem manipulierten zu unterscheiden — und allowUntaggedBodies ist
nicht der Weg daran vorbei, denn es lässt Bodies wieder zu, die gar
kein Tag tragen, während ein unter dem alten Schlüssel getaggter Body
weiterhin am HMAC-Vergleich scheitert, bevor dieser Zweig erreicht
wird. Was funktioniert, ist das integrity-Override pro Aufruf,
unabhängig für Lesen und Schreiben, eine persistenceId nach der
anderen: siehe Integrität und
Master-Key-Rotation
sowie #1354 für
die Form, die ein echter Roll bräuchte.
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 Objekte, die er
umschreiben würde, 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. Derselbe Durchgang beantwortet die Integritätsfrage:
Ein gesampeltes Objekt mit Tag, für das integrity keinen
Schlüssel auflöst, stoppt den Sweep hier statt mitten im Korpus.
Mit verifyKeyringCompleteness: false abschalten — nur, wenn du
unabhängig sicher bist, dass der Keyring vollständig ist.
Beide Hälften sampeln, statt aufzuzählen; beide sind also ein
Fallstrick-Fänger und kein Beweis — ein Nachzügler jenseits von
sampleSize bricht den Sweep weiterhin mitten im Korpus ab.
Welche Keys der Sweep verarbeiten kann
Abschnitt betitelt „Welche Keys der Sweep verarbeiten kann“Der Sweep leitet sein HKDF-Salt aus dem Key des Objekts ab und schreibt den Body anschließend neu — ein Key, den er nicht verlässlich lesen kann, ist also ein Body, den er nicht anfassen darf. Daraus folgen zwei Regeln, beide werden erzwungen:
- Keine Control-Characters. Jedes Object-Storage-Backend weist
sie bei
putzurück; ein Key, den dieses Framework schreibt, ist damit immer ein Key, den der Sweep akzeptiert. Lesen und Löschen bleiben permissiv, sodass ein von einer älteren Version geschriebenes Objekt weiterhin erreichbar ist. - Exakt
<keyPrefix><persistenceId>/<leaf>. Das ist das Layout beider eingebauter Stores —<pid>/<seq>.jsonfür Snapshots,<pid>/state.jsonfür Durable State. Ein Key mit einer zusätzlichen Ebene wird verweigert statt auf sein erstes Segment reduziert: einepersistenceIdmit/darin oder einkeyPrefix, der kürzer ist als derprefixdes Stores, ergäbe sonst ein plausibles, aber falsches Salt für den gesamten Korpus. Ein abweichendes Layout bringt sein eigenespidFromKeymit.
Setze keyPrefix exakt auf den prefix des Stores. 'snapshots/'
zu sweepen, während der Store unter 'snapshots/prod/' schreibt,
ist genau der zweite Fall — und der Sweep verweigert ihn.
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}Diese Gleichung lässt skippedMalformedKey bewusst weg: ein
zurückkehrender Sweep hat den Zähler immer auf null. Ein Lauf, der
auf so einen Key trifft, rotiert alles Übrige fertig und wirft dann
ReEncryptIncompleteError, statt zurückzukehren — denn diese Objekte
liegen noch unter dem Schlüssel, den du gleich ausmustern wolltest.
Der Fehler trägt das vollständige result plus eine Stichprobe der
betroffenen Keys.
import { ReEncryptIncompleteError } from 'actor-ts/persistence';
try { await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info });} catch (thrown) { if (thrown instanceof ReEncryptIncompleteError) { console.error(thrown.message); // names the keys, says not to drop the key console.error(thrown.malformedKeys); // a bounded sample console.error(thrown.result.rewrote); // what did get rotated } throw thrown;}Korrigiere diese Keys — oder schließe sie, falls es gar keine
Objekte dieses Frameworks sind, mit dem skip-Prädikat oben aus —
und lass den Sweep erneut laufen. 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.
