|
| 1 | +//! Page-level encryption key management for `PagedbStorage`. |
| 2 | +//! |
| 3 | +//! Callers choose an `Encryption` variant when opening a persistent database. |
| 4 | +//! The variant determines how the 32-byte key-encryption key (KEK) that pagedb |
| 5 | +//! uses for AES-256-GCM page encryption is obtained. |
| 6 | +//! |
| 7 | +//! In-memory storage (`open_in_memory`) is volatile and does not use this |
| 8 | +//! module — no at-rest encryption is meaningful there. |
| 9 | +
|
| 10 | +use crate::error::LiteError; |
| 11 | + |
| 12 | +// ─── Public enum ───────────────────────────────────────────────────────────── |
| 13 | + |
| 14 | +/// How the pagedb page-encryption key is obtained when opening a persistent |
| 15 | +/// database. |
| 16 | +/// |
| 17 | +/// No `Default` implementation is provided — the choice must be made |
| 18 | +/// explicitly by the caller. |
| 19 | +#[derive(Clone)] |
| 20 | +pub enum Encryption { |
| 21 | + /// Explicit opt-out: data is written unencrypted (KEK = all-zero bytes). |
| 22 | + /// Must be chosen consciously; plaintext databases are readable by anyone |
| 23 | + /// with filesystem access. |
| 24 | + Plaintext, |
| 25 | + |
| 26 | + /// Derive the 32-byte pagedb KEK from a passphrase via Argon2id. |
| 27 | + /// |
| 28 | + /// A random 16-byte salt is persisted in a plaintext sidecar file next to |
| 29 | + /// the database (path `<db_path>.salt`) so the same passphrase reproduces |
| 30 | + /// the same key on every reopen. The sidecar is created on first open with |
| 31 | + /// mode 0o600 on Unix. |
| 32 | + Passphrase { |
| 33 | + passphrase: String, |
| 34 | + /// Argon2id memory cost in KiB (OWASP minimum: 19 456). |
| 35 | + m_cost: u32, |
| 36 | + /// Argon2id iteration count (OWASP minimum: 2). |
| 37 | + t_cost: u32, |
| 38 | + /// Argon2id parallelism lanes (OWASP minimum: 1). |
| 39 | + p_cost: u32, |
| 40 | + }, |
| 41 | + |
| 42 | + /// Use a caller-supplied 32-byte key directly as the page-encryption key. |
| 43 | + /// |
| 44 | + /// No salt is stored; the caller owns key management entirely. |
| 45 | + RawKey([u8; 32]), |
| 46 | +} |
| 47 | + |
| 48 | +impl std::fmt::Debug for Encryption { |
| 49 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 50 | + match self { |
| 51 | + Encryption::Plaintext => write!(f, "Encryption::Plaintext"), |
| 52 | + Encryption::Passphrase { .. } => write!(f, "Encryption::Passphrase {{ .. }}"), |
| 53 | + Encryption::RawKey(..) => write!(f, "Encryption::RawKey(..)"), |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +impl Encryption { |
| 59 | + /// Construct a `Passphrase` variant using the OWASP-recommended Argon2id |
| 60 | + /// defaults: m_cost=19_456 KiB, t_cost=2, p_cost=1. |
| 61 | + pub fn passphrase(passphrase: impl Into<String>) -> Self { |
| 62 | + Encryption::Passphrase { |
| 63 | + passphrase: passphrase.into(), |
| 64 | + m_cost: 19_456, |
| 65 | + t_cost: 2, |
| 66 | + p_cost: 1, |
| 67 | + } |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +// ─── Key derivation ─────────────────────────────────────────────────────────── |
| 72 | + |
| 73 | +/// Derive a 32-byte KEK from `passphrase` + `salt` via Argon2id. |
| 74 | +pub(crate) fn derive_key( |
| 75 | + passphrase: &str, |
| 76 | + salt: &[u8; 16], |
| 77 | + m_cost: u32, |
| 78 | + t_cost: u32, |
| 79 | + p_cost: u32, |
| 80 | +) -> Result<[u8; 32], LiteError> { |
| 81 | + let mut key = [0u8; 32]; |
| 82 | + let argon2 = argon2::Argon2::new( |
| 83 | + argon2::Algorithm::Argon2id, |
| 84 | + argon2::Version::V0x13, |
| 85 | + argon2::Params::new(m_cost, t_cost, p_cost, Some(32)).map_err(|e| { |
| 86 | + LiteError::Encryption { |
| 87 | + detail: format!("argon2 params invalid: {e}"), |
| 88 | + } |
| 89 | + })?, |
| 90 | + ); |
| 91 | + argon2 |
| 92 | + .hash_password_into(passphrase.as_bytes(), salt, &mut key) |
| 93 | + .map_err(|e| LiteError::Encryption { |
| 94 | + detail: format!("argon2 key derivation failed: {e}"), |
| 95 | + })?; |
| 96 | + Ok(key) |
| 97 | +} |
| 98 | + |
| 99 | +// ─── Native-only helpers (salt sidecar + KEK resolution) ───────────────────── |
| 100 | + |
| 101 | +#[cfg(not(target_arch = "wasm32"))] |
| 102 | +fn salt_sidecar_path(db_path: &std::path::Path) -> std::path::PathBuf { |
| 103 | + std::path::PathBuf::from(format!("{}.salt", db_path.display())) |
| 104 | +} |
| 105 | + |
| 106 | +#[cfg(not(target_arch = "wasm32"))] |
| 107 | +fn load_or_create_salt(db_path: &std::path::Path) -> Result<[u8; 16], LiteError> { |
| 108 | + let sidecar = salt_sidecar_path(db_path); |
| 109 | + |
| 110 | + if sidecar.exists() { |
| 111 | + let bytes = std::fs::read(&sidecar).map_err(|e| LiteError::Encryption { |
| 112 | + detail: format!("failed to read salt sidecar {}: {e}", sidecar.display()), |
| 113 | + })?; |
| 114 | + if bytes.len() != 16 { |
| 115 | + return Err(LiteError::Encryption { |
| 116 | + detail: format!( |
| 117 | + "salt sidecar {} has wrong length: expected 16, got {}", |
| 118 | + sidecar.display(), |
| 119 | + bytes.len() |
| 120 | + ), |
| 121 | + }); |
| 122 | + } |
| 123 | + let mut salt = [0u8; 16]; |
| 124 | + salt.copy_from_slice(&bytes); |
| 125 | + Ok(salt) |
| 126 | + } else { |
| 127 | + let mut salt = [0u8; 16]; |
| 128 | + getrandom::fill(&mut salt).map_err(|e| LiteError::Encryption { |
| 129 | + detail: format!("getrandom failed for salt generation: {e}"), |
| 130 | + })?; |
| 131 | + std::fs::write(&sidecar, salt).map_err(|e| LiteError::Encryption { |
| 132 | + detail: format!("failed to write salt sidecar {}: {e}", sidecar.display()), |
| 133 | + })?; |
| 134 | + |
| 135 | + #[cfg(unix)] |
| 136 | + { |
| 137 | + use std::os::unix::fs::PermissionsExt; |
| 138 | + std::fs::set_permissions(&sidecar, std::fs::Permissions::from_mode(0o600)).map_err( |
| 139 | + |e| LiteError::Encryption { |
| 140 | + detail: format!( |
| 141 | + "failed to set permissions on salt sidecar {}: {e}", |
| 142 | + sidecar.display() |
| 143 | + ), |
| 144 | + }, |
| 145 | + )?; |
| 146 | + } |
| 147 | + |
| 148 | + Ok(salt) |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +/// Resolve the 32-byte pagedb KEK for a native (non-WASM) persistent database. |
| 153 | +/// |
| 154 | +/// - `Encryption::Plaintext` returns an all-zero key (no encryption). |
| 155 | +/// - `Encryption::RawKey(k)` returns `k` directly. |
| 156 | +/// - `Encryption::Passphrase { .. }` loads or generates the `.salt` sidecar |
| 157 | +/// adjacent to `db_path`, then runs Argon2id to derive the key. |
| 158 | +#[cfg(not(target_arch = "wasm32"))] |
| 159 | +pub(crate) fn resolve_kek_native( |
| 160 | + enc: &Encryption, |
| 161 | + db_path: &std::path::Path, |
| 162 | +) -> Result<[u8; 32], LiteError> { |
| 163 | + match enc { |
| 164 | + Encryption::Plaintext => Ok([0u8; 32]), |
| 165 | + Encryption::RawKey(k) => Ok(*k), |
| 166 | + Encryption::Passphrase { |
| 167 | + passphrase, |
| 168 | + m_cost, |
| 169 | + t_cost, |
| 170 | + p_cost, |
| 171 | + } => { |
| 172 | + let salt = load_or_create_salt(db_path)?; |
| 173 | + derive_key(passphrase, &salt, *m_cost, *t_cost, *p_cost) |
| 174 | + } |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +// ─── WASM-only helpers (OPFS salt sidecar + KEK resolution) ───────────────── |
| 179 | + |
| 180 | +/// Open (or create) the salt sidecar file at `salt_path` inside OPFS, read |
| 181 | +/// or generate the 16-byte random salt, and return it. |
| 182 | +/// |
| 183 | +/// If the file does not yet exist (or is shorter than 16 bytes) a fresh salt |
| 184 | +/// is generated via `getrandom::fill`, written at offset 0, and flushed |
| 185 | +/// before returning. |
| 186 | +#[cfg(target_arch = "wasm32")] |
| 187 | +pub(crate) async fn load_or_create_salt_opfs( |
| 188 | + vfs: &pagedb::vfs::opfs::OpfsVfs, |
| 189 | + salt_path: &str, |
| 190 | +) -> Result<[u8; 16], LiteError> { |
| 191 | + use pagedb::vfs::traits::{Vfs, VfsFile}; |
| 192 | + use pagedb::vfs::types::OpenMode; |
| 193 | + |
| 194 | + let mut file = vfs |
| 195 | + .open(salt_path, OpenMode::CreateOrOpen) |
| 196 | + .await |
| 197 | + .map_err(|e| LiteError::Encryption { |
| 198 | + detail: format!("failed to open OPFS salt sidecar '{salt_path}': {e}"), |
| 199 | + })?; |
| 200 | + |
| 201 | + let file_len = file.len().await.map_err(|e| LiteError::Encryption { |
| 202 | + detail: format!("failed to query length of OPFS salt sidecar '{salt_path}': {e}"), |
| 203 | + })?; |
| 204 | + |
| 205 | + if file_len >= 16 { |
| 206 | + let mut salt = [0u8; 16]; |
| 207 | + file.read_at(0, &mut salt) |
| 208 | + .await |
| 209 | + .map_err(|e| LiteError::Encryption { |
| 210 | + detail: format!("failed to read OPFS salt sidecar '{salt_path}': {e}"), |
| 211 | + })?; |
| 212 | + return Ok(salt); |
| 213 | + } |
| 214 | + |
| 215 | + // Generate a fresh salt and persist it. |
| 216 | + let mut salt = [0u8; 16]; |
| 217 | + getrandom::fill(&mut salt).map_err(|e| LiteError::Encryption { |
| 218 | + detail: format!("getrandom failed for OPFS salt generation: {e}"), |
| 219 | + })?; |
| 220 | + file.write_at(0, &salt) |
| 221 | + .await |
| 222 | + .map_err(|e| LiteError::Encryption { |
| 223 | + detail: format!("failed to write OPFS salt sidecar '{salt_path}': {e}"), |
| 224 | + })?; |
| 225 | + file.sync().await.map_err(|e| LiteError::Encryption { |
| 226 | + detail: format!("failed to flush OPFS salt sidecar '{salt_path}': {e}"), |
| 227 | + })?; |
| 228 | + |
| 229 | + Ok(salt) |
| 230 | +} |
| 231 | + |
| 232 | +/// Resolve the 32-byte pagedb KEK for an OPFS-backed persistent database. |
| 233 | +/// |
| 234 | +/// - [`Encryption::Plaintext`] returns an all-zero key (no encryption). |
| 235 | +/// - [`Encryption::RawKey(k)`] returns `k` directly. |
| 236 | +/// - [`Encryption::Passphrase { .. }`] loads or generates a 16-byte random |
| 237 | +/// salt persisted in an OPFS sidecar file at `__nodedb_salt` (adjacent to |
| 238 | +/// the database root in the OPFS origin sandbox), then runs Argon2id to |
| 239 | +/// derive the key. |
| 240 | +/// |
| 241 | +/// `vfs` is used only for salt I/O; pass a clone so the caller can forward |
| 242 | +/// the original into `Db::open`. |
| 243 | +#[cfg(target_arch = "wasm32")] |
| 244 | +pub(crate) async fn resolve_kek_opfs( |
| 245 | + enc: &Encryption, |
| 246 | + vfs: &pagedb::vfs::opfs::OpfsVfs, |
| 247 | +) -> Result<[u8; 32], LiteError> { |
| 248 | + match enc { |
| 249 | + Encryption::Plaintext => Ok([0u8; 32]), |
| 250 | + Encryption::RawKey(k) => Ok(*k), |
| 251 | + Encryption::Passphrase { |
| 252 | + passphrase, |
| 253 | + m_cost, |
| 254 | + t_cost, |
| 255 | + p_cost, |
| 256 | + } => { |
| 257 | + let salt = load_or_create_salt_opfs(vfs, "__nodedb_salt").await?; |
| 258 | + derive_key(passphrase, &salt, *m_cost, *t_cost, *p_cost) |
| 259 | + } |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +// ─── Tests ──────────────────────────────────────────────────────────────────── |
| 264 | + |
| 265 | +#[cfg(test)] |
| 266 | +mod tests { |
| 267 | + use super::*; |
| 268 | + |
| 269 | + #[test] |
| 270 | + fn same_passphrase_and_salt_derives_same_key() { |
| 271 | + let salt = [0x42u8; 16]; |
| 272 | + let k1 = derive_key("hunter2", &salt, 8, 1, 1).unwrap(); |
| 273 | + let k2 = derive_key("hunter2", &salt, 8, 1, 1).unwrap(); |
| 274 | + assert_eq!(k1, k2); |
| 275 | + } |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn different_salt_derives_different_key() { |
| 279 | + let salt_a = [0x01u8; 16]; |
| 280 | + let salt_b = [0x02u8; 16]; |
| 281 | + let k1 = derive_key("same-pass", &salt_a, 8, 1, 1).unwrap(); |
| 282 | + let k2 = derive_key("same-pass", &salt_b, 8, 1, 1).unwrap(); |
| 283 | + assert_ne!(k1, k2); |
| 284 | + } |
| 285 | + |
| 286 | + #[test] |
| 287 | + fn plaintext_resolves_to_zero_key() { |
| 288 | + #[cfg(not(target_arch = "wasm32"))] |
| 289 | + { |
| 290 | + let dir = tempfile::tempdir().unwrap(); |
| 291 | + let path = dir.path().join("dummy.pagedb"); |
| 292 | + let kek = resolve_kek_native(&Encryption::Plaintext, &path).unwrap(); |
| 293 | + assert_eq!(kek, [0u8; 32]); |
| 294 | + } |
| 295 | + } |
| 296 | + |
| 297 | + #[test] |
| 298 | + fn raw_key_resolves_directly() { |
| 299 | + #[cfg(not(target_arch = "wasm32"))] |
| 300 | + { |
| 301 | + let dir = tempfile::tempdir().unwrap(); |
| 302 | + let path = dir.path().join("dummy.pagedb"); |
| 303 | + let raw = [0xABu8; 32]; |
| 304 | + let kek = resolve_kek_native(&Encryption::RawKey(raw), &path).unwrap(); |
| 305 | + assert_eq!(kek, raw); |
| 306 | + } |
| 307 | + } |
| 308 | + |
| 309 | + #[test] |
| 310 | + fn debug_does_not_leak_secrets() { |
| 311 | + let passphrase_variant = Encryption::passphrase("my-secret-pass"); |
| 312 | + let debug_str = format!("{passphrase_variant:?}"); |
| 313 | + assert!( |
| 314 | + !debug_str.contains("my-secret-pass"), |
| 315 | + "passphrase leaked in Debug" |
| 316 | + ); |
| 317 | + assert!(debug_str.contains("Passphrase")); |
| 318 | + |
| 319 | + let raw_variant = Encryption::RawKey([0xDE; 32]); |
| 320 | + let debug_str = format!("{raw_variant:?}"); |
| 321 | + assert!(!debug_str.contains("222"), "raw key bytes leaked in Debug"); |
| 322 | + assert!(debug_str.contains("RawKey")); |
| 323 | + |
| 324 | + let plain = Encryption::Plaintext; |
| 325 | + let debug_str = format!("{plain:?}"); |
| 326 | + assert!(debug_str.contains("Plaintext")); |
| 327 | + } |
| 328 | +} |
0 commit comments