core_auth/
password.rs

1//! Password authentication backend.
2//!
3//! Runs Argon2id client-side to derive a KEK, then unwraps the master key
4//! from the `.password-wrap` blob. For enrollment, wraps the master key
5//! under the Argon2id-derived KEK and writes the blob to disk.
6//!
7//! Password bytes are injected via `with_password()` before calling
8//! `unlock()` or `enroll()`.
9
10use crate::AuthError;
11use crate::backend::{AuthInteraction, IpcUnlockStrategy, UnlockOutcome, VaultAuthBackend};
12use crate::password_wrap::PasswordWrapBlob;
13use core_crypto::{SecureBytes, SecureVec};
14use core_types::{AuthFactorId, TrustProfileName};
15use std::collections::BTreeMap;
16use std::path::Path;
17use zeroize::Zeroize;
18
19/// Password-based vault authentication.
20///
21/// Uses Argon2id to derive a KEK from password bytes, then wraps/unwraps
22/// the master key via AES-256-GCM in a `.password-wrap` blob.
23///
24/// Password bytes must be injected via `with_password()` before calling
25/// `unlock()` or `enroll()`.
26pub struct PasswordBackend {
27    /// Password bytes, set via `with_password()`. Zeroized on drop.
28    password: Option<SecureVec>,
29}
30
31impl PasswordBackend {
32    #[must_use]
33    pub fn new() -> Self {
34        Self { password: None }
35    }
36
37    /// Inject password bytes for the next unlock/enroll operation.
38    ///
39    /// The password is stored in a `SecureVec` (mlock'd, zeroize-on-drop).
40    #[must_use]
41    pub fn with_password(mut self, password: SecureVec) -> Self {
42        self.password = Some(password);
43        self
44    }
45
46    /// Set password bytes on an existing instance.
47    pub fn set_password(&mut self, password: SecureVec) {
48        self.password = Some(password);
49    }
50
51    /// Derive KEK from password bytes and salt via Argon2id.
52    ///
53    /// Salt must be exactly 16 bytes.
54    fn derive_kek(password: &[u8], salt: &[u8]) -> Result<[u8; 32], AuthError> {
55        let salt_arr: [u8; 16] = salt.try_into().map_err(|_| {
56            AuthError::InvalidBlob(format!("salt must be 16 bytes, got {}", salt.len()))
57        })?;
58        let secure = core_crypto::derive_key_argon2(password, &salt_arr).map_err(|e| {
59            AuthError::BackendNotApplicable(format!("Argon2id derivation failed: {e}"))
60        })?;
61        let mut kek = [0u8; 32];
62        let bytes = secure.as_bytes();
63        kek.copy_from_slice(&bytes[..32]);
64        Ok(kek)
65    }
66}
67
68impl Default for PasswordBackend {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74#[async_trait::async_trait]
75#[allow(clippy::unnecessary_literal_bound)]
76impl VaultAuthBackend for PasswordBackend {
77    fn factor_id(&self) -> AuthFactorId {
78        AuthFactorId::Password
79    }
80
81    fn name(&self) -> &str {
82        "Password"
83    }
84
85    fn backend_id(&self) -> &str {
86        "password"
87    }
88
89    fn is_enrolled(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
90        PasswordWrapBlob::path(config_dir, profile).exists()
91    }
92
93    async fn can_unlock(&self, profile: &TrustProfileName, config_dir: &Path) -> bool {
94        self.is_enrolled(profile, config_dir) && self.password.is_some()
95    }
96
97    fn requires_interaction(&self) -> AuthInteraction {
98        AuthInteraction::PasswordEntry
99    }
100
101    async fn unlock(
102        &self,
103        profile: &TrustProfileName,
104        config_dir: &Path,
105        salt: &[u8],
106    ) -> Result<UnlockOutcome, AuthError> {
107        // We need mutable access to take the password; use interior trick
108        // via the self reference. Since this is &self, we need a workaround.
109        // The trait requires &self for unlock, but we need to consume the password.
110        // We'll read the password without taking it (it gets zeroized on drop anyway).
111        let password_bytes = self
112            .password
113            .as_ref()
114            .ok_or_else(|| {
115                AuthError::BackendNotApplicable(
116                    "password backend requires password bytes via with_password()".into(),
117                )
118            })?
119            .as_bytes();
120
121        // Read the password-wrap blob.
122        let blob = PasswordWrapBlob::load(config_dir, profile)?;
123
124        // Derive KEK via Argon2id.
125        let mut kek = Self::derive_kek(password_bytes, salt)?;
126
127        // Unwrap master key.
128        let master_key = blob.unwrap(&mut kek)?;
129
130        let mut audit_metadata = BTreeMap::new();
131        audit_metadata.insert("backend".into(), "password".into());
132
133        Ok(UnlockOutcome {
134            master_key,
135            audit_metadata,
136            ipc_strategy: IpcUnlockStrategy::DirectMasterKey,
137            factor_id: AuthFactorId::Password,
138        })
139    }
140
141    async fn enroll(
142        &self,
143        profile: &TrustProfileName,
144        master_key: &SecureBytes,
145        config_dir: &Path,
146        salt: &[u8],
147        _selected_key_index: Option<usize>,
148    ) -> Result<(), AuthError> {
149        let password_bytes = self
150            .password
151            .as_ref()
152            .ok_or_else(|| {
153                AuthError::BackendNotApplicable(
154                    "password backend requires password bytes via with_password()".into(),
155                )
156            })?
157            .as_bytes();
158
159        // Derive KEK via Argon2id.
160        let mut kek = Self::derive_kek(password_bytes, salt)?;
161
162        // Wrap master key under KEK.
163        let blob = PasswordWrapBlob::wrap(master_key.as_bytes(), &mut kek)?;
164
165        // Write to disk.
166        blob.save(config_dir, profile)?;
167
168        tracing::info!(
169            profile = %profile,
170            "password enrollment created"
171        );
172
173        Ok(())
174    }
175
176    async fn revoke(&self, profile: &TrustProfileName, config_dir: &Path) -> Result<(), AuthError> {
177        let path = PasswordWrapBlob::path(config_dir, profile);
178        if path.exists() {
179            // Overwrite with zeros before deletion.
180            #[allow(clippy::cast_possible_truncation)]
181            let file_len = std::fs::metadata(&path)
182                .map(|m| m.len() as usize)
183                .unwrap_or(64);
184            let mut zeros = vec![0u8; file_len];
185            let _ = std::fs::write(&path, &zeros);
186            zeros.zeroize();
187            std::fs::remove_file(&path)?;
188
189            tracing::info!(
190                profile = %profile,
191                "password enrollment revoked"
192            );
193        }
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn test_profile() -> TrustProfileName {
203        TrustProfileName::try_from("test-profile").unwrap()
204    }
205
206    fn make_password() -> SecureVec {
207        let mut sv = SecureVec::for_password();
208        for ch in "test-password".chars() {
209            sv.push_char(ch);
210        }
211        sv
212    }
213
214    #[test]
215    fn is_enrolled_checks_wrap_file() {
216        let dir = tempfile::tempdir().unwrap();
217        let backend = PasswordBackend::new();
218        let profile = test_profile();
219
220        assert!(!backend.is_enrolled(&profile, dir.path()));
221
222        let vaults = dir.path().join("vaults");
223        std::fs::create_dir_all(&vaults).unwrap();
224        std::fs::write(vaults.join("test-profile.password-wrap"), b"blob").unwrap();
225
226        assert!(backend.is_enrolled(&profile, dir.path()));
227    }
228
229    #[test]
230    fn requires_password_entry() {
231        let backend = PasswordBackend::new();
232        assert_eq!(
233            backend.requires_interaction(),
234            AuthInteraction::PasswordEntry
235        );
236    }
237
238    #[test]
239    fn factor_id_is_password() {
240        let backend = PasswordBackend::new();
241        assert_eq!(backend.factor_id(), AuthFactorId::Password);
242    }
243
244    #[test]
245    fn backend_id_is_password() {
246        let backend = PasswordBackend::new();
247        assert_eq!(backend.backend_id(), "password");
248    }
249
250    #[tokio::test]
251    async fn enroll_and_unlock_round_trip() {
252        let dir = tempfile::tempdir().unwrap();
253        let profile = test_profile();
254        let salt = [0xAA; 16];
255
256        // Generate a random master key.
257        let master_key_bytes = vec![0x42u8; 32];
258        let master_key = SecureBytes::new(master_key_bytes.clone());
259
260        // Enroll.
261        let backend = PasswordBackend::new().with_password(make_password());
262        backend
263            .enroll(&profile, &master_key, dir.path(), &salt, None)
264            .await
265            .unwrap();
266
267        // Verify enrollment file exists.
268        assert!(PasswordWrapBlob::path(dir.path(), &profile).exists());
269
270        // Unlock.
271        let backend2 = PasswordBackend::new().with_password(make_password());
272        let outcome = backend2.unlock(&profile, dir.path(), &salt).await.unwrap();
273
274        assert_eq!(outcome.master_key.as_bytes(), &master_key_bytes);
275        assert_eq!(outcome.factor_id, AuthFactorId::Password);
276        assert_eq!(outcome.ipc_strategy, IpcUnlockStrategy::DirectMasterKey);
277    }
278
279    #[tokio::test]
280    async fn unlock_fails_wrong_password() {
281        let dir = tempfile::tempdir().unwrap();
282        let profile = test_profile();
283        let salt = [0xAA; 16];
284
285        let master_key = SecureBytes::new(vec![0x42u8; 32]);
286
287        // Enroll with correct password.
288        let backend = PasswordBackend::new().with_password(make_password());
289        backend
290            .enroll(&profile, &master_key, dir.path(), &salt, None)
291            .await
292            .unwrap();
293
294        // Try unlock with wrong password.
295        let mut wrong_pw = SecureVec::for_password();
296        for ch in "wrong-password".chars() {
297            wrong_pw.push_char(ch);
298        }
299        let backend2 = PasswordBackend::new().with_password(wrong_pw);
300        let result = backend2.unlock(&profile, dir.path(), &salt).await;
301        assert!(matches!(result, Err(AuthError::UnwrapFailed)));
302    }
303
304    #[tokio::test]
305    async fn unlock_fails_no_password_set() {
306        let dir = tempfile::tempdir().unwrap();
307        let profile = test_profile();
308        let backend = PasswordBackend::new();
309        let result = backend.unlock(&profile, dir.path(), &[0; 16]).await;
310        assert!(matches!(result, Err(AuthError::BackendNotApplicable(_))));
311    }
312
313    #[tokio::test]
314    async fn revoke_removes_wrap_file() {
315        let dir = tempfile::tempdir().unwrap();
316        let profile = test_profile();
317        let vaults = dir.path().join("vaults");
318        std::fs::create_dir_all(&vaults).unwrap();
319        let path = vaults.join("test-profile.password-wrap");
320        std::fs::write(&path, b"blob").unwrap();
321        assert!(path.exists());
322
323        let backend = PasswordBackend::new();
324        backend.revoke(&profile, dir.path()).await.unwrap();
325        assert!(!path.exists());
326    }
327
328    #[tokio::test]
329    async fn revoke_noop_when_no_file() {
330        let dir = tempfile::tempdir().unwrap();
331        let backend = PasswordBackend::new();
332        backend.revoke(&test_profile(), dir.path()).await.unwrap();
333    }
334
335    #[tokio::test]
336    async fn can_unlock_requires_enrollment_and_password() {
337        let dir = tempfile::tempdir().unwrap();
338        let profile = test_profile();
339
340        let backend = PasswordBackend::new();
341        assert!(!backend.can_unlock(&profile, dir.path()).await);
342
343        let backend2 = PasswordBackend::new().with_password(make_password());
344        assert!(!backend2.can_unlock(&profile, dir.path()).await);
345
346        // Create enrollment file.
347        let vaults = dir.path().join("vaults");
348        std::fs::create_dir_all(&vaults).unwrap();
349        std::fs::write(vaults.join("test-profile.password-wrap"), b"x").unwrap();
350
351        let backend3 = PasswordBackend::new().with_password(make_password());
352        assert!(backend3.can_unlock(&profile, dir.path()).await);
353    }
354}