Skip to content

Encryption

Object-storage payloads support client-side AES-GCM encryption — the framework encrypts before put, decrypts on get, keys managed via a MasterKeyRing.

import {
ObjectStorageDurableStateStore,
S3ObjectStorageBackend,
MasterKeyRing,
} from 'actor-ts';
const keyRing = new MasterKeyRing({
keys: { 'k1': process.env.MASTER_KEY_V1! },
currentKeyId: 'k1',
});
const store = new ObjectStorageDurableStateStore({
backend: new S3ObjectStorageBackend({ /* ... */ }),
encryption: { keyRing },
});

Now every persisted state is AES-GCM-encrypted under k1 before upload. Reads transparently decrypt.

Object stores (S3, GCS, Azure Blob) offer server-side encryption built-in. Why also encrypt client-side?

ThreatServer-sideClient-side
Storage compromise (someone reads from disk)
Account compromise (someone has S3 credentials)
Cloud provider compromise
Audit / compliance requiring “we hold the keys”

Client-side encryption protects against more threats but costs more (CPU per op, key-management overhead). Most apps should use both: server-side as baseline + client-side for sensitive payloads.

interface EncryptionConfig {
keyRing: MasterKeyRing;
/** Override the algorithm; default 'AES-GCM'. */
algorithm?: 'AES-GCM';
}

The MasterKeyRing carries:

  • keys — keyId → key bytes map.
  • currentKeyId — which key new writes use.

Multiple keys in the ring let you read older payloads (encrypted under older keys) while new payloads use the current key. This is the foundation of key rotation.

On put:
serialize value → bytes → AES-GCM(bytes, currentKey) → ciphertext
→ metadata { keyId: currentKeyId, iv, tag }
→ S3.put(ciphertext, x-amz-meta-key-id: currentKeyId, etc.)
On get:
S3.get → ciphertext + metadata{ keyId, iv, tag }
→ keyRing.get(keyId) → decrypt(ciphertext, key, iv, tag)
→ bytes → deserialize

The key ID is stored alongside the ciphertext — every blob knows which key it was encrypted with. This lets the framework decrypt old payloads using the right key even after the current key has been rotated.

  • The body — serialized state / event / snapshot.
  • Not — the object key, metadata headers (other than the key ID), the bucket name.

For object metadata that shouldn’t leak (sensitive persistenceIds), use a separate naming scheme (hash IDs before they become object keys).

The master key bytes come from somewhere. Common patterns:

new MasterKeyRing({
keys: {
'k1': Buffer.from(process.env.MASTER_KEY_V1!, 'base64'),
},
currentKeyId: 'k1',
});

Simplest. Each key is a 32-byte buffer (for AES-256-GCM), base64-encoded in the env.

Risk: env vars are visible to anything that can read the process environment. Use only when the env is itself secured (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 keyRing = new MasterKeyRing({
keys: { 'k1': decrypted.Plaintext! },
currentKeyId: 'k1',
});

Master key is stored encrypted under a cloud KMS key. The app fetches it on startup, decrypts via KMS, holds in memory.

Better than raw env vars — only KMS access is required to recover keys.

Similar pattern: pull master keys from Vault at startup.

AES-GCM is fast — modern CPUs have hardware support.

Per 100 KB encrypt + decrypt:

  • ~0.5-1 ms on modern x86 / Apple Silicon.
  • Effectively free on small objects.

For most workloads, encryption is invisible in profiles.

encrypt → compress → S3.put # ✗ no compression benefit on ciphertext
compress → encrypt → S3.put # ✓ this is what the framework does

The framework’s order is compress first, then encrypt — compressed bytes are still compressible (not random); after encryption, they’re effectively random and uncompressable.

If you set both compression and encryption, you get this order automatically.

After enabling encryption on a previously-unencrypted bucket:

state/cart-42 ← old, plaintext, no key-id metadata
state/cart-43 ← new, encrypted, key-id: k1

The framework detects each per-payload via the metadata:

  • No key-id metadata → plaintext path.
  • Key-id present → decrypt via the keyRing.

Means you can enable encryption gradually — new writes get encrypted, old reads still work, and a background re-encryption sweep can migrate the rest.

See key rotation for the rotation flow.