Skip to content

Commit d221be2

Browse files
committed
feat(storage): add at-rest encryption for pagedb storage
Introduce `Encryption` enum with three variants: - `Plaintext`: explicit opt-out (all-zero key) - `Passphrase`: Argon2id key derivation with a persisted 16-byte salt sidecar at `<path>.salt` (mode 0o600 on Unix) - `RawKey`: caller-supplied 32-byte key, no sidecar written `PagedbStorage::open` now takes an `Encryption` argument and uses `resolve_kek_native` to derive the 32-byte pagedb page-encryption key before opening the database. Corruption recovery reuses the same `Encryption` variant when recreating the database. Remove the hardcoded all-zero KEK placeholder and the related documentation references to internal gap tracking documents.
1 parent 35ca6f8 commit d221be2

3 files changed

Lines changed: 426 additions & 41 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
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+
}

nodedb-lite/src/storage/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod checksum;
44
#[cfg(not(target_arch = "wasm32"))]
55
pub mod columnar_segment_ext;
66
pub mod encrypted;
7+
pub mod encryption;
78
pub mod engine;
89
#[cfg(not(target_arch = "wasm32"))]
910
pub mod fts_segment_ext;

0 commit comments

Comments
 (0)