lean_ctx/core/
knowledge_vault.rs1use serde::{Deserialize, Serialize};
15
16use super::index_bundle::{BundleError, decrypt, encrypt};
17
18pub const VAULT_VERSION: u32 = 1;
20
21#[derive(Debug, Serialize, Deserialize)]
23pub struct VaultEnvelope {
24 pub v: u32,
26 pub entries: Vec<serde_json::Value>,
30}
31
32#[must_use]
35pub fn derive_vault_key(api_key: &str) -> [u8; 32] {
36 derive_key(api_key, b"knowledge-vault-v1")
37}
38
39#[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
55pub 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
66pub 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 let last = blob.len() - 1;
106 blob[last] ^= 0x01;
107 assert!(matches!(open(&blob, &key), Err(BundleError::Decrypt)));
108
109 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 #[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 #[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}