use serde::{Deserialize, Serialize};
use super::index_bundle::{BundleError, decrypt, encrypt};
pub const VAULT_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub struct VaultEnvelope {
pub v: u32,
pub entries: Vec<serde_json::Value>,
}
#[must_use]
pub fn derive_vault_key(api_key: &str) -> [u8; 32] {
derive_key(api_key, b"knowledge-vault-v1")
}
#[must_use]
pub fn derive_gotcha_vault_key(api_key: &str) -> [u8; 32] {
derive_key(api_key, b"gotcha-vault-v1")
}
fn derive_key(api_key: &str, info: &[u8]) -> [u8; 32] {
let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(b"leanctx"), api_key.as_bytes());
let mut okm = [0u8; 32];
hk.expand(info, &mut okm)
.expect("32 bytes is a valid HKDF-SHA256 output length");
okm
}
pub fn seal(entries: &[serde_json::Value], key: &[u8; 32]) -> Result<Vec<u8>, BundleError> {
let envelope = VaultEnvelope {
v: VAULT_VERSION,
entries: entries.to_vec(),
};
let plain = serde_json::to_vec(&envelope)
.map_err(|e| BundleError::Corrupt(format!("vault serialize: {e}")))?;
encrypt(&plain, key)
}
pub fn open(blob: &[u8], key: &[u8; 32]) -> Result<Vec<serde_json::Value>, BundleError> {
let plain = decrypt(blob, key)?;
let envelope: VaultEnvelope = serde_json::from_slice(&plain)
.map_err(|e| BundleError::Corrupt(format!("vault parse: {e}")))?;
if envelope.v != VAULT_VERSION {
return Err(BundleError::Corrupt(format!(
"unsupported vault version {}",
envelope.v
)));
}
Ok(envelope.entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_entries() -> Vec<serde_json::Value> {
vec![
serde_json::json!({"category": "decision", "key": "db", "value": "postgres"}),
serde_json::json!({"category": "gotcha", "key": "launchd respawn", "value": "lean-ctx stop first"}),
]
}
#[test]
fn seal_open_roundtrip_preserves_entries() {
let key = derive_vault_key("lc_test_api_key");
let blob = seal(&sample_entries(), &key).unwrap();
let out = open(&blob, &key).unwrap();
assert_eq!(out, sample_entries());
}
#[test]
fn tampered_blob_and_wrong_key_fail_closed() {
let key = derive_vault_key("lc_test_api_key");
let mut blob = seal(&sample_entries(), &key).unwrap();
let last = blob.len() - 1;
blob[last] ^= 0x01;
assert!(matches!(open(&blob, &key), Err(BundleError::Decrypt)));
let blob_ok = seal(&sample_entries(), &key).unwrap();
let other = derive_vault_key("different_api_key");
assert!(matches!(open(&blob_ok, &other), Err(BundleError::Decrypt)));
}
#[test]
fn vault_key_is_domain_separated_from_index_bundle_key() {
let api_key = "lc_same_account_key";
let vault_key = derive_vault_key(api_key);
let index_key = crate::core::index_bundle::derive_key(api_key);
assert_ne!(vault_key, index_key);
let blob = seal(&sample_entries(), &vault_key).unwrap();
assert!(matches!(open(&blob, &index_key), Err(BundleError::Decrypt)));
}
#[test]
fn gotcha_vault_key_is_domain_separated() {
let api_key = "lc_same_account_key";
let gotcha_key = derive_gotcha_vault_key(api_key);
assert_ne!(gotcha_key, derive_vault_key(api_key));
assert_ne!(gotcha_key, crate::core::index_bundle::derive_key(api_key));
let blob = seal(&sample_entries(), &gotcha_key).unwrap();
assert!(matches!(
open(&blob, &derive_vault_key(api_key)),
Err(BundleError::Decrypt)
));
assert_eq!(open(&blob, &gotcha_key).unwrap(), sample_entries());
}
}