1use std::io::Write as _;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use argon2::Argon2;
15use chacha20poly1305::aead::generic_array::GenericArray;
16use chacha20poly1305::aead::Aead;
17use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
18use rand::RngCore;
19use serde::{Deserialize, Serialize};
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22use crate::keys::KeyError;
23
24#[derive(ZeroizeOnDrop)]
28pub struct Passphrase(String);
29
30impl Passphrase {
31 pub fn new(secret: String) -> Self {
32 Self(secret)
33 }
34
35 fn expose(&self) -> &str {
36 &self.0
37 }
38}
39
40const ARGON2_M_COST_KIB: u32 = 64 * 1024;
43const ARGON2_T_COST: u32 = 3;
44const ARGON2_P_COST: u32 = 4;
45const ARGON2_OUTPUT_LEN: usize = 32;
46const SALT_LEN: usize = 16;
47const ENVELOPE_VERSION: u32 = 1;
48const ARGON2ID_ALGO: &str = "argon2id";
49const NONCE_LEN: usize = 24;
51
52#[derive(Serialize, Deserialize, Clone)]
57struct Envelope {
58 v: u32,
59 kdf: KdfParams,
60 nonce_b64: String,
61 ciphertext_b64: String,
62}
63
64#[derive(Serialize, Deserialize, Clone)]
65struct KdfParams {
66 algo: String,
67 m_cost: u32,
68 t_cost: u32,
69 p_cost: u32,
70 salt_b64: String,
71}
72
73#[derive(Clone, ZeroizeOnDrop)]
78struct CachedDerivation {
79 salt: Vec<u8>,
80 m_cost: u32,
81 t_cost: u32,
82 p_cost: u32,
83 key: [u8; ARGON2_OUTPUT_LEN],
84}
85
86pub(crate) struct PassphraseVault {
91 passphrase: Passphrase,
92 path: PathBuf,
93 derived: Mutex<Option<CachedDerivation>>,
94}
95
96impl PassphraseVault {
97 pub(crate) fn new(passphrase: Passphrase, path: PathBuf) -> Self {
98 Self {
99 passphrase,
100 path,
101 derived: Mutex::new(None),
102 }
103 }
104
105 #[cfg(test)]
106 pub(crate) fn path(&self) -> &Path {
107 &self.path
108 }
109
110 fn read_envelope(&self) -> Result<Option<Envelope>, KeyError> {
111 match std::fs::read(&self.path) {
112 Ok(bytes) => {
113 let envelope: Envelope = serde_json::from_slice(&bytes).map_err(|e| {
114 KeyError::Persistence(format!("passphrase envelope is malformed: {e}"))
115 })?;
116 validate_envelope_header(&envelope)?;
117 Ok(Some(envelope))
118 }
119 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
120 Err(e) => Err(KeyError::Persistence(format!(
121 "read passphrase envelope file: {e}"
122 ))),
123 }
124 }
125
126 fn derived_key(&self) -> Result<CachedDerivation, KeyError> {
132 if let Some(cached) = self.derived.lock().unwrap().clone() {
133 return Ok(cached);
134 }
135
136 let (salt, m_cost, t_cost, p_cost) = match self.read_envelope()? {
137 Some(envelope) => {
138 validate_params_floor(
139 envelope.kdf.m_cost,
140 envelope.kdf.t_cost,
141 envelope.kdf.p_cost,
142 )?;
143 (
144 base64_decode(&envelope.kdf.salt_b64)?,
145 envelope.kdf.m_cost,
146 envelope.kdf.t_cost,
147 envelope.kdf.p_cost,
148 )
149 }
150 None => {
151 let mut salt = vec![0u8; SALT_LEN];
152 rand::rng().fill_bytes(&mut salt);
153 (salt, ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST)
154 }
155 };
156
157 let key = derive_wrapping_key(self.passphrase.expose(), &salt, m_cost, t_cost, p_cost)?;
158 let cached = CachedDerivation {
159 salt,
160 m_cost,
161 t_cost,
162 p_cost,
163 key,
164 };
165 *self.derived.lock().unwrap() = Some(cached.clone());
166 Ok(cached)
167 }
168
169 pub(crate) fn unlock(&self) -> Result<Option<Vec<u8>>, KeyError> {
173 let Some(envelope) = self.read_envelope()? else {
174 return Ok(None);
175 };
176 let derivation = self.derived_key()?;
177 let nonce = base64_decode(&envelope.nonce_b64)?;
178 let ciphertext = base64_decode(&envelope.ciphertext_b64)?;
179 open(&derivation.key, &nonce, &ciphertext).map(Some)
180 }
181
182 pub(crate) fn persist(&self, plaintext: &[u8]) -> Result<(), KeyError> {
184 let derivation = self.derived_key()?;
185 self.seal_and_write(&derivation, plaintext)
186 }
187
188 pub(crate) fn rewrap(&self, new_passphrase: &Passphrase) -> Result<(), KeyError> {
207 let Some(mut plaintext) = self.unlock()? else {
208 return Err(KeyError::Persistence(format!(
209 "nothing established at {}; cannot re-wrap a custody that was never established",
210 self.path.display()
211 )));
212 };
213
214 let mut salt = vec![0u8; SALT_LEN];
218 rand::rng().fill_bytes(&mut salt);
219 let key = derive_wrapping_key(
220 new_passphrase.expose(),
221 &salt,
222 ARGON2_M_COST_KIB,
223 ARGON2_T_COST,
224 ARGON2_P_COST,
225 )?;
226 let derivation = CachedDerivation {
227 salt,
228 m_cost: ARGON2_M_COST_KIB,
229 t_cost: ARGON2_T_COST,
230 p_cost: ARGON2_P_COST,
231 key,
232 };
233
234 let result = self.seal_and_write(&derivation, &plaintext);
235 plaintext.zeroize();
236 result?;
237
238 *self.derived.lock().unwrap() = None;
239 Ok(())
240 }
241
242 fn seal_and_write(
248 &self,
249 derivation: &CachedDerivation,
250 plaintext: &[u8],
251 ) -> Result<(), KeyError> {
252 let (nonce, ciphertext) = seal(&derivation.key, plaintext);
253 let envelope = Envelope {
254 v: ENVELOPE_VERSION,
255 kdf: KdfParams {
256 algo: ARGON2ID_ALGO.to_string(),
257 m_cost: derivation.m_cost,
258 t_cost: derivation.t_cost,
259 p_cost: derivation.p_cost,
260 salt_b64: base64_encode(&derivation.salt),
261 },
262 nonce_b64: base64_encode(&nonce),
263 ciphertext_b64: base64_encode(&ciphertext),
264 };
265 let bytes = serde_json::to_vec(&envelope)
266 .map_err(|e| KeyError::Persistence(format!("serialize passphrase envelope: {e}")))?;
267 write_atomic(&self.path, &bytes)
268 }
269
270 pub(crate) fn forget(&self) -> Result<(), KeyError> {
272 match std::fs::remove_file(&self.path) {
273 Ok(()) => Ok(()),
274 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
275 Err(e) => Err(KeyError::Persistence(format!(
276 "remove passphrase envelope file: {e}"
277 ))),
278 }
279 }
280}
281
282fn validate_envelope_header(envelope: &Envelope) -> Result<(), KeyError> {
289 if envelope.v < ENVELOPE_VERSION {
290 return Err(KeyError::Persistence(format!(
291 "passphrase envelope is v{}, older than this build's v{ENVELOPE_VERSION}; \
292 re-establish it under the current version",
293 envelope.v
294 )));
295 }
296 if envelope.v > ENVELOPE_VERSION {
297 return Err(KeyError::Persistence(format!(
298 "passphrase envelope is v{}, newer than this build's v{ENVELOPE_VERSION}; \
299 update to a build that understands it",
300 envelope.v
301 )));
302 }
303 if envelope.kdf.algo != ARGON2ID_ALGO {
304 return Err(KeyError::Persistence(format!(
305 "passphrase envelope names KDF {:?}, but this module only wraps with {ARGON2ID_ALGO:?}",
306 envelope.kdf.algo
307 )));
308 }
309 Ok(())
310}
311
312fn validate_params_floor(m_cost: u32, t_cost: u32, p_cost: u32) -> Result<(), KeyError> {
325 if m_cost < ARGON2_M_COST_KIB {
326 return Err(KeyError::Crypto(format!(
327 "passphrase envelope's Argon2id m_cost ({m_cost} KiB) is below this module's floor \
328 ({ARGON2_M_COST_KIB} KiB); refusing to derive a wrapping key at reduced memory cost"
329 )));
330 }
331 if t_cost < ARGON2_T_COST {
332 return Err(KeyError::Crypto(format!(
333 "passphrase envelope's Argon2id t_cost ({t_cost}) is below this module's floor \
334 ({ARGON2_T_COST}); refusing to derive a wrapping key at a reduced iteration count"
335 )));
336 }
337 if p_cost < ARGON2_P_COST {
338 return Err(KeyError::Crypto(format!(
339 "passphrase envelope's Argon2id p_cost ({p_cost}) is below this module's floor \
340 ({ARGON2_P_COST}); refusing to derive a wrapping key at reduced parallelism"
341 )));
342 }
343 Ok(())
344}
345
346fn derive_wrapping_key(
347 passphrase: &str,
348 salt: &[u8],
349 m_cost: u32,
350 t_cost: u32,
351 p_cost: u32,
352) -> Result<[u8; ARGON2_OUTPUT_LEN], KeyError> {
353 let params = argon2::Params::new(m_cost, t_cost, p_cost, Some(ARGON2_OUTPUT_LEN))
354 .map_err(|e| KeyError::Crypto(format!("invalid argon2id params: {e}")))?;
355 let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
356 let mut out = [0u8; ARGON2_OUTPUT_LEN];
357 argon2
358 .hash_password_into(passphrase.as_bytes(), salt, &mut out)
359 .map_err(|e| KeyError::Crypto(format!("argon2id derivation failed: {e}")))?;
360 Ok(out)
361}
362
363fn seal(wrapping_key: &[u8; ARGON2_OUTPUT_LEN], plaintext: &[u8]) -> (Vec<u8>, Vec<u8>) {
364 let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(wrapping_key));
365 let mut nonce = vec![0u8; NONCE_LEN];
366 rand::rng().fill_bytes(&mut nonce);
367 let ciphertext = cipher
368 .encrypt(GenericArray::from_slice(&nonce), plaintext)
369 .expect("XChaCha20-Poly1305 encryption should not fail");
370 (nonce, ciphertext)
371}
372
373fn open(
374 wrapping_key: &[u8; ARGON2_OUTPUT_LEN],
375 nonce: &[u8],
376 ciphertext: &[u8],
377) -> Result<Vec<u8>, KeyError> {
378 if nonce.len() != NONCE_LEN {
379 return Err(KeyError::Crypto(format!(
380 "envelope nonce is {} bytes, expected {NONCE_LEN}: a corrupt file, not a wrong \
381 passphrase",
382 nonce.len()
383 )));
384 }
385 let cipher = XChaCha20Poly1305::new(GenericArray::from_slice(wrapping_key));
386 let nonce = GenericArray::from_slice(nonce);
387 cipher.decrypt(nonce, ciphertext).map_err(|_| {
388 KeyError::Crypto(
389 "envelope decryption failed: wrong passphrase or a corrupt file".to_string(),
390 )
391 })
392}
393
394fn base64_encode(bytes: &[u8]) -> String {
395 use base64::Engine;
396 base64::engine::general_purpose::STANDARD.encode(bytes)
397}
398
399fn base64_decode(s: &str) -> Result<Vec<u8>, KeyError> {
400 use base64::Engine;
401 base64::engine::general_purpose::STANDARD
402 .decode(s)
403 .map_err(|e| KeyError::Persistence(format!("passphrase envelope base64: {e}")))
404}
405
406fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), KeyError> {
412 let parent = path.parent().ok_or_else(|| {
413 KeyError::Persistence(format!(
414 "passphrase envelope path has no parent directory: {}",
415 path.display()
416 ))
417 })?;
418 let file_name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
419 KeyError::Persistence(format!(
420 "passphrase envelope path has no valid file name: {}",
421 path.display()
422 ))
423 })?;
424 std::fs::create_dir_all(parent)
425 .map_err(|e| KeyError::Persistence(format!("create passphrase envelope directory: {e}")))?;
426 let temp_path = parent.join(format!(
427 "{}{file_name}.{}",
428 crate::local_blob::TEMP_BLOB_PREFIX,
429 uuid::Uuid::new_v4()
430 ));
431 let mut file = std::fs::File::create(&temp_path)
432 .map_err(|e| KeyError::Persistence(format!("write passphrase envelope temp file: {e}")))?;
433 file.write_all(bytes)
434 .map_err(|e| KeyError::Persistence(format!("write passphrase envelope temp file: {e}")))?;
435 file.sync_all()
436 .map_err(|e| KeyError::Persistence(format!("fsync passphrase envelope temp file: {e}")))?;
437 drop(file);
438 std::fs::rename(&temp_path, path)
439 .map_err(|e| KeyError::Persistence(format!("install passphrase envelope file: {e}")))?;
440 std::fs::File::open(parent)
445 .map_err(|e| KeyError::Persistence(format!("open the parent dir to fsync it: {e}")))?
446 .sync_all()
447 .map_err(|e| KeyError::Persistence(format!("fsync the parent dir: {e}")))?;
448 Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn temp_vault(passphrase: &str) -> (tempfile::TempDir, PassphraseVault) {
456 let tmp = tempfile::tempdir().expect("temp dir");
457 let path = tmp.path().join("payload.envelope");
458 let vault = PassphraseVault::new(Passphrase::new(passphrase.to_string()), path);
459 (tmp, vault)
460 }
461
462 fn write_envelope(path: &Path, envelope: &Envelope) {
466 std::fs::write(
467 path,
468 serde_json::to_vec(envelope).expect("serialize test envelope"),
469 )
470 .expect("write test envelope");
471 }
472
473 fn read_envelope_from_disk(path: &Path) -> Envelope {
474 let bytes = std::fs::read(path).expect("read envelope file");
475 serde_json::from_slice(&bytes).expect("parse envelope file")
476 }
477
478 #[test]
479 fn establish_then_unlock_round_trips_arbitrary_bytes() {
480 let (_tmp, vault) = temp_vault("correct horse battery staple");
481 assert!(vault.unlock().expect("unlock before establish").is_none());
482
483 let payload: Vec<u8> = (0u8..=255).collect();
486 vault.persist(&payload).expect("establish");
487 let unlocked = vault
488 .unlock()
489 .expect("unlock after establish")
490 .expect("payload is established");
491 assert_eq!(unlocked, payload);
492 }
493
494 #[test]
495 fn wrong_passphrase_is_err_not_none() {
496 let (tmp, writer) = temp_vault("right passphrase");
497 writer.persist(b"secret payload").expect("establish");
498
499 let path = tmp.path().join("payload.envelope");
500 let reader = PassphraseVault::new(Passphrase::new("wrong passphrase".to_string()), path);
501 let error = reader
502 .unlock()
503 .expect_err("wrong passphrase must not unlock");
504 assert!(matches!(error, KeyError::Crypto(_)), "got {error:?}");
505 }
506
507 #[test]
508 fn missing_file_is_none() {
509 let (_tmp, vault) = temp_vault("unused");
510 assert!(vault.unlock().expect("unlock with no file").is_none());
511 }
512
513 #[test]
514 fn rotation_re_wraps_and_old_passphrase_still_unlocks() {
515 let passphrase_text = "stable passphrase across rotation".to_string();
516 let (tmp, vault) = temp_vault(&passphrase_text);
517
518 vault.persist(b"first payload").expect("establish");
519 let file_after_first = std::fs::read(vault.path()).expect("read after first persist");
520
521 vault.persist(b"rotated payload").expect("rotate");
522 let file_after_rotation = std::fs::read(vault.path()).expect("read after rotation");
523
524 assert_ne!(
525 file_after_first, file_after_rotation,
526 "the file changes after a rotation persist",
527 );
528
529 let path = tmp.path().join("payload.envelope");
532 let reopened = PassphraseVault::new(Passphrase::new(passphrase_text), path);
533 let unlocked = reopened
534 .unlock()
535 .expect("unlock after rotation")
536 .expect("payload present");
537 assert_eq!(unlocked, b"rotated payload");
538 }
539
540 #[test]
541 fn persist_over_an_existing_file_never_leaves_a_torn_file() {
542 let (tmp, vault) = temp_vault("atomic-write-test");
543 vault.persist(b"first").expect("first persist");
544 vault
545 .persist(b"second, longer payload")
546 .expect("second persist");
547
548 let bytes = std::fs::read(vault.path()).expect("read final file");
551 let _: Envelope =
552 serde_json::from_slice(&bytes).expect("the file is complete, parseable JSON");
553 let siblings: Vec<_> = std::fs::read_dir(tmp.path())
554 .expect("read temp dir")
555 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
556 .filter(|name| name != "payload.envelope")
557 .collect();
558 assert!(
559 siblings.is_empty(),
560 "no leftover temp file after a persist over an existing file: {siblings:?}",
561 );
562 }
563
564 #[test]
565 fn two_vaults_at_different_paths_never_collide_on_a_temp_name() {
566 let tmp = tempfile::tempdir().expect("temp dir");
567 let a = PassphraseVault::new(
568 Passphrase::new("a".to_string()),
569 tmp.path().join("a.envelope"),
570 );
571 let b = PassphraseVault::new(
572 Passphrase::new("b".to_string()),
573 tmp.path().join("b.envelope"),
574 );
575 a.persist(b"payload-a").expect("persist a");
576 b.persist(b"payload-b").expect("persist b");
577
578 assert_eq!(a.unlock().unwrap().unwrap(), b"payload-a");
579 assert_eq!(b.unlock().unwrap().unwrap(), b"payload-b");
580 }
581
582 #[test]
589 fn a_weak_params_envelope_is_refused() {
590 let (_tmp, vault) = temp_vault("victim passphrase");
591 let weak = Envelope {
592 v: ENVELOPE_VERSION,
593 kdf: KdfParams {
594 algo: ARGON2ID_ALGO.to_string(),
595 m_cost: 8,
596 t_cost: 1,
597 p_cost: 1,
598 salt_b64: base64_encode(&[0u8; SALT_LEN]),
599 },
600 nonce_b64: base64_encode(&[0u8; NONCE_LEN]),
601 ciphertext_b64: base64_encode(b"irrelevant: the floor rejects before decryption"),
602 };
603 write_envelope(vault.path(), &weak);
604
605 let unlock_error = vault
606 .unlock()
607 .expect_err("weak Argon2id params must be refused");
608 assert!(
609 matches!(unlock_error, KeyError::Crypto(_)),
610 "got {unlock_error:?}"
611 );
612
613 let persist_error = vault
614 .persist(b"the real secret")
615 .expect_err("persist must refuse to re-wrap under weak params rather than establish");
616 assert!(
617 matches!(persist_error, KeyError::Crypto(_)),
618 "got {persist_error:?}"
619 );
620
621 let on_disk = read_envelope_from_disk(vault.path());
624 assert_eq!(on_disk.kdf.m_cost, 8);
625 assert_eq!(on_disk.kdf.t_cost, 1);
626 assert_eq!(on_disk.kdf.p_cost, 1);
627 }
628
629 #[test]
634 fn an_envelope_with_higher_than_current_params_still_unlocks() {
635 let (_tmp, vault) = temp_vault("upgrade passphrase");
636 let salt = vec![7u8; SALT_LEN];
637 let m_cost = ARGON2_M_COST_KIB * 2;
638 let t_cost = ARGON2_T_COST + 1;
639 let p_cost = ARGON2_P_COST + 1;
640 let key = derive_wrapping_key("upgrade passphrase", &salt, m_cost, t_cost, p_cost)
641 .expect("derive at higher-than-floor params");
642 let (nonce, ciphertext) = seal(&key, b"payload sealed above the floor");
643 let envelope = Envelope {
644 v: ENVELOPE_VERSION,
645 kdf: KdfParams {
646 algo: ARGON2ID_ALGO.to_string(),
647 m_cost,
648 t_cost,
649 p_cost,
650 salt_b64: base64_encode(&salt),
651 },
652 nonce_b64: base64_encode(&nonce),
653 ciphertext_b64: base64_encode(&ciphertext),
654 };
655 write_envelope(vault.path(), &envelope);
656
657 let unlocked = vault
658 .unlock()
659 .expect("params above the floor must still unlock")
660 .expect("payload present");
661 assert_eq!(unlocked, b"payload sealed above the floor");
662 }
663
664 #[test]
669 fn a_corrupt_nonce_is_an_error_not_a_panic() {
670 let (_tmp, vault) = temp_vault("passphrase");
671 vault.persist(b"payload").expect("establish");
672
673 let mut envelope = read_envelope_from_disk(vault.path());
674 envelope.nonce_b64 = base64_encode(&[0u8; 8]);
675 write_envelope(vault.path(), &envelope);
676
677 let error = vault
678 .unlock()
679 .expect_err("a corrupt nonce length must be an error, not a panic");
680 assert!(matches!(error, KeyError::Crypto(_)), "got {error:?}");
681 }
682
683 #[test]
687 fn an_unknown_envelope_version_is_refused() {
688 let (_tmp, vault) = temp_vault("passphrase");
689 vault.persist(b"payload").expect("establish");
690 let established = read_envelope_from_disk(vault.path());
691
692 let mut older = established.clone();
693 older.v = ENVELOPE_VERSION - 1;
694 write_envelope(vault.path(), &older);
695 let error = vault
696 .unlock()
697 .expect_err("an older envelope version must be refused");
698 assert!(matches!(error, KeyError::Persistence(_)), "got {error:?}");
699
700 let mut newer = established;
701 newer.v = ENVELOPE_VERSION + 1;
702 write_envelope(vault.path(), &newer);
703 let error = vault
704 .unlock()
705 .expect_err("a newer envelope version must be refused");
706 assert!(matches!(error, KeyError::Persistence(_)), "got {error:?}");
707 }
708
709 #[test]
712 fn an_unknown_kdf_algo_is_refused() {
713 let (_tmp, vault) = temp_vault("passphrase");
714 vault.persist(b"payload").expect("establish");
715
716 let mut envelope = read_envelope_from_disk(vault.path());
717 envelope.kdf.algo = "scrypt".to_string();
718 write_envelope(vault.path(), &envelope);
719
720 let error = vault
721 .unlock()
722 .expect_err("an unrecognized KDF algo must be refused");
723 assert!(matches!(error, KeyError::Persistence(_)), "got {error:?}");
724 }
725
726 #[test]
730 fn rewrap_makes_the_old_passphrase_stop_unlocking_and_the_new_one_start() {
731 let (tmp, vault) = temp_vault("old passphrase");
732 let payload: Vec<u8> = (0u8..=255).collect();
734 vault
735 .persist(&payload)
736 .expect("establish under the old passphrase");
737
738 vault
739 .rewrap(&Passphrase::new("new passphrase".to_string()))
740 .expect("re-wrap under the new passphrase");
741
742 let path = tmp.path().join("payload.envelope");
743 let with_old =
744 PassphraseVault::new(Passphrase::new("old passphrase".to_string()), path.clone());
745 let old_error = with_old
746 .unlock()
747 .expect_err("the old passphrase must no longer unlock after a re-wrap");
748 assert!(
749 matches!(old_error, KeyError::Crypto(_)),
750 "got {old_error:?}"
751 );
752
753 let with_new = PassphraseVault::new(Passphrase::new("new passphrase".to_string()), path);
754 let unlocked = with_new
755 .unlock()
756 .expect("the new passphrase unlocks")
757 .expect("payload present");
758 assert_eq!(unlocked, payload);
759 }
760
761 #[test]
765 fn rewrap_seals_under_a_fresh_salt_not_the_old_envelopes() {
766 let (_tmp, vault) = temp_vault("old passphrase");
767 vault.persist(b"payload").expect("establish");
768 let salt_before = read_envelope_from_disk(vault.path()).kdf.salt_b64;
769
770 vault
771 .rewrap(&Passphrase::new("new passphrase".to_string()))
772 .expect("re-wrap");
773 let salt_after = read_envelope_from_disk(vault.path()).kdf.salt_b64;
774
775 assert_ne!(
776 salt_before, salt_after,
777 "re-wrap must derive under a fresh random salt, never reuse the old envelope's",
778 );
779 }
780
781 #[test]
786 fn rewrap_with_a_wrong_current_passphrase_is_crypto_and_leaves_the_file_intact() {
787 let (tmp, vault) = temp_vault("right passphrase");
788 vault.persist(b"the real secret").expect("establish");
789 let before = std::fs::read(vault.path()).expect("read established file");
790
791 let path = tmp.path().join("payload.envelope");
792 let wrong = PassphraseVault::new(
793 Passphrase::new("wrong passphrase".to_string()),
794 path.clone(),
795 );
796 let error = wrong
797 .rewrap(&Passphrase::new("new passphrase".to_string()))
798 .expect_err("a wrong current passphrase must not re-wrap");
799 assert!(matches!(error, KeyError::Crypto(_)), "got {error:?}");
800
801 let after = std::fs::read(&path).expect("read file after failed re-wrap");
802 assert_eq!(before, after, "a failed re-wrap must not touch the file");
803 let reopened = PassphraseVault::new(Passphrase::new("right passphrase".to_string()), path);
804 assert_eq!(reopened.unlock().unwrap().unwrap(), b"the real secret");
805 }
806
807 #[test]
810 fn rewrap_on_a_never_established_path_is_an_error_not_silent_success() {
811 let (_tmp, vault) = temp_vault("passphrase");
812 let error = vault
813 .rewrap(&Passphrase::new("new passphrase".to_string()))
814 .expect_err("re-wrapping a custody that was never established must be an error");
815 assert!(matches!(error, KeyError::Persistence(_)), "got {error:?}");
816 assert!(
817 !vault.path().exists(),
818 "a failed re-wrap must not create a file",
819 );
820 }
821
822 #[test]
827 fn rewrap_to_a_target_passphrase_can_be_retried_after_a_partial_failure() {
828 let (tmp, vault) = temp_vault("old passphrase");
829 vault.persist(b"payload").expect("establish");
830 vault
831 .rewrap(&Passphrase::new("new passphrase".to_string()))
832 .expect("first re-wrap to the new passphrase");
833
834 let path = tmp.path().join("payload.envelope");
835 let again =
836 PassphraseVault::new(Passphrase::new("new passphrase".to_string()), path.clone());
837 again
838 .rewrap(&Passphrase::new("new passphrase".to_string()))
839 .expect("re-wrap under the same passphrase is safe to repeat");
840
841 let reopened = PassphraseVault::new(Passphrase::new("new passphrase".to_string()), path);
842 assert_eq!(reopened.unlock().unwrap().unwrap(), b"payload");
843 }
844}