core_auth/
vault_meta.rs

1//! Vault metadata: tracks enrolled factors, auth policy, and init mode.
2//!
3//! Stored as JSON at `{config_dir}/vaults/{profile}.vault-meta`.
4//! JSON is used (not TOML) to distinguish machine-managed metadata from
5//! user-editable configuration.
6
7use crate::AuthError;
8use core_types::{AuthCombineMode, AuthFactorId};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// Current metadata format version.
13pub const VAULT_META_VERSION: u32 = 1;
14
15/// Maximum metadata version this code can read.
16pub const MAX_SUPPORTED_VERSION: u32 = 1;
17
18/// Persistent metadata for a vault.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct VaultMetadata {
21    /// Metadata format version.
22    pub version: u32,
23    /// How this vault was initialized.
24    pub init_mode: VaultInitMode,
25    /// Which auth methods are enrolled.
26    pub enrolled_factors: Vec<EnrolledFactor>,
27    /// The unlock policy for this vault.
28    pub auth_policy: AuthCombineMode,
29    /// Timestamp of vault creation (Unix epoch seconds).
30    pub created_at: u64,
31    /// Timestamp of last policy change.
32    pub policy_changed_at: u64,
33}
34
35/// How the vault was originally initialized.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum VaultInitMode {
39    /// Initialized with password only.
40    Password,
41    /// Initialized with SSH key only (random master key, no password).
42    SshKeyOnly,
43    /// Initialized with multiple factors.
44    MultiFactor {
45        /// The factors used at init time.
46        factors: Vec<AuthFactorId>,
47    },
48}
49
50/// Record of an enrolled authentication factor.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct EnrolledFactor {
53    /// Which factor type.
54    pub factor_id: AuthFactorId,
55    /// Human-readable label (e.g., SSH key fingerprint, "master password").
56    pub label: String,
57    /// When this factor was enrolled (Unix epoch seconds).
58    pub enrolled_at: u64,
59}
60
61/// Get the current Unix epoch timestamp.
62fn 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    /// Path to the metadata file for a profile.
71    #[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    /// Read metadata from disk.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the file does not exist, is unreadable, unparseable,
83    /// or has an unsupported version.
84    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    /// Write metadata to disk atomically.
108    ///
109    /// # Errors
110    ///
111    /// Returns an I/O error if the write fails.
112    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    /// Create metadata for a new password-only vault.
139    #[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    /// Create metadata for a new SSH-key-only vault.
157    #[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    /// Create metadata for a new multi-factor vault.
175    #[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    /// Check whether a specific factor is enrolled.
192    #[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    /// Add an enrolled factor. Does nothing if already enrolled with the same ID.
198    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    /// Remove an enrolled factor by ID.
210    pub fn remove_factor(&mut self, factor_id: AuthFactorId) {
211        self.enrolled_factors.retain(|f| f.factor_id != factor_id);
212    }
213
214    /// What kind of contribution each factor makes under this vault's policy.
215    #[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}