Skip to main content

solo_storage/
key_material.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `KeyMaterial`: holds the raw 32-byte SQLCipher key derived once at startup
4//! from the user passphrase via Argon2id.
5//!
6//! Per ADR-0003 §P8-F:
7//!   - Argon2id, m_cost = 64 MiB, t_cost = 3, p_cost = 4 (~500 ms one-time)
8//!   - 16-byte salt, persisted in `solo.config.toml` (the salt is NOT secret)
9//!   - 32-byte output key, formatted as `x'<hex>'` for SQLCipher's `PRAGMA key`
10//!   - Wrapped in `Zeroizing<[u8; 32]>` so the key is wiped from memory on drop
11//!
12//! The Argon2 salt is distinct from SQLCipher's per-database salt (which lives
13//! in the file header and feeds HMAC subkey derivation). Don't conflate them.
14
15use argon2::{Algorithm, Argon2, Params, Version};
16use solo_core::{Error, Result};
17use zeroize::Zeroizing;
18
19/// Argon2id `m_cost`, in KiB. 64 MiB.
20pub const ARGON2_M_COST_KIB: u32 = 64 * 1024;
21/// Argon2id `t_cost` (iterations).
22pub const ARGON2_T_COST: u32 = 3;
23/// Argon2id `p_cost` (parallelism).
24pub const ARGON2_P_COST: u32 = 4;
25/// SQLCipher raw key length (256 bits).
26pub const KEY_LEN: usize = 32;
27/// Argon2 salt length. 128 bits is the OWASP recommendation for password
28/// hashing; SQLCipher's own per-database salt is separate.
29pub const SALT_LEN: usize = 16;
30
31/// Raw 32-byte SQLCipher key. Always wrapped in `Zeroizing` so the underlying
32/// bytes are wiped when the value drops.
33///
34/// The struct is `Clone` (each clone produces its own zeroized buffer) but
35/// deliberately does NOT impl `Copy` — that would defeat zeroization.
36pub struct KeyMaterial {
37    raw: Zeroizing<[u8; KEY_LEN]>,
38}
39
40impl KeyMaterial {
41    /// Derive a 32-byte SQLCipher key from a UTF-8 passphrase + 16-byte salt.
42    ///
43    /// Argon2id with the parameters specified in ADR-0003 §P8-F. Takes ~500 ms
44    /// on a modern laptop — call this once at daemon startup, never per-write.
45    pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<Self> {
46        let params = Params::new(
47            ARGON2_M_COST_KIB,
48            ARGON2_T_COST,
49            ARGON2_P_COST,
50            Some(KEY_LEN),
51        )
52        .map_err(|e| Error::storage(format!("argon2 params: {e}")))?;
53        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
54
55        let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
56        argon2
57            .hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
58            .map_err(|e| Error::storage(format!("argon2 hash: {e}")))?;
59        Ok(Self { raw: out })
60    }
61
62    /// Generate a fresh cryptographically random 16-byte salt for first-run
63    /// setup. Persist in `solo.config.toml` alongside the database.
64    pub fn fresh_salt() -> Result<[u8; SALT_LEN]> {
65        let mut salt = [0u8; SALT_LEN];
66        getrandom::getrandom(&mut salt)
67            .map_err(|e| Error::storage(format!("getrandom: {e}")))?;
68        Ok(salt)
69    }
70
71    /// Raw 32-byte key as 64-character lowercase hex, wrapped in
72    /// `Zeroizing` so the underlying buffer is wiped on drop. Used to
73    /// build the `PRAGMA key = "x'<hex>'"` statement on every fresh
74    /// SQLCipher connection.
75    ///
76    /// Callers should hold the returned value just long enough to build
77    /// the PRAGMA, then let it drop. The PRAGMA string itself isn't
78    /// wrapped — once it's been formatted into a static prefix +
79    /// dynamic hex + static suffix, the resulting `String` doesn't
80    /// zeroize on its own. That's a known v0.2 hardening item; for now
81    /// the smaller `Zeroizing<String>` from this method is the cleanest
82    /// boundary.
83    pub fn as_hex(&self) -> Zeroizing<String> {
84        Zeroizing::new(hex::encode(self.raw.as_ref()))
85    }
86
87    /// Constant-time-ish equality. For tests + property checks; production
88    /// code should never need to compare keys directly.
89    #[cfg(test)]
90    fn eq_for_test(&self, other: &Self) -> bool {
91        self.raw.as_ref() == other.raw.as_ref()
92    }
93}
94
95impl Clone for KeyMaterial {
96    fn clone(&self) -> Self {
97        Self {
98            raw: Zeroizing::new(*self.raw),
99        }
100    }
101}
102
103impl std::fmt::Debug for KeyMaterial {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.write_str("KeyMaterial { raw: <redacted> }")
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    /// Lower-cost params for tests so the suite stays fast. Production callers
114    /// must use `derive` (which uses the full params).
115    fn derive_fast(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<KeyMaterial> {
116        let params = Params::new(8, 1, 1, Some(KEY_LEN))
117            .map_err(|e| Error::storage(format!("params: {e}")))?;
118        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
119        let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
120        argon2
121            .hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
122            .map_err(|e| Error::storage(format!("hash: {e}")))?;
123        Ok(KeyMaterial { raw: out })
124    }
125
126    #[test]
127    fn derive_is_deterministic_with_same_salt() {
128        let salt = [0u8; SALT_LEN];
129        let a = derive_fast("hunter2", &salt).unwrap();
130        let b = derive_fast("hunter2", &salt).unwrap();
131        assert!(a.eq_for_test(&b));
132        assert_eq!(&*a.as_hex(), &*b.as_hex());
133    }
134
135    #[test]
136    fn derive_differs_with_different_salt() {
137        let s1 = [0u8; SALT_LEN];
138        let mut s2 = [0u8; SALT_LEN];
139        s2[0] = 1;
140        let a = derive_fast("hunter2", &s1).unwrap();
141        let b = derive_fast("hunter2", &s2).unwrap();
142        assert!(!a.eq_for_test(&b));
143    }
144
145    #[test]
146    fn derive_differs_with_different_passphrase() {
147        let salt = [0u8; SALT_LEN];
148        let a = derive_fast("hunter2", &salt).unwrap();
149        let b = derive_fast("hunter3", &salt).unwrap();
150        assert!(!a.eq_for_test(&b));
151    }
152
153    #[test]
154    fn fresh_salt_is_random() {
155        let s1 = KeyMaterial::fresh_salt().unwrap();
156        let s2 = KeyMaterial::fresh_salt().unwrap();
157        // Probability of collision is 2^-128 — if this ever fails, getrandom
158        // is broken.
159        assert_ne!(s1, s2);
160    }
161
162    #[test]
163    fn as_hex_has_correct_length_and_charset() {
164        let salt = [0u8; SALT_LEN];
165        let k = derive_fast("hunter2", &salt).unwrap();
166        let h = k.as_hex();
167        assert_eq!(h.len(), KEY_LEN * 2);
168        assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
169    }
170
171    #[test]
172    fn debug_redacts_key_material() {
173        let salt = [0u8; SALT_LEN];
174        let k = derive_fast("hunter2", &salt).unwrap();
175        let dbg = format!("{k:?}");
176        assert!(dbg.contains("redacted"));
177        assert!(!dbg.contains(&k.as_hex()[..8]));
178    }
179}