Skip to main content

coven_keys/
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
10#[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/// A memorized secret that wraps a payload under Argon2id. Held zeroizing —
26/// the whole struct is cleared on drop, so no copy of the passphrase outlives
27/// it.
28#[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
41/// OWASP's current interactive Argon2id recommendation: 64 MiB memory, 3
42/// iterations, 4-way parallelism. `argon2::Params::m_cost` is in KiB.
43const 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";
50/// XChaCha20-Poly1305's nonce length.
51const NONCE_LEN: usize = 24;
52
53/// The on-disk wrapped-payload format. The KDF params travel with the
54/// ciphertext so a future change to the module constants only affects new
55/// wraps — unlock always re-derives from what the file itself names, never
56/// from the current constants.
57#[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/// The Argon2id-derived wrapping key, cached after first use — Argon2id is
75/// deliberately slow, so this is paid once per [`PassphraseVault`] instance
76/// (its "session"), not once per unlock/persist call. Zeroized on drop along
77/// with the salt/params it was derived from.
78#[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
87/// Argon2id over a [`Passphrase`] wraps an arbitrary plaintext payload; the
88/// wrapped blob is a JSON envelope in a file at `path`, not a keyring entry —
89/// files have no Windows Credential Manager size cap, and a payload that has
90/// rotated N times carries N generations.
91pub(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    /// The wrapping key for this instance, deriving and caching it on first
122    /// use. The salt/params are fixed for the life of an established wrapped
123    /// file: read from the file if one exists (so a rotation re-wraps under
124    /// the same derivation, only a fresh AEAD nonce), or freshly generated on
125    /// the very first establishment.
126    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    /// The vault's plaintext payload, or `None` if nothing has ever been
165    /// established. A wrong passphrase or a corrupt file is `Err`, never
166    /// `Ok(None)`.
167    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    /// Wrap and store `plaintext`, replacing whatever is stored. Idempotent.
178    ///
179    /// Sealed under this instance's cached derivation with a fresh AEAD nonce,
180    /// and written atomically over the retained file.
181    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    /// Remove the stored envelope. `Ok` when nothing was stored.
204    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
214/// Reject a header this module cannot safely act on: an envelope version this
215/// build does not implement, or a KDF other than Argon2id. Runs on every read
216/// that returns `Some`, before the file's declared version, algorithm, or
217/// parameters ever reach [`derive_wrapping_key`] or the AEAD — the header is
218/// unauthenticated (nothing has decrypted yet), so it is untrusted input that
219/// this module must not act on blindly.
220fn 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
236/// Reject Argon2id parameters weaker than this module's floor
237/// (`ARGON2_M_COST_KIB`/`ARGON2_T_COST`/`ARGON2_P_COST`). These values come
238/// from an existing file, read verbatim rather than regenerated from the
239/// current constants, so that a future increase to the floor does not strand
240/// a file already wrapped at the old (still-adequate) strength — reading a
241/// file's own params is what makes that upgrade non-breaking. But nothing
242/// authenticates the header before this point, so an on-disk value is
243/// otherwise free for a file-write-capable attacker to set arbitrarily low;
244/// this floor is what stops a subsequent `persist` from re-wrapping the real
245/// secret at that attacker-chosen strength. Only a value below the floor is
246/// refused — a file already wrapped at a stronger derivation than today's
247/// constants call for unlocks unchanged.
248fn 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    /// Write `envelope` straight to `path`, bypassing `PassphraseVault`
351    /// entirely — stands in for a file an attacker planted, or one corrupted
352    /// on disk, neither of which goes through this module's own `persist`.
353    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        // Non-UTF-8 bytes: the vault must not assume a string payload — a raw
372        // 64-byte Ed25519 keypair is not valid UTF-8 in general.
373        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        // A fresh vault instance over the same passphrase and file — proving
421        // the passphrase (not the process-cached derivation) is what unlocks.
422        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        // The file is valid, complete JSON after two writes — never a
440        // half-written temp left in place of (or beside) the real file.
441        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    /// A file declaring Argon2id parameters below this module's floor —
474    /// planted by whoever can write the file, without needing to read the
475    /// passphrase — must not be honored: `unlock` refuses it, and `persist`
476    /// must refuse too, since re-wrapping the real secret under those
477    /// attacker-chosen parameters is exactly the escalation the floor exists
478    /// to prevent.
479    #[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        // The refusal must happen before any write — the planted file, weak
513        // params and all, is exactly as it was.
514        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    /// An envelope wrapped at parameters stronger than today's floor still
521    /// unlocks: the stored values travel with the ciphertext precisely so a
522    /// future increase to the module's constants does not strand an
523    /// already-established file. Only a value *below* the floor is refused.
524    #[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    /// A file whose `nonce_b64` decodes to something other than 24 bytes (a
556    /// corrupt file, not a wrong passphrase) must surface as `KeyError`, not
557    /// panic `GenericArray::from_slice` — the module's stated contract is
558    /// that a corrupt file is always `Err`.
559    #[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    /// An envelope naming any version other than this build's is refused —
578    /// the check is on the version being the one implemented, not on which
579    /// side of it the file falls.
580    #[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    /// An envelope naming a KDF other than Argon2id is refused — the `algo`
610    /// field exists to prevent KDF confusion, not to be read and ignored.
611    #[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}