Skip to main content

huddle_core/storage/
keychain.rs

1//! Master-key derivation for at-rest encryption.
2//!
3//! On launch the user enters a master passphrase. We combine it with a
4//! per-installation salt (kept in the data dir, unencrypted — its only
5//! job is to make rainbow-table attacks unreasonable) and feed both into
6//! Argon2id to derive a 32-byte master key. That key is used for:
7//!
8//!  1. `PRAGMA key` on the SQLCipher connection
9//!  2. HKDF input for the Megolm session-persistence key
10//!     (replaces the hardcoded all-zero key from Phase 1)
11
12use std::fs;
13use std::path::PathBuf;
14
15use argon2::{Algorithm, Argon2, Params, Version};
16use hkdf::Hkdf;
17use rand::RngCore;
18use sha2::Sha256;
19
20use crate::config;
21use crate::error::{HuddleError, Result};
22
23pub const MASTER_KEY_LEN: usize = 32;
24pub const KEYCHAIN_SALT_LEN: usize = 16;
25
26/// Returns the path holding the keychain salt. The salt is not secret;
27/// only the passphrase is.
28pub fn keychain_salt_path() -> PathBuf {
29    config::data_dir().join("keychain.salt")
30}
31
32/// Load the keychain salt, generating + persisting it on first launch.
33pub fn load_or_create_salt() -> Result<[u8; KEYCHAIN_SALT_LEN]> {
34    let path = keychain_salt_path();
35    if let Ok(bytes) = fs::read(&path) {
36        if bytes.len() == KEYCHAIN_SALT_LEN {
37            let mut out = [0u8; KEYCHAIN_SALT_LEN];
38            out.copy_from_slice(&bytes);
39            return Ok(out);
40        }
41    }
42    config::ensure_data_dir()?;
43    let mut salt = [0u8; KEYCHAIN_SALT_LEN];
44    rand::thread_rng().fill_bytes(&mut salt);
45    fs::write(&path, salt).map_err(|e| HuddleError::Other(format!("write salt: {e}")))?;
46    Ok(salt)
47}
48
49/// Derive a 32-byte master key from passphrase + salt via Argon2id.
50/// Parameters follow the strong RFC 9106 / OWASP profile (64 MiB memory,
51/// 3 iterations, 4 lanes) and must stay in sync with the room-passphrase
52/// KDF in `crypto::passphrase::derive_key`.
53pub fn derive_master_key(
54    passphrase: &str,
55    salt: &[u8; KEYCHAIN_SALT_LEN],
56) -> Result<[u8; MASTER_KEY_LEN]> {
57    let params = Params::new(65_536, 3, 4, Some(MASTER_KEY_LEN))
58        .map_err(|e| HuddleError::Other(format!("argon2 params: {e}")))?;
59    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
60    let mut out = [0u8; MASTER_KEY_LEN];
61    argon
62        .hash_password_into(passphrase.as_bytes(), salt, &mut out)
63        .map_err(|e| HuddleError::Other(format!("argon2 derive: {e}")))?;
64    Ok(out)
65}
66
67/// Return a 32-byte subkey for `purpose` (e.g. "megolm-persist") derived
68/// from the master key via HKDF-SHA256. The master key is the input key
69/// material and `purpose` is the HKDF `info` parameter — proper domain
70/// separation, no ad-hoc separator ambiguity.
71pub fn derive_subkey(master_key: &[u8; MASTER_KEY_LEN], purpose: &[u8]) -> [u8; 32] {
72    let hk = Hkdf::<Sha256>::new(None, master_key);
73    let mut out = [0u8; 32];
74    hk.expand(purpose, &mut out)
75        .expect("32 bytes is well within HKDF-SHA256's output limit");
76    out
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn derive_is_deterministic() {
85        let salt = [42u8; KEYCHAIN_SALT_LEN];
86        let k1 = derive_master_key("hunter2", &salt).unwrap();
87        let k2 = derive_master_key("hunter2", &salt).unwrap();
88        assert_eq!(k1, k2);
89    }
90
91    #[test]
92    fn derive_differs_with_passphrase() {
93        let salt = [42u8; KEYCHAIN_SALT_LEN];
94        let k1 = derive_master_key("hunter2", &salt).unwrap();
95        let k2 = derive_master_key("hunter3", &salt).unwrap();
96        assert_ne!(k1, k2);
97    }
98
99    #[test]
100    fn subkeys_are_purpose_separated() {
101        let mk = [9u8; MASTER_KEY_LEN];
102        let a = derive_subkey(&mk, b"megolm-persist");
103        let b = derive_subkey(&mk, b"db-encryption");
104        assert_ne!(a, b);
105    }
106}