Encryption
Complete encryption architecture: at-rest and end-to-end.
Overview
EnvCat uses two encryption layers:
- At-rest encryption (database) - Fernet (AES-128-CBC + HMAC-SHA256)
- End-to-end encryption (CLI) - NaCl sealed boxes (X25519 + XSalsa20-Poly1305)
┌─────────────────────────────────────────────────────┐
│ At-Rest Encryption (Database) │
│ │
│ Plaintext: "sk_live_51abc..." │
│ ↓ │
│ Fernet(BUNDLE_KMS_KEY).encrypt() │
│ ↓ │
│ Ciphertext: gAAAABl... (stored in SQLite) │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ End-to-End Encryption (CLI) │
│ │
│ Server: Load from database │
│ ↓ │
│ Server: Decrypt at-rest (Fernet) │
│ ↓ │
│ Server: Plaintext: "sk_live_51abc..." │
│ ↓ │
│ Server: sealed_box(plaintext, cli_pubkey) │
│ ↓ │
│ Server: Ciphertext → Redis │
│ ↓ │
│ CLI: Poll, receive ciphertext │
│ ↓ │
│ CLI: sealed_box_open(ciphertext, cli_privkey) │
│ ↓ │
│ CLI: Plaintext: "sk_live_51abc..." │
└─────────────────────────────────────────────────────┘
Security property: Server encrypts data twice (once for storage, once for CLI). CLI only decrypts end-to-end layer (never sees at-rest encryption key).
At-Rest Encryption
Algorithm: libsodium secretbox (PyNaCl SecretBox)
EnvCat stores key values encrypted at rest using libsodium's SecretBox construction via PyNaCl. This uses XSalsa20-Poly1305 authenticated encryption with a 32‑byte symmetric key derived from BUNDLE_KMS_KEY.
Why SecretBox?
- ✅ Fast and well-audited (libsodium / NaCl)
- ✅ Authenticated encryption (Poly1305 MAC prevents tampering)
- ✅ Simple key material: single 32‑byte symmetric key
- ✅ Matches the existing codebase implementation (see
api/crypto/store.py)
Format
Nonce (24 bytes) | Ciphertext (plaintext + 16 byte MAC)
Implementation
Encryption (api/crypto/store.py):
import os
import base64
import nacl.secret
# BUNDLE_KMS_KEY should provide at least 32 bytes of material
# _get_key() pads/truncates the env value to 32 bytes
def encrypt_value(plaintext: str) -> str:
key = _get_key()
box = nacl.secret.SecretBox(key)
plaintext_bytes = plaintext.encode('utf-8')
ciphertext = box.encrypt(plaintext_bytes) # includes nonce
return base64.b64encode(ciphertext).decode('ascii')
def decrypt_value(ciphertext_b64: str) -> str:
key = _get_key()
box = nacl.secret.SecretBox(key)
ciphertext = base64.b64decode(ciphertext_b64)
plaintext_bytes = box.decrypt(ciphertext)
return plaintext_bytes.decode('utf-8')
Usage in models (api/models/key.py):
from crypto.store import encrypt_value, decrypt_value
class Key(Base):
__tablename__ = "keys"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
value = Column(Text, nullable=False) # Encrypted ciphertext
def set_value(self, plaintext: str):
"""Encrypt and store value"""
self.value = encrypt_value(plaintext)
def get_value(self) -> str:
"""Decrypt and return value"""
return decrypt_value(self.value)
Create key (api/routes/keys.py):
@app.post("/api/v1/keys")
@verify_bff_signature
def create_key():
data = request.get_json()
key = Key(
id=str(uuid.uuid4()),
tenant_id=g.tenant_id,
name=data['name'],
is_secret=data.get('is_secret', True),
)
key.set_value(data['value']) # Encrypted before storage
db.session.add(key)
db.session.commit()
return jsonify(key.to_dict(mask_value=True)), 201
Key Management
Generating BUNDLE_KMS_KEY (dev)
# Provide at least 32 bytes of randomness. Base64 is fine but the runtime pads/truncates.
python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
Storing key
# .env (local development)
BUNDLE_KMS_KEY=<base64-or-plain-32-bytes>
# Production (Fly.io secrets)
fly secrets set BUNDLE_KMS_KEY=<base64-or-plain-32-bytes>
⚠️ Security: Never commit BUNDLE_KMS_KEY to git. Store in environment variables or a secrets manager.
Key Rotation (future)
For rotation you can follow a pattern similar to MultiFernet: keep old key material available to decrypt old values, re-encrypt values with the new key, then retire old key material from configuration.
Notes
- The code currently derives a 32‑byte key from
BUNDLE_KMS_KEY(padding/truncating). For production consider storing an exact 32‑byte key or using an envelope/encryption mechanism (KMS) for improved key management.
End-to-End Encryption
Algorithm: NaCl Sealed Boxes
NaCl (libsodium) = X25519 (key exchange) + XSalsa20-Poly1305 (encryption)
Why sealed boxes?
- ✅ Asymmetric: Server encrypts with public key, CLI decrypts with private key
- ✅ Server can't decrypt: Server never has private key (zero-knowledge)
- ✅ Simple: Single function call (no nonce management)
- ✅ Anonymous: Sender doesn't reveal identity (no sender public key in ciphertext)
Format:
Ephemeral Public Key | Encrypted Message
32 bytes | variable (plaintext + 16 byte MAC)
Total overhead: 48 bytes (32 ephemeral key + 16 MAC)
Implementation
CLI keypair generation (cli/internal/crypto/crypto.go):
import "github.com/kevinburke/nacl/box"
// Generate ephemeral keypair (destroyed after use)
pubKey, privKey, err := box.GenerateKey(rand.Reader)
if err != nil {
return err
}
// pubKey: 32 bytes (send to server)
// privKey: 32 bytes (keep in memory, never send)
Server encryption (api/crypto/e2e.py):
import nacl.public
import nacl.encoding
import json
import base64
def encrypt_for_cli(payload: dict, cli_pubkey_base64: str) -> str:
"""
Encrypt payload for CLI using sealed box.
Args:
payload: Dictionary of environment variables
cli_pubkey_base64: CLI's public key (base64-encoded)
Returns:
Base64-encoded ciphertext
"""
# Decode CLI public key
cli_pubkey_bytes = base64.b64decode(cli_pubkey_base64)
cli_pubkey = nacl.public.PublicKey(cli_pubkey_bytes)
# Serialize payload to JSON
plaintext = json.dumps(payload).encode('utf-8')
# Encrypt with sealed box
sealed_box = nacl.public.SealedBox(cli_pubkey)
ciphertext = sealed_box.encrypt(plaintext)
# Return base64-encoded ciphertext
return base64.b64encode(ciphertext).decode('utf-8')
CLI decryption (cli/internal/crypto/crypto.go):
import (
"encoding/base64"
"encoding/json"
"github.com/kevinburke/nacl/box"
)
func DecryptPayload(ciphertextBase64 string, privKey *[32]byte) (map[string]string, error) {
// Decode base64 ciphertext
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
if err != nil {
return nil, err
}
// Decrypt sealed box
plaintext, ok := box.OpenAnonymous(nil, ciphertext, pubKey, privKey)
if !ok {
return nil, errors.New("decryption failed")
}
// Parse JSON payload
var payload map[string]string
if err := json.Unmarshal(plaintext, &payload); err != nil {
return nil, err
}
return payload, nil
}
Approval Flow with Encryption
Complete flow:
1. CLI: Generate ephemeral keypair
├─ Private key: Keep in memory
└─ Public key: Send to server (base64)
2. CLI → Server: Create request
POST /api/v1/requests
Body: {
"client_pubkey": "<base64_public_key>",
"bundle_id": "prod-api-bundle",
}
3. Server: Store in Redis
req:abc123 = {
status: "pending",
cli_pub: "<base64_public_key>",
bundle_id: "prod-api-bundle",
}
4. User: Approve in browser
├─ Select keys: STRIPE_KEY, DATABASE_URL
└─ Click "Approve"
5. Server: Re-encrypt for CLI
├─ Load keys from database (encrypted at-rest)
├─ Decrypt with Fernet (BUNDLE_KMS_KEY)
│ └─ Plaintext: {"STRIPE_KEY": "sk_live_...", "DATABASE_URL": "postgres://..."}
├─ Encrypt with NaCl sealed box (CLI public key)
│ └─ Ciphertext: <48 bytes overhead + encrypted JSON>
└─ Store in Redis:
req:abc123 = {
status: "ready",
ciphertext: "<base64_ciphertext>",
}
6. CLI: Poll and receive
GET /api/v1/requests/abc123/wait
Response: {
"status": "ready",
"ciphertext_base64": "<base64_ciphertext>"
}
7. CLI: Decrypt
├─ Base64 decode ciphertext
├─ Decrypt sealed box with private key
├─ Parse JSON payload
└─ Plaintext: {"STRIPE_KEY": "sk_live_...", "DATABASE_URL": "postgres://..."}
8. CLI: Output
export STRIPE_KEY='sk_live_...'
export DATABASE_URL='postgres://...'
9. Cleanup
├─ Server: Delete req:abc123 from Redis (single-use)
└─ CLI: Zero out private key in memory
Security properties:
- ✅ Server never knows CLI private key (zero-knowledge)
- ✅ Ciphertext useless without CLI private key
- ✅ One-time use (Redis key deleted after retrieval)
- ✅ Ephemeral keypair (destroyed after use)
Security Analysis
Threat Model
Attacker goals:
- Read secrets from database
- Read secrets in transit (network sniffing)
- Replay captured ciphertext
- Tamper with ciphertext
- Impersonate CLI to server
Defenses
1. Database compromise
Threat: Attacker gains read access to SQLite database.
Defense:
- ✅ All keys encrypted at-rest with Fernet
- ✅ Requires
BUNDLE_KMS_KEYto decrypt - ✅
BUNDLE_KMS_KEYstored in environment (not in database)
Result: Attacker sees ciphertext (e.g., gAAAAABmJk5r...), but can't decrypt without key.
2. Network sniffing
Threat: Attacker captures traffic between CLI and server.
Defense:
- ✅ HTTPS/TLS in production (all traffic encrypted)
- ✅ End-to-end encryption (even if TLS broken, sealed box ciphertext useless)
- ✅ Ephemeral keypair (private key never sent over network)
Result: Attacker sees ciphertext, but can't decrypt without CLI private key.
3. Replay attacks
Threat: Attacker captures ciphertext, tries to reuse.
Defense:
- ✅ Single-use (Redis key deleted after retrieval)
- ✅ TTL (request expires after 5 minutes)
Result: Replaying old request returns "expired" error.
4. Ciphertext tampering
Threat: Attacker modifies ciphertext to inject values.
Defense:
- ✅ Fernet HMAC (tampering fails HMAC check → decrypt fails)
- ✅ NaCl Poly1305 MAC (tampering fails MAC check → decrypt fails)
Result: Modified ciphertext rejected (decrypt fails).
5. Impersonation
Threat: Attacker tries to create fake CLI request.
Defense:
- ✅ CLI generates ephemeral keypair (attacker doesn't know private key)
- ✅ Server encrypts to public key (only real CLI can decrypt)
Result: Attacker can create request, but can't decrypt response (doesn't have private key).
Assumptions
What we trust:
- ✅
BUNDLE_KMS_KEYkept secret (not leaked in logs, git, etc.) - ✅
BFF_SHARED_SECRETkept secret (for HMAC signing) - ✅ Fernet and NaCl implementations correct (Python
cryptography, libsodium) - ✅ System clock accurate (for Fernet timestamp, HMAC timestamp)
- ✅ Secure random number generator (Python
os.urandom, Gocrypto/rand)
What we DON'T trust:
- ❌ Database contents (could be compromised)
- ❌ Redis contents (ephemeral, could be compromised)
- ❌ Network (could be sniffed)
- ❌ Client input (could be malicious)
Key Management
Development
Generate keys:
# Fernet key (at-rest encryption)
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Output: bXQ7vZ3rW8xYpL4sN9mK2fH6jG1dC5aT8uI0oP3wE7=
# BFF shared secret (HMAC signing)
openssl rand -hex 32
# Output: a3f8c2d1e5b4f6a7c9d8e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
Store in .env:
# api/.env
BUNDLE_KMS_KEY=bXQ7vZ3rW8xYpL4sN9mK2fH6jG1dC5aT8uI0oP3wE7=
# web/.env
BFF_SHARED_SECRET=a3f8c2d1e5b4f6a7c9d8e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
# api/.env (must match web)
BFF_SHARED_SECRET=a3f8c2d1e5b4f6a7c9d8e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
Production
Store as secrets (Fly.io):
# Set secrets (never stored in git)
fly secrets set BUNDLE_KMS_KEY=bXQ7vZ3rW8xYpL4sN9mK2fH6jG1dC5aT8uI0oP3wE7= -a api
fly secrets set BFF_SHARED_SECRET=a3f8c2d1... -a web
fly secrets set BFF_SHARED_SECRET=a3f8c2d1... -a api
# Verify secrets set
fly secrets list -a api
Best practices:
- ✅ Use different keys for dev/staging/production
- ✅ Rotate keys annually (or after suspected compromise)
- ✅ Audit secret access (who can view Fly.io secrets?)
- ✅ Never log keys (check application logs for leaks)
Performance
At-Rest Encryption (Fernet)
Encrypt:
- Single key: ~0.1 ms per value (Python 3.11)
- Batch (100 keys): ~10 ms total
Decrypt:
- Single key: ~0.1 ms per value
- Batch (100 keys): ~10 ms total
Overhead:
- Ciphertext size: Plaintext + 57 bytes (version + timestamp + IV + MAC)
- Example: 32-byte secret → 89-byte ciphertext (2.8x)
End-to-End Encryption (NaCl)
Encrypt (server):
- Single payload: ~0.5 ms (Python PyNaCl)
- 10 keys in payload: ~0.5 ms (same, JSON serialization dominates)
Decrypt (CLI):
- Single payload: ~0.3 ms (Go libsodium bindings)
Overhead:
- Ciphertext size: Plaintext + 48 bytes (ephemeral key + MAC)
- Example: 200-byte JSON → 248-byte ciphertext (1.24x)
Bottleneck: Network latency (10-50 ms) dominates crypto (< 1 ms).
Testing Encryption
At-Rest Round-Trip
# tests/test_crypto.py
from crypto.store import encrypt_value, decrypt_value
def test_fernet_roundtrip():
plaintext = "sk_live_51abc123..."
# Encrypt
ciphertext = encrypt_value(plaintext)
assert ciphertext.startswith("gAAAAA") # Fernet version byte
# Decrypt
decrypted = decrypt_value(ciphertext)
assert decrypted == plaintext
def test_fernet_tampering():
plaintext = "sk_live_51abc123..."
ciphertext = encrypt_value(plaintext)
# Tamper with ciphertext
tampered = ciphertext[:-10] + "AAAAAAAAAA"
# Decrypt should fail
with pytest.raises(cryptography.fernet.InvalidToken):
decrypt_value(tampered)
End-to-End Round-Trip
# tests/test_e2e_crypto.py
import nacl.public
import base64
from crypto.e2e import encrypt_for_cli
def test_sealed_box_roundtrip():
# CLI generates keypair
privkey = nacl.public.PrivateKey.generate()
pubkey = privkey.public_key
pubkey_b64 = base64.b64encode(bytes(pubkey)).decode()
# Server encrypts
payload = {"STRIPE_KEY": "sk_live_51abc..."}
ciphertext_b64 = encrypt_for_cli(payload, pubkey_b64)
# CLI decrypts
ciphertext = base64.b64decode(ciphertext_b64)
sealed_box = nacl.public.SealedBox(privkey)
plaintext = sealed_box.decrypt(ciphertext)
# Verify payload
import json
decrypted_payload = json.loads(plaintext)
assert decrypted_payload == payload
Full Approval Flow
# tests/test_e2e_bundle.py (runs in Docker)
docker compose run --rm tests pytest test_e2e_bundle.py -v
Flow tested:
- Create bundle with encrypted keys
- CLI generates keypair, creates request
- Server encrypts bundle to CLI pubkey
- CLI polls, receives ciphertext
- CLI decrypts, verifies plaintext
Next Steps
- Authentication - How requests are authenticated (Clerk + HMAC)
- Multi-Tenancy - How data is isolated between users
- API Reference - All encryption-related endpoints