Skip to main content

coven/
envelope.rs

1//! The passphrase-wrapped envelope: Argon2id derives a wrapping key from a
2//! memorized secret, XChaCha20-Poly1305 seals arbitrary plaintext bytes under
3//! it, and the result is written atomically to a file as versioned JSON. One
4//! implementation, shared by every passphrase-protected secret coven stores
5//! this way (a store's master keyring, a device's signing identity) —
6//! [`PassphraseVault`] is parameterized only by its plaintext payload (opaque
7//! bytes) and its file path; the payload's own shape (a JSON keyring, a raw
8//! 64-byte keypair) is the caller's concern, not this module's.
9
10use 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/// A memorized secret that wraps a payload under Argon2id. Held zeroizing —
25/// the whole struct is cleared on drop, so no copy of the passphrase outlives
26/// it.
27#[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
40/// OWASP's current interactive Argon2id recommendation: 64 MiB memory, 3
41/// iterations, 4-way parallelism. `argon2::Params::m_cost` is in KiB.
42const 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";
49/// XChaCha20-Poly1305's nonce length.
50const NONCE_LEN: usize = 24;
51
52/// The on-disk wrapped-payload format. The KDF params travel with the
53/// ciphertext so a future change to the module constants only affects new
54/// wraps — unlock always re-derives from what the file itself names, never
55/// from the current constants.
56#[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/// The Argon2id-derived wrapping key, cached after first use — Argon2id is
74/// deliberately slow, so this is paid once per [`PassphraseVault`] instance
75/// (its "session"), not once per unlock/persist call. Zeroized on drop along
76/// with the salt/params it was derived from.
77#[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
86/// Argon2id over a [`Passphrase`] wraps an arbitrary plaintext payload; the
87/// wrapped blob is a JSON envelope in a file at `path`, not a keyring entry —
88/// files have no Windows Credential Manager size cap, and a payload that has
89/// rotated N times carries N generations.
90pub(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    /// The wrapping key for this instance, deriving and caching it on first
127    /// use. The salt/params are fixed for the life of an established wrapped
128    /// file: read from the file if one exists (so a rotation re-wraps under
129    /// the same derivation, only a fresh AEAD nonce), or freshly generated on
130    /// the very first establishment.
131    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    /// The vault's plaintext payload, or `None` if nothing has ever been
170    /// established. A wrong passphrase or a corrupt file is `Err`, never
171    /// `Ok(None)`.
172    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    /// Wrap and store `plaintext`, replacing whatever is stored. Idempotent.
183    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    /// Re-wrap the established payload under `new_passphrase`. The current
189    /// (this instance's) passphrase unlocks the file; the same plaintext is
190    /// then sealed under a wrapping key derived for `new_passphrase` with a
191    /// *fresh* random salt and this module's current default params (never the
192    /// old envelope's salt — that would tie the new derivation to the old
193    /// one), and written atomically over `self.path`.
194    ///
195    /// Nothing established at `self.path` is an error, not a no-op: re-wrapping
196    /// a custody that was never established is a caller bug, and silent success
197    /// would mask it. A wrong current passphrase surfaces from `unlock` as
198    /// [`KeyError::Crypto`]; because the write only follows a successful
199    /// unlock-and-reseal, the existing file is left untouched on that failure.
200    ///
201    /// After a successful re-wrap this instance is stale — its `passphrase`
202    /// field still holds the *old* secret, so it can no longer unlock the file
203    /// it just wrote. The cached derivation is cleared to keep nothing reusing
204    /// a stale wrapping key; the caller re-opens custody under `new_passphrase`
205    /// for any further operation.
206    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        // Mirror the fresh-file branch of `derived_key`: a new random salt and
215        // the current default params, derived under the new passphrase. Held
216        // as a `CachedDerivation` so its wrapping key zeroizes on drop.
217        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    /// Seal `plaintext` under `derivation` (a fresh AEAD nonce) and write the
243    /// resulting envelope atomically over `self.path`. The shared tail of
244    /// [`persist`](Self::persist) and [`rewrap`](Self::rewrap) — they differ
245    /// only in where the derivation comes from (this instance's cached one, or
246    /// a fresh one under a new passphrase).
247    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    /// Remove the stored envelope. `Ok` when nothing was stored.
271    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
282/// Reject a header this module cannot safely act on: an envelope version this
283/// build does not implement, or a KDF other than Argon2id. Runs on every read
284/// that returns `Some`, before the file's declared version, algorithm, or
285/// parameters ever reach [`derive_wrapping_key`] or the AEAD — the header is
286/// unauthenticated (nothing has decrypted yet), so it is untrusted input that
287/// this module must not act on blindly.
288fn 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
312/// Reject Argon2id parameters weaker than this module's floor
313/// (`ARGON2_M_COST_KIB`/`ARGON2_T_COST`/`ARGON2_P_COST`). These values come
314/// from an existing file, read verbatim rather than regenerated from the
315/// current constants, so that a future increase to the floor does not strand
316/// a file already wrapped at the old (still-adequate) strength — reading a
317/// file's own params is what makes that upgrade non-breaking. But nothing
318/// authenticates the header before this point, so an on-disk value is
319/// otherwise free for a file-write-capable attacker to set arbitrarily low;
320/// this floor is what stops a subsequent `persist` from re-wrapping the real
321/// secret at that attacker-chosen strength. Only a value below the floor is
322/// refused — a file already wrapped at a stronger derivation than today's
323/// constants call for unlocks unchanged.
324fn 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
406/// Write `bytes` to `path` atomically: a uniquely-named temp sibling (derived
407/// from `path`'s own file name, so two vaults at different paths never share
408/// a temp name), fsynced, then renamed over the destination — the same
409/// temp-then-rename discipline coven's blob store writes with, so a crash
410/// mid-write never leaves a torn file.
411fn 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    // fsync the parent directory so the rename that installed the envelope is
441    // itself durable — the same discipline coven's `sync_parent_dir` and
442    // coven-core's `local_blob::sync_parent_dir` keep, both of which propagate
443    // these errors rather than swallowing them.
444    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    /// Write `envelope` straight to `path`, bypassing `PassphraseVault`
463    /// entirely — stands in for a file an attacker planted, or one corrupted
464    /// on disk, neither of which goes through this module's own `persist`.
465    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        // Non-UTF-8 bytes: the vault must not assume a string payload — a raw
484        // 64-byte Ed25519 keypair is not valid UTF-8 in general.
485        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        // A fresh vault instance over the same passphrase and file — proving
530        // the passphrase (not the process-cached derivation) is what unlocks.
531        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        // The file is valid, complete JSON after two writes — never a
549        // half-written temp left in place of (or beside) the real file.
550        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    /// A file declaring Argon2id parameters below this module's floor —
583    /// planted by whoever can write the file, without needing to read the
584    /// passphrase — must not be honored: `unlock` refuses it, and `persist`
585    /// must refuse too, since re-wrapping the real secret under those
586    /// attacker-chosen parameters is exactly the escalation the floor exists
587    /// to prevent.
588    #[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        // The refusal must happen before any write — the planted file, weak
622        // params and all, is exactly as it was.
623        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    /// An envelope wrapped at parameters stronger than today's floor still
630    /// unlocks: the stored values travel with the ciphertext precisely so a
631    /// future increase to the module's constants does not strand an
632    /// already-established file. Only a value *below* the floor is refused.
633    #[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    /// A file whose `nonce_b64` decodes to something other than 24 bytes (a
665    /// corrupt file, not a wrong passphrase) must surface as `KeyError`, not
666    /// panic `GenericArray::from_slice` — the module's stated contract is
667    /// that a corrupt file is always `Err`.
668    #[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    /// An envelope naming a version this build does not implement is
684    /// refused, both when it is older (this build no longer knows those
685    /// semantics) and when it is newer (this build doesn't know them yet).
686    #[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    /// An envelope naming a KDF other than Argon2id is refused — the `algo`
710    /// field exists to prevent KDF confusion, not to be read and ignored.
711    #[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    /// Re-wrapping under a new passphrase moves custody: the old passphrase can
727    /// no longer unlock the file, the new one can, and the plaintext survives
728    /// the move byte-for-byte.
729    #[test]
730    fn rewrap_makes_the_old_passphrase_stop_unlocking_and_the_new_one_start() {
731        let (tmp, vault) = temp_vault("old passphrase");
732        // Non-UTF-8 payload: re-wrap must not assume a string, same as persist.
733        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    /// The re-wrapped envelope derives under a fresh random salt — never the
762    /// old envelope's, which would tie the new passphrase's derivation to the
763    /// old one's.
764    #[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    /// A wrong current passphrase surfaces from the unlock as `Crypto`, and
782    /// because the write only follows a successful unlock-and-reseal, the
783    /// established file is left byte-for-byte intact — the right passphrase
784    /// still unlocks it.
785    #[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    /// Re-wrapping a custody that was never established is a caller bug, not a
808    /// no-op: it is an error, and no file is created.
809    #[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    /// Re-wrap is safe to retry after a partial failure: a caller that crashed
823    /// between the write and its own bookkeeping re-opens custody under the new
824    /// passphrase and re-wraps to that same passphrase again — which succeeds
825    /// and still unlocks, rather than being stranded.
826    #[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}