Skip to main content

qualia_client_core/wellfair/
vault_container.rs

1//! Sanctuary vault v2 on-disk container (S3) — **CBOR-native, additive**; not yet wired into the vault.
2//!
3//! The vault is an **n-layer, CBOR-serialized** container (per the vault-v2 ADR). Each [`Layer`] is
4//! independently keyed; the collection generalises to any number of layers (real + decoy(s) + reserved
5//! padding) so the *count* of layers on disk is a constant, revealing nothing about how many are real /
6//! decoy / empty (ADR §9 constant-shape).
7//!
8//! **No JSON, no migration.** There is no deployed vault, so there is nothing to migrate — the format
9//! is CBOR from the start, and no JSON path exists here. When S5 reconciles this with the live vault it
10//! removes `serde_json` from the vault entirely (records + container both CBOR).
11//!
12//! **Honest scope:** CBOR is binary but self-describing — this is *consistency + not-text-editor-
13//! readable*, not cryptographic hiding (a decoder still recovers the structure). The reserved padding
14//! layers here carry empty blobs; making them **byte-indistinguishable** from real layers (size-matched
15//! random ciphertext) is finished in S5. What S3 fixes is the *structural* shape.
16
17use serde::{Deserialize, Serialize};
18
19use qualia_core_db::crypto::sanctuary_audit_dag::AuditRecord;
20
21/// Number of layer slots every container carries, so the layer *count* is constant regardless of how
22/// many are actually in use (real + decoy(s) + reserved).
23pub const CONTAINER_SLOTS: usize = 4;
24pub const CONTAINER_VERSION: u16 = 2;
25
26/// An AEAD ciphertext blob (hex) + the chunk index used to derive its nonce.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
28pub struct EncBlob {
29    pub chunk_index: u64,
30    pub ct_hex: String,
31    pub tag_hex: String,
32}
33
34/// Per-layer KDF descriptor. Every real/decoy layer carries one (Argon2id in production); `None` only
35/// on reserved padding, which is never opened.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case", tag = "algo")]
38pub enum KdfDescriptor {
39    Pbkdf2 {
40        iterations: u32,
41    },
42    Argon2id {
43        m_cost_kib: u32,
44        t_cost: u32,
45        p_cost: u32,
46    },
47}
48
49/// The role a layer plays. `Reserved` layers exist only to keep the container shape constant.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum LayerRole {
53    Real,
54    Decoy,
55    Reserved,
56}
57
58/// A key wrapped under a superior layer's key (the one-way hierarchy: the real layer wraps the decoy
59/// layer key and the audit secret). `blob_hex` is the output of `sanctuary_audit::wrap_key`.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct WrappedKey {
62    /// e.g. `"decoy_lane_key"`, `"audit_secret"`.
63    pub purpose: String,
64    pub blob_hex: String,
65}
66
67/// One independently-keyed layer.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Layer {
70    /// Stable, non-secret layer id (addresses the manifold coordinate; e.g. `"real"`, `"decoy:0"`).
71    pub id: String,
72    pub role: LayerRole,
73    pub salt_hex: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub kdf: Option<KdfDescriptor>,
76    pub verifier: EncBlob,
77    pub records: EncBlob,
78    pub next_counter: u64,
79    /// The audit channel public key for this layer (a decoy layer's coercer-writes seal to this).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub audit_pubkey_hex: Option<String>,
82    /// Subordinate keys this layer's key can unwrap (real → decoy key + audit secret).
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub wrapped_keys: Vec<WrappedKey>,
85}
86
87impl Layer {
88    /// Find a wrapped subordinate key by its `purpose` (e.g. `"decoy_lane_key"`, `"audit_secret"`).
89    pub fn wrapped_key(&self, purpose: &str) -> Option<&WrappedKey> {
90        self.wrapped_keys.iter().find(|w| w.purpose == purpose)
91    }
92
93    /// A reserved padding layer: fresh random salt, empty blobs. (S5 makes these byte-indistinguishable.)
94    pub fn reserved(index: usize) -> Self {
95        Layer {
96            id: format!("reserved:{index}"),
97            role: LayerRole::Reserved,
98            salt_hex: random_salt_hex(),
99            kdf: None,
100            verifier: EncBlob::default(),
101            records: EncBlob::default(),
102            next_counter: 0,
103            audit_pubkey_hex: None,
104            wrapped_keys: Vec::new(),
105        }
106    }
107}
108
109/// The v2 vault container.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct VaultContainerV2 {
112    pub version: u16,
113    pub layers: Vec<Layer>,
114    #[serde(default)]
115    pub keychain_wrapped: bool,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub vault_id: Option<String>,
118    /// **Append-only** audit DAG (ADR §10): the sealed records a decoy session writes but cannot
119    /// read. Only the real lane holds the audit secret that opens each record's `sealed` blob. The
120    /// vault code only ever *appends* here; the per-branch head anchor (held inside the real lane's
121    /// encrypted records) is what makes truncation / rewrite of the *witnessed* prefix detectable.
122    #[serde(default)]
123    pub audit_log: Vec<AuditRecord>,
124}
125
126impl VaultContainerV2 {
127    /// Build a container from the in-use layers, padded to constant shape. Errors if there are more
128    /// in-use layers than [`CONTAINER_SLOTS`].
129    pub fn new(
130        layers: Vec<Layer>,
131        keychain_wrapped: bool,
132        vault_id: Option<String>,
133    ) -> Result<Self, String> {
134        if layers.len() > CONTAINER_SLOTS {
135            return Err("more in-use layers than container slots".into());
136        }
137        let mut container = VaultContainerV2 {
138            version: CONTAINER_VERSION,
139            layers,
140            keychain_wrapped,
141            vault_id,
142            audit_log: Vec::new(),
143        };
144        container.pad_to_constant_shape();
145        Ok(container)
146    }
147
148    /// Serialize to CBOR bytes.
149    pub fn to_cbor(&self) -> Result<Vec<u8>, String> {
150        let mut buf = Vec::new();
151        ciborium::into_writer(self, &mut buf).map_err(|e| e.to_string())?;
152        Ok(buf)
153    }
154
155    /// Deserialize from CBOR bytes.
156    pub fn from_cbor(bytes: &[u8]) -> Result<Self, String> {
157        ciborium::from_reader(bytes).map_err(|e| e.to_string())
158    }
159
160    /// Find a layer by role (first match).
161    pub fn layer_by_role(&self, role: LayerRole) -> Option<&Layer> {
162        self.layers.iter().find(|l| l.role == role)
163    }
164
165    /// Pad with reserved layers up to [`CONTAINER_SLOTS`] so the layer count is constant.
166    pub fn pad_to_constant_shape(&mut self) {
167        while self.layers.len() < CONTAINER_SLOTS {
168            self.layers.push(Layer::reserved(self.layers.len()));
169        }
170    }
171}
172
173fn random_salt_hex() -> String {
174    // uuid v4 is CSPRNG-backed; 16 bytes of salt is ample.
175    hex::encode(uuid::Uuid::new_v4().into_bytes())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn sample_layer(id: &str, role: LayerRole) -> Layer {
183        Layer {
184            id: id.into(),
185            role,
186            salt_hex: "00112233445566778899aabbccddeeff".into(),
187            kdf: Some(KdfDescriptor::Argon2id {
188                m_cost_kib: 65536,
189                t_cost: 3,
190                p_cost: 1,
191            }),
192            verifier: EncBlob {
193                chunk_index: u64::MAX,
194                ct_hex: "aa".into(),
195                tag_hex: "bb".into(),
196            },
197            records: EncBlob {
198                chunk_index: 0,
199                ct_hex: "cc".into(),
200                tag_hex: "dd".into(),
201            },
202            next_counter: 1,
203            audit_pubkey_hex: Some("ee".repeat(32)),
204            wrapped_keys: vec![WrappedKey {
205                purpose: "decoy_lane_key".into(),
206                blob_hex: "ff".into(),
207            }],
208        }
209    }
210
211    fn sample_container() -> VaultContainerV2 {
212        VaultContainerV2::new(
213            vec![
214                sample_layer("real", LayerRole::Real),
215                sample_layer("decoy:0", LayerRole::Decoy),
216            ],
217            false,
218            None,
219        )
220        .unwrap()
221    }
222
223    #[test]
224    fn cbor_round_trips() {
225        let c = sample_container();
226        let bytes = c.to_cbor().unwrap();
227        let back = VaultContainerV2::from_cbor(&bytes).unwrap();
228        assert_eq!(c, back);
229    }
230
231    #[test]
232    fn constant_shape_is_fixed_and_padded_with_reserved() {
233        let c = sample_container();
234        assert_eq!(c.layers.len(), CONTAINER_SLOTS);
235        assert_eq!(
236            c.layers
237                .iter()
238                .filter(|l| l.role == LayerRole::Reserved)
239                .count(),
240            CONTAINER_SLOTS - 2
241        );
242        // Each reserved layer has a distinct random salt.
243        let reserved_salts: std::collections::HashSet<_> = c
244            .layers
245            .iter()
246            .filter(|l| l.role == LayerRole::Reserved)
247            .map(|l| l.salt_hex.clone())
248            .collect();
249        assert_eq!(reserved_salts.len(), CONTAINER_SLOTS - 2);
250    }
251
252    #[test]
253    fn layer_by_role_finds_real_and_decoy() {
254        let c = sample_container();
255        assert_eq!(c.layer_by_role(LayerRole::Real).unwrap().id, "real");
256        assert_eq!(c.layer_by_role(LayerRole::Decoy).unwrap().id, "decoy:0");
257    }
258
259    #[test]
260    fn too_many_layers_is_rejected() {
261        let many: Vec<Layer> = (0..CONTAINER_SLOTS + 1)
262            .map(|i| sample_layer(&format!("l{i}"), LayerRole::Decoy))
263            .collect();
264        assert!(VaultContainerV2::new(many, false, None).is_err());
265    }
266}