Skip to main content

kovra_core/
keyring.rs

1//! Master-key acquisition behind a mockable trait (spec §10.2, decision §19).
2//!
3//! The 32-byte vault master key lives in the **OS keyring** (macOS Keychain,
4//! Windows Credential Manager, Linux Secret Service) — never on a per-operation
5//! passphrase, never in a Docker image (I9). When no keyring is available
6//! (headless), it is derived from a passphrase with Argon2id.
7//!
8//! All of this sits behind the [`Keyring`] trait so core logic is tested with a
9//! deterministic [`MockKeyring`]; the real OS backend ([`OsKeyring`]) is
10//! validated on hardware in a later layer (`[host]`), not by unit tests.
11
12use argon2::Argon2;
13use zeroize::{Zeroize, Zeroizing};
14
15use crate::crypto::KEY_LEN;
16use crate::error::CoreError;
17
18/// The vault master key (32 bytes), held in protected memory.
19///
20/// Zeroized on drop, redacted `Debug`, and — like [`crate::SecretValue`] — has
21/// **no** `Display` and is not serializable. The bytes are reachable only via
22/// [`MasterKey::expose`], for handing to [`crate::seal`]/[`crate::open`].
23pub struct MasterKey([u8; KEY_LEN]);
24
25impl MasterKey {
26    /// Wrap raw key bytes.
27    pub fn new(bytes: [u8; KEY_LEN]) -> Self {
28        Self(bytes)
29    }
30
31    /// Borrow the key bytes. Use deliberately — this is the one path out.
32    pub fn expose(&self) -> &[u8; KEY_LEN] {
33        &self.0
34    }
35}
36
37impl Drop for MasterKey {
38    fn drop(&mut self) {
39        self.0.zeroize();
40    }
41}
42
43/// Redacted `Debug`: never reveals key material (I12). No `Display` impl exists.
44impl core::fmt::Debug for MasterKey {
45    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
46        f.write_str("MasterKey(REDACTED)")
47    }
48}
49
50/// Source of the vault master key. The store and registry depend on this trait,
51/// not on any concrete backend, so they are testable without an OS keyring.
52pub trait Keyring {
53    /// Fetch the master key, materializing it (e.g. reading the OS keyring or
54    /// deriving from a passphrase).
55    fn get_master_key(&self) -> Result<MasterKey, CoreError>;
56
57    /// Persist the master key (e.g. write it to the OS keyring). Backends that
58    /// *derive* the key (see [`Argon2Keyring`]) have nothing to store and return
59    /// an error.
60    fn set_master_key(&self, key: &MasterKey) -> Result<(), CoreError>;
61}
62
63/// In-memory, deterministic keyring for tests. Never touches the OS.
64#[derive(Default)]
65pub struct MockKeyring {
66    key: std::sync::Mutex<Option<[u8; KEY_LEN]>>,
67}
68
69impl MockKeyring {
70    /// An empty keyring (no key yet); `get_master_key` errors until one is set.
71    pub fn empty() -> Self {
72        Self::default()
73    }
74
75    /// A keyring pre-seeded with a fixed key — convenient for tests.
76    pub fn with_key(bytes: [u8; KEY_LEN]) -> Self {
77        Self {
78            key: std::sync::Mutex::new(Some(bytes)),
79        }
80    }
81}
82
83impl Keyring for MockKeyring {
84    fn get_master_key(&self) -> Result<MasterKey, CoreError> {
85        self.key
86            .lock()
87            .expect("mock keyring mutex poisoned")
88            .map(MasterKey::new)
89            .ok_or_else(|| CoreError::Keyring("no master key set".to_string()))
90    }
91
92    fn set_master_key(&self, key: &MasterKey) -> Result<(), CoreError> {
93        *self.key.lock().expect("mock keyring mutex poisoned") = Some(*key.expose());
94        Ok(())
95    }
96}
97
98/// Real OS keyring backend (`[host]`). Stores the master key as a binary secret
99/// under a fixed service/user. Compiled on every platform behind the trait, but
100/// validated on real hardware in a later layer — unit tests use
101/// [`MockKeyring`].
102pub struct OsKeyring {
103    service: String,
104    user: String,
105}
106
107impl OsKeyring {
108    /// The default kovra keyring entry (`service = "kovra"`, `user =
109    /// "master-key"`).
110    pub fn new() -> Self {
111        Self {
112            service: "kovra".to_string(),
113            user: "master-key".to_string(),
114        }
115    }
116}
117
118impl Default for OsKeyring {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Keyring for OsKeyring {
125    fn get_master_key(&self) -> Result<MasterKey, CoreError> {
126        let entry = keyring::Entry::new(&self.service, &self.user)
127            .map_err(|e| CoreError::Keyring(e.to_string()))?;
128        let secret = entry
129            .get_secret()
130            .map_err(|e| CoreError::Keyring(e.to_string()))?;
131        let bytes: [u8; KEY_LEN] = secret
132            .as_slice()
133            .try_into()
134            .map_err(|_| CoreError::Keyring("stored key has wrong length".to_string()))?;
135        Ok(MasterKey::new(bytes))
136    }
137
138    fn set_master_key(&self, key: &MasterKey) -> Result<(), CoreError> {
139        let entry = keyring::Entry::new(&self.service, &self.user)
140            .map_err(|e| CoreError::Keyring(e.to_string()))?;
141        entry
142            .set_secret(key.expose())
143            .map_err(|e| CoreError::Keyring(e.to_string()))
144    }
145}
146
147/// Headless fallback (spec §10.2): derive the master key from a passphrase with
148/// Argon2id. Deterministic given the same passphrase and salt, so the same
149/// vault unlocks across runs without an OS keyring. There is nothing to
150/// *store* — `set_master_key` is unsupported.
151pub struct Argon2Keyring {
152    passphrase: Zeroizing<Vec<u8>>,
153    salt: Vec<u8>,
154}
155
156/// Minimum Argon2 salt length (the crate rejects shorter salts).
157pub const MIN_SALT_LEN: usize = 8;
158
159impl Argon2Keyring {
160    /// Build a fallback keyring from a passphrase and a salt. The salt is not
161    /// secret but must be **stable** for a given vault (store it alongside the
162    /// vault) and at least [`MIN_SALT_LEN`] bytes.
163    pub fn new(
164        passphrase: impl Into<Vec<u8>>,
165        salt: impl Into<Vec<u8>>,
166    ) -> Result<Self, CoreError> {
167        let salt = salt.into();
168        if salt.len() < MIN_SALT_LEN {
169            return Err(CoreError::Keyring(format!(
170                "salt must be at least {MIN_SALT_LEN} bytes"
171            )));
172        }
173        Ok(Self {
174            passphrase: Zeroizing::new(passphrase.into()),
175            salt,
176        })
177    }
178}
179
180/// Stack reserved for the Argon2 derivation thread. Argon2's block-fill uses
181/// large stack frames (each `Block` is 1 KiB and the compression function holds
182/// several plus an unrolled permutation); in a debug build this overflows
183/// Windows' 1 MiB default main-thread stack (Unix gives 8 MiB). Deriving on a
184/// worker thread with an explicit stack makes the KDF behave identically on
185/// every platform. 8 MiB mirrors the Unix default.
186const ARGON2_STACK_BYTES: usize = 8 * 1024 * 1024;
187
188impl Keyring for Argon2Keyring {
189    fn get_master_key(&self) -> Result<MasterKey, CoreError> {
190        let derive = || {
191            let mut key = [0u8; KEY_LEN];
192            let res = Argon2::default()
193                .hash_password_into(&self.passphrase, &self.salt, &mut key)
194                .map_err(|e| CoreError::Keyring(e.to_string()));
195            let out = res.map(|()| MasterKey::new(key));
196            key.zeroize();
197            out
198        };
199        // Scoped so the closure can borrow `self` without a `'static` bound; the
200        // explicit stack size avoids a Windows debug-build stack overflow.
201        std::thread::scope(|s| {
202            std::thread::Builder::new()
203                .stack_size(ARGON2_STACK_BYTES)
204                .spawn_scoped(s, derive)
205                .map_err(|e| CoreError::Keyring(format!("spawning Argon2 worker: {e}")))?
206                .join()
207                .map_err(|_| CoreError::Keyring("Argon2 worker panicked".to_string()))?
208        })
209    }
210
211    fn set_master_key(&self, _key: &MasterKey) -> Result<(), CoreError> {
212        Err(CoreError::Keyring(
213            "passphrase-derived key cannot be stored; it is recomputed from the passphrase"
214                .to_string(),
215        ))
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn mock_keyring_round_trips() {
225        let kr = MockKeyring::empty();
226        assert!(kr.get_master_key().is_err());
227        kr.set_master_key(&MasterKey::new([5u8; KEY_LEN])).unwrap();
228        assert_eq!(kr.get_master_key().unwrap().expose(), &[5u8; KEY_LEN]);
229    }
230
231    #[test]
232    fn mock_with_key_seeds_value() {
233        let kr = MockKeyring::with_key([9u8; KEY_LEN]);
234        assert_eq!(kr.get_master_key().unwrap().expose(), &[9u8; KEY_LEN]);
235    }
236
237    #[test]
238    fn master_key_debug_is_redacted() {
239        let mk = MasterKey::new([1u8; KEY_LEN]);
240        assert_eq!(format!("{mk:?}"), "MasterKey(REDACTED)");
241    }
242
243    #[test]
244    fn argon2_is_deterministic_for_same_inputs() {
245        let a =
246            Argon2Keyring::new(b"correct horse".to_vec(), b"stable-salt-1234".to_vec()).unwrap();
247        let b =
248            Argon2Keyring::new(b"correct horse".to_vec(), b"stable-salt-1234".to_vec()).unwrap();
249        assert_eq!(
250            a.get_master_key().unwrap().expose(),
251            b.get_master_key().unwrap().expose()
252        );
253    }
254
255    #[test]
256    fn argon2_differs_for_different_passphrase() {
257        let salt = b"stable-salt-1234".to_vec();
258        let a = Argon2Keyring::new(b"passphrase-a".to_vec(), salt.clone()).unwrap();
259        let b = Argon2Keyring::new(b"passphrase-b".to_vec(), salt).unwrap();
260        assert_ne!(
261            a.get_master_key().unwrap().expose(),
262            b.get_master_key().unwrap().expose()
263        );
264    }
265
266    #[test]
267    fn argon2_rejects_short_salt() {
268        assert!(matches!(
269            Argon2Keyring::new(b"pw".to_vec(), b"short".to_vec()),
270            Err(CoreError::Keyring(_))
271        ));
272    }
273
274    #[test]
275    fn argon2_key_is_not_settable() {
276        let kr = Argon2Keyring::new(b"pw".to_vec(), b"stable-salt-1234".to_vec()).unwrap();
277        assert!(kr.set_master_key(&MasterKey::new([0u8; KEY_LEN])).is_err());
278    }
279}