Hefesto
Field-level encryption for multi-tenant applications. Each encrypted value requires two independent keys to decrypt — one owned by the tenant, one by the server. Losing either key makes the data unreadable.
Built on AES-256-GCM + Argon2id. No key management — bring your own keys.
Install
[]
= "1.0.3"
Quickstart
use HefestoError;
// Passwords: one-way only, never decrypted
Why two keys?
A single-key encryption scheme means whoever controls the server controls all tenant data. Two independent keys create a meaningful separation:
| Scenario | Single-key | Hefesto |
|---|---|---|
| Server compromised, master key leaked | All tenants exposed | Attacker still needs each tenant's key |
| Tenant key leaked | N/A | Attacker still needs the master key |
| Both keys leaked | — | Data exposed |
| DB dumped, no keys | Offline brute-force possible | Argon2id makes it expensive per-tenant |
The tenant key also acts as AAD on the outer encryption layer. A ciphertext encrypted for tenant A will fail to decrypt even if someone supplies the correct master key with a different tenant key — it can't be moved between tenants silently.
DB schema pattern
For each encrypted field, store two columns:
-- email_encrypted: the ciphertext (changes every write)
-- email_lookup: deterministic hash for WHERE queries (indexed)
users
ADD COLUMN email_encrypted TEXT NOT NULL,
ADD COLUMN email_lookup TEXT NOT NULL,
ADD INDEX idx_email_lookup (email_lookup);
// Write
let encrypted = encrypt?;
let lookup = hash_for_lookup;
// INSERT INTO users (email_encrypted, email_lookup) VALUES (?, ?)
// Search
let lookup = hash_for_lookup;
// SELECT * FROM users WHERE email_lookup = ? AND tenant_id = ?
// Read
let email = decrypt?;
hash_for_lookupuses HMAC-SHA256 keyed ontenant_key. Two tenants with the same email produce different lookup hashes — no cross-tenant correlation from the DB.
Performance
Each encrypt or decrypt call runs Argon2id twice (once per key) with 64 MB RAM and 3 iterations. This is intentional — it makes offline brute-force attacks against stolen ciphertexts expensive.
Typical latency: ~200–400 ms per call on server hardware.
This is appropriate for:
- Encrypting/decrypting individual fields at request time
- Background jobs that process one record at a time
This is not appropriate for:
- Bulk imports of thousands of rows inline — offload to a background worker queue
- Hot paths that run on every HTTP request — encrypt at write time, cache the plaintext in memory for the request lifetime
Error handling
use HefestoError;
match decrypt
| Error | Cause |
|---|---|
DecryptionFailed |
Wrong key or payload was tampered with |
InvalidPayload |
Corrupted, truncated, or unrecognized payload version |
InvalidKey(msg) |
Key shorter than 8 bytes |
InvalidUtf8 |
Decrypted bytes are not valid UTF-8 |
KeyDerivationFailed |
Argon2id internal error (rare) |
PasswordHashFailed |
Argon2id internal error (rare) |
Key requirements
Keys must be at least 8 bytes. Recommended: 32+ random bytes.
# e3b0c44298fc1c149afbf4c8996fb924...
- Tenant key — one per tenant. Store in tenant config or secrets manager.
- Master key — one per deployment. Store as an env var. Rotating it requires re-encrypting all fields.
- Never log keys. They must not appear in error messages, traces, or stack dumps.
- Use random keys, not passwords. Human-chosen strings have low entropy. If your keys come from user passwords, hash them with Argon2id first before passing to Hefesto.
How it works
encrypt(value, tenant_key, master_key)
├── salt_1 = OsRng[16]
├── key_1 = Argon2id(tenant_key, salt_1) → [u8; 32]
├── layer_1 = AES-256-GCM(value, key_1, nonce=OsRng[12])
│
├── salt_2 = OsRng[16]
├── key_2 = Argon2id(master_key, salt_2) → [u8; 32]
├── layer_2 = AES-256-GCM(layer_1, key_2, nonce=OsRng[12], aad=tenant_key)
│ ↑ tenant_key bound as AAD: wrong tenant_key → auth tag fails
│
└── output = Base64( 0x01 | salt_1 | salt_2 | layer_2 )
↑ version byte — reserved for future algorithm changes
All derived keys are held in Zeroizing<[u8; 32]> — wiped from memory on drop.
Security properties
| Property | Mechanism |
|---|---|
| Confidentiality | AES-256-GCM — 256-bit key, IND-CPA secure |
| Integrity / tamper detection | GCM authentication tag — any bit flip fails decryption |
| Key stretching | Argon2id — memory-hard, resists GPU/ASIC brute-force |
| Non-deterministic ciphertext | Random 16-byte salt + 12-byte nonce per operation |
| Key isolation | Two independent KDF invocations with independent salts |
| Tenant isolation | tenant_key as AEAD associated data on the outer layer |
| Memory safety | zeroize wipes key material from RAM after use |
| Lookup privacy | HMAC-SHA256(value, tenant_key) — no cross-tenant correlation |
| Forward compatibility | Version byte in payload |
License
MIT