2026-08-23 00:33:53 -04:00
|
|
|
//! Password → SQLCipher key derivation: `key = Argon2id(password, salt)`,
|
|
|
|
|
//! used directly as the raw key so the KDF cost is paid once per unlock, not
|
|
|
|
|
//! per connection. The salt is per-install, not secret. Callers own password
|
|
|
|
|
//! hygiene: hold it in a [`zeroize::Zeroizing`] buffer and drop it fast.
|
2026-08-02 20:21:19 -04:00
|
|
|
|
|
|
|
|
use argon2::{Algorithm, Argon2, Params, Version};
|
|
|
|
|
use zeroize::{Zeroize, ZeroizeOnDrop};
|
|
|
|
|
|
|
|
|
|
pub const SALT_LEN: usize = 16;
|
|
|
|
|
pub const KEY_LEN: usize = 32;
|
|
|
|
|
|
|
|
|
|
/// Argon2id cost parameters. Changing any of these changes every derived
|
|
|
|
|
/// key, which makes every protected index unreadable — treat them like a
|
|
|
|
|
/// schema version. ~0.5 s on desktop hardware; paid once per unlock, never
|
|
|
|
|
/// per connection (the keychain path skips it entirely).
|
|
|
|
|
const ARGON2_MEM_KIB: u32 = 64 * 1024;
|
|
|
|
|
const ARGON2_ITERS: u32 = 3;
|
|
|
|
|
const ARGON2_LANES: u32 = 1;
|
|
|
|
|
|
|
|
|
|
/// The derived SQLCipher key. Zeroed on drop; `Debug` is redacted so it can
|
|
|
|
|
/// never leak through logs or error formatting.
|
|
|
|
|
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
|
|
|
|
|
pub struct IndexKey([u8; KEY_LEN]);
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Debug for IndexKey {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
f.write_str("IndexKey(<redacted>)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IndexKey {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Lowercase hex, for SQLCipher's raw-key form and keychain storage.
|
2026-08-02 20:21:19 -04:00
|
|
|
pub fn to_hex(&self) -> String {
|
|
|
|
|
hex_encode(&self.0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Strict inverse of [`IndexKey::to_hex`]: exactly 64 hex digits.
|
2026-08-02 20:21:19 -04:00
|
|
|
pub fn from_hex(hex: &str) -> Result<IndexKey, String> {
|
|
|
|
|
let bytes = hex_decode(hex)?;
|
|
|
|
|
let arr: [u8; KEY_LEN] = bytes
|
|
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| format!("key must be {} hex digits", KEY_LEN * 2))?;
|
|
|
|
|
Ok(IndexKey(arr))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fresh random salt. Called only when a password is being set — a salt is
|
|
|
|
|
/// never invented anywhere else (no default exists).
|
|
|
|
|
pub fn generate_salt() -> [u8; SALT_LEN] {
|
|
|
|
|
let mut salt = [0u8; SALT_LEN];
|
|
|
|
|
getrandom::getrandom(&mut salt).expect("OS randomness unavailable");
|
|
|
|
|
salt
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn salt_to_hex(salt: &[u8; SALT_LEN]) -> String {
|
|
|
|
|
hex_encode(salt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Strict decode of a config-stored salt: exactly 32 hex digits. Anything
|
2026-08-23 00:33:53 -04:00
|
|
|
/// else is an error, never silently truncated or padded.
|
2026-08-02 20:21:19 -04:00
|
|
|
pub fn salt_from_hex(hex: &str) -> Result<[u8; SALT_LEN], String> {
|
|
|
|
|
let bytes = hex_decode(hex)?;
|
|
|
|
|
bytes
|
|
|
|
|
.try_into()
|
|
|
|
|
.map_err(|_| format!("salt must be {} hex digits", SALT_LEN * 2))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Derive the SQLCipher key from a password and the per-install salt.
|
|
|
|
|
pub fn derive_key(password: &str, salt: &[u8; SALT_LEN]) -> IndexKey {
|
|
|
|
|
let params = Params::new(ARGON2_MEM_KIB, ARGON2_ITERS, ARGON2_LANES, Some(KEY_LEN))
|
|
|
|
|
.expect("static Argon2 params are valid");
|
|
|
|
|
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
|
|
|
|
let mut out = [0u8; KEY_LEN];
|
|
|
|
|
argon
|
|
|
|
|
.hash_password_into(password.as_bytes(), salt, &mut out)
|
|
|
|
|
.expect("Argon2 accepts any password with a fixed-size salt");
|
|
|
|
|
IndexKey(out)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 21:39:54 -04:00
|
|
|
/// The crate's only hex encoder — also how a duplicate group spells its hash
|
|
|
|
|
/// ([`crate::search::DuplicateGroup::hash_hex`]).
|
|
|
|
|
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
|
2026-08-02 20:21:19 -04:00
|
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
|
|
|
for b in bytes {
|
|
|
|
|
use std::fmt::Write;
|
|
|
|
|
write!(s, "{:02x}", b).expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
2026-08-05 18:05:04 -04:00
|
|
|
if !hex.len().is_multiple_of(2) {
|
2026-08-02 20:21:19 -04:00
|
|
|
return Err("hex string has odd length".to_string());
|
|
|
|
|
}
|
|
|
|
|
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
|
|
|
return Err("hex string contains non-hex characters".to_string());
|
|
|
|
|
}
|
|
|
|
|
(0..hex.len())
|
|
|
|
|
.step_by(2)
|
|
|
|
|
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
/// Golden vector: pins password + salt → key. If this test breaks, the
|
|
|
|
|
/// derivation changed and every existing protected index just became
|
|
|
|
|
/// unreadable — that must never happen by accident.
|
|
|
|
|
#[test]
|
|
|
|
|
fn derive_key_is_pinned() {
|
|
|
|
|
let salt = salt_from_hex("000102030405060708090a0b0c0d0e0f").unwrap();
|
|
|
|
|
let key = derive_key("correct horse battery staple", &salt);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
key.to_hex(),
|
|
|
|
|
"0d1a3c6523c8f06e4e0af9c515aa5b5448cfebd6838f2d52c3d8b6ef8ddc3c2e"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn derive_key_is_deterministic_and_salt_sensitive() {
|
|
|
|
|
let salt_a = salt_from_hex("000102030405060708090a0b0c0d0e0f").unwrap();
|
|
|
|
|
let salt_b = salt_from_hex("ffffffffffffffffffffffffffffffff").unwrap();
|
|
|
|
|
assert_eq!(derive_key("pw", &salt_a), derive_key("pw", &salt_a));
|
|
|
|
|
assert_ne!(derive_key("pw", &salt_a), derive_key("pw", &salt_b));
|
|
|
|
|
assert_ne!(derive_key("pw", &salt_a), derive_key("pw2", &salt_a));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn empty_and_unicode_passwords_derive() {
|
|
|
|
|
let salt = generate_salt();
|
|
|
|
|
let _ = derive_key("", &salt);
|
|
|
|
|
let _ = derive_key("på55wörd 🗝️", &salt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn generated_salts_are_unique_and_sized() {
|
|
|
|
|
let a = generate_salt();
|
|
|
|
|
let b = generate_salt();
|
|
|
|
|
assert_eq!(a.len(), SALT_LEN);
|
|
|
|
|
assert_ne!(a, b, "two fresh salts must not collide");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn salt_hex_round_trips() {
|
|
|
|
|
let salt = generate_salt();
|
|
|
|
|
let hex = salt_to_hex(&salt);
|
|
|
|
|
assert_eq!(hex.len(), SALT_LEN * 2);
|
|
|
|
|
assert_eq!(salt_from_hex(&hex).unwrap(), salt);
|
|
|
|
|
assert_eq!(salt_from_hex(&hex.to_uppercase()).unwrap(), salt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_salts_are_rejected() {
|
|
|
|
|
assert!(salt_from_hex("").is_err());
|
|
|
|
|
assert!(salt_from_hex("abcd").is_err());
|
|
|
|
|
assert!(salt_from_hex(&"ab".repeat(SALT_LEN + 1)).is_err());
|
|
|
|
|
assert!(salt_from_hex(&"ab".repeat(SALT_LEN * 64)).is_err());
|
|
|
|
|
assert!(salt_from_hex("0g0102030405060708090a0b0c0d0e0f").is_err());
|
|
|
|
|
assert!(salt_from_hex("00010203040506070809 a0b0c0d0e0f").is_err());
|
|
|
|
|
let odd = "000102030405060708090a0b0c0d0e0";
|
|
|
|
|
assert!(salt_from_hex(odd).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn key_hex_round_trips_and_rejects_malformed() {
|
|
|
|
|
let salt = generate_salt();
|
|
|
|
|
let key = derive_key("pw", &salt);
|
|
|
|
|
let hex = key.to_hex();
|
|
|
|
|
assert_eq!(hex.len(), KEY_LEN * 2);
|
|
|
|
|
assert_eq!(IndexKey::from_hex(&hex).unwrap(), key);
|
|
|
|
|
assert!(IndexKey::from_hex("").is_err());
|
|
|
|
|
assert!(IndexKey::from_hex("abcd").is_err());
|
|
|
|
|
assert!(IndexKey::from_hex(&"ab".repeat(KEY_LEN + 1)).is_err());
|
|
|
|
|
assert!(IndexKey::from_hex(&hex[..hex.len() - 1]).is_err());
|
|
|
|
|
assert!(IndexKey::from_hex(&format!("zz{}", &hex[2..])).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn debug_output_is_redacted() {
|
|
|
|
|
let key = derive_key("secret", &generate_salt());
|
|
|
|
|
let dbg = format!("{:?}", key);
|
|
|
|
|
assert_eq!(dbg, "IndexKey(<redacted>)");
|
|
|
|
|
assert!(!dbg.contains(&key.to_hex()));
|
|
|
|
|
}
|
|
|
|
|
}
|