Skip to content

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

ConcernTDE alone+ ZKE
Disk theft / cold backupsEncryptedEncrypted
Memory dump while DB is runningPlaintext visibleEncrypted (key never on server)
Insider with DBA privilegesPlaintext visibleCannot decrypt
Replay of an old requestPossibleRejected 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:

  1. Key hash matches what was registered for the user (constant-time comparison).
  2. Nonce has not been seen inside the validity window (default 5 min).
  3. 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=true
let 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 ok
assert!(!tracker.check_and_record(&nonce)); // replay rejected

Tune 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