1use crate::AuthError;
8use core_types::{AuthCombineMode, AuthFactorId};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12pub const VAULT_META_VERSION: u32 = 1;
14
15pub const MAX_SUPPORTED_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct VaultMetadata {
21 pub version: u32,
23 pub init_mode: VaultInitMode,
25 pub enrolled_factors: Vec<EnrolledFactor>,
27 pub auth_policy: AuthCombineMode,
29 pub created_at: u64,
31 pub policy_changed_at: u64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum VaultInitMode {
39 Password,
41 SshKeyOnly,
43 MultiFactor {
45 factors: Vec<AuthFactorId>,
47 },
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct EnrolledFactor {
53 pub factor_id: AuthFactorId,
55 pub label: String,
57 pub enrolled_at: u64,
59}
60
61fn now_epoch() -> u64 {
63 std::time::SystemTime::now()
64 .duration_since(std::time::UNIX_EPOCH)
65 .unwrap_or_default()
66 .as_secs()
67}
68
69impl VaultMetadata {
70 #[must_use]
72 pub fn path(config_dir: &Path, profile: &core_types::TrustProfileName) -> PathBuf {
73 config_dir
74 .join("vaults")
75 .join(format!("{profile}.vault-meta"))
76 }
77
78 pub fn load(
85 config_dir: &Path,
86 profile: &core_types::TrustProfileName,
87 ) -> Result<Self, AuthError> {
88 let path = Self::path(config_dir, profile);
89 let data = std::fs::read_to_string(&path).map_err(|e| {
90 AuthError::Io(std::io::Error::new(
91 e.kind(),
92 format!("failed to read vault metadata {}: {e}", path.display()),
93 ))
94 })?;
95 let meta: Self = serde_json::from_str(&data).map_err(|e| {
96 AuthError::InvalidBlob(format!("corrupt vault metadata {}: {e}", path.display()))
97 })?;
98 if meta.version > MAX_SUPPORTED_VERSION {
99 return Err(AuthError::InvalidBlob(format!(
100 "vault metadata version {} exceeds max supported {}",
101 meta.version, MAX_SUPPORTED_VERSION
102 )));
103 }
104 Ok(meta)
105 }
106
107 pub fn save(
113 &self,
114 config_dir: &Path,
115 profile: &core_types::TrustProfileName,
116 ) -> Result<(), AuthError> {
117 let path = Self::path(config_dir, profile);
118 let vaults_dir = config_dir.join("vaults");
119 std::fs::create_dir_all(&vaults_dir)?;
120
121 let json = serde_json::to_string_pretty(self).map_err(|e| {
122 AuthError::InvalidBlob(format!("failed to serialize vault metadata: {e}"))
123 })?;
124
125 let tmp_path = path.with_extension("vault-meta.tmp");
126 std::fs::write(&tmp_path, json.as_bytes())?;
127
128 #[cfg(unix)]
129 {
130 use std::os::unix::fs::PermissionsExt;
131 std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))?;
132 }
133
134 std::fs::rename(&tmp_path, &path)?;
135 Ok(())
136 }
137
138 #[must_use]
140 pub fn new_password(auth_policy: AuthCombineMode) -> Self {
141 let now = now_epoch();
142 Self {
143 version: VAULT_META_VERSION,
144 init_mode: VaultInitMode::Password,
145 enrolled_factors: vec![EnrolledFactor {
146 factor_id: AuthFactorId::Password,
147 label: "master password".into(),
148 enrolled_at: now,
149 }],
150 auth_policy,
151 created_at: now,
152 policy_changed_at: now,
153 }
154 }
155
156 #[must_use]
158 pub fn new_ssh_only(fingerprint: &str, auth_policy: AuthCombineMode) -> Self {
159 let now = now_epoch();
160 Self {
161 version: VAULT_META_VERSION,
162 init_mode: VaultInitMode::SshKeyOnly,
163 enrolled_factors: vec![EnrolledFactor {
164 factor_id: AuthFactorId::SshAgent,
165 label: fingerprint.to_string(),
166 enrolled_at: now,
167 }],
168 auth_policy,
169 created_at: now,
170 policy_changed_at: now,
171 }
172 }
173
174 #[must_use]
176 pub fn new_multi_factor(factors: Vec<EnrolledFactor>, auth_policy: AuthCombineMode) -> Self {
177 let now = now_epoch();
178 let factor_ids: Vec<AuthFactorId> = factors.iter().map(|f| f.factor_id).collect();
179 Self {
180 version: VAULT_META_VERSION,
181 init_mode: VaultInitMode::MultiFactor {
182 factors: factor_ids,
183 },
184 enrolled_factors: factors,
185 auth_policy,
186 created_at: now,
187 policy_changed_at: now,
188 }
189 }
190
191 #[must_use]
193 pub fn has_factor(&self, factor: AuthFactorId) -> bool {
194 self.enrolled_factors.iter().any(|f| f.factor_id == factor)
195 }
196
197 pub fn add_factor(&mut self, factor_id: AuthFactorId, label: String) {
199 if self.has_factor(factor_id) {
200 return;
201 }
202 self.enrolled_factors.push(EnrolledFactor {
203 factor_id,
204 label,
205 enrolled_at: now_epoch(),
206 });
207 }
208
209 pub fn remove_factor(&mut self, factor_id: AuthFactorId) {
211 self.enrolled_factors.retain(|f| f.factor_id != factor_id);
212 }
213
214 #[must_use]
216 pub fn contribution_type(&self) -> crate::FactorContribution {
217 match self.auth_policy {
218 AuthCombineMode::All => crate::FactorContribution::FactorPiece,
219 AuthCombineMode::Any | AuthCombineMode::Policy(_) => {
220 crate::FactorContribution::CompleteMasterKey
221 }
222 }
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use core_types::TrustProfileName;
230
231 fn test_profile() -> TrustProfileName {
232 TrustProfileName::try_from("test-profile").unwrap()
233 }
234
235 #[test]
236 fn load_fails_when_no_file() {
237 let dir = tempfile::tempdir().unwrap();
238 let result = VaultMetadata::load(dir.path(), &test_profile());
239 assert!(result.is_err());
240 }
241
242 #[test]
243 fn save_and_load_round_trip() {
244 let dir = tempfile::tempdir().unwrap();
245 let profile = test_profile();
246
247 let meta = VaultMetadata::new_password(AuthCombineMode::Any);
248
249 meta.save(dir.path(), &profile).unwrap();
250 let loaded = VaultMetadata::load(dir.path(), &profile).unwrap();
251
252 assert_eq!(loaded.version, VAULT_META_VERSION);
253 assert_eq!(loaded.enrolled_factors.len(), 1);
254 assert_eq!(loaded.enrolled_factors[0].factor_id, AuthFactorId::Password);
255 assert_eq!(loaded.auth_policy, AuthCombineMode::Any);
256 }
257
258 #[test]
259 fn rejects_unsupported_version() {
260 let dir = tempfile::tempdir().unwrap();
261 let profile = test_profile();
262
263 let vaults = dir.path().join("vaults");
264 std::fs::create_dir_all(&vaults).unwrap();
265 let path = vaults.join("test-profile.vault-meta");
266 let json = r#"{"version":999,"init_mode":"password","enrolled_factors":[],"auth_policy":"any","created_at":0,"policy_changed_at":0}"#;
267 std::fs::write(&path, json).unwrap();
268
269 let result = VaultMetadata::load(dir.path(), &profile);
270 assert!(result.is_err());
271 }
272
273 #[test]
274 fn new_password_creates_correct_metadata() {
275 let meta = VaultMetadata::new_password(AuthCombineMode::Any);
276 assert_eq!(meta.enrolled_factors.len(), 1);
277 assert_eq!(meta.enrolled_factors[0].factor_id, AuthFactorId::Password);
278 assert!(matches!(meta.init_mode, VaultInitMode::Password));
279 assert_eq!(meta.auth_policy, AuthCombineMode::Any);
280 }
281
282 #[test]
283 fn new_ssh_only_creates_correct_metadata() {
284 let meta = VaultMetadata::new_ssh_only("SHA256:test", AuthCombineMode::Any);
285 assert_eq!(meta.enrolled_factors.len(), 1);
286 assert_eq!(meta.enrolled_factors[0].factor_id, AuthFactorId::SshAgent);
287 assert_eq!(meta.enrolled_factors[0].label, "SHA256:test");
288 assert!(matches!(meta.init_mode, VaultInitMode::SshKeyOnly));
289 }
290
291 #[test]
292 fn new_multi_factor_creates_correct_metadata() {
293 let factors = vec![
294 EnrolledFactor {
295 factor_id: AuthFactorId::Password,
296 label: "master password".into(),
297 enrolled_at: 0,
298 },
299 EnrolledFactor {
300 factor_id: AuthFactorId::SshAgent,
301 label: "SHA256:abc".into(),
302 enrolled_at: 0,
303 },
304 ];
305 let meta = VaultMetadata::new_multi_factor(factors, AuthCombineMode::All);
306 assert_eq!(meta.enrolled_factors.len(), 2);
307 assert!(matches!(meta.init_mode, VaultInitMode::MultiFactor { .. }));
308 assert_eq!(meta.auth_policy, AuthCombineMode::All);
309 }
310
311 #[test]
312 fn has_factor_works() {
313 let meta = VaultMetadata::new_password(AuthCombineMode::Any);
314 assert!(meta.has_factor(AuthFactorId::Password));
315 assert!(!meta.has_factor(AuthFactorId::SshAgent));
316 }
317
318 #[test]
319 fn add_factor_idempotent() {
320 let mut meta = VaultMetadata::new_password(AuthCombineMode::Any);
321
322 meta.add_factor(AuthFactorId::Password, "duplicate".into());
323 assert_eq!(meta.enrolled_factors.len(), 1);
324
325 meta.add_factor(AuthFactorId::SshAgent, "SHA256:abc".into());
326 assert_eq!(meta.enrolled_factors.len(), 2);
327 }
328
329 #[test]
330 fn remove_factor_works() {
331 let mut meta = VaultMetadata::new_password(AuthCombineMode::Any);
332 meta.add_factor(AuthFactorId::SshAgent, "ssh".into());
333 assert_eq!(meta.enrolled_factors.len(), 2);
334
335 meta.remove_factor(AuthFactorId::SshAgent);
336 assert_eq!(meta.enrolled_factors.len(), 1);
337 assert!(!meta.has_factor(AuthFactorId::SshAgent));
338 }
339
340 #[test]
341 fn policy_mode_serializes_correctly() {
342 let meta = VaultMetadata::new_multi_factor(
343 vec![],
344 AuthCombineMode::Policy(core_types::AuthPolicy {
345 required: vec![AuthFactorId::Password],
346 additional_required: 1,
347 }),
348 );
349
350 let json = serde_json::to_string(&meta).unwrap();
351 let parsed: VaultMetadata = serde_json::from_str(&json).unwrap();
352 assert!(matches!(parsed.auth_policy, AuthCombineMode::Policy(_)));
353 }
354
355 #[test]
356 fn file_permissions_are_restrictive() {
357 let dir = tempfile::tempdir().unwrap();
358 let profile = test_profile();
359
360 let meta = VaultMetadata::new_password(AuthCombineMode::Any);
361 meta.save(dir.path(), &profile).unwrap();
362
363 #[cfg(unix)]
364 {
365 use std::os::unix::fs::PermissionsExt;
366 let path = VaultMetadata::path(dir.path(), &profile);
367 let perms = std::fs::metadata(&path).unwrap().permissions();
368 assert_eq!(perms.mode() & 0o777, 0o600);
369 }
370 }
371}