Skip to main content

qualia_core_db/crypto/
sanctuary_audit.rs

1//! Sanctuary audit primitives (vault v2, first slice) — the crypto under the decoy-mirroring design.
2//!
3//! Three isolated, independently-tested primitives. Nothing here touches the vault yet; the vault
4//! wiring is a later slice. See `docs/plans/adr-sanctuary-vault-v2-cbor-decoy-mirroring.md`.
5//!
6//! 1. **Blind write-only audit channel** — an **X25519 sealed box** (anonymous ECIES): a decoy
7//!    session, holding only the audit *public* key, can [`seal_to`] a record so that only the holder
8//!    of the audit *secret* (the real lane) can [`open_sealed`] it. The writer cannot read back what
9//!    it wrote, and cannot forge or tamper without detection. This is how a coercer's actions get
10//!    logged into a channel they can append to but never read.
11//! 2. **One-way key wrapping** — [`wrap_key`]/[`unwrap_key`]: the real lane key wraps the decoy lane
12//!    key (and the audit secret), so a real session can reach *down* into the decoy to curate it,
13//!    but the decoy can never reach *up*.
14//! 3. **Hash-chained content addressing** — [`chain_hash`]: an append-only, tamper-evident DAG link
15//!    (BLAKE3 over `parent ‖ payload`); rewriting or dropping a record breaks every link after it.
16//!
17//! Symmetric AEAD is XChaCha20-Poly1305 (24-byte nonce). Sealed-box key/nonce are derived from the
18//! ECDH shared secret via BLAKE3 `derive_key` (domain-separated); each seal uses a fresh ephemeral
19//! key, so sealing is non-deterministic (no plaintext-equality leak) and nonce reuse is impossible.
20
21use chacha20poly1305::aead::{AeadInOut, KeyInit};
22use chacha20poly1305::XChaCha20Poly1305;
23use x25519_dalek::{PublicKey, StaticSecret};
24use zeroize::{Zeroize, ZeroizeOnDrop};
25
26const TAG_BYTES: usize = 16;
27const EPK_BYTES: usize = 32;
28const XNONCE_BYTES: usize = 24;
29
30const SEAL_KEY_CTX: &str = "q42:sanctuary:audit:seal:key:v1";
31const SEAL_NONCE_CTX: &str = "q42:sanctuary:audit:seal:nonce:v1";
32const CHAIN_HASH_LEN: usize = 32;
33
34/// Genesis parent for a fresh hash chain / DAG branch.
35pub const GENESIS_PARENT: [u8; CHAIN_HASH_LEN] = [0u8; CHAIN_HASH_LEN];
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SanctuaryAuditError {
39    Rng,
40    Encrypt,
41    Decrypt,
42    Malformed,
43}
44
45fn rand_bytes<const N: usize>() -> Result<[u8; N], SanctuaryAuditError> {
46    let mut b = [0u8; N];
47    getrandom::fill(&mut b).map_err(|_| SanctuaryAuditError::Rng)?;
48    Ok(b)
49}
50
51/// An audit keypair. The **public** key is exposed to any session (including the decoy) so it can
52/// append sealed records; the **secret** lives only in the real lane (wrapped under the real key)
53/// and is the sole means of reading them.
54#[derive(Zeroize, ZeroizeOnDrop)]
55pub struct AuditKeypair {
56    #[zeroize(skip)]
57    pub public: [u8; 32],
58    secret: [u8; 32],
59}
60
61impl AuditKeypair {
62    pub fn generate() -> Result<Self, SanctuaryAuditError> {
63        let secret = rand_bytes::<32>()?;
64        let public = PublicKey::from(&StaticSecret::from(secret)).to_bytes();
65        Ok(Self { public, secret })
66    }
67
68    /// Construct a keypair from a caller-supplied 32-byte secret (e.g. a KDF-derived key). Lets an envelope
69    /// keypair be **re-derived from a root secret** rather than stored at rest. `StaticSecret::from` clamps
70    /// the scalar deterministically, so any 32 bytes are a valid secret and `seal_to(public)` /
71    /// `open_sealed(secret)` stay consistent.
72    pub fn from_secret(secret: [u8; 32]) -> Self {
73        let public = PublicKey::from(&StaticSecret::from(secret)).to_bytes();
74        Self { public, secret }
75    }
76
77    /// The secret half — hold this only inside the real lane.
78    pub fn secret_bytes(&self) -> &[u8; 32] {
79        &self.secret
80    }
81}
82
83fn derive_seal_key_nonce(
84    shared: &[u8],
85    ephemeral_public: &[u8; 32],
86    recipient_public: &[u8; 32],
87) -> ([u8; 32], [u8; XNONCE_BYTES]) {
88    // Bind the derivation to both endpoints so a record can't be replayed under another recipient.
89    let mut km = Vec::with_capacity(shared.len() + 64);
90    km.extend_from_slice(shared);
91    km.extend_from_slice(ephemeral_public);
92    km.extend_from_slice(recipient_public);
93    let key = blake3::derive_key(SEAL_KEY_CTX, &km);
94    let nonce_full = blake3::derive_key(SEAL_NONCE_CTX, &km);
95    let mut nonce = [0u8; XNONCE_BYTES];
96    nonce.copy_from_slice(&nonce_full[..XNONCE_BYTES]);
97    (key, nonce)
98}
99
100fn cipher_for(key: &[u8; 32]) -> Result<XChaCha20Poly1305, SanctuaryAuditError> {
101    let key =
102        <&chacha20poly1305::Key>::try_from(&key[..]).map_err(|_| SanctuaryAuditError::Encrypt)?;
103    Ok(XChaCha20Poly1305::new(key))
104}
105
106fn aead_seal(
107    key: &[u8; 32],
108    nonce: &[u8; XNONCE_BYTES],
109    plaintext: &[u8],
110    aad: &[u8],
111) -> Result<Vec<u8>, SanctuaryAuditError> {
112    let cipher = cipher_for(key)?;
113    let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
114        .map_err(|_| SanctuaryAuditError::Encrypt)?;
115    let mut buffer = plaintext.to_vec();
116    let tag = cipher
117        .encrypt_inout_detached(nonce, aad, buffer.as_mut_slice().into())
118        .map_err(|_| SanctuaryAuditError::Encrypt)?;
119    buffer.extend_from_slice(tag.as_slice());
120    Ok(buffer)
121}
122
123fn aead_open(
124    key: &[u8; 32],
125    nonce: &[u8; XNONCE_BYTES],
126    ct_and_tag: &[u8],
127    aad: &[u8],
128) -> Result<Vec<u8>, SanctuaryAuditError> {
129    if ct_and_tag.len() < TAG_BYTES {
130        return Err(SanctuaryAuditError::Malformed);
131    }
132    let split = ct_and_tag.len() - TAG_BYTES;
133    let (ct, tag_bytes) = ct_and_tag.split_at(split);
134    let cipher = cipher_for(key)?;
135    let nonce = <&chacha20poly1305::XNonce>::try_from(&nonce[..])
136        .map_err(|_| SanctuaryAuditError::Decrypt)?;
137    let tag = <&chacha20poly1305::Tag>::try_from(tag_bytes)
138        .map_err(|_| SanctuaryAuditError::Malformed)?;
139    let mut buffer = ct.to_vec();
140    cipher
141        .decrypt_inout_detached(nonce, aad, buffer.as_mut_slice().into(), tag)
142        .map_err(|_| SanctuaryAuditError::Decrypt)?;
143    Ok(buffer)
144}
145
146/// Seal `plaintext` so that only the holder of the secret matching `recipient_public` can open it.
147/// Anonymous: the sealer needs no identity, only the recipient's public key. Output layout:
148/// `ephemeral_public(32) ‖ ciphertext ‖ tag(16)`. Non-deterministic (fresh ephemeral key per call).
149pub fn seal_to(
150    recipient_public: &[u8; 32],
151    plaintext: &[u8],
152    aad: &[u8],
153) -> Result<Vec<u8>, SanctuaryAuditError> {
154    let ephemeral_secret = StaticSecret::from(rand_bytes::<32>()?);
155    let ephemeral_public = PublicKey::from(&ephemeral_secret).to_bytes();
156    let shared = ephemeral_secret.diffie_hellman(&PublicKey::from(*recipient_public));
157    let (key, nonce) =
158        derive_seal_key_nonce(shared.as_bytes(), &ephemeral_public, recipient_public);
159    let body = aead_seal(&key, &nonce, plaintext, aad)?;
160    let mut out = Vec::with_capacity(EPK_BYTES + body.len());
161    out.extend_from_slice(&ephemeral_public);
162    out.extend_from_slice(&body);
163    Ok(out)
164}
165
166/// Open a sealed box produced by [`seal_to`]. Requires the recipient secret; the public key alone
167/// cannot open it (that is the whole point — the decoy session writes but cannot read).
168pub fn open_sealed(
169    recipient_secret: &[u8; 32],
170    sealed: &[u8],
171    aad: &[u8],
172) -> Result<Vec<u8>, SanctuaryAuditError> {
173    if sealed.len() < EPK_BYTES + TAG_BYTES {
174        return Err(SanctuaryAuditError::Malformed);
175    }
176    let mut ephemeral_public = [0u8; 32];
177    ephemeral_public.copy_from_slice(&sealed[..EPK_BYTES]);
178    let body = &sealed[EPK_BYTES..];
179
180    let secret = StaticSecret::from(*recipient_secret);
181    let recipient_public = PublicKey::from(&secret).to_bytes();
182    let shared = secret.diffie_hellman(&PublicKey::from(ephemeral_public));
183    let (key, nonce) =
184        derive_seal_key_nonce(shared.as_bytes(), &ephemeral_public, &recipient_public);
185    aead_open(&key, &nonce, body, aad)
186}
187
188/// Wrap `key_material` under `wrapping_key` (AEAD). Output: `nonce(24) ‖ ciphertext ‖ tag(16)`.
189/// Used for the one-way hierarchy: the real lane key wraps the decoy key + the audit secret.
190pub fn wrap_key(
191    wrapping_key: &[u8; 32],
192    key_material: &[u8],
193    aad: &[u8],
194) -> Result<Vec<u8>, SanctuaryAuditError> {
195    let nonce = rand_bytes::<XNONCE_BYTES>()?;
196    let body = aead_seal(wrapping_key, &nonce, key_material, aad)?;
197    let mut out = Vec::with_capacity(XNONCE_BYTES + body.len());
198    out.extend_from_slice(&nonce);
199    out.extend_from_slice(&body);
200    Ok(out)
201}
202
203/// Unwrap a blob produced by [`wrap_key`]. Fails on the wrong key, wrong AAD, or tampering.
204pub fn unwrap_key(
205    wrapping_key: &[u8; 32],
206    wrapped: &[u8],
207    aad: &[u8],
208) -> Result<Vec<u8>, SanctuaryAuditError> {
209    if wrapped.len() < XNONCE_BYTES + TAG_BYTES {
210        return Err(SanctuaryAuditError::Malformed);
211    }
212    let mut nonce = [0u8; XNONCE_BYTES];
213    nonce.copy_from_slice(&wrapped[..XNONCE_BYTES]);
214    aead_open(wrapping_key, &nonce, &wrapped[XNONCE_BYTES..], aad)
215}
216
217/// One append-only DAG link: `BLAKE3(parent ‖ payload)`. Rewriting or reordering any record changes
218/// its hash and breaks the parent link of everything after it (tamper-evidence). Start a branch from
219/// [`GENESIS_PARENT`].
220pub fn chain_hash(parent: &[u8; CHAIN_HASH_LEN], payload: &[u8]) -> [u8; CHAIN_HASH_LEN] {
221    let mut hasher = blake3::Hasher::new();
222    hasher.update(parent);
223    hasher.update(payload);
224    *hasher.finalize().as_bytes()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn sealed_box_round_trips() {
233        let kp = AuditKeypair::generate().unwrap();
234        let msg = b"decoy session 1: coercer added note at 12:04";
235        let sealed = seal_to(&kp.public, msg, b"branch:session-1").unwrap();
236        let opened = open_sealed(kp.secret_bytes(), &sealed, b"branch:session-1").unwrap();
237        assert_eq!(opened, msg);
238    }
239
240    #[test]
241    fn only_the_secret_holder_can_open() {
242        let kp = AuditKeypair::generate().unwrap();
243        let other = AuditKeypair::generate().unwrap();
244        let sealed = seal_to(&kp.public, b"evidence", b"").unwrap();
245        assert!(open_sealed(other.secret_bytes(), &sealed, b"").is_err());
246    }
247
248    #[test]
249    fn public_key_alone_cannot_read() {
250        // The whole point: the decoy session holds only the public key and must not be able to read
251        // what it sealed. Using the public bytes as if they were the secret does not recover it.
252        let kp = AuditKeypair::generate().unwrap();
253        let sealed = seal_to(&kp.public, b"only-the-real-lane-reads-this", b"").unwrap();
254        match open_sealed(&kp.public, &sealed, b"") {
255            Err(_) => {}
256            Ok(pt) => assert_ne!(pt.as_slice(), b"only-the-real-lane-reads-this"),
257        }
258    }
259
260    #[test]
261    fn tampered_sealed_box_is_rejected() {
262        let kp = AuditKeypair::generate().unwrap();
263        let mut sealed = seal_to(&kp.public, b"unaltered", b"").unwrap();
264        let mid = sealed.len() / 2;
265        sealed[mid] ^= 0xFF;
266        assert!(open_sealed(kp.secret_bytes(), &sealed, b"").is_err());
267    }
268
269    #[test]
270    fn aad_is_bound() {
271        let kp = AuditKeypair::generate().unwrap();
272        let sealed = seal_to(&kp.public, b"m", b"branch:session-1").unwrap();
273        assert!(open_sealed(kp.secret_bytes(), &sealed, b"branch:session-2").is_err());
274    }
275
276    #[test]
277    fn sealing_is_non_deterministic() {
278        // Fresh ephemeral key per seal => identical plaintext yields distinct ciphertext (no
279        // equality leak to whoever can see the audit region).
280        let kp = AuditKeypair::generate().unwrap();
281        let a = seal_to(&kp.public, b"same-note", b"").unwrap();
282        let b = seal_to(&kp.public, b"same-note", b"").unwrap();
283        assert_ne!(a, b);
284    }
285
286    #[test]
287    fn key_wrap_round_trips_and_binds_key_and_aad() {
288        let real_key = rand_bytes::<32>().unwrap();
289        let decoy_key = [7u8; 32];
290        let wrapped = wrap_key(&real_key, &decoy_key, b"role:decoy-lane-key").unwrap();
291        assert_eq!(
292            unwrap_key(&real_key, &wrapped, b"role:decoy-lane-key").unwrap(),
293            decoy_key
294        );
295        // Wrong wrapping key (the decoy cannot reach up).
296        assert!(unwrap_key(&[9u8; 32], &wrapped, b"role:decoy-lane-key").is_err());
297        // Wrong AAD.
298        assert!(unwrap_key(&real_key, &wrapped, b"role:something-else").is_err());
299    }
300
301    #[test]
302    fn hash_chain_is_deterministic_and_tamper_evident() {
303        let r1 = chain_hash(&GENESIS_PARENT, b"session-1 opened");
304        let r2 = chain_hash(&r1, b"note added: 'call me'");
305        let r3 = chain_hash(&r2, b"note edited");
306
307        // Deterministic.
308        assert_eq!(r1, chain_hash(&GENESIS_PARENT, b"session-1 opened"));
309        assert_ne!(r1, r2);
310        assert_ne!(r2, r3);
311
312        // Rewrite record 2's payload => r2 changes => r3's parent link no longer matches.
313        let r2_tampered = chain_hash(&r1, b"note added: 'do NOT call'");
314        assert_ne!(r2, r2_tampered);
315        assert_ne!(r3, chain_hash(&r2_tampered, b"note edited"));
316    }
317}