1use 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
19pub struct PasswordBackend {
27 password: Option<SecureVec>,
29}
30
31impl PasswordBackend {
32 #[must_use]
33 pub fn new() -> Self {
34 Self { password: None }
35 }
36
37 #[must_use]
41 pub fn with_password(mut self, password: SecureVec) -> Self {
42 self.password = Some(password);
43 self
44 }
45
46 pub fn set_password(&mut self, password: SecureVec) {
48 self.password = Some(password);
49 }
50
51 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 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 let blob = PasswordWrapBlob::load(config_dir, profile)?;
123
124 let mut kek = Self::derive_kek(password_bytes, salt)?;
126
127 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 let mut kek = Self::derive_kek(password_bytes, salt)?;
161
162 let blob = PasswordWrapBlob::wrap(master_key.as_bytes(), &mut kek)?;
164
165 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 #[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 let master_key_bytes = vec![0x42u8; 32];
258 let master_key = SecureBytes::new(master_key_bytes.clone());
259
260 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 assert!(PasswordWrapBlob::path(dir.path(), &profile).exists());
269
270 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 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 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 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}