Verschlüsselung
Object-Storage-Payloads unterstützen Client-Side-AES-GCM-Verschlüsselung — das Framework verschlüsselt vor dem Put, entschlüsselt beim Get, Schlüssel werden als einzelner Master-Key oder als versionierter Key-Ring für Rotation verwaltet.
import { ObjectStorageDurableStateStore, ObjectStorageDurableStateStoreOptions, S3ObjectStorageBackend, S3ObjectStorageOptions,} from 'actor-ts/persistence';
const masterKey = Buffer.from(process.env.MASTER_KEY_V1!, 'base64'); // 32 Bytes
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create() .withBackend(new S3ObjectStorageBackend(S3ObjectStorageOptions.create() /* .withRegion(...).withBucket(...) */)) .withEncryption({ mode: 'client-aes256-gcm', masterKey, info: 'acme/prod/durable-state/v1' });const store = new ObjectStorageDurableStateStore(objectStorageDurableStateStoreOptions);Jetzt wird jeder persistierte State vor dem Upload mit AES-GCM
verschlüsselt — unter einem Per-persistenceId-Subkey, der aus
dem Master-Key abgeleitet wird. Reads entschlüsseln transparent.
info ist Pflicht und deployment-spezifisch. Es ist keine
Dekoration — siehe info wählen weiter unten.
Warum Client-Side-Verschlüsselung
Abschnitt betitelt „Warum Client-Side-Verschlüsselung“Object Stores (S3, GCS, Azure Blob) bieten eingebaute Server-Side-Encryption. Warum auch Client-Side verschlüsseln?
| Bedrohung | Server-Side | Client-Side |
|---|---|---|
| Storage-Kompromittierung (jemand liest von der Disk) | ✓ | ✓ |
| Account-Kompromittierung — Lesen (jemand hat S3-Credentials) | ✗ | ✓ |
| Account-Kompromittierung — Schreiben (siehe unten) | ✗ | teilweise |
| Cloud-Provider-Kompromittierung | ✗ | ✓ |
| Audit / Compliance, die “wir halten die Schlüssel” verlangt | ✗ | ✓ |
Client-Side-Verschlüsselung schützt vor mehr Bedrohungen, kostet aber mehr (CPU pro Op, Schlüsselverwaltungs-Overhead). Die meisten Apps sollten beides verwenden: Server-Side als Baseline + Client-Side für sensible Payloads.
Was Verschlüsselung nicht abdeckt
Abschnitt betitelt „Was Verschlüsselung nicht abdeckt“Wer in den Bucket schreiben kann, ist ein anderer Angreifer als jemand, der ihn nur lesen kann — und Verschlüsselung beantwortet nur die Hälfte dessen, was er tun kann. Fälschen kann er keinen Body, dafür braucht es den Schlüssel; aber er kann die vorhandenen echten Bodies verschieben und erneut einspielen, und das Auth-Tag reist mit den Bytes mit.
Zwei Ausprägungen davon, beide seit #612 geschlossen und keine davon durch Verschlüsselung allein:
- Einen Body auf ein anderes Objekt einspielen. Der State einer
persistenceIdauf den Key einer anderen kopiert, oder ein Snapshot über eine spätere Sequenznummer. Der Storage-Key wird jetzt zusammen mit dem Body authentifiziert, die Kopie verifiziert also nicht mehr — siehe Einen Body an seinen Key binden. - Einen älteren Body über einen neueren einspielen. Gleicher Key, alles gleich, nur veraltet. Kein Authentifikator kann das sehen: Die Revision liegt innerhalb der authentifizierten Bytes. Der DurableState-Store führt stattdessen eine prozesslokale Untergrenze — siehe Rollback-Schutz.
Konfiguration
Abschnitt betitelt „Konfiguration“type EncryptionConfig = | { mode: 'none' } | { mode: 'sse-s3' } // serverseitig, S3-verwaltet | { mode: 'sse-kms'; kmsKeyId: string } // serverseitig, KMS-verwaltet | { mode: 'client-aes256-gcm'; masterKey: Uint8Array; info: string } | { mode: 'client-aes256-gcm'; masterKeys: MasterKeyRing; info: string };
type MasterKeyRing = { active: MasterKeyRingEntry; // neue Writes verschlüsseln hierunter retired?: MasterKeyRingEntry[]; // ältere Schlüssel, für Entschlüsselung behalten};type MasterKeyRingEntry = { version: number; // 0..255, im Body-Manifest eingebettet key: Uint8Array; // 32 Bytes (AES-256)};Client-Side-Verschlüsselung verwendet mode: 'client-aes256-gcm'
mit entweder einem einzelnen masterKey (32 Bytes) oder
einem masterKeys-Ring. Der Ring trägt:
active— den Schlüssel, unter dem neue Writes verschlüsseln.retired— ältere Schlüssel, behalten, um historische Blobs zu entschlüsseln.
Das version-Byte jedes Eintrags wird im Body-Manifest
mitgeführt, damit die Entschlüsselung den passenden Schlüssel
wählen kann. Den aktiven Schlüssel plus die retired Schlüssel
zu führen, ist das Fundament der
Schlüsselrotation.
info wählen
Abschnitt betitelt „info wählen“info ist der Kontext-Bindungs-Eingang von HKDF (RFC 5869
§3.2). Der Subkey eines Blobs wird aus drei Dingen abgeleitet:
dem Master-Key, der persistenceId (als HKDF-Salt) und info.
Ändert sich eines davon, entsteht ein völlig anderer Schlüssel.
Das Feld ist Pflicht, und es gibt bewusst keinen Default. Ein
gemeinsamer Default hätte bedeutet, dass zwei Deployments mit
demselben Master-Key für dieselbe persistenceId Byte für Byte
denselben Subkey ableiten — ein Staging-System, das aus einem
Produktions-Dump wiederhergestellt wurde, oder eine DR-Region
hätte damit Produktions-Blobs lesen können, ohne dass die
Konfiguration das irgendwo erwähnt. Das ist eine Entscheidung,
die nur der Betreiber treffen kann — also erzwingt das Framework
sie.
Kodiere Umgebung + Zweck + Version, das Spezifischste zuerst:
'acme/prod/snapshot/v1''acme/staging/snapshot/v1''acme/prod/durable-state/v1'- Verschiedene Umgebungen MÜSSEN sich unterscheiden, auch bei identischem Master-Key. Genau darum geht es.
- Verschiedene Payload-Arten SOLLTEN sich unterscheiden (Snapshots vs. Durable State), damit ein kompromittierter Ableitungskontext nicht auf den anderen übergreift.
- Eine angehängte Version gibt einer späteren Kontext-Rotation ein Ziel.
info steht nicht auf der Wire. Anders als die
Schlüsselversion hält kein Manifest-Byte fest, unter welchem
info ein Blob geschrieben wurde. Eine Änderung macht jeden
bestehenden Blob unlesbar, bis ein Sweep ihn neu schreibt — siehe
Kontext rotieren.
Wähle den Wert vor dem ersten Write.
Wie es funktioniert
Abschnitt betitelt „Wie es funktioniert“Beim Put: Wert serialisieren → komprimieren → Per-pid-Subkey ableiten (HKDF aus active key) → AES-GCM(bytes, subkey, iv) → Ciphertext → Body-Manifest "ATS1" { flags, keyVersion, iv, ciphertext } → S3.put(body) // kein Key-ID-Metadaten-Header
Beim Get: S3.get → Body-Manifest { flags, keyVersion, iv, ciphertext } → Master-Key nach keyVersion wählen (active oder ein retired-Eintrag) → Per-pid-Subkey ableiten (HKDF) → AES-GCM entschlüsseln → dekomprimieren → deserialisierenDie Schlüsselversion ist im Body-Manifest eingebettet — jeder Blob hält fest, unter welcher Schlüsselversion er verschlüsselt wurde. Das lässt das Framework alte Payloads mit dem richtigen Schlüssel entschlüsseln, auch nachdem der active Schlüssel rotiert wurde (retired Schlüssel bleiben im Ring).
Was es verschlüsselt
Abschnitt betitelt „Was es verschlüsselt“- Den Body — serialisierter State / Event / Snapshot.
- Nicht — den Object-Key, Object-Metadaten-Header, den Bucket-Namen.
Für Object-Metadaten, die nicht durchsickern sollten (sensible persistenceIds), verwende ein separates Naming-Schema (hashe IDs, bevor sie zu Object-Keys werden).
Schlüsselquellen
Abschnitt betitelt „Schlüsselquellen“Die Master-Key-Bytes kommen irgendwoher. Häufige Muster:
Env-Vars
Abschnitt betitelt „Env-Vars“const masterKey = Buffer.from(process.env.MASTER_KEY_V1!, 'base64'); // 32 Bytes// → .withEncryption({ mode: 'client-aes256-gcm', masterKey, info: 'acme/prod/snapshot/v1' })Am einfachsten. Jeder Schlüssel ist ein 32-Byte-Buffer (für AES-256-GCM), base64-kodiert in der Env.
Risiko: Env-Vars sind für alles sichtbar, das die Prozess-Umgebung lesen kann. Verwende nur, wenn die Env selbst gesichert ist (K8s-Secrets, etc.).
KMS-on-Load
Abschnitt betitelt „KMS-on-Load“import { KMS } from '@aws-sdk/client-kms';
const kms = new KMS();const decrypted = await kms.decrypt({ KeyId: 'alias/master', CiphertextBlob: Buffer.from(process.env.WRAPPED_KEY!, 'base64'),});
const masterKey = decrypted.Plaintext!; // 32 Bytes, im Speicher gehalten// → .withEncryption({ mode: 'client-aes256-gcm', masterKey, info: 'acme/prod/snapshot/v1' })Der Master-Key wird verschlüsselt unter einem Cloud-KMS-Key gespeichert. Die App holt ihn beim Start, entschlüsselt über KMS, hält ihn im Speicher.
Besser als rohe Env-Vars — nur KMS-Zugriff ist erforderlich, um Schlüssel wiederherzustellen.
HashiCorp Vault
Abschnitt betitelt „HashiCorp Vault“Ähnliches Muster: Master-Keys beim Start aus Vault ziehen.
CPU-Kosten
Abschnitt betitelt „CPU-Kosten“AES-GCM ist schnell — moderne CPUs haben Hardware-Unterstützung.
Pro 100 KB Verschlüsseln + Entschlüsseln:
- ~0,5-1 ms auf modernem x86 / Apple Silicon.
- Auf kleinen Objekten effektiv kostenlos.
Für die meisten Workloads ist Verschlüsselung in Profilen unsichtbar.
Verschlüsselung + Kompression
Abschnitt betitelt „Verschlüsselung + Kompression“verschlüsseln → komprimieren → S3.put # ✗ kein Kompressionsnutzen auf Ciphertextkomprimieren → verschlüsseln → S3.put # ✓ das macht das FrameworkDie Reihenfolge des Frameworks ist zuerst komprimieren, dann verschlüsseln — komprimierte Bytes sind immer noch komprimierbar (nicht zufällig); nach der Verschlüsselung sind sie effektiv zufällig und unkomprimierbar.
Wenn du sowohl Kompression als auch Verschlüsselung setzt, bekommst du diese Reihenfolge automatisch.
Alte Payloads lesen
Abschnitt betitelt „Alte Payloads lesen“Nachdem du Verschlüsselung auf einem zuvor unverschlüsselten Bucket aktiviert hast:
state/cart-42 ← alt, Plaintext (Encrypted-Flag im Manifest nicht gesetzt)state/cart-43 ← neu, verschlüsselt (Encrypted-Flag gesetzt, Schlüsselversion 0)Das Framework erkennt jedes Pro-Payload über das Body-Manifest:
- Encrypted-Flag nicht gesetzt → Plaintext-Pfad.
- Encrypted-Flag gesetzt → mit der Schlüsselversion entschlüsseln, die das Manifest festhält.
Bedeutet, du kannst Verschlüsselung schrittweise aktivieren — neue Writes werden verschlüsselt, alte Reads funktionieren immer noch, und ein Hintergrund-Re-Encryption-Sweep kann den Rest migrieren.
Siehe Schlüsselrotation für den Rotation-Flow.
Body-Integrität (HMAC)
Abschnitt betitelt „Body-Integrität (HMAC)“Verschlüsselung ist keine Integrität. Ein unverschlüsselter Body
ist schlichtes JSON in einem Bucket: Wer das Objekt schreiben kann,
kann es auch ändern — und beim ObjectStorageDurableStateStore
gehört dazu das Feld revision, auf dem der Compare-and-Swap-Check
aufsetzt.
Aktiviere den Schutz über eine integrity-Konfiguration. Ihr
Schlüssel ist 32 Bytes lang und vom Encryption-Master-Key getrennt,
denn die Bedrohung ist hier Manipulation, nicht Offenlegung:
const integrityKey = Buffer.from(process.env.INTEGRITY_KEY_V1!, 'base64'); // 32 bytes
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create() .withBackend(backend) .withIntegrity({ mode: 'hmac-sha256', integrityKey });const store = new ObjectStorageDurableStateStore(objectStorageDurableStateStoreOptions);Jeder Write hängt jetzt ein HMAC-SHA256-Tag (auf 16 Bytes gekürzt) über den gesamten gerahmten Body an — inklusive Manifest-Header, womit auch die Kompressions- und Verschlüsselungs-Flags abgedeckt sind — und jeder Read prüft es, bevor ein einziges Payload-Byte die Store-Schicht erreicht.
Snapshots brauchen es ebenso
Abschnitt betitelt „Snapshots brauchen es ebenso“ObjectStorageSnapshotStore nimmt dieselbe Option, und die
Ein-Aufruf-Verdrahtung reicht sie an beide Stores zugleich weiter:
const objectStoragePluginOptions = ObjectStoragePluginOptions.create() .withBackend({ kind: 's3', bucket: 'my-app', region: 'eu-central-1' }) .withIntegrity({ mode: 'hmac-sha256', integrityKey });const { durableStateStore } = await registerObjectStoragePlugins(ext, objectStoragePluginOptions);Ein Snapshot ist dabei wohl das lohnendere Ziel von beiden: Recovery
faltet Events auf ihn obendrauf, wer also einen umschreiben kann,
bestimmt den Zustand, mit dem ein Actor zurückkehrt. Replay begrenzt
die sequenceNr, die ein Snapshot behaupten darf — ein Snapshot vor
dem Journal wird abgelehnt —, aber sonst authentifiziert nichts das
state-Payload.
Das Tag ist Pflicht, nicht bloß geprüft
Abschnitt betitelt „Das Tag ist Pflicht, nicht bloß geprüft“Eine konfigurierte Integrität macht das Tag beim Lesen verbindlich. Ein Body, der ohne Tag ankommt, wird abgelehnt:
BodyCodec: body carries no integrity tag but an integrityKey wassupplied for decoding.Das ist der Sinn des Mechanismus, keine Härte am Rand. Das Bit, das „dieser Body hat ein Tag“ festhält, liegt im Body — wer das Objekt überschreiben kann, kann es also ebenso löschen und die 16 Tag-Bytes abschneiden. Hieße ein fehlendes Tag „Prüfung überspringen“, schützte die Prüfung niemanden: Ein Tag zu entfernen ist weit einfacher, als eines zu fälschen.
Ein Bucket von vor der Integrität migrieren
Abschnitt betitelt „Ein Bucket von vor der Integrität migrieren“Bodies, die vor der Aktivierung geschrieben wurden, tragen kein Tag und fallen damit unter dieselbe Regel. Öffne das Migrationsfenster ausdrücklich:
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create() .withBackend(backend) .withIntegrity({ mode: 'hmac-sha256', integrityKey }) .withAllowUntaggedBodies(true);Schreibe anschließend jedes Objekt neu — ein load gefolgt von einem
upsert pro persistenceId rahmt es mit Tag — und entferne die
Option wieder. Solange sie gesetzt ist, wird ein Body ohne Tag von
jedem akzeptiert; ein Body mit Tag wird weiterhin geprüft, aber der
Downgrade-Weg steht so lange offen wie das Fenster.
Snapshots migrieren von selbst: keepN räumt die ungetaggten weg,
während neue getaggte Snapshots entstehen — das Fenster lässt sich also
meist nach keepN Saves pro persistenceId schließen statt nach einem
eigenen Sweep.
Integrität und Master-Key-Rotation
Abschnitt betitelt „Integrität und Master-Key-Rotation“Die beiden greifen ineinander. Übergib dem Sweep dieselbe
Integritätskonfiguration, die auch der Store hat — eine flache Config
oder genau denselben Resolver pro persistenceId —, und jedes Tag wird
beim Lesen geprüft und beim Schreiben neu berechnet:
await reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring, info: 'acme/prod/durable-state/v1', integrity: { mode: 'hmac-sha256', integrityKey },});Lässt du integrity bei einem getaggten Korpus weg, verweigert der
Sweep den Start, bevor er irgendetwas anfasst, und nennt einen der
betroffenen Keys: Ohne den Schlüssel kann er einen getaggten Body weder
lesen noch zurückschreiben, und das Objekt für Objekt herauszufinden
hinterließe einen halb rotierten Korpus
(#739).
Zwei Dinge tut der Sweep bewusst nicht:
-
Er versieht keinen ungetaggten Body nachträglich mit einem Tag. Er versiegelt nur neu, was schon eines trug. Den Rest zu befördern machte jedes neu geschriebene Objekt für jeden Leser unlesbar, dem du den Integritätsschlüssel noch nicht gegeben hast — und eine Rotation läuft, während die Anwendung bedient. Integrität korpusweit zu aktivieren ist die Migration oben, kein Nebeneffekt einer Schlüsselrotation.
-
Er rotiert den Integritätsschlüssel selbst nicht, und es gibt kein Fenster, das es erlauben würde. Für den Integritätsschlüssel gibt es kein Versions-Byte, ein halb gesweepter Korpus wäre also nicht von einem manipulierten zu unterscheiden — und
allowUntaggedBodieshilft nicht, denn es lässt Bodies ohne Tag wieder zu. Ein unter dem alten Schlüssel getaggter Body trägt weiterhinFLAG_INTEGRITY_HMACund scheitert am Vergleich, bevor dieser Zweig erreicht wird — mit neuem Schlüssel wie ohne. Der Sweep ist hier fail-closed statt still halb zu rollen, was die richtige Richtung ist, aber es heißt: einen sweep-förmigen Weg, den Schlüssel zu wechseln, gibt es nicht.Was funktioniert, ist die Per-Call-Überschreibung
PersistenceOptions.integrity, lesend und schreibend getrennt:load(pid, { integrity: alt }), dannupsert(pid, revision, state, { integrity: neu }). Sie wirkt propersistenceId, hebt über das CAS die Revision jeder Entity an und hat kein Snapshot-Gegenstück. #1354 trägt die Form, die eine echte Rotation bräuchte.
Einen Korpus zu sweepen, der mitten in der Integritätsmigration steckt
— manche Bodies getaggt, manche nicht —, braucht allowUntaggedBodies: true auch am Sweep, aus demselben Grund wie am Store. Ohne das hält
der Sweep beim ersten ungetaggten Body an, statt anzunehmen, das Tag
habe nie existiert.
Einen Body an seinen Key binden
Abschnitt betitelt „Einen Body an seinen Key binden“Ein Tag beantwortet „hat der Inhaber des Schlüssels diese Bytes erzeugt?“ und sonst nichts. Es beantwortet nicht „wurden sie für dieses Objekt erzeugt?“ — ein echter Body, der auf einen anderen Storage-Key verschoben wurde, verifizierte dort also genauso.
Am schwersten wog das in der reinen HMAC-Konfiguration. integrityKey
ist ein einziges flaches Geheimnis für das gesamte Deployment, ohne
jede Ableitung pro persistenceId, und load liefert die
angefragte persistenceId mit dem State des Bodys zurück. Das
Objekt eines Kontos auf den Key eines anderen kopiert kam also als der
State dieses anderen Kontos zurück. Client-Side-Verschlüsselung
verengte das, ohne es zu schließen: HKDF salzt den Subkey mit der
persistenceId, was zwei pids trennt, aber nicht zwei Objekte einer
pid — ein Snapshot ließ sich also weiterhin auf eine andere
Sequenznummer desselben Actors einspielen.
Jeder Write bindet jetzt den Storage-Key ein. Er geht bei einem verschlüsselten Body in die Additional Authenticated Data von AES-GCM und bei einem getaggten, längenpräfigiert, in die HMAC-Eingabe; ein Manifest-Bit hält fest, dass es geschehen ist. Nichts zu konfigurieren — beide Object-Storage-Stores kennen den Key, auf den sie schreiben.
Die Bindung verlangen
Abschnitt betitelt „Die Bindung verlangen“Bodies, die vor dieser Änderung geschrieben wurden, tragen keine Bindung und werden weiterhin dekodiert: Ein alter Bucket funktioniert weiter. Genau das ist auch die Lücke, denn das Bit, das „dieser Body ist gebunden“ sagt, ist ein Manifest-Byte wie jedes andere — ein einziger echter Body von vor der Bindung bleibt also ein Replay-Token für jeden Key im Bucket, solange ungebundene Bodies akzeptiert werden:
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create() .withBackend(backend) .withIntegrity({ mode: 'hmac-sha256', integrityKey }) .withRequireContextBinding();Schreibe den Korpus zuerst neu, sonst beginnen die Reads zu scheitern.
Für DurableState ist das ein load + upsert pro persistenceId;
Snapshots kommen über das keepN-Pruning dorthin; ein Bucket mitten in
einer Rotation über reEncryptObjectStorage, das jeden passierten Body
neu bindet und einen nicht mehr allein deshalb überspringt, weil seine
Schlüsselversion aktuell ist. Das schließt die Konfiguration aus HMAC
ohne Verschlüsselung ein, in der es gar keinen Master-Key zu rotieren
gibt: Ein solcher Body wird einmal für seinen Key neu gerahmt und bei
jedem späteren Durchlauf in Ruhe gelassen.
Rollback-Schutz (Durable State)
Abschnitt betitelt „Rollback-Schutz (Durable State)“Ein Replay bleibt für jeden Authentifikator unsichtbar: derselbe
Body am selben Key, nur ein älterer. Er wurde vom legitimen
Schreiber erzeugt, sein Tag ist echt, und die revision, die ein
Angreifer zurückdrehen will, liegt in den Bytes, die das Tag abdeckt.
Es gibt nichts zu erkennen — außer dass dieser Prozess bereits eine
höhere Revision gesehen hat.
ObjectStorageDurableStateStore merkt sich diese höchste Revision pro
persistenceId und weigert sich, darunter zu laden. Standardmäßig
aktiv:
const objectStorageDurableStateStoreOptions = ObjectStorageDurableStateStoreOptions.create() .withBackend(backend) .withRejectRevisionRollback(false); // opt outZwei Grenzen, die man kennen sollte:
- Sie gilt je Store-Instanz, nicht je Prozess. Ein Neustart vergisst die Untergrenze — und alles andere, was einen neuen Store baut, ebenso, denn jeder Knoten legt seinen eigenen an. Ein Shard-Rebalance übergibt dem nächsten Schreiber daher eine kalte Untergrenze, ohne dass irgendwo ein Neustart stattfand. Eine dauerhafte Untergrenze bräuchte vertrauenswürdigen Zustand außerhalb des Buckets, wofür das Framework noch keine Naht hat.
- Sie schlägt bei einem legitimen Delete-and-Recreate eines anderen
Schreibers an. Ein neu angelegter Record beginnt wieder bei
Revision 1. Ein Delete über diesen Store nimmt die Untergrenze mit,
es betrifft also nur den prozessübergreifenden Fall — und genau dafür
gibt es
withRejectRevisionRollback(false).
Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Object Storage im Überblick — das größere Bild.
- Schlüsselrotation — der Online-Rotations-Flow.
- Master-Key-Rotation (Operations) — die operative Seite.
- Per-Actor-Policies — Per-Actor-Verschlüsselungskonfiguration.
