Skip to main content

coven_crypto/custody/
passphrase.rs

1//! Argon2id and an authenticated file, accessed only through `AtomicFile`.
2
3use super::KeyError;
4use crate::{cipher, randomness, wire, CryptoError, SecretBytes};
5use coven_foundation::locks::lock_unpoisoned;
6use coven_foundation::{files::AtomicFile, id_source::StoreId};
7use std::{marker::PhantomData, sync::Mutex};
8use zeroize::Zeroizing;
9
10/// A memorized secret that protects custody with Argon2id (E1).
11pub struct Passphrase(Zeroizing<String>);
12
13impl Passphrase {
14    /// Take ownership of a passphrase and erase its allocation on drop.
15    pub fn new(secret: String) -> Self {
16        Self(Zeroizing::new(secret))
17    }
18}
19
20impl std::fmt::Debug for Passphrase {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_str("Passphrase([REDACTED])")
23    }
24}
25
26const HEADER: &[u8] = b"CVPF\x01";
27const SALT_LEN: usize = 16;
28// RFC 9106's memory-constrained Argon2id parameters, with a 256-bit output.
29const WRITE_PARAMETERS: [u32; 3] = [65536, 3, 4];
30
31/// Passphrase file custody for `StoreKeyring` or `MemberIdentity` (E1).
32/// The composition root supplies the corresponding reserved `AtomicFile`.
33pub struct PassphraseCustody<T> {
34    protection: Mutex<Option<Protection>>,
35    file: AtomicFile,
36    store: StoreId,
37    material: PhantomData<fn() -> T>,
38}
39
40enum Protection {
41    Locked(Passphrase),
42    Unlocked(Sealing),
43}
44
45struct Sealing {
46    header: Vec<u8>,
47    key: Zeroizing<[u8; 32]>,
48}
49
50impl<T> PassphraseCustody<T> {
51    /// Retain the passphrase until the session opens. Construction performs no IO.
52    /// Successful unlocking erases the passphrase and keeps its derived key.
53    pub fn new(passphrase: Passphrase, file: AtomicFile, store: StoreId) -> Self {
54        Self {
55            protection: Mutex::new(Some(Protection::Locked(passphrase))),
56            file,
57            store,
58            material: PhantomData,
59        }
60    }
61
62    pub(crate) fn read(&self, kind: &str) -> Result<Option<SecretBytes>, KeyError> {
63        let mut protection = lock_unpoisoned(&self.protection);
64        let Protection::Locked(passphrase) = protection.as_ref().ok_or(KeyError::StoreClosed)?
65        else {
66            panic!("passphrase custody must be unlocked once by its session owner");
67        };
68        let (sealing, material) = match self.file.read_optional()? {
69            Some(bytes) => {
70                let sealing = Sealing::open(passphrase, &bytes)?;
71                let aad =
72                    cipher::context(&[&sealing.header, self.store.0.as_bytes(), kind.as_bytes()]);
73                let material =
74                    match cipher::open_random(&sealing.key, &aad, &bytes[sealing.header.len()..]) {
75                        Ok(bytes) => SecretBytes::new(bytes),
76                        Err(CryptoError::Authentication) => {
77                            return Err(KeyError::PassphraseAuthentication)
78                        }
79                        Err(error) => return Err(error.into()),
80                    };
81                (sealing, Some(material))
82            }
83            None => (Sealing::create(passphrase)?, None),
84        };
85        *protection = Some(Protection::Unlocked(sealing));
86        Ok(material)
87    }
88
89    pub(crate) fn write(&self, kind: &str, plaintext: &[u8]) -> Result<(), KeyError> {
90        let protection = lock_unpoisoned(&self.protection);
91        let Protection::Unlocked(sealing) = protection.as_ref().ok_or(KeyError::StoreClosed)?
92        else {
93            panic!("passphrase custody must be unlocked before persisting");
94        };
95        let aad = cipher::context(&[&sealing.header, self.store.0.as_bytes(), kind.as_bytes()]);
96        let mut bytes = sealing.header.clone();
97        // Each save uses a fresh nonce under the session's held key. It reads
98        // neither the existing file nor the erased passphrase.
99        bytes.extend(cipher::seal_random(&sealing.key, &aad, plaintext)?);
100        self.file.replace(&bytes)?;
101        Ok(())
102    }
103
104    pub(crate) fn remove(&self) -> Result<(), KeyError> {
105        let protection = lock_unpoisoned(&self.protection);
106        protection.as_ref().ok_or(KeyError::StoreClosed)?;
107        self.file.remove_with_temporaries().map_err(KeyError::from)
108    }
109
110    pub(crate) fn release(&self) {
111        lock_unpoisoned(&self.protection).take();
112    }
113}
114
115impl Sealing {
116    fn create(passphrase: &Passphrase) -> Result<Self, KeyError> {
117        let mut salt = [0; SALT_LEN];
118        randomness::fill(&mut salt)?;
119        let params = read_parameters(WRITE_PARAMETERS)
120            .expect("custody writer parameters must satisfy the passphrase resource bounds");
121        let key = derive(passphrase, &salt, params)?;
122        let mut header = HEADER.to_vec();
123        header.extend_from_slice(&0x13u32.to_le_bytes());
124        for param in WRITE_PARAMETERS {
125            header.extend_from_slice(&param.to_le_bytes());
126        }
127        header.extend_from_slice(&salt);
128        Ok(Self { header, key })
129    }
130
131    #[expect(
132        clippy::map_err_ignore,
133        reason = "each wire read fails only with Encoding: the header's damage"
134    )]
135    fn open(passphrase: &Passphrase, bytes: &[u8]) -> Result<Self, KeyError> {
136        let mut body = bytes;
137        wire::prefix(&mut body, HEADER).map_err(|_| KeyError::PassphraseHeader)?;
138        let version =
139            u32::from_le_bytes(wire::array(&mut body).map_err(|_| KeyError::PassphraseHeader)?);
140        if version != 0x13 {
141            return Err(KeyError::PassphraseHeader);
142        }
143        let mut params = [0; 3];
144        for param in &mut params {
145            *param =
146                u32::from_le_bytes(wire::array(&mut body).map_err(|_| KeyError::PassphraseHeader)?);
147        }
148        let salt = wire::array::<SALT_LEN>(&mut body).map_err(|_| KeyError::PassphraseHeader)?;
149        if body.len() < 40 {
150            return Err(KeyError::PassphraseHeader);
151        }
152        let header = bytes[..bytes.len() - body.len()].to_vec();
153        let key = derive(passphrase, &salt, read_parameters(params)?)?;
154        Ok(Self { header, key })
155    }
156}
157
158impl<T> std::fmt::Debug for PassphraseCustody<T> {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.write_str("PassphraseCustody([REDACTED])")
161    }
162}
163
164#[expect(
165    clippy::map_err_ignore,
166    reason = "argon2 refusing stored parameters is their damage: PassphraseParameters"
167)]
168fn read_parameters(params: [u32; 3]) -> Result<argon2::Params, KeyError> {
169    let [memory, iterations, lanes] = params;
170    // These read bounds are independent of writer defaults: raising defaults
171    // never strands a stored file. Upper bounds reject unauthenticated resource
172    // exhaustion before allocating memory or performing Argon2 work.
173    if !(65536..=1048576).contains(&memory)
174        || !(3..=16).contains(&iterations)
175        || !(1..=16).contains(&lanes)
176    {
177        return Err(KeyError::PassphraseParameters);
178    }
179    argon2::Params::new(memory, iterations, lanes, Some(32))
180        .map_err(|_| KeyError::PassphraseParameters)
181}
182
183fn allocate_blocks(count: usize) -> Result<Zeroizing<Vec<argon2::Block>>, KeyError> {
184    // Argon2's allocating convenience method frees its work area without
185    // zeroizing it. Own that area so every return path erases it.
186    let mut blocks = Zeroizing::new(Vec::new());
187    blocks.try_reserve_exact(count).map_err(|error| KeyError::Unavailable(Box::new(error)))?;
188    blocks.resize(count, argon2::Block::default());
189    Ok(blocks)
190}
191
192fn derive(
193    passphrase: &Passphrase,
194    salt: &[u8; SALT_LEN],
195    params: argon2::Params,
196) -> Result<Zeroizing<[u8; 32]>, KeyError> {
197    #[cfg(any(test, feature = "test-utils"))]
198    coven_foundation::test_work::custody_derivation();
199    let mut blocks = allocate_blocks(params.block_count())?;
200    let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
201    let mut key = Zeroizing::new([0; 32]);
202    argon.hash_password_into_with_memory(
203        passphrase.0.as_bytes(),
204        salt,
205        key.as_mut(),
206        blocks.as_mut_slice(),
207    )
208    .expect("custody Argon2id requires a 32-byte output, a 16-byte salt, sufficient blocks and a passphrase no longer than u32::MAX bytes");
209    Ok(key)
210}
211
212#[cfg(test)]
213#[path = "passphrase_tests.rs"]
214mod tests;