Encryption patterns for HKDF key derivation, XChaCha20-Poly1305 symmetric encryption, encrypted blob formats, key hierarchy, and key rotation. Use when the user says "encrypt this", "add encryption", "key management", or when working with crypto primitives, encrypted CRDT values, or the EncryptedBlob type.
When working with encryption, consult these repositories for patterns and documentation:
vault:v1:base64| Concern | Inspiration | Why |
|---|---|---|
| Key derivation | Signal Protocol | HKDF-SHA256 with domain-separation info strings (unversioned, per RFC 5869) |
| Symmetric cipher | libsodium / WireGuard | XChaCha20-Poly1305: 2.3x faster in pure JS, 24-byte nonce safe for random generation |
| Key hierarchy | Bitwarden | Root secret -> per-user key -> per-workspace key |
| Key version in ciphertext | Tink / Vault | Key version byte prefix inside ct binary |
| Key rotation model | Vault Transit | Keyring with versioned secrets, lazy re-encryption |
| Design philosophy | age | Simplicity over configurability |
# Required. Completely independent from BETTER_AUTH_SECRET.
# Always uses versioned format: "version:secret" pairs, comma-separated.
# Generate secret: openssl rand -base64 32
# Single key (initial setup):
ENCRYPTION_SECRETS="1:base64encodedSecret"
# After rotation (add new version, keep old for decryption):
ENCRYPTION_SECRETS="2:newBase64Secret,1:oldBase64Secret"
ENCRYPTION_SECRETS (always plural, always versioned format)version:secret pairs, comma-separated. Highest version = current key for new encryptions.BETTER_AUTH_SECRET--rotating one never affects the otherBETTER_AUTH_SECRETS conventionENCRYPTION_SECRETS="1:base64Secret"
|
| Parse -> keyring[{ version: 1, secret: "base64Secret" }]
| Current = highest version
|
| SHA-256(currentSecret) -> root key material
| HKDF(root, info="user:{userId}") -> per-user key (32 bytes)
|
| HKDF(userKey, info="workspace:{wsId}") -> per-workspace key (32 bytes)
v
XChaCha20-Poly1305 encrypt/decrypt with @noble/ciphers
Prefer inline key delivery over separate endpoints. If the session already authenticates the user, derive and embed key material in the session response. HKDF-SHA256 derivation adds <0.1ms—the optimization of splitting key delivery from session delivery costs more in complexity (version-tracking state, extra round-trips, duplicated callbacks) than it saves in compute.
Make unlock operations idempotent. Calling unlock() with the same key twice should be a no-op, and calling it with a different key should cleanly replace the active key. This eliminates client-side version tracking—the client receives the key, calls unlock, done. No mutable lastVersion state, no conditional fetches.
Embed key version in the ciphertext, not in application logic. The blob header (blob[1]) carries the version that encrypted it. Decryption reads the version from the blob and selects the matching key from the keyring. Clients never need to track which version they’re using—the data is self-describing.
Minimize client-side key state. Ideally zero mutable state. The session carries the key, the client passes it to unlock(), the workspace derives per-workspace keys internally. No caches to invalidate, no version comparisons, no separate fetch methods.
| Concern | AES-256-GCM | XChaCha20-Poly1305 (chosen) |
|---|---|---|
| Performance (pure JS, 64B) | 201K ops/sec @ 4us | 468K ops/sec @ 2us (2.3x faster) |
| Nonce size | 12 bytes (collision risk with random) | 24 bytes (safe for random nonces) |
| Max messages per key (random nonce) | 2^23 (8M) | 2^72 (practically unlimited) |
| Nonce-reuse impact | Catastrophic (full key recovery) | Catastrophic (but 2^72 makes it irrelevant) |
| Used by | NIST, TLS 1.3 | libsodium, WireGuard, TLS 1.3, Noise Protocol |
AES-256-GCM via WebCrypto uses hardware AES-NI and is faster, but it's async. We need synchronous encrypt/decrypt for the CRDT hot path.
user:{userId} for per-user keys, workspace:{wsId} for per-workspace keystype EncryptedBlob = Uint8Array;
// Bare Uint8Array with self-describing binary header.
// v:1 binary layout:
// blob[0] = format version (0x01 = XChaCha20-Poly1305)
// blob[1] = key version (which secret from ENCRYPTION_SECRETS keyring)
// blob[2..25] = random nonce (24 bytes, XChaCha20)
// blob[26..] = XChaCha20-Poly1305 ciphertext || authentication tag (16 bytes)
blob[0] = format version. Currently always 1 (XChaCha20-Poly1305).blob[1] = key version, identifying which secret encrypted this blobvalue instanceof Uint8Array && value[0] === 1getKeyVersion(blob) to read blob[1] without decryptinggetFormatVersion(blob) to read blob[0] without decrypting# Rotate by adding a new highest-version entry:
ENCRYPTION_SECRETS="2:newBase64Secret,1:oldBase64Secret"
,, then each entry by first : -> { version: number, secret: string }Format version bumps only needed for algorithm or binary layout changes (extremely rare):
| Scenario | Bumps format version? |
|---|---|
| Secret rotation (new entry in ENCRYPTION_SECRETS) | No--key version in blob[1] handles this |
| Switch to different algorithm (unlikely) | Yes--different cipher |
| Add compression before encryption | Yes--different plaintext encoding |
| Change HKDF parameters (SHA-384, non-empty salt) | Yes--different key derivation |
function isEncryptedBlob(value: unknown): value is EncryptedBlob {
return value instanceof Uint8Array && value[0] === 1;
}
User values in the CRDT are always JS objects (from schema definitions), never Uint8Arrays.
This makes instanceof Uint8Array a reliable discriminant. Truncated or corrupted blobs
that pass this check will fail during decryptValue() and get quarantined by the
encrypted wrapper's error containment.
When encrypting workspace values, the entry key is bound as AAD to prevent ciphertext transplant attacks (moving an encrypted value from one key to another).