Skip to main content

nodedb_lite/storage/
encryption.rs

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
10use 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)]
20pub 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
48impl 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
58impl 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.
74pub(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"))]
102pub(crate) 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"))]
107pub(crate) 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"))]
159pub(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")]
187pub(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::{OpenMode, Vfs, VfsFile};
192
193    let mut file = vfs
194        .open(salt_path, OpenMode::CreateOrOpen)
195        .await
196        .map_err(|e| LiteError::Encryption {
197            detail: format!("failed to open OPFS salt sidecar '{salt_path}': {e}"),
198        })?;
199
200    let file_len = file.len().await.map_err(|e| LiteError::Encryption {
201        detail: format!("failed to query length of OPFS salt sidecar '{salt_path}': {e}"),
202    })?;
203
204    if file_len >= 16 {
205        let mut salt = [0u8; 16];
206        file.read_at(0, &mut salt)
207            .await
208            .map_err(|e| LiteError::Encryption {
209                detail: format!("failed to read OPFS salt sidecar '{salt_path}': {e}"),
210            })?;
211        return Ok(salt);
212    }
213
214    // Generate a fresh salt and persist it.
215    let mut salt = [0u8; 16];
216    getrandom::fill(&mut salt).map_err(|e| LiteError::Encryption {
217        detail: format!("getrandom failed for OPFS salt generation: {e}"),
218    })?;
219    file.write_at(0, &salt)
220        .await
221        .map_err(|e| LiteError::Encryption {
222            detail: format!("failed to write OPFS salt sidecar '{salt_path}': {e}"),
223        })?;
224    file.sync().await.map_err(|e| LiteError::Encryption {
225        detail: format!("failed to flush OPFS salt sidecar '{salt_path}': {e}"),
226    })?;
227
228    Ok(salt)
229}
230
231/// Resolve the 32-byte pagedb KEK for an OPFS-backed persistent database.
232///
233/// - [`Encryption::Plaintext`] returns an all-zero key (no encryption).
234/// - [`Encryption::RawKey(k)`] returns `k` directly.
235/// - [`Encryption::Passphrase { .. }`] loads or generates a 16-byte random
236///   salt persisted in an OPFS sidecar file at `__nodedb_salt` (adjacent to
237///   the database root in the OPFS origin sandbox), then runs Argon2id to
238///   derive the key.
239///
240/// `vfs` is used only for salt I/O; pass a clone so the caller can forward
241/// the original into `Db::open`.
242#[cfg(target_arch = "wasm32")]
243pub(crate) async fn resolve_kek_opfs(
244    enc: &Encryption,
245    vfs: &pagedb::vfs::opfs::OpfsVfs,
246) -> Result<[u8; 32], LiteError> {
247    match enc {
248        Encryption::Plaintext => Ok([0u8; 32]),
249        Encryption::RawKey(k) => Ok(*k),
250        Encryption::Passphrase {
251            passphrase,
252            m_cost,
253            t_cost,
254            p_cost,
255        } => {
256            let salt = load_or_create_salt_opfs(vfs, "__nodedb_salt").await?;
257            derive_key(passphrase, &salt, *m_cost, *t_cost, *p_cost)
258        }
259    }
260}
261
262// ─── Tests ────────────────────────────────────────────────────────────────────
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[cfg(all(unix, not(target_arch = "wasm32")))]
269    #[test]
270    fn existing_non_utf8_salt_sidecar_remains_readable() {
271        use std::os::unix::ffi::OsStringExt;
272
273        let directory = tempfile::tempdir().unwrap();
274        let path = directory
275            .path()
276            .join(std::ffi::OsString::from_vec(b"database-\xff".to_vec()));
277        let legacy_sidecar = std::path::PathBuf::from(format!("{}.salt", path.display()));
278        let salt = [0x42; 16];
279        std::fs::write(&legacy_sidecar, salt).unwrap();
280
281        assert_eq!(load_or_create_salt(&path).unwrap(), salt);
282        let mut byte_preserving_path = path.into_os_string();
283        byte_preserving_path.push(".salt");
284        assert!(!std::path::PathBuf::from(byte_preserving_path).exists());
285    }
286
287    #[test]
288    fn same_passphrase_and_salt_derives_same_key() {
289        let salt = [0x42u8; 16];
290        let k1 = derive_key("hunter2", &salt, 8, 1, 1).unwrap();
291        let k2 = derive_key("hunter2", &salt, 8, 1, 1).unwrap();
292        assert_eq!(k1, k2);
293    }
294
295    #[test]
296    fn different_salt_derives_different_key() {
297        let salt_a = [0x01u8; 16];
298        let salt_b = [0x02u8; 16];
299        let k1 = derive_key("same-pass", &salt_a, 8, 1, 1).unwrap();
300        let k2 = derive_key("same-pass", &salt_b, 8, 1, 1).unwrap();
301        assert_ne!(k1, k2);
302    }
303
304    #[test]
305    fn plaintext_resolves_to_zero_key() {
306        #[cfg(not(target_arch = "wasm32"))]
307        {
308            let dir = tempfile::tempdir().unwrap();
309            let path = dir.path().join("dummy.pagedb");
310            let kek = resolve_kek_native(&Encryption::Plaintext, &path).unwrap();
311            assert_eq!(kek, [0u8; 32]);
312        }
313    }
314
315    #[test]
316    fn raw_key_resolves_directly() {
317        #[cfg(not(target_arch = "wasm32"))]
318        {
319            let dir = tempfile::tempdir().unwrap();
320            let path = dir.path().join("dummy.pagedb");
321            let raw = [0xABu8; 32];
322            let kek = resolve_kek_native(&Encryption::RawKey(raw), &path).unwrap();
323            assert_eq!(kek, raw);
324        }
325    }
326
327    #[test]
328    fn debug_does_not_leak_secrets() {
329        let passphrase_variant = Encryption::passphrase("my-secret-pass");
330        let debug_str = format!("{passphrase_variant:?}");
331        assert!(
332            !debug_str.contains("my-secret-pass"),
333            "passphrase leaked in Debug"
334        );
335        assert!(debug_str.contains("Passphrase"));
336
337        let raw_variant = Encryption::RawKey([0xDE; 32]);
338        let debug_str = format!("{raw_variant:?}");
339        assert!(!debug_str.contains("222"), "raw key bytes leaked in Debug");
340        assert!(debug_str.contains("RawKey"));
341
342        let plain = Encryption::Plaintext;
343        let debug_str = format!("{plain:?}");
344        assert!(debug_str.contains("Plaintext"));
345    }
346}