Zum Inhalt springen
Deutsch

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.

Object Stores (S3, GCS, Azure Blob) bieten eingebaute Server-Side-Encryption. Warum auch Client-Side verschlüsseln?

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

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

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
→ deserialisieren

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

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

Die Master-Key-Bytes kommen irgendwoher. Häufige Muster:

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

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.

Ähnliches Muster: Master-Keys beim Start aus Vault ziehen.

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üsseln → komprimieren → S3.put # ✗ kein Kompressionsnutzen auf Ciphertext
komprimieren → verschlüsseln → S3.put # ✓ das macht das Framework

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

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.

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.

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.

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

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.

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 allowUntaggedBodies hilft nicht, denn es lässt Bodies ohne Tag wieder zu. Ein unter dem alten Schlüssel getaggter Body trägt weiterhin FLAG_INTEGRITY_HMAC und 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 }), dann upsert(pid, revision, state, { integrity: neu }). Sie wirkt pro persistenceId, 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.

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.

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.

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 out

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