qualia_core_db/crypto/sanctuary_keychain.rs
1//! OS-keychain-held pepper for the Sanctuary vault (T1.2 — optional second factor).
2//!
3//! When a vault is **keychain-wrapped**, its PBKDF2 input is peppered with a 32-byte secret held
4//! in the platform keychain (Windows Credential Manager / macOS Keychain / Linux Secret Service).
5//! Disk + a guessed or weak PIN alone can then no longer open the vault — the machine's keychain is
6//! also required.
7//!
8//! **Recovery model (why this is opt-in / off by default).** Losing the keychain entry — OS
9//! reinstall, moving to a new machine, credential-store reset — makes the vault unopenable *unless*
10//! the one-time **recovery code** (the hex pepper handed back when wrapping is enabled) is supplied.
11//! Enabling wrapping is therefore a deliberate, recovery-aware choice; the default vault is
12//! unwrapped and unchanged.
13//!
14//! This module owns only the keychain I/O. The pepper-mixing itself lives in the vault layer
15//! (`qualia-client-core::wellfair::sanctuary_vault`) and is hermetically testable without touching
16//! the real OS keychain.
17
18const SERVICE: &str = "qualia_db_sanctuary";
19
20fn entry(vault_id: &str) -> Result<keyring::Entry, String> {
21 keyring::Entry::new(SERVICE, &format!("pepper_{vault_id}"))
22 .map_err(|e| format!("Keyring error: {e}"))
23}
24
25/// Generate a fresh 32-byte pepper from the OS CSPRNG.
26pub fn generate_pepper() -> Result<[u8; 32], String> {
27 let mut pepper = [0u8; 32];
28 getrandom::fill(&mut pepper).map_err(|e| format!("OS RNG failed: {e}"))?;
29 Ok(pepper)
30}
31
32/// Store (or overwrite) the pepper for `vault_id` in the OS keychain.
33pub fn store_pepper(vault_id: &str, pepper: &[u8; 32]) -> Result<(), String> {
34 entry(vault_id)?
35 .set_password(&hex::encode(pepper))
36 .map_err(|e| format!("Keyring store failed: {e}"))
37}
38
39/// Read the pepper for `vault_id`. `Ok(None)` means no entry exists on this device (the caller
40/// then falls back to the recovery code).
41pub fn get_pepper(vault_id: &str) -> Result<Option<[u8; 32]>, String> {
42 match entry(vault_id)?.get_password() {
43 Ok(hex_str) => {
44 let bytes = hex::decode(hex_str).map_err(|e| format!("Invalid pepper hex: {e}"))?;
45 if bytes.len() != 32 {
46 return Err("Corrupted pepper length in keychain".into());
47 }
48 let mut pepper = [0u8; 32];
49 pepper.copy_from_slice(&bytes);
50 Ok(Some(pepper))
51 }
52 Err(keyring::Error::NoEntry) => Ok(None),
53 Err(e) => Err(format!("Keyring read failed: {e}")),
54 }
55}
56
57/// Remove the pepper for `vault_id` (idempotent — a missing entry is not an error).
58pub fn delete_pepper(vault_id: &str) -> Result<(), String> {
59 match entry(vault_id)?.delete_credential() {
60 Ok(()) => Ok(()),
61 Err(keyring::Error::NoEntry) => Ok(()),
62 Err(e) => Err(format!("Keyring delete failed: {e}")),
63 }
64}