qualia_client_core/wellfair/
vault_container.rs1use serde::{Deserialize, Serialize};
18
19use qualia_core_db::crypto::sanctuary_audit_dag::AuditRecord;
20
21pub const CONTAINER_SLOTS: usize = 4;
24pub const CONTAINER_VERSION: u16 = 2;
25
26#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct WrappedKey {
62 pub purpose: String,
64 pub blob_hex: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Layer {
70 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 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub audit_pubkey_hex: Option<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub wrapped_keys: Vec<WrappedKey>,
85}
86
87impl Layer {
88 pub fn wrapped_key(&self, purpose: &str) -> Option<&WrappedKey> {
90 self.wrapped_keys.iter().find(|w| w.purpose == purpose)
91 }
92
93 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#[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 #[serde(default)]
123 pub audit_log: Vec<AuditRecord>,
124}
125
126impl VaultContainerV2 {
127 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 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 pub fn from_cbor(bytes: &[u8]) -> Result<Self, String> {
157 ciborium::from_reader(bytes).map_err(|e| e.to_string())
158 }
159
160 pub fn layer_by_role(&self, role: LayerRole) -> Option<&Layer> {
162 self.layers.iter().find(|l| l.role == role)
163 }
164
165 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 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 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}