Zero-Knowledge Encryption (ZKE) Tutorial
Zero-Knowledge Encryption (ZKE) Tutorial
Available since: v3.5.0
Build: default — encryption feature is on by default
Module: heliosdb_lite::crypto::zero_knowledge (src/crypto/zero_knowledge.rs)
UVP
Standard TDE encrypts at rest — but the database still sees plaintext when it serves a query. Zero-Knowledge Encryption inverts the trust model: the server never holds your encryption key. Each request carries a per-request key, validated server-side via a SHA-256 hash, paired with a unique nonce that prevents replay. Three modes (Full / Hybrid / PerRequest) let you trade SQL capabilities for privacy. Argon2 derives separate auth and encryption keys client-side; zeroize clears them from memory the instant they leave scope.
Prerequisites
- HeliosDB Lite v3.5+
- Rust 1.75+
- Familiarity with symmetric encryption concepts (key, nonce, AEAD)
- 15 minutes
1. Threat Model
| Concern | TDE alone | + ZKE |
|---|---|---|
| Disk theft / cold backups | Encrypted | Encrypted |
| Memory dump while DB is running | Plaintext visible | Encrypted (key never on server) |
| Insider with DBA privileges | Plaintext visible | Cannot decrypt |
| Replay of an old request | Possible | Rejected by nonce tracker |
ZKE is the difference between “the storage is encrypted” and “the operator literally cannot read your data.”
2. The Three Modes
pub enum ZkeMode { /// Server never sees plaintext. No server-side search. Full,
/// Metadata (schemas, table names) visible; row data encrypted. Hybrid,
/// Key provided per-request, server temporarily decrypts for query /// execution, key is zeroized immediately after. Full SQL works. PerRequest,}PerRequest is the default and the practical sweet spot: you keep all of SQL (joins, indexes, aggregates) and lose plaintext-at-rest only during the microsecond a query is being executed.
3. Client-Side Key Derivation
ZkeKeyDerivation::derive_keys produces two keys from one password — Argon2 hashed against an identifier-based salt:
auth_key— sent (as a hash) to the server for login.encryption_key— never sent. Encrypts your data.
use heliosdb_lite::crypto::zero_knowledge::ZkeKeyDerivation;
let keys = ZkeKeyDerivation::derive_keys( "user_password", "alice@example.com", // salt material; must be unique per user)?;
// Auth half: send THIS hash to the server during login.let key_hash_hex = keys.key_hash_hex();println!("server-side key hash: {}", key_hash_hex);
// Encryption half: KEEP THIS LOCAL.// keys.encryption_key -- Zeroizing<[u8; 32]>, zeroed on drop.ZkeDerivedKeys wraps both keys in zeroize::Zeroizing, which fills the memory with zeros the moment it goes out of scope. Avoid clone()’ing it unless you have to.
4. The Session Object
A ZeroKnowledgeSession holds an encryption key for a single request (or a short-lived batch).
use heliosdb_lite::crypto::zero_knowledge::{ ZkeKeyDerivation, ZeroKnowledgeSession,};
let keys = ZkeKeyDerivation::derive_keys("password", "alice@x.com")?;let session = ZeroKnowledgeSession::from_derived_keys(&keys)? .with_random_nonce();
let plaintext = b"SELECT secret FROM accounts WHERE id = 42";let ciphertext = session.encrypt(plaintext)?;let nonce = session.nonce_hex().expect("nonce was set");let key_hash = session.key_hash_hex();
// You'd ship `ciphertext`, `nonce`, and `key_hash` to the server here.// On reply:let decrypted = session.decrypt(&response_ciphertext)?;When session drops, the underlying key buffer is zeroed automatically.
You can also construct a session from a hex-encoded key (32 bytes / 64 hex chars):
let s = ZeroKnowledgeSession::from_hex_key( "0123456789abcdef0123456789abcdef\ 0123456789abcdef0123456789abcdef")?;5. Server-Side Validation
The server runs three checks, in order, via ZkeRequestContext::validate:
- Key hash matches what was registered for the user (constant-time comparison).
- Nonce has not been seen inside the validity window (default 5 min).
- Timestamp is within the allowed clock-skew window.
use std::sync::Arc;use heliosdb_lite::crypto::zero_knowledge::{ NonceTracker, TimestampValidator, ZkeConfig, ZkeRequestContext, ZeroKnowledgeSession,};
let config = ZkeConfig::default(); // require_key_hash=true, replay=truelet nonce_tracker = Arc::new(NonceTracker::default());
let session = ZeroKnowledgeSession::from_hex_key(client_provided_hex_key)?;let context = ZkeRequestContext::new(session, nonce_tracker.clone(), config);
context.validate( Some(client_key_hash_hex), // must match Some(&client_nonce), // must not have been seen Some(client_timestamp_secs), // must be inside clock-skew window)?;
// Use the context to decrypt the SQL, execute, encrypt the response.let plain_sql = context.decrypt(&request_ciphertext)?;// ... execute plain_sql ...let resp_ct = context.encrypt(&response_plaintext)?;If any of the three checks fails, validate returns an Error::encryption(...) with the specific reason — typically "Replay attack detected: nonce already used" or "Key hash validation failed".
6. Replay Protection — NonceTracker
The tracker keeps a HashSet of seen nonces with timestamps, evicts on a sliding window, and force-cleans the oldest 10% when capacity is hit:
use heliosdb_lite::crypto::zero_knowledge::NonceTracker;
let tracker = NonceTracker::new( 300, // window_secs: 5 minutes 10_000, // max_cached_nonces);
let nonce: [u8; 16] = rand::random();assert!(tracker.check_and_record(&nonce)); // first use okassert!(!tracker.check_and_record(&nonce)); // replay rejectedTune window_secs to the longest reasonable round-trip in your environment. max_cached_nonces × per-nonce-cost (~20 bytes amortised) bounds memory.
7. Configuration
use heliosdb_lite::crypto::zero_knowledge::{ZkeConfig, ZkeMode};
let cfg = ZkeConfig { mode: ZkeMode::PerRequest, require_key_hash: true, replay_protection: true, nonce_window_secs: 300, max_cached_nonces: 10_000,};require_key_hash = false skips the hash check — useful only for development. Leave both flags on in production.
8. Memory Hygiene
The Zeroizing wrapper around the keys means a panic mid-request still triggers the zeroing destructor. To guarantee the same for any intermediate buffers you derive:
use zeroize::Zeroizing;
let intermediate: Zeroizing<Vec<u8>> = Zeroizing::new( session.decrypt(&ciphertext)?);// Use `intermediate` ... it'll be zeroed when this scope ends.For long-lived caches, Zeroizing is overkill — but for any buffer that touches a key or plaintext sensitive material, it’s free safety.
Where Next
- ENCRYPTION_TUTORIAL — TDE setup, AES-256-GCM at rest.
- DATABASE_BRANCHING_TUTORIAL — branches inherit encryption settings.
- MULTI_TENANT_QUOTAS_TUTORIAL — per-tenant key derivation with shared infrastructure.
- ENCRYPTION_QUICKSTART — the 60-second cheat sheet.