Agent skill

encryption

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.

Stars 4,333
Forks 311

Install this agent skill to your Project

npx add-skill https://github.com/EpicenterHQ/epicenter/tree/main/.agents/skills/encryption

SKILL.md

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

bash
# 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:secret pairs, 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_SECRETS convention

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)
       v
  Session response -> client receives { encryptionKey, keyVersion }
       |
       |  HKDF(userKey, info="workspace:{wsId}") -> per-workspace key (32 bytes)
       v
  XChaCha20-Poly1305 encrypt/decrypt with @noble/ciphers

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

typescript
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 read blob[1] without decrypting
  • Use getFormatVersion(blob) to read blob[0] without decrypting

Key Rotation

bash
# 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

typescript
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).

Didn't find tool you were looking for?

Be as detailed as possible for better results