encryption
Encryption Patterns
Reference Repositories
When working with encryption, consult these repositories for patterns and documentation:
- noble-ciphers — Audited JS implementation of ChaCha, Salsa, AES (our crypto primitive library)
- libsodium — Crypto primitives, secretbox/AEAD patterns, XChaCha20-Poly1305
- Signal Protocol (libsignal) — Key hierarchy, HKDF usage, Double Ratchet, message encryption
- Vault Transit — Key versioning, rotation, ciphertext format (
vault:v1:base64) - Bitwarden — Client-side vault encryption, key hierarchy (master key -> org key -> cipher key)
- AWS KMS — Envelope encryption patterns, key rotation lifecycle
- age — Simple file encryption design philosophy
What We Borrow From Each
| 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 |
Epicenter's Encryption Architecture
Environment Variables
# 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"
- ONE env var:
ENCRYPTION_SECRETS(always plural, always versioned format) - Format:
version:secretpairs, comma-separated. Highest version = current key for new encryptions. - Completely decoupled from
BETTER_AUTH_SECRET--rotating one never affects the other - Matches Better Auth's own
BETTER_AUTH_SECRETSconvention
Key Hierarchy
ENCRYPTION_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
Key Delivery Best Practices
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.
Why XChaCha20-Poly1305 Over AES-256-GCM
| 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.
Key Derivation
- Uses Web Crypto HKDF with SHA-256 hash
- Empty salt (acceptable for HKDF when input key material has high entropy)
- Info strings are domain-separation labels, NOT version identifiers
user:{userId}for per-user keys,workspace:{wsId}for per-workspace keys- Different secrets with the same info string produce cryptographically independent keys (RFC 5869)
EncryptedBlob Format
type 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 blob- Detection:
value instanceof Uint8Array && value[0] === 1 - User values in the CRDT are always JS objects, never Uint8Arrays
- Use
getKeyVersion(blob)to readblob[1]without decrypting - Use
getFormatVersion(blob)to readblob[0]without decrypting
Key Rotation
# Rotate by adding a new highest-version entry:
ENCRYPTION_SECRETS="2:newBase64Secret,1:oldBase64Secret"
- Parser splits by
,, then each entry by first:->{ version: number, secret: string } - Sorted by version descending; first entry = current for new encryptions
- Encrypt: always uses current (highest version) key, embeds version as blob[1]
- Decrypt: read blob[1] to know which key version was used, select matching key from keyring
- Lazy re-encryption: on read with non-current key version, re-encrypt on next write
- Keep old secrets in keyring for at least 90 days to handle offline devices
Format Version Upgrade Path
- Format version 1 = XChaCha20-Poly1305 with key version byte at blob[1]
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 |
isEncryptedBlob Detection
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.
AAD (Additional Authenticated Data)
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).