Skip to main content

spacedb_store/
crypto.rs

1//! The AEAD value boundary — the zero-knowledge linchpin.
2//!
3//! SpaceDB stores **opaque ciphertext**; the engine never sees plaintext. This
4//! module owns the crypto that makes that true, mirroring the shipped, audited
5//! MATA `dek_manager` envelope:
6//!
7//! - Each **collection** has a random 32-byte **DEK** (data encryption key) that
8//!   encrypts its rows.
9//! - The DEK is **wrapped** (AES-256-GCM) under the owner's **vault key**, with
10//!   the collection id bound as AAD so a wrapping cannot be relocated to another
11//!   collection. Rotating the passphrase/vault key **re-wraps the DEK**, never
12//!   the rows ([`rewrap_dek`]).
13//! - Rows are sealed under the DEK with a fresh nonce and an AAD that binds the
14//!   row's **location** — `table ‖ key ‖ schema_version` — so a ciphertext can't
15//!   be moved to a different key, table, or format version ([`seal_row`] /
16//!   [`open_row`]). This follows the `file_id ‖ chunk_index` precedent and ADR
17//!   0006 S2 ("AAD binds the field, not just the record").
18//!
19//! ## The open-core seam: [`KeyProvider`]
20//!
21//! `spacedb-store` does the wrapping itself but never *owns* the vault key — it
22//! asks for it through [`KeyProvider`], a trait an operator implements. The
23//! shipped [`StaticKeyProvider`] is enough for a local/self-hosted developer (who
24//! supplies a key derived from their passphrase); MATA's hosted product
25//! implements it against the Home Computer's cold-on-boot / warm-TTL vault
26//! coordinator, so a locked vault returns [`CryptoError::Cold`] and no row can be
27//! read. The key is fetched per operation precisely so that cold-gating is
28//! honoured mid-session rather than bypassed by a cached key.
29
30use aes_gcm::aead::{Aead, KeyInit, Payload};
31use aes_gcm::{Aes256Gcm, Key, Nonce};
32use rand::rngs::OsRng;
33use rand::RngCore;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36use zeroize::Zeroizing;
37
38use crate::error::StoreError;
39
40/// AES-256-GCM key length (the DEK and the vault key are both this size).
41pub const KEY_LEN: usize = 32;
42/// AES-256-GCM nonce length.
43pub const NONCE_LEN: usize = 12;
44/// AES-256-GCM authentication tag length (appended to ciphertext).
45pub const TAG_LEN: usize = 16;
46
47#[derive(Debug, Error)]
48pub enum CryptoError {
49    /// The vault is locked — no key material is available. Callers surface this
50    /// as "the user must unlock from a paired device" rather than an error.
51    #[error("vault is cold; unlock required")]
52    Cold,
53    /// AES-GCM encryption or, more importantly, **decryption/verification**
54    /// failed — corrupted ciphertext, a wrong key, or an AAD mismatch (a
55    /// ciphertext presented at the wrong location).
56    #[error("AEAD failure: {0}")]
57    Aead(String),
58    /// An unwrapped DEK was not exactly [`KEY_LEN`] bytes — a sign of corruption
59    /// or a wrap produced by a different scheme.
60    #[error("unwrapped DEK has wrong length: expected {KEY_LEN}, got {0}")]
61    BadDekLength(usize),
62    /// A sealed row was shorter than a bare nonce — truncated/corrupt.
63    #[error("sealed row too short: {0} bytes")]
64    ShortRow(usize),
65}
66
67impl From<CryptoError> for StoreError {
68    fn from(e: CryptoError) -> Self {
69        match e {
70            CryptoError::Cold => StoreError::Cold,
71            other => StoreError::Crypto(other.to_string()),
72        }
73    }
74}
75
76/// The seam through which an operator supplies the vault key.
77///
78/// Object-safe on purpose: a [`crate::Collection`] holds an `Arc<dyn KeyProvider>`
79/// so the store is not generic over the provider. Implementations MUST return
80/// [`CryptoError::Cold`] when no key is currently available, and SHOULD hand back
81/// a [`Zeroizing`] copy so the key is wiped when the borrow ends.
82pub trait KeyProvider: Send + Sync {
83    /// The 32-byte vault key, or [`CryptoError::Cold`] if the vault is locked.
84    fn vault_key(&self) -> Result<Zeroizing<[u8; KEY_LEN]>, CryptoError>;
85}
86
87/// A fixed-key provider. Suitable for a local/self-hosted developer who derives a
88/// 32-byte key from their passphrase (e.g. Argon2id) and holds it for the
89/// session. `cold()` models a locked vault for tests and for "no key yet" states.
90#[derive(Clone)]
91pub struct StaticKeyProvider {
92    key: Option<[u8; KEY_LEN]>,
93}
94
95impl StaticKeyProvider {
96    /// A warm provider that always yields `key`.
97    pub fn new(key: [u8; KEY_LEN]) -> Self {
98        Self { key: Some(key) }
99    }
100
101    /// A cold provider that always returns [`CryptoError::Cold`].
102    pub fn cold() -> Self {
103        Self { key: None }
104    }
105}
106
107impl KeyProvider for StaticKeyProvider {
108    fn vault_key(&self) -> Result<Zeroizing<[u8; KEY_LEN]>, CryptoError> {
109        self.key.map(Zeroizing::new).ok_or(CryptoError::Cold)
110    }
111}
112
113/// A DEK encrypted under the vault key. The collection id it is bound to is the
114/// key it is stored under (in the reserved `_dek_wrappings` table), and is passed
115/// explicitly as AAD to [`unwrap_dek`] — it is not stored in the struct.
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct WrappedDek {
118    /// The nonce used to wrap the DEK.
119    pub wrap_nonce: [u8; NONCE_LEN],
120    /// The wrapped DEK: `AES-256-GCM(vault_key, dek, aad = collection_id)`.
121    /// `KEY_LEN + TAG_LEN` bytes.
122    pub wrap_ciphertext: Vec<u8>,
123}
124
125fn cipher_for(key: &[u8; KEY_LEN]) -> Aes256Gcm {
126    Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key.as_slice()))
127}
128
129/// Generate a fresh random DEK and wrap it under `vault_key`, binding
130/// `collection_id` as AAD. Returns the wrapping (to persist) and the raw DEK (to
131/// use for the current operation, then drop — it zeroizes).
132pub fn wrap_fresh_dek(
133    vault_key: &[u8; KEY_LEN],
134    collection_id: &str,
135) -> Result<(WrappedDek, Zeroizing<[u8; KEY_LEN]>), CryptoError> {
136    let mut dek = Zeroizing::new([0u8; KEY_LEN]);
137    OsRng.fill_bytes(dek.as_mut());
138
139    let mut nonce = [0u8; NONCE_LEN];
140    OsRng.fill_bytes(&mut nonce);
141
142    let ciphertext = cipher_for(vault_key)
143        .encrypt(
144            Nonce::from_slice(&nonce),
145            Payload {
146                msg: dek.as_slice(),
147                aad: collection_id.as_bytes(),
148            },
149        )
150        .map_err(|e| CryptoError::Aead(format!("wrap: {e}")))?;
151
152    Ok((
153        WrappedDek {
154            wrap_nonce: nonce,
155            wrap_ciphertext: ciphertext,
156        },
157        dek,
158    ))
159}
160
161/// Unwrap a DEK with `vault_key`, verifying it was bound to `collection_id`.
162/// Fails with [`CryptoError::Aead`] on a wrong key, tampered bytes, or a
163/// collection-id (AAD) mismatch.
164pub fn unwrap_dek(
165    vault_key: &[u8; KEY_LEN],
166    collection_id: &str,
167    wrapped: &WrappedDek,
168) -> Result<Zeroizing<[u8; KEY_LEN]>, CryptoError> {
169    let plaintext = Zeroizing::new(
170        cipher_for(vault_key)
171            .decrypt(
172                Nonce::from_slice(&wrapped.wrap_nonce),
173                Payload {
174                    msg: &wrapped.wrap_ciphertext,
175                    aad: collection_id.as_bytes(),
176                },
177            )
178            .map_err(|e| CryptoError::Aead(format!("unwrap: {e}")))?,
179    );
180    if plaintext.len() != KEY_LEN {
181        return Err(CryptoError::BadDekLength(plaintext.len()));
182    }
183    let mut dek = Zeroizing::new([0u8; KEY_LEN]);
184    dek.copy_from_slice(&plaintext);
185    Ok(dek)
186}
187
188/// Re-wrap an existing DEK from `old_vault_key` to `new_vault_key` — the
189/// passphrase/vault-key **rotation** primitive. The DEK (and therefore every row
190/// encrypted under it) is unchanged, so rotation costs one re-wrap per collection
191/// rather than re-encrypting the data (ADR 0006 S3/S4).
192pub fn rewrap_dek(
193    old_vault_key: &[u8; KEY_LEN],
194    new_vault_key: &[u8; KEY_LEN],
195    collection_id: &str,
196    wrapped: &WrappedDek,
197) -> Result<WrappedDek, CryptoError> {
198    let dek = unwrap_dek(old_vault_key, collection_id, wrapped)?;
199
200    let mut nonce = [0u8; NONCE_LEN];
201    OsRng.fill_bytes(&mut nonce);
202    let ciphertext = cipher_for(new_vault_key)
203        .encrypt(
204            Nonce::from_slice(&nonce),
205            Payload {
206                msg: dek.as_slice(),
207                aad: collection_id.as_bytes(),
208            },
209        )
210        .map_err(|e| CryptoError::Aead(format!("rewrap: {e}")))?;
211
212    Ok(WrappedDek {
213        wrap_nonce: nonce,
214        wrap_ciphertext: ciphertext,
215    })
216}
217
218/// The AAD that binds a row's ciphertext to its **location**:
219/// `len(table) ‖ table ‖ len(key) ‖ key ‖ schema_version`. Length-prefixing makes
220/// the boundary between `table` and `key` unambiguous, so no two distinct
221/// locations can produce the same AAD.
222fn row_aad(table: &str, key: &[u8], schema_version: u32) -> Vec<u8> {
223    let mut aad = Vec::with_capacity(4 + table.len() + 4 + key.len() + 4);
224    aad.extend_from_slice(&(table.len() as u32).to_be_bytes());
225    aad.extend_from_slice(table.as_bytes());
226    aad.extend_from_slice(&(key.len() as u32).to_be_bytes());
227    aad.extend_from_slice(key);
228    aad.extend_from_slice(&schema_version.to_be_bytes());
229    aad
230}
231
232/// Seal a row's plaintext under the collection `dek`, binding its location. The
233/// returned bytes are `nonce ‖ ciphertext` — what the engine stores.
234pub fn seal_row(
235    dek: &[u8; KEY_LEN],
236    table: &str,
237    key: &[u8],
238    schema_version: u32,
239    plaintext: &[u8],
240) -> Result<Vec<u8>, CryptoError> {
241    let mut nonce = [0u8; NONCE_LEN];
242    OsRng.fill_bytes(&mut nonce);
243    let aad = row_aad(table, key, schema_version);
244    let ciphertext = cipher_for(dek)
245        .encrypt(
246            Nonce::from_slice(&nonce),
247            Payload {
248                msg: plaintext,
249                aad: &aad,
250            },
251        )
252        .map_err(|e| CryptoError::Aead(format!("seal: {e}")))?;
253    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
254    out.extend_from_slice(&nonce);
255    out.extend_from_slice(&ciphertext);
256    Ok(out)
257}
258
259/// Open a `nonce ‖ ciphertext` row sealed by [`seal_row`], verifying it was
260/// sealed at exactly this `(table, key, schema_version)`. An AAD mismatch (a row
261/// presented at the wrong location) fails as [`CryptoError::Aead`].
262pub fn open_row(
263    dek: &[u8; KEY_LEN],
264    table: &str,
265    key: &[u8],
266    schema_version: u32,
267    sealed: &[u8],
268) -> Result<Vec<u8>, CryptoError> {
269    if sealed.len() < NONCE_LEN {
270        return Err(CryptoError::ShortRow(sealed.len()));
271    }
272    let (nonce, ciphertext) = sealed.split_at(NONCE_LEN);
273    let aad = row_aad(table, key, schema_version);
274    cipher_for(dek)
275        .decrypt(
276            Nonce::from_slice(nonce),
277            Payload {
278                msg: ciphertext,
279                aad: &aad,
280            },
281        )
282        .map_err(|e| CryptoError::Aead(format!("open: {e}")))
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    const VK_A: [u8; KEY_LEN] = [0xAA; KEY_LEN];
290    const VK_B: [u8; KEY_LEN] = [0xBB; KEY_LEN];
291
292    // --- DEK envelope ---
293
294    #[test]
295    fn wrap_then_unwrap_yields_same_dek() {
296        let (w, dek) = wrap_fresh_dek(&VK_A, "people").unwrap();
297        let dek2 = unwrap_dek(&VK_A, "people", &w).unwrap();
298        assert_eq!(dek.as_slice(), dek2.as_slice());
299    }
300
301    #[test]
302    fn fresh_dek_is_not_all_zero() {
303        let (_w, dek) = wrap_fresh_dek(&VK_A, "c").unwrap();
304        assert!(dek.iter().any(|b| *b != 0));
305    }
306
307    #[test]
308    fn different_collections_get_different_deks() {
309        let (_w1, d1) = wrap_fresh_dek(&VK_A, "c1").unwrap();
310        let (_w2, d2) = wrap_fresh_dek(&VK_A, "c2").unwrap();
311        assert_ne!(d1.as_slice(), d2.as_slice());
312    }
313
314    #[test]
315    fn unwrap_with_wrong_vault_key_fails() {
316        let (w, _dek) = wrap_fresh_dek(&VK_A, "c").unwrap();
317        let err = unwrap_dek(&VK_B, "c", &w).unwrap_err();
318        assert!(matches!(err, CryptoError::Aead(_)));
319    }
320
321    #[test]
322    fn unwrap_with_wrong_collection_id_fails_on_aad() {
323        let (w, _dek) = wrap_fresh_dek(&VK_A, "people").unwrap();
324        let err = unwrap_dek(&VK_A, "passwords", &w).unwrap_err();
325        assert!(matches!(err, CryptoError::Aead(_)));
326    }
327
328    #[test]
329    fn tampered_wrap_ciphertext_fails() {
330        let (mut w, _dek) = wrap_fresh_dek(&VK_A, "c").unwrap();
331        w.wrap_ciphertext[0] ^= 0x01;
332        let err = unwrap_dek(&VK_A, "c", &w).unwrap_err();
333        assert!(matches!(err, CryptoError::Aead(_)));
334    }
335
336    #[test]
337    fn rewrap_rotates_vault_key_without_changing_the_dek() {
338        let (w_a, dek_a) = wrap_fresh_dek(&VK_A, "c").unwrap();
339        let w_b = rewrap_dek(&VK_A, &VK_B, "c", &w_a).unwrap();
340        // old key no longer opens the new wrapping
341        assert!(unwrap_dek(&VK_A, "c", &w_b).is_err());
342        // new key opens it to the SAME dek (so existing rows still decrypt)
343        let dek_b = unwrap_dek(&VK_B, "c", &w_b).unwrap();
344        assert_eq!(dek_a.as_slice(), dek_b.as_slice());
345    }
346
347    // --- row seal/open ---
348
349    #[test]
350    fn seal_then_open_round_trips() {
351        let dek = [0x11; KEY_LEN];
352        let sealed = seal_row(&dek, "people", b"alice", 1, b"payload").unwrap();
353        assert!(!sealed
354            .windows(b"payload".len())
355            .any(|w| w == b"payload"), "engine bytes must be ciphertext");
356        let opened = open_row(&dek, "people", b"alice", 1, &sealed).unwrap();
357        assert_eq!(opened, b"payload");
358    }
359
360    #[test]
361    fn row_with_wrong_dek_fails() {
362        let sealed = seal_row(&[0x11; KEY_LEN], "t", b"k", 1, b"v").unwrap();
363        let err = open_row(&[0x22; KEY_LEN], "t", b"k", 1, &sealed).unwrap_err();
364        assert!(matches!(err, CryptoError::Aead(_)));
365    }
366
367    #[test]
368    fn row_relocated_to_different_key_fails_on_aad() {
369        let dek = [0x11; KEY_LEN];
370        let sealed = seal_row(&dek, "t", b"key1", 1, b"v").unwrap();
371        assert!(open_row(&dek, "t", b"key2", 1, &sealed).is_err());
372    }
373
374    #[test]
375    fn row_relocated_to_different_table_fails_on_aad() {
376        let dek = [0x11; KEY_LEN];
377        let sealed = seal_row(&dek, "table_a", b"k", 1, b"v").unwrap();
378        assert!(open_row(&dek, "table_b", b"k", 1, &sealed).is_err());
379    }
380
381    #[test]
382    fn row_with_wrong_schema_version_fails_on_aad() {
383        let dek = [0x11; KEY_LEN];
384        let sealed = seal_row(&dek, "t", b"k", 1, b"v").unwrap();
385        assert!(open_row(&dek, "t", b"k", 2, &sealed).is_err());
386    }
387
388    #[test]
389    fn tampered_row_fails() {
390        let dek = [0x11; KEY_LEN];
391        let mut sealed = seal_row(&dek, "t", b"k", 1, b"v").unwrap();
392        let last = sealed.len() - 1;
393        sealed[last] ^= 0x01; // flip a tag byte
394        assert!(open_row(&dek, "t", b"k", 1, &sealed).is_err());
395    }
396
397    #[test]
398    fn open_short_row_is_typed_error() {
399        let err = open_row(&[0; KEY_LEN], "t", b"k", 1, &[0u8; 4]).unwrap_err();
400        assert!(matches!(err, CryptoError::ShortRow(4)));
401    }
402
403    // --- key provider ---
404
405    #[test]
406    fn static_provider_warm_and_cold() {
407        assert_eq!(StaticKeyProvider::new(VK_A).vault_key().unwrap().as_slice(), &VK_A);
408        assert!(matches!(
409            StaticKeyProvider::cold().vault_key().unwrap_err(),
410            CryptoError::Cold
411        ));
412    }
413}