Skip to main content

lean_ctx/core/
knowledge_vault.rs

1//! Zero-knowledge Personal-Cloud knowledge vault (GL #467) — the E2E
2//! envelope for `/api/sync/knowledge`.
3//!
4//! Same construction as the hosted index bundles (XChaCha20-Poly1305,
5//! HKDF-SHA256 from the stable account API key) but with **domain-separated
6//! key material** (`knowledge-vault-v1` HKDF info): a leaked index-bundle key
7//! can never open a knowledge vault and vice versa.
8//!
9//! The vault is a whole-account snapshot, last-writer-wins — knowledge stores
10//! are small (≤ a few thousand entries) and device-generated, so blob-level
11//! replacement is the same consistency model the index bundles already use.
12//! Contract: `docs/contracts/personal-cloud-encryption-v1.md`.
13
14use serde::{Deserialize, Serialize};
15
16use super::index_bundle::{BundleError, decrypt, encrypt};
17
18/// Envelope version inside the ciphertext.
19pub const VAULT_VERSION: u32 = 1;
20
21/// Plaintext payload of a sealed vault.
22#[derive(Debug, Serialize, Deserialize)]
23pub struct VaultEnvelope {
24    /// Envelope format version ([`VAULT_VERSION`]).
25    pub v: u32,
26    /// Knowledge entries exactly as the legacy JSON push sent them
27    /// (`{category, key, value}` objects) — the local stores stay the
28    /// source of truth for richer fields.
29    pub entries: Vec<serde_json::Value>,
30}
31
32/// Derive the vault key from the account API key. Distinct HKDF `info` keeps
33/// this key domain-separated from `index_bundle::derive_key`.
34#[must_use]
35pub fn derive_vault_key(api_key: &str) -> [u8; 32] {
36    derive_key(api_key, b"knowledge-vault-v1")
37}
38
39/// The gotcha vault key — same construction, own HKDF domain
40/// (`gotcha-vault-v1`): a leaked knowledge-vault key can never open the
41/// gotcha vault and vice versa.
42#[must_use]
43pub fn derive_gotcha_vault_key(api_key: &str) -> [u8; 32] {
44    derive_key(api_key, b"gotcha-vault-v1")
45}
46
47fn derive_key(api_key: &str, info: &[u8]) -> [u8; 32] {
48    let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(b"leanctx"), api_key.as_bytes());
49    let mut okm = [0u8; 32];
50    hk.expand(info, &mut okm)
51        .expect("32 bytes is a valid HKDF-SHA256 output length");
52    okm
53}
54
55/// Serialize + encrypt the entries into a vault blob (`nonce || ciphertext`).
56pub fn seal(entries: &[serde_json::Value], key: &[u8; 32]) -> Result<Vec<u8>, BundleError> {
57    let envelope = VaultEnvelope {
58        v: VAULT_VERSION,
59        entries: entries.to_vec(),
60    };
61    let plain = serde_json::to_vec(&envelope)
62        .map_err(|e| BundleError::Corrupt(format!("vault serialize: {e}")))?;
63    encrypt(&plain, key)
64}
65
66/// Decrypt + parse a vault blob back into its entries.
67pub fn open(blob: &[u8], key: &[u8; 32]) -> Result<Vec<serde_json::Value>, BundleError> {
68    let plain = decrypt(blob, key)?;
69    let envelope: VaultEnvelope = serde_json::from_slice(&plain)
70        .map_err(|e| BundleError::Corrupt(format!("vault parse: {e}")))?;
71    if envelope.v != VAULT_VERSION {
72        return Err(BundleError::Corrupt(format!(
73            "unsupported vault version {}",
74            envelope.v
75        )));
76    }
77    Ok(envelope.entries)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn sample_entries() -> Vec<serde_json::Value> {
85        vec![
86            serde_json::json!({"category": "decision", "key": "db", "value": "postgres"}),
87            serde_json::json!({"category": "gotcha", "key": "launchd respawn", "value": "lean-ctx stop first"}),
88        ]
89    }
90
91    #[test]
92    fn seal_open_roundtrip_preserves_entries() {
93        let key = derive_vault_key("lc_test_api_key");
94        let blob = seal(&sample_entries(), &key).unwrap();
95        let out = open(&blob, &key).unwrap();
96        assert_eq!(out, sample_entries());
97    }
98
99    #[test]
100    fn tampered_blob_and_wrong_key_fail_closed() {
101        let key = derive_vault_key("lc_test_api_key");
102        let mut blob = seal(&sample_entries(), &key).unwrap();
103
104        // Flip one ciphertext byte → AEAD must reject.
105        let last = blob.len() - 1;
106        blob[last] ^= 0x01;
107        assert!(matches!(open(&blob, &key), Err(BundleError::Decrypt)));
108
109        // Wrong account key → reject.
110        let blob_ok = seal(&sample_entries(), &key).unwrap();
111        let other = derive_vault_key("different_api_key");
112        assert!(matches!(open(&blob_ok, &other), Err(BundleError::Decrypt)));
113    }
114
115    /// The whole point of the domain separation: index-bundle key material
116    /// must never open a knowledge vault, even for the same account.
117    #[test]
118    fn vault_key_is_domain_separated_from_index_bundle_key() {
119        let api_key = "lc_same_account_key";
120        let vault_key = derive_vault_key(api_key);
121        let index_key = crate::core::index_bundle::derive_key(api_key);
122        assert_ne!(vault_key, index_key);
123
124        let blob = seal(&sample_entries(), &vault_key).unwrap();
125        assert!(matches!(open(&blob, &index_key), Err(BundleError::Decrypt)));
126    }
127
128    /// Gotcha vault keys are their own domain: neither the knowledge-vault
129    /// key nor the index-bundle key can open a gotcha vault.
130    #[test]
131    fn gotcha_vault_key_is_domain_separated() {
132        let api_key = "lc_same_account_key";
133        let gotcha_key = derive_gotcha_vault_key(api_key);
134        assert_ne!(gotcha_key, derive_vault_key(api_key));
135        assert_ne!(gotcha_key, crate::core::index_bundle::derive_key(api_key));
136
137        let blob = seal(&sample_entries(), &gotcha_key).unwrap();
138        assert!(matches!(
139            open(&blob, &derive_vault_key(api_key)),
140            Err(BundleError::Decrypt)
141        ));
142        assert_eq!(open(&blob, &gotcha_key).unwrap(), sample_entries());
143    }
144}