1use crate::error::LiteError;
11
12#[derive(Clone)]
20pub enum Encryption {
21 Plaintext,
25
26 Passphrase {
33 passphrase: String,
34 m_cost: u32,
36 t_cost: u32,
38 p_cost: u32,
40 },
41
42 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 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
71pub(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#[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#[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#[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 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#[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#[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}