coven_crypto/custody/
passphrase.rs1use 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
10pub struct Passphrase(Zeroizing<String>);
12
13impl Passphrase {
14 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;
28const WRITE_PARAMETERS: [u32; 3] = [65536, 3, 4];
30
31pub 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 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 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(¶m.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 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 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;