Skip to main content

ai_memory/encryption/
mod.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 (issue #228) — E2E memory content encryption at rest.
5//!
6//! This module is the substrate primitive for end-to-end encryption of
7//! memory `content` columns at rest. It pairs a per-agent X25519 ECDH
8//! keypair with ChaCha20-Poly1305 AEAD encryption so a single recipient
9//! (an agent identified by `agent_id`) can decrypt content encrypted to
10//! its public key.
11//!
12//! ## Wire shape
13//!
14//! Each encrypted payload is serialised as a self-describing [`Envelope`]
15//! and persisted into the new `memories.encrypted_envelope` BLOB column
16//! (schema v44). The envelope layout is the byte concatenation of:
17//!
18//! ```text
19//! version (1 byte = 0x02)
20//! ephemeral_pub (32 bytes — X25519 sender ephemeral pubkey)
21//! nonce (12 bytes — ChaCha20-Poly1305 nonce, random)
22//! ciphertext_with_tag (variable — AEAD ciphertext + 16-byte tag)
23//! ```
24//!
25//! The recipient's static X25519 secret key (per-agent, generated and
26//! cached via [`get_or_create_keypair`]) plus the envelope's ephemeral
27//! pubkey produce the shared secret. That secret is **not** used directly
28//! as the symmetric key — H3 runs it through HKDF-SHA256 (domain-separated
29//! by [`HKDF_INFO`]) to derive the ChaCha20-Poly1305 key, and binds the
30//! envelope version + ephemeral pubkey into the AEAD associated data (AAD)
31//! so the header cannot be swapped without failing authentication. Derived
32//! key material is zeroized immediately after the cipher is constructed.
33//!
34//! ## Key lifecycle
35//!
36//! Keypairs live in-memory only by default (per-process cache). A
37//! follow-up issue will add on-disk persistence under
38//! `[`crate::identity::keypair`]`-style files; today the in-memory
39//! cache is sufficient for the encrypt → store → recall → decrypt round
40//! trip exercised by `tests/encryption_at_rest.rs`.
41//!
42//! ## Activation
43//!
44//! Callers gate at-rest encryption behind either:
45//!
46//! * The `[encryption].at_rest = true` config field (operator opt-in
47//!   via `config.toml`), OR
48//! * The `AI_MEMORY_ENCRYPT_AT_REST=1` environment variable (CLI /
49//!   container-runtime opt-in).
50//!
51//! Both surfaces feed the same [`encryption_enabled`] gate, which the
52//! storage write path consults before invoking [`encrypt`] / [`decrypt`].
53
54use anyhow::{Context, Result, anyhow};
55use chacha20poly1305::aead::{Aead, KeyInit, Payload};
56use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
57use hkdf::Hkdf;
58use rand_core::{OsRng, RngCore};
59use sha2::Sha256;
60use std::collections::HashMap;
61use std::sync::Mutex;
62use x25519_dalek::{PublicKey, SharedSecret, StaticSecret};
63use zeroize::Zeroize;
64
65/// Envelope wire-version. Bumped when the byte layout OR the
66/// cryptographic scheme (KDF / AAD construction) changes; readers refuse
67/// unknown versions with a typed error so a bump doesn't silently
68/// mis-parse or mis-decrypt legacy rows. `0x02` introduced the H3
69/// HKDF-SHA256 key derivation + AAD header binding (the `0x01` MVP used
70/// the raw X25519 output directly with empty AAD).
71pub const ENVELOPE_VERSION: u8 = 0x02;
72
73/// X25519 pubkey length in bytes.
74pub const PUBKEY_LEN: usize = 32;
75
76/// ChaCha20-Poly1305 nonce length in bytes.
77pub const NONCE_LEN: usize = 12;
78
79/// ChaCha20-Poly1305 AEAD tag length in bytes (appended to ciphertext
80/// by `Aead::encrypt`).
81pub const TAG_LEN: usize = 16;
82
83/// ChaCha20-Poly1305 key length in bytes, and therefore the HKDF output
84/// length the [`derive_aead_key`] expand step produces.
85pub const AEAD_KEY_LEN: usize = 32;
86
87/// HKDF `info` (domain-separation label) for deriving the AEAD key from
88/// the X25519 shared secret. Encodes the crate, scheme version, and use
89/// so the same shared secret would derive a different key under any other
90/// label — preventing cross-protocol key reuse. Tied to the `0x02`
91/// envelope scheme; a future scheme bump rotates this label too.
92const HKDF_INFO: &[u8] = b"ai-memory/v0.7.0/e2e-content/chacha20poly1305-key/v2";
93
94/// Per-agent X25519 keypair. The static-secret variant supports cloning
95/// so the per-process cache can hand out copies without re-deriving
96/// from the random generator.
97#[derive(Clone)]
98pub struct Keypair {
99    pub agent_id: String,
100    pub public: PublicKey,
101    pub secret: StaticSecret,
102}
103
104impl std::fmt::Debug for Keypair {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        // Never print the secret material.
107        f.debug_struct("Keypair")
108            .field("agent_id", &self.agent_id)
109            .field("public", &"<x25519 pubkey>")
110            .field("secret", &crate::REDACTED_PLACEHOLDER)
111            .finish()
112    }
113}
114
115/// Decrypt-able envelope produced by [`encrypt`]. Carries the sender's
116/// ephemeral X25519 pubkey + the AEAD nonce + the ciphertext-with-tag.
117/// [`Envelope::to_bytes`] / [`Envelope::from_bytes`] handle the
118/// substrate-stable wire shape; storage callers persist the bytes
119/// verbatim into the `encrypted_envelope` column.
120#[derive(Debug, Clone)]
121pub struct Envelope {
122    pub ephemeral_pub: [u8; PUBKEY_LEN],
123    pub nonce: [u8; NONCE_LEN],
124    pub ciphertext: Vec<u8>,
125}
126
127impl Envelope {
128    /// Serialise the envelope to its on-disk byte layout. See module
129    /// docs for the layout. Length = 1 + 32 + 12 + ciphertext.len().
130    #[must_use]
131    pub fn to_bytes(&self) -> Vec<u8> {
132        let mut out = Vec::with_capacity(1 + PUBKEY_LEN + NONCE_LEN + self.ciphertext.len());
133        out.push(ENVELOPE_VERSION);
134        out.extend_from_slice(&self.ephemeral_pub);
135        out.extend_from_slice(&self.nonce);
136        out.extend_from_slice(&self.ciphertext);
137        out
138    }
139
140    /// Parse the envelope back out of its on-disk byte layout. Refuses
141    /// unknown versions and truncated buffers with a typed error so a
142    /// corrupted row surfaces cleanly instead of decrypting garbage.
143    ///
144    /// # Errors
145    /// * Returns `Err` when the buffer is too short to contain the
146    ///   fixed header (version + ephemeral_pub + nonce) plus at least
147    ///   one byte of ciphertext-with-tag.
148    /// * Returns `Err` when the leading version byte is not
149    ///   [`ENVELOPE_VERSION`].
150    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
151        let header_len = 1 + PUBKEY_LEN + NONCE_LEN;
152        if bytes.len() < header_len + TAG_LEN {
153            return Err(anyhow!(
154                "envelope buffer too short: got {} bytes, need at least {}",
155                bytes.len(),
156                header_len + TAG_LEN
157            ));
158        }
159        if bytes[0] != ENVELOPE_VERSION {
160            return Err(anyhow!(
161                "unknown envelope version: got 0x{:02x}, expected 0x{:02x}",
162                bytes[0],
163                ENVELOPE_VERSION
164            ));
165        }
166        let mut ephemeral_pub = [0u8; PUBKEY_LEN];
167        ephemeral_pub.copy_from_slice(&bytes[1..1 + PUBKEY_LEN]);
168        let mut nonce = [0u8; NONCE_LEN];
169        nonce.copy_from_slice(&bytes[1 + PUBKEY_LEN..header_len]);
170        let ciphertext = bytes[header_len..].to_vec();
171        Ok(Envelope {
172            ephemeral_pub,
173            nonce,
174            ciphertext,
175        })
176    }
177}
178
179/// Process-wide cache of per-agent X25519 keypairs. The cache is
180/// populated lazily on first [`get_or_create_keypair`] call for each
181/// `agent_id` and persists for the lifetime of the process. A future
182/// issue will swap this for an on-disk store; the in-memory shape lets
183/// the encryption substrate land without forcing a key-rotation tool
184/// design decision in the same patch.
185///
186/// v0.7.x (issue #1174 follow-up #1196) — the cache lives on
187/// [`crate::runtime_context::RuntimeContext::keypair_cache`]. The
188/// returned `&'static` reference is stable because
189/// `RuntimeContext::global()` itself is a `OnceLock`-backed
190/// process-wide singleton; the `Arc<Mutex<HashMap<...>>>` inside it
191/// is allocated once and outlives every caller.
192fn keypair_cache() -> &'static Mutex<HashMap<String, Keypair>> {
193    &crate::runtime_context::RuntimeContext::global().keypair_cache
194}
195
196/// Look up the per-agent X25519 [`Keypair`], generating + caching it on
197/// first call. Subsequent calls for the same `agent_id` return clones
198/// of the cached entry, so plaintext encrypt + recall + decrypt within
199/// a single process always round-trips through the same recipient
200/// secret.
201///
202/// # Errors
203/// * Returns `Err` only when the internal mutex is poisoned (a callee
204///   panic in another thread while the lock was held). This is a
205///   process-fatal condition; callers may treat it as such.
206pub fn get_or_create_keypair(agent_id: &str) -> Result<Keypair> {
207    let cache = keypair_cache();
208    let mut guard = cache
209        .lock()
210        .map_err(|e| anyhow!("encryption keypair cache mutex poisoned: {e}"))?;
211    if let Some(kp) = guard.get(agent_id) {
212        return Ok(kp.clone());
213    }
214    let secret = StaticSecret::random_from_rng(OsRng);
215    let public = PublicKey::from(&secret);
216    let kp = Keypair {
217        agent_id: agent_id.to_string(),
218        public,
219        secret,
220    };
221    guard.insert(agent_id.to_string(), kp.clone());
222    Ok(kp)
223}
224
225/// H3 — derive the ChaCha20-Poly1305 key from the raw X25519 shared
226/// secret via HKDF-SHA256.
227///
228/// Raw ECDH output is a curve point's `u`-coordinate, not a uniformly
229/// distributed symmetric key. HKDF (extract-then-expand) conditions it
230/// into a clean [`AEAD_KEY_LEN`]-byte key and, via the [`HKDF_INFO`]
231/// domain-separation label, isolates this key space from any other use
232/// of the same shared secret. Salt is `None` (the empty/all-zero salt) —
233/// standard for ECDH-derived keys where there is no pre-shared random
234/// salt; binding context lives in `info` (here) and the AEAD AAD.
235///
236/// The returned array must be zeroized by the caller once the cipher has
237/// been constructed.
238fn derive_aead_key(shared: &SharedSecret) -> [u8; AEAD_KEY_LEN] {
239    let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
240    let mut okm = [0u8; AEAD_KEY_LEN];
241    // `expand` only errors when the requested length exceeds 255*HashLen
242    // (255*32 = 8160 bytes); AEAD_KEY_LEN is far below that bound, so this
243    // is infallible by construction.
244    hk.expand(HKDF_INFO, &mut okm)
245        .expect("HKDF expand of AEAD_KEY_LEN bytes is within the 255*HashLen limit");
246    okm
247}
248
249/// H3 — associated data bound into every AEAD operation: the envelope
250/// version followed by the sender's ephemeral pubkey. Authenticating
251/// these header fields means an attacker cannot downgrade the version or
252/// substitute a different ephemeral key without failing the AEAD tag
253/// check. Both [`encrypt`] and [`decrypt`] construct the AAD identically
254/// from the same fields, so a well-formed envelope always verifies.
255fn envelope_aad(ephemeral_pub: &[u8; PUBKEY_LEN]) -> [u8; 1 + PUBKEY_LEN] {
256    let mut aad = [0u8; 1 + PUBKEY_LEN];
257    aad[0] = ENVELOPE_VERSION;
258    aad[1..].copy_from_slice(ephemeral_pub);
259    aad
260}
261
262/// Encrypt `content` to the given recipient X25519 public key, returning
263/// a self-describing [`Envelope`].
264///
265/// The sender generates an ephemeral X25519 secret on every call; the
266/// matching ephemeral public key is included in the envelope so the
267/// recipient can derive the same shared secret. H3: the shared secret is
268/// run through HKDF-SHA256 ([`derive_aead_key`]) to produce the AEAD key
269/// — never used raw — and the envelope version + ephemeral pubkey are
270/// bound into the AEAD associated data ([`envelope_aad`]). The derived
271/// key is zeroized immediately after the cipher is built.
272///
273/// # Errors
274/// * Returns `Err` when the underlying AEAD encrypt call fails (should
275///   not happen in practice for in-memory inputs of any size; rusqlite
276///   already bounds content length).
277pub fn encrypt(content: &str, recipient_pk: &PublicKey) -> Result<Envelope> {
278    let ephemeral_secret = StaticSecret::random_from_rng(OsRng);
279    let ephemeral_public = PublicKey::from(&ephemeral_secret);
280    let shared = ephemeral_secret.diffie_hellman(recipient_pk);
281
282    let mut okm = derive_aead_key(&shared);
283    let cipher = ChaCha20Poly1305::new(Key::from_slice(&okm));
284    okm.zeroize();
285
286    let mut nonce_bytes = [0u8; NONCE_LEN];
287    OsRng.fill_bytes(&mut nonce_bytes);
288    let nonce = Nonce::from_slice(&nonce_bytes);
289
290    let ephemeral_pub = ephemeral_public.to_bytes();
291    let aad = envelope_aad(&ephemeral_pub);
292    let ciphertext = cipher
293        .encrypt(
294            nonce,
295            Payload {
296                msg: content.as_bytes(),
297                aad: &aad,
298            },
299        )
300        .map_err(|e| anyhow!("ChaCha20-Poly1305 encrypt failed: {e}"))?;
301
302    Ok(Envelope {
303        ephemeral_pub,
304        nonce: nonce_bytes,
305        ciphertext,
306    })
307}
308
309/// Decrypt an [`Envelope`] using the recipient's static X25519 secret
310/// key (`my_sk`). Returns the original UTF-8 plaintext.
311///
312/// Mirrors [`encrypt`]: derives the AEAD key from the shared secret via
313/// HKDF-SHA256 and reconstructs the same version+ephemeral-pubkey AAD, so
314/// any header tampering surfaces as an authentication failure.
315///
316/// # Errors
317/// * Returns `Err` when the AEAD verification fails (tampered
318///   ciphertext, swapped header, wrong recipient key, truncated nonce,
319///   etc.).
320/// * Returns `Err` when the decrypted bytes are not valid UTF-8 — the
321///   write path always feeds `&str`, so a UTF-8 failure on read is a
322///   corruption signal.
323pub fn decrypt(envelope: &Envelope, my_sk: &StaticSecret) -> Result<String> {
324    let ephemeral_public = PublicKey::from(envelope.ephemeral_pub);
325    let shared = my_sk.diffie_hellman(&ephemeral_public);
326
327    let mut okm = derive_aead_key(&shared);
328    let cipher = ChaCha20Poly1305::new(Key::from_slice(&okm));
329    okm.zeroize();
330
331    let nonce = Nonce::from_slice(&envelope.nonce);
332    let aad = envelope_aad(&envelope.ephemeral_pub);
333    let plaintext = cipher
334        .decrypt(
335            nonce,
336            Payload {
337                msg: &envelope.ciphertext,
338                aad: &aad,
339            },
340        )
341        .map_err(|e| anyhow!("ChaCha20-Poly1305 decrypt failed (authentication): {e}"))?;
342
343    String::from_utf8(plaintext).context("decrypted plaintext is not valid UTF-8")
344}
345
346/// Consult the [encryption].at_rest config flag OR the
347/// `AI_MEMORY_ENCRYPT_AT_REST=1` env var. Truthy env values:
348/// `1` / `true` / `yes` / `on` (case-insensitive). Used by the storage
349/// write path to gate the encrypt-on-insert / decrypt-on-read branches.
350///
351/// The config flag is consulted first when present, then the env var.
352/// Either truthy source enables encryption. This mirrors the precedence
353/// shape of the existing `AI_MEMORY_PERMISSIONS_MODE` config knob.
354#[must_use]
355pub fn encryption_enabled(config_flag: Option<bool>) -> bool {
356    if let Some(true) = config_flag {
357        return true;
358    }
359    matches!(
360        std::env::var("AI_MEMORY_ENCRYPT_AT_REST")
361            .ok()
362            .as_deref()
363            .map(str::to_ascii_lowercase)
364            .as_deref(),
365        Some("1" | "true" | "yes" | "on")
366    )
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn keypair_round_trip_returns_same_secret() {
375        // Cache-hit path: second call returns the same secret material.
376        let agent = "test-agent-roundtrip";
377        let a = get_or_create_keypair(agent).expect("first generate");
378        let b = get_or_create_keypair(agent).expect("second fetch");
379        assert_eq!(a.public.as_bytes(), b.public.as_bytes());
380        assert_eq!(a.secret.to_bytes(), b.secret.to_bytes());
381    }
382
383    #[test]
384    fn keypair_distinct_for_distinct_agents() {
385        let a = get_or_create_keypair("agent-a").expect("a");
386        let b = get_or_create_keypair("agent-b").expect("b");
387        assert_ne!(a.public.as_bytes(), b.public.as_bytes());
388    }
389
390    #[test]
391    fn encrypt_decrypt_round_trip_recovers_plaintext() {
392        let kp = get_or_create_keypair("roundtrip-agent").expect("keypair");
393        let plaintext = "hello world — encryption substrate MVP";
394        let env = encrypt(plaintext, &kp.public).expect("encrypt");
395        let recovered = decrypt(&env, &kp.secret).expect("decrypt");
396        assert_eq!(recovered, plaintext);
397    }
398
399    #[test]
400    fn envelope_wire_format_round_trips() {
401        let kp = get_or_create_keypair("envelope-bytes").expect("kp");
402        let env = encrypt("payload bytes", &kp.public).expect("encrypt");
403        let bytes = env.to_bytes();
404        let parsed = Envelope::from_bytes(&bytes).expect("parse");
405        assert_eq!(env.ephemeral_pub, parsed.ephemeral_pub);
406        assert_eq!(env.nonce, parsed.nonce);
407        assert_eq!(env.ciphertext, parsed.ciphertext);
408        // And the round-tripped envelope decrypts.
409        let recovered = decrypt(&parsed, &kp.secret).expect("decrypt parsed");
410        assert_eq!(recovered, "payload bytes");
411    }
412
413    #[test]
414    fn envelope_parse_rejects_short_buffer() {
415        assert!(Envelope::from_bytes(&[]).is_err());
416        assert!(Envelope::from_bytes(&[0x01; 10]).is_err());
417    }
418
419    #[test]
420    fn envelope_parse_rejects_unknown_version() {
421        let mut bad = vec![0xFF];
422        bad.extend_from_slice(&[0u8; PUBKEY_LEN + NONCE_LEN + TAG_LEN + 1]);
423        assert!(Envelope::from_bytes(&bad).is_err());
424    }
425
426    #[test]
427    fn decrypt_with_wrong_secret_fails() {
428        let kp_alice = get_or_create_keypair("alice-wrong-key").expect("alice");
429        let kp_eve = get_or_create_keypair("eve-wrong-key").expect("eve");
430        let env = encrypt("secret-for-alice", &kp_alice.public).expect("encrypt");
431        // Eve cannot decrypt Alice's payload — AEAD authentication fails.
432        assert!(decrypt(&env, &kp_eve.secret).is_err());
433    }
434
435    #[test]
436    fn decrypt_with_tampered_ciphertext_fails() {
437        let kp = get_or_create_keypair("tamper-detect").expect("kp");
438        let mut env = encrypt("dont change this", &kp.public).expect("encrypt");
439        // Flip a bit in the ciphertext — AEAD authentication catches it.
440        env.ciphertext[0] ^= 0x01;
441        assert!(decrypt(&env, &kp.secret).is_err());
442    }
443
444    // --- v0.7.0 H3 — HKDF key derivation + AAD header binding ---
445
446    #[test]
447    fn hkdf_derived_key_is_deterministic_and_differs_from_raw_shared_secret() {
448        // Same shared secret -> same derived key (decrypt must reproduce
449        // the encrypt-side key), but the derived key must NOT equal the
450        // raw X25519 output — proving HKDF actually conditions the secret
451        // rather than passing it through.
452        let alice = get_or_create_keypair("h3-hkdf-alice").expect("alice");
453        let bob = get_or_create_keypair("h3-hkdf-bob").expect("bob");
454        let shared_a = alice.secret.diffie_hellman(&bob.public);
455        let shared_b = bob.secret.diffie_hellman(&alice.public);
456        // ECDH symmetry precondition.
457        assert_eq!(shared_a.as_bytes(), shared_b.as_bytes());
458
459        let key1 = derive_aead_key(&shared_a);
460        let key2 = derive_aead_key(&shared_b);
461        assert_eq!(key1, key2, "HKDF derivation must be deterministic");
462        assert_eq!(key1.len(), AEAD_KEY_LEN);
463        assert_ne!(
464            &key1,
465            shared_a.as_bytes(),
466            "derived key must not be the raw shared secret (HKDF must transform it)"
467        );
468    }
469
470    #[test]
471    fn envelope_aad_binds_version_and_ephemeral_pub() {
472        let pubkey = [7u8; PUBKEY_LEN];
473        let aad = envelope_aad(&pubkey);
474        assert_eq!(aad.len(), 1 + PUBKEY_LEN);
475        assert_eq!(aad[0], ENVELOPE_VERSION, "AAD[0] must pin the version");
476        assert_eq!(&aad[1..], &pubkey, "AAD tail must be the ephemeral pubkey");
477    }
478
479    #[test]
480    fn decrypt_fails_when_ephemeral_pub_swapped() {
481        // Swapping the envelope's ephemeral pubkey for another valid one
482        // must fail: it both changes the ECDH shared secret AND breaks the
483        // AAD binding. No silent plaintext recovery.
484        let kp = get_or_create_keypair("h3-aad-swap").expect("kp");
485        let mut env = encrypt("aad-bound payload", &kp.public).expect("encrypt");
486        let other = get_or_create_keypair("h3-aad-swap-other").expect("other");
487        env.ephemeral_pub = other.public.to_bytes();
488        assert!(
489            decrypt(&env, &kp.secret).is_err(),
490            "a swapped ephemeral pubkey must fail AEAD authentication"
491        );
492    }
493
494    #[test]
495    fn envelope_version_is_the_hkdf_aad_scheme() {
496        // Pin the scheme marker so an accidental revert to the raw-DH MVP
497        // (0x01) is caught: the on-the-wire version byte must be 0x02.
498        let kp = get_or_create_keypair("h3-version-pin").expect("kp");
499        let env = encrypt("scheme marker", &kp.public).expect("encrypt");
500        assert_eq!(ENVELOPE_VERSION, 0x02);
501        assert_eq!(env.to_bytes()[0], 0x02, "wire version byte must be 0x02");
502    }
503
504    #[test]
505    fn encryption_enabled_config_flag_wins() {
506        // Save + clear the env var so other tests aren't perturbed.
507        let prev = std::env::var("AI_MEMORY_ENCRYPT_AT_REST").ok();
508        // SAFETY: tests run with serial scope around env-var mutation in
509        // the keypair-cache module; this single-threaded read/restore is
510        // safe for the assertions below.
511        unsafe { std::env::remove_var("AI_MEMORY_ENCRYPT_AT_REST") };
512        assert!(encryption_enabled(Some(true)));
513        assert!(!encryption_enabled(Some(false)));
514        assert!(!encryption_enabled(None));
515        unsafe { std::env::set_var("AI_MEMORY_ENCRYPT_AT_REST", "1") };
516        assert!(encryption_enabled(None));
517        assert!(encryption_enabled(Some(true)));
518        // Restore.
519        if let Some(v) = prev {
520            unsafe { std::env::set_var("AI_MEMORY_ENCRYPT_AT_REST", v) };
521        } else {
522            unsafe { std::env::remove_var("AI_MEMORY_ENCRYPT_AT_REST") };
523        }
524    }
525}