1#[cfg(test)]
11use std::path::Path;
12use std::path::PathBuf;
13use std::sync::Mutex;
14
15use argon2::Argon2;
16use chacha20poly1305::aead::generic_array::GenericArray;
17use chacha20poly1305::aead::Aead;
18use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
19use rand::RngCore;
20use serde::{Deserialize, Serialize};
21use zeroize::ZeroizeOnDrop;
22
23use crate::keys::KeyError;
24
25#[derive(ZeroizeOnDrop)]
29pub struct Passphrase(String);
30
31impl Passphrase {
32 pub fn new(secret: String) -> Self {
33 Self(secret)
34 }
35
36 fn expose(&self) -> &str {
37 &self.0
38 }
39}
40
41const ARGON2_M_COST_KIB: u32 = 64 * 1024;
44const ARGON2_T_COST: u32 = 3;
45const ARGON2_P_COST: u32 = 4;
46const ARGON2_OUTPUT_LEN: usize = 32;
47const SALT_LEN: usize = 16;
48const ENVELOPE_VERSION: u32 = 1;
49const ARGON2ID_ALGO: &str = "argon2id";
50const NONCE_LEN: usize = 24;
52
53#[derive(Serialize, Deserialize, Clone)]
58struct Envelope {
59 v: u32,
60 kdf: KdfParams,
61 nonce_b64: String,
62 ciphertext_b64: String,
63}
64
65#[derive(Serialize, Deserialize, Clone)]
66struct KdfParams {
67 algo: String,
68 m_cost: u32,
69 t_cost: u32,
70 p_cost: u32,
71 salt_b64: String,
72}
73
74#[derive(Clone, ZeroizeOnDrop)]
79struct CachedDerivation {
80 salt: Vec<u8>,
81 m_cost: u32,
82 t_cost: u32,
83 p_cost: u32,
84 key: [u8; ARGON2_OUTPUT_LEN],
85}
86
87pub(crate) struct PassphraseVault {
92 passphrase: Passphrase,
93 file: coven_foundation::atomic_file::AtomicFile,
94 derived: Mutex<Option<CachedDerivation>>,
95}
96
97impl PassphraseVault {
98 pub(crate) fn new(passphrase: Passphrase, path: PathBuf) -> Self {
99 Self {
100 passphrase,
101 file: coven_foundation::atomic_file::AtomicFile::new(path),
102 derived: Mutex::new(None),
103 }
104 }
105
106 fn read_envelope(&self) -> Result<Option<Envelope>, KeyError> {
107 match self.file.read_optional().map_err(KeyError::File)? {
108 Some(bytes) => {
109 let envelope: Envelope =
110 serde_json::from_slice(&bytes).map_err(|source| KeyError::Json {
111 operation: "parse passphrase envelope",
112 source,
113 })?;
114 validate_envelope_header(&envelope)?;
115 Ok(Some(envelope))
116 }
117 None => Ok(None),
118 }
119 }
120
121 fn derived_key(&self) -> Result<CachedDerivation, KeyError> {
127 if let Some(cached) = self.derived.lock().unwrap().clone() {
128 return Ok(cached);
129 }
130
131 let (salt, m_cost, t_cost, p_cost) = match self.read_envelope()? {
132 Some(envelope) => {
133 validate_params_floor(
134 envelope.kdf.m_cost,
135 envelope.kdf.t_cost,
136 envelope.kdf.p_cost,
137 )?;
138 (
139 base64_decode(&envelope.kdf.salt_b64)?,
140 envelope.kdf.m_cost,
141 envelope.kdf.t_cost,
142 envelope.kdf.p_cost,
143 )
144 }
145 None => {
146 let mut salt = vec![0u8; SALT_LEN];
147 rand::rng().fill_bytes(&mut salt);
148 (salt, ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST)
149 }
150 };
151
152 let key = derive_wrapping_key(self.passphrase.expose(), &salt, m_cost, t_cost, p_cost)?;
153 let cached = CachedDerivation {
154 salt,
155 m_cost,
156 t_cost,
157 p_cost,
158 key,
159 };
160 *self.derived.lock().unwrap() = Some(cached.clone());
161 Ok(cached)
162 }
163
164 pub(crate) fn unlock(&self) -> Result<Option<Vec<u8>>, KeyError> {
168 let Some(envelope) = self.read_envelope()? else {
169 return Ok(None);
170 };
171 let derivation = self.derived_key()?;
172 let nonce = base64_decode(&envelope.nonce_b64)?;
173 let ciphertext = base64_decode(&envelope.ciphertext_b64)?;
174 open(&derivation.key, &nonce, &ciphertext).map(Some)
175 }
176
177 pub(crate) fn persist(&self, plaintext: &[u8]) -> Result<(), KeyError> {
182 let derivation = self.derived_key()?;
183 let (nonce, ciphertext) = seal(&derivation.key, plaintext);
184 let envelope = Envelope {
185 v: ENVELOPE_VERSION,
186 kdf: KdfParams {
187 algo: ARGON2ID_ALGO.to_string(),
188 m_cost: derivation.m_cost,
189 t_cost: derivation.t_cost,
190 p_cost: derivation.p_cost,
191 salt_b64: base64_encode(&derivation.salt),
192 },
193 nonce_b64: base64_encode(&nonce),
194 ciphertext_b64: base64_encode(&ciphertext),
195 };
196 let bytes = serde_json::to_vec(&envelope).map_err(|source| KeyError::Json {
197 operation: "serialize passphrase envelope",
198 source,
199 })?;
200 self.file.replace(&bytes).map_err(KeyError::File)
201 }
202
203 pub(crate) fn forget(&self) -> Result<(), KeyError> {
205 self.file.remove().map_err(KeyError::File)
206 }
207
208 #[cfg(test)]
209 pub(crate) fn path(&self) -> &Path {
210 self.file.path()
211 }
212}
213
214fn validate_envelope_header(envelope: &Envelope) -> Result<(), KeyError> {
221 if envelope.v != ENVELOPE_VERSION {
222 return Err(KeyError::UnsupportedPassphraseEnvelopeVersion {
223 actual: envelope.v,
224 expected: ENVELOPE_VERSION,
225 });
226 }
227 if envelope.kdf.algo != ARGON2ID_ALGO {
228 return Err(KeyError::UnsupportedPassphraseKdf {
229 actual: envelope.kdf.algo.clone(),
230 expected: ARGON2ID_ALGO,
231 });
232 }
233 Ok(())
234}
235
236fn validate_params_floor(m_cost: u32, t_cost: u32, p_cost: u32) -> Result<(), KeyError> {
249 if m_cost < ARGON2_M_COST_KIB {
250 return Err(KeyError::WeakArgon2Parameter {
251 parameter: "m_cost in KiB",
252 actual: m_cost,
253 minimum: ARGON2_M_COST_KIB,
254 });
255 }
256 if t_cost < ARGON2_T_COST {
257 return Err(KeyError::WeakArgon2Parameter {
258 parameter: "t_cost",
259 actual: t_cost,
260 minimum: ARGON2_T_COST,
261 });
262 }
263 if p_cost < ARGON2_P_COST {
264 return Err(KeyError::WeakArgon2Parameter {
265 parameter: "p_cost",
266 actual: p_cost,
267 minimum: ARGON2_P_COST,
268 });
269 }
270 Ok(())
271}
272
273fn derive_wrapping_key(
274 passphrase: &str,
275 salt: &[u8],
276 m_cost: u32,
277 t_cost: u32,
278 p_cost: u32,
279) -> Result<[u8; ARGON2_OUTPUT_LEN], KeyError> {
280 let params =
281 argon2::Params::new(m_cost, t_cost, p_cost, Some(ARGON2_OUTPUT_LEN)).map_err(|source| {
282 KeyError::PassphraseKdf {
283 operation: "parameter validation",
284 source: Box::new(source),
285 }
286 })?;
287 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
288 let mut out = [0u8; ARGON2_OUTPUT_LEN];
289 argon2
290 .hash_password_into(passphrase.as_bytes(), salt, &mut out)
291 .map_err(|source| KeyError::PassphraseKdf {
292 operation: "derivation",
293 source: Box::new(source),
294 })?;
295 Ok(out)
296}
297
298fn seal(wrapping_key: &[u8; ARGON2_OUTPUT_LEN], plaintext: &[u8]) -> (Vec<u8>, Vec<u8>) {
299 let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(wrapping_key));
300 let mut nonce = vec![0u8; NONCE_LEN];
301 rand::rng().fill_bytes(&mut nonce);
302 let ciphertext = cipher
303 .encrypt(GenericArray::from_slice(&nonce), plaintext)
304 .expect("XChaCha20-Poly1305 encryption should not fail");
305 (nonce, ciphertext)
306}
307
308fn open(
309 wrapping_key: &[u8; ARGON2_OUTPUT_LEN],
310 nonce: &[u8],
311 ciphertext: &[u8],
312) -> Result<Vec<u8>, KeyError> {
313 if nonce.len() != NONCE_LEN {
314 return Err(KeyError::InvalidLength {
315 subject: "passphrase envelope nonce",
316 expected: NONCE_LEN,
317 actual: nonce.len(),
318 });
319 }
320 let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(wrapping_key));
321 let nonce = GenericArray::from_slice(nonce);
322 cipher
323 .decrypt(nonce, ciphertext)
324 .map_err(|_| KeyError::PassphraseEnvelopeDecryption)
325}
326
327fn base64_encode(bytes: &[u8]) -> String {
328 use base64::Engine;
329 base64::engine::general_purpose::STANDARD.encode(bytes)
330}
331
332fn base64_decode(s: &str) -> Result<Vec<u8>, KeyError> {
333 use base64::Engine;
334 base64::engine::general_purpose::STANDARD
335 .decode(s)
336 .map_err(KeyError::Base64)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn temp_vault(passphrase: &str) -> (tempfile::TempDir, PassphraseVault) {
344 let tmp = tempfile::tempdir().expect("temp dir");
345 let path = tmp.path().join("payload.envelope");
346 let vault = PassphraseVault::new(Passphrase::new(passphrase.to_string()), path);
347 (tmp, vault)
348 }
349
350 fn write_envelope(path: &Path, envelope: &Envelope) {
354 std::fs::write(
355 path,
356 serde_json::to_vec(envelope).expect("serialize test envelope"),
357 )
358 .expect("write test envelope");
359 }
360
361 fn read_envelope_from_disk(path: &Path) -> Envelope {
362 let bytes = std::fs::read(path).expect("read envelope file");
363 serde_json::from_slice(&bytes).expect("parse envelope file")
364 }
365
366 #[test]
367 fn establish_then_unlock_round_trips_arbitrary_bytes() {
368 let (_tmp, vault) = temp_vault("correct horse battery staple");
369 assert!(vault.unlock().expect("unlock before establish").is_none());
370
371 let payload: Vec<u8> = (0u8..=255).collect();
374 vault.persist(&payload).expect("establish");
375 let unlocked = vault
376 .unlock()
377 .expect("unlock after establish")
378 .expect("payload is established");
379 assert_eq!(unlocked, payload);
380 }
381
382 #[test]
383 fn wrong_passphrase_is_err_not_none() {
384 let (tmp, writer) = temp_vault("right passphrase");
385 writer.persist(b"secret payload").expect("establish");
386
387 let path = tmp.path().join("payload.envelope");
388 let reader = PassphraseVault::new(Passphrase::new("wrong passphrase".to_string()), path);
389 let error = reader
390 .unlock()
391 .expect_err("wrong passphrase must not unlock");
392 assert!(
393 matches!(error, KeyError::PassphraseEnvelopeDecryption),
394 "got {error:?}"
395 );
396 }
397
398 #[test]
399 fn missing_file_is_none() {
400 let (_tmp, vault) = temp_vault("unused");
401 assert!(vault.unlock().expect("unlock with no file").is_none());
402 }
403
404 #[test]
405 fn rotation_re_wraps_and_old_passphrase_still_unlocks() {
406 let passphrase_text = "stable passphrase across rotation".to_string();
407 let (tmp, vault) = temp_vault(&passphrase_text);
408
409 vault.persist(b"first payload").expect("establish");
410 let file_after_first = std::fs::read(vault.path()).expect("read after first persist");
411
412 vault.persist(b"rotated payload").expect("rotate");
413 let file_after_rotation = std::fs::read(vault.path()).expect("read after rotation");
414
415 assert_ne!(
416 file_after_first, file_after_rotation,
417 "the file changes after a rotation persist",
418 );
419
420 let path = tmp.path().join("payload.envelope");
423 let reopened = PassphraseVault::new(Passphrase::new(passphrase_text), path);
424 let unlocked = reopened
425 .unlock()
426 .expect("unlock after rotation")
427 .expect("payload present");
428 assert_eq!(unlocked, b"rotated payload");
429 }
430
431 #[test]
432 fn persist_over_an_existing_file_never_leaves_a_torn_file() {
433 let (tmp, vault) = temp_vault("atomic-write-test");
434 vault.persist(b"first").expect("first persist");
435 vault
436 .persist(b"second, longer payload")
437 .expect("second persist");
438
439 let bytes = std::fs::read(vault.path()).expect("read final file");
442 let _: Envelope =
443 serde_json::from_slice(&bytes).expect("the file is complete, parseable JSON");
444 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
445 .expect("read temp dir")
446 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
447 .filter(|name| name != "payload.envelope")
448 .collect();
449 assert!(
450 siblings.is_empty(),
451 "no leftover temp file after a persist over an existing file: {siblings:?}",
452 );
453 }
454
455 #[test]
456 fn two_vaults_at_different_paths_never_collide_on_a_temp_name() {
457 let tmp = tempfile::tempdir().expect("temp dir");
458 let a = PassphraseVault::new(
459 Passphrase::new("a".to_string()),
460 tmp.path().join("a.envelope"),
461 );
462 let b = PassphraseVault::new(
463 Passphrase::new("b".to_string()),
464 tmp.path().join("b.envelope"),
465 );
466 a.persist(b"payload-a").expect("persist a");
467 b.persist(b"payload-b").expect("persist b");
468
469 assert_eq!(a.unlock().unwrap().unwrap(), b"payload-a");
470 assert_eq!(b.unlock().unwrap().unwrap(), b"payload-b");
471 }
472
473 #[test]
480 fn a_weak_params_envelope_is_refused() {
481 let (_tmp, vault) = temp_vault("victim passphrase");
482 let weak = Envelope {
483 v: ENVELOPE_VERSION,
484 kdf: KdfParams {
485 algo: ARGON2ID_ALGO.to_string(),
486 m_cost: 8,
487 t_cost: 1,
488 p_cost: 1,
489 salt_b64: base64_encode(&[0u8; SALT_LEN]),
490 },
491 nonce_b64: base64_encode(&[0u8; NONCE_LEN]),
492 ciphertext_b64: base64_encode(b"irrelevant: the floor rejects before decryption"),
493 };
494 write_envelope(vault.path(), &weak);
495
496 let unlock_error = vault
497 .unlock()
498 .expect_err("weak Argon2id params must be refused");
499 assert!(
500 matches!(unlock_error, KeyError::WeakArgon2Parameter { .. }),
501 "got {unlock_error:?}"
502 );
503
504 let persist_error = vault
505 .persist(b"the real secret")
506 .expect_err("persist must refuse to re-wrap under weak params rather than establish");
507 assert!(
508 matches!(persist_error, KeyError::WeakArgon2Parameter { .. }),
509 "got {persist_error:?}"
510 );
511
512 let on_disk = read_envelope_from_disk(vault.path());
515 assert_eq!(on_disk.kdf.m_cost, 8);
516 assert_eq!(on_disk.kdf.t_cost, 1);
517 assert_eq!(on_disk.kdf.p_cost, 1);
518 }
519
520 #[test]
525 fn an_envelope_with_higher_than_current_params_still_unlocks() {
526 let (_tmp, vault) = temp_vault("upgrade passphrase");
527 let salt = vec![7u8; SALT_LEN];
528 let m_cost = ARGON2_M_COST_KIB * 2;
529 let t_cost = ARGON2_T_COST + 1;
530 let p_cost = ARGON2_P_COST + 1;
531 let key = derive_wrapping_key("upgrade passphrase", &salt, m_cost, t_cost, p_cost)
532 .expect("derive at higher-than-floor params");
533 let (nonce, ciphertext) = seal(&key, b"payload sealed above the floor");
534 let envelope = Envelope {
535 v: ENVELOPE_VERSION,
536 kdf: KdfParams {
537 algo: ARGON2ID_ALGO.to_string(),
538 m_cost,
539 t_cost,
540 p_cost,
541 salt_b64: base64_encode(&salt),
542 },
543 nonce_b64: base64_encode(&nonce),
544 ciphertext_b64: base64_encode(&ciphertext),
545 };
546 write_envelope(vault.path(), &envelope);
547
548 let unlocked = vault
549 .unlock()
550 .expect("params above the floor must still unlock")
551 .expect("payload present");
552 assert_eq!(unlocked, b"payload sealed above the floor");
553 }
554
555 #[test]
560 fn a_corrupt_nonce_is_an_error_not_a_panic() {
561 let (_tmp, vault) = temp_vault("passphrase");
562 vault.persist(b"payload").expect("establish");
563
564 let mut envelope = read_envelope_from_disk(vault.path());
565 envelope.nonce_b64 = base64_encode(&[0u8; 8]);
566 write_envelope(vault.path(), &envelope);
567
568 let error = vault
569 .unlock()
570 .expect_err("a corrupt nonce length must be an error, not a panic");
571 assert!(
572 matches!(error, KeyError::InvalidLength { .. }),
573 "got {error:?}"
574 );
575 }
576
577 #[test]
581 fn an_unknown_envelope_version_is_refused() {
582 let (_tmp, vault) = temp_vault("passphrase");
583 vault.persist(b"payload").expect("establish");
584 let established = read_envelope_from_disk(vault.path());
585
586 let mut older = established.clone();
587 older.v = ENVELOPE_VERSION - 1;
588 write_envelope(vault.path(), &older);
589 let error = vault
590 .unlock()
591 .expect_err("an older envelope version must be refused");
592 assert!(
593 matches!(error, KeyError::UnsupportedPassphraseEnvelopeVersion { .. }),
594 "got {error:?}"
595 );
596
597 let mut newer = established;
598 newer.v = ENVELOPE_VERSION + 1;
599 write_envelope(vault.path(), &newer);
600 let error = vault
601 .unlock()
602 .expect_err("a newer envelope version must be refused");
603 assert!(
604 matches!(error, KeyError::UnsupportedPassphraseEnvelopeVersion { .. }),
605 "got {error:?}"
606 );
607 }
608
609 #[test]
612 fn an_unknown_kdf_algo_is_refused() {
613 let (_tmp, vault) = temp_vault("passphrase");
614 vault.persist(b"payload").expect("establish");
615
616 let mut envelope = read_envelope_from_disk(vault.path());
617 envelope.kdf.algo = "scrypt".to_string();
618 write_envelope(vault.path(), &envelope);
619
620 let error = vault
621 .unlock()
622 .expect_err("an unrecognized KDF algo must be refused");
623 assert!(
624 matches!(error, KeyError::UnsupportedPassphraseKdf { .. }),
625 "got {error:?}"
626 );
627 }
628}