Skip to main content

Encryption

Complete encryption architecture: at-rest and end-to-end.

Overview

EnvCat uses two encryption layers:

  1. At-rest encryption (database) - Fernet (AES-128-CBC + HMAC-SHA256)
  2. 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:

  1. Read secrets from database
  2. Read secrets in transit (network sniffing)
  3. Replay captured ciphertext
  4. Tamper with ciphertext
  5. 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_KEY to decrypt
  • BUNDLE_KMS_KEY stored 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_KEY kept secret (not leaked in logs, git, etc.)
  • BFF_SHARED_SECRET kept 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, Go crypto/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:

  1. Create bundle with encrypted keys
  2. CLI generates keypair, creates request
  3. Server encrypts bundle to CLI pubkey
  4. CLI polls, receives ciphertext
  5. CLI decrypts, verifies plaintext

Next Steps