Перейти к содержимому
Русский

Encryption

Это содержимое пока не доступно на вашем языке.

Object-storage payloads support client-side AES-GCM encryption — the framework encrypts before put, decrypts on get, keys managed as a single master key or a versioned key ring for rotation.

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

Now every persisted state is AES-GCM-encrypted before upload — under a per-persistenceId subkey derived from the master key. Reads transparently decrypt.

info is required and deployment-specific. It is not decoration — see Choosing info below.

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.

type EncryptionConfig =
| { mode: 'none' }
| { mode: 'sse-s3' } // server-side, S3-managed
| { mode: 'sse-kms'; kmsKeyId: string } // server-side, KMS-managed
| { mode: 'client-aes256-gcm'; masterKey: Uint8Array; info: string }
| { mode: 'client-aes256-gcm'; masterKeys: MasterKeyRing; info: string };
type MasterKeyRing = {
active: MasterKeyRingEntry; // new writes encrypt under this
retired?: MasterKeyRingEntry[]; // older keys, kept for decryption
};
type MasterKeyRingEntry = {
version: number; // 0..255, embedded in the body manifest
key: Uint8Array; // 32 bytes (AES-256)
};

Client-side encryption uses mode: 'client-aes256-gcm' with either a single masterKey (32 bytes) or a masterKeys ring. The ring carries:

  • active — the key new writes encrypt under.
  • retired — older keys, kept to decrypt historical blobs.

Each entry’s version byte travels in the body manifest so decrypt can pick the matching key. Carrying the active key plus retired keys is the foundation of key rotation.

info is HKDF’s context-binding input (RFC 5869 §3.2). The subkey for a blob is derived from three things: the master key, the persistenceId (as HKDF salt), and info. Change any one and you get an unrelated key.

It is required, and there is no default, deliberately. A shared default would mean any two deployments holding the same master key derive byte-for-byte the same subkey for the same persistenceId — so a staging environment restored from a production dump, or a DR region, could read production’s blobs and nothing in the config would say so. That is a decision only the operator can make, so the framework insists you make it.

Encode environment + purpose + version, most specific first:

'acme/prod/snapshot/v1'
'acme/staging/snapshot/v1'
'acme/prod/durable-state/v1'
  • Different environments MUST differ, even on the same master key. This is the whole point.
  • Different payload kinds SHOULD differ (snapshots vs. durable state), so one compromised derivation context does not extend to the other.
  • A trailing version gives a future context rotation somewhere to go.

info is not recorded on the wire. Unlike the key version, no manifest byte records which info a blob was written under. Changing it makes every existing blob undecryptable until a sweep rewrites them — see rotating the context. Pick the value before the first write.

On put:
serialize value → compress → derive per-pid subkey (HKDF from active key)
→ AES-GCM(bytes, subkey, iv) → ciphertext
→ body manifest "ATS1" { flags, keyVersion, iv, ciphertext }
→ S3.put(body) // no key-id metadata header
On get:
S3.get → body manifest { flags, keyVersion, iv, ciphertext }
→ pick master key by keyVersion (active or a retired entry)
→ derive per-pid subkey (HKDF) → AES-GCM decrypt → decompress
→ deserialize

The key version is embedded in the body manifest — every blob records which key version it was encrypted under. This lets the framework decrypt old payloads using the right key even after the active key has been rotated (retired keys stay in the ring).

  • The body — serialized state / event / snapshot.
  • Not — the object key, object metadata headers, 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:

const masterKey = Buffer.from(process.env.MASTER_KEY_V1!, 'base64'); // 32 bytes
// → .withEncryption({ mode: 'client-aes256-gcm', masterKey, info: 'acme/prod/snapshot/v1' })

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 masterKey = decrypted.Plaintext!; // 32 bytes, held in memory
// → .withEncryption({ mode: 'client-aes256-gcm', masterKey, info: 'acme/prod/snapshot/v1' })

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 (encrypted flag unset in the manifest)
state/cart-43 ← new, encrypted (encrypted flag set, key version 0)

The framework detects each per-payload from the body manifest:

  • Encrypted flag unset → plaintext path.
  • Encrypted flag set → decrypt using the key version the manifest records.

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.