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';
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 (jemand hat S3-Credentials)
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.

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.