use crate::error::LiteError;
#[derive(Clone)]
pub enum Encryption {
Plaintext,
Passphrase {
passphrase: String,
m_cost: u32,
t_cost: u32,
p_cost: u32,
},
RawKey([u8; 32]),
}
impl std::fmt::Debug for Encryption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Encryption::Plaintext => write!(f, "Encryption::Plaintext"),
Encryption::Passphrase { .. } => write!(f, "Encryption::Passphrase {{ .. }}"),
Encryption::RawKey(..) => write!(f, "Encryption::RawKey(..)"),
}
}
}
impl Encryption {
pub fn passphrase(passphrase: impl Into<String>) -> Self {
Encryption::Passphrase {
passphrase: passphrase.into(),
m_cost: 19_456,
t_cost: 2,
p_cost: 1,
}
}
}
pub(crate) fn derive_key(
passphrase: &str,
salt: &[u8; 16],
m_cost: u32,
t_cost: u32,
p_cost: u32,
) -> Result<[u8; 32], LiteError> {
let mut key = [0u8; 32];
let argon2 = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(m_cost, t_cost, p_cost, Some(32)).map_err(|e| {
LiteError::Encryption {
detail: format!("argon2 params invalid: {e}"),
}
})?,
);
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|e| LiteError::Encryption {
detail: format!("argon2 key derivation failed: {e}"),
})?;
Ok(key)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn salt_sidecar_path(db_path: &std::path::Path) -> std::path::PathBuf {
std::path::PathBuf::from(format!("{}.salt", db_path.display()))
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn load_or_create_salt(db_path: &std::path::Path) -> Result<[u8; 16], LiteError> {
let sidecar = salt_sidecar_path(db_path);
if sidecar.exists() {
let bytes = std::fs::read(&sidecar).map_err(|e| LiteError::Encryption {
detail: format!("failed to read salt sidecar {}: {e}", sidecar.display()),
})?;
if bytes.len() != 16 {
return Err(LiteError::Encryption {
detail: format!(
"salt sidecar {} has wrong length: expected 16, got {}",
sidecar.display(),
bytes.len()
),
});
}
let mut salt = [0u8; 16];
salt.copy_from_slice(&bytes);
Ok(salt)
} else {
let mut salt = [0u8; 16];
getrandom::fill(&mut salt).map_err(|e| LiteError::Encryption {
detail: format!("getrandom failed for salt generation: {e}"),
})?;
std::fs::write(&sidecar, salt).map_err(|e| LiteError::Encryption {
detail: format!("failed to write salt sidecar {}: {e}", sidecar.display()),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&sidecar, std::fs::Permissions::from_mode(0o600)).map_err(
|e| LiteError::Encryption {
detail: format!(
"failed to set permissions on salt sidecar {}: {e}",
sidecar.display()
),
},
)?;
}
Ok(salt)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn resolve_kek_native(
enc: &Encryption,
db_path: &std::path::Path,
) -> Result<[u8; 32], LiteError> {
match enc {
Encryption::Plaintext => Ok([0u8; 32]),
Encryption::RawKey(k) => Ok(*k),
Encryption::Passphrase {
passphrase,
m_cost,
t_cost,
p_cost,
} => {
let salt = load_or_create_salt(db_path)?;
derive_key(passphrase, &salt, *m_cost, *t_cost, *p_cost)
}
}
}
#[cfg(target_arch = "wasm32")]
pub(crate) async fn load_or_create_salt_opfs(
vfs: &pagedb::vfs::opfs::OpfsVfs,
salt_path: &str,
) -> Result<[u8; 16], LiteError> {
use pagedb::vfs::{OpenMode, Vfs, VfsFile};
let mut file = vfs
.open(salt_path, OpenMode::CreateOrOpen)
.await
.map_err(|e| LiteError::Encryption {
detail: format!("failed to open OPFS salt sidecar '{salt_path}': {e}"),
})?;
let file_len = file.len().await.map_err(|e| LiteError::Encryption {
detail: format!("failed to query length of OPFS salt sidecar '{salt_path}': {e}"),
})?;
if file_len >= 16 {
let mut salt = [0u8; 16];
file.read_at(0, &mut salt)
.await
.map_err(|e| LiteError::Encryption {
detail: format!("failed to read OPFS salt sidecar '{salt_path}': {e}"),
})?;
return Ok(salt);
}
let mut salt = [0u8; 16];
getrandom::fill(&mut salt).map_err(|e| LiteError::Encryption {
detail: format!("getrandom failed for OPFS salt generation: {e}"),
})?;
file.write_at(0, &salt)
.await
.map_err(|e| LiteError::Encryption {
detail: format!("failed to write OPFS salt sidecar '{salt_path}': {e}"),
})?;
file.sync().await.map_err(|e| LiteError::Encryption {
detail: format!("failed to flush OPFS salt sidecar '{salt_path}': {e}"),
})?;
Ok(salt)
}
#[cfg(target_arch = "wasm32")]
pub(crate) async fn resolve_kek_opfs(
enc: &Encryption,
vfs: &pagedb::vfs::opfs::OpfsVfs,
) -> Result<[u8; 32], LiteError> {
match enc {
Encryption::Plaintext => Ok([0u8; 32]),
Encryption::RawKey(k) => Ok(*k),
Encryption::Passphrase {
passphrase,
m_cost,
t_cost,
p_cost,
} => {
let salt = load_or_create_salt_opfs(vfs, "__nodedb_salt").await?;
derive_key(passphrase, &salt, *m_cost, *t_cost, *p_cost)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(unix, not(target_arch = "wasm32")))]
#[test]
fn existing_non_utf8_salt_sidecar_remains_readable() {
use std::os::unix::ffi::OsStringExt;
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.join(std::ffi::OsString::from_vec(b"database-\xff".to_vec()));
let legacy_sidecar = std::path::PathBuf::from(format!("{}.salt", path.display()));
let salt = [0x42; 16];
std::fs::write(&legacy_sidecar, salt).unwrap();
assert_eq!(load_or_create_salt(&path).unwrap(), salt);
let mut byte_preserving_path = path.into_os_string();
byte_preserving_path.push(".salt");
assert!(!std::path::PathBuf::from(byte_preserving_path).exists());
}
#[test]
fn same_passphrase_and_salt_derives_same_key() {
let salt = [0x42u8; 16];
let k1 = derive_key("hunter2", &salt, 8, 1, 1).unwrap();
let k2 = derive_key("hunter2", &salt, 8, 1, 1).unwrap();
assert_eq!(k1, k2);
}
#[test]
fn different_salt_derives_different_key() {
let salt_a = [0x01u8; 16];
let salt_b = [0x02u8; 16];
let k1 = derive_key("same-pass", &salt_a, 8, 1, 1).unwrap();
let k2 = derive_key("same-pass", &salt_b, 8, 1, 1).unwrap();
assert_ne!(k1, k2);
}
#[test]
fn plaintext_resolves_to_zero_key() {
#[cfg(not(target_arch = "wasm32"))]
{
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dummy.pagedb");
let kek = resolve_kek_native(&Encryption::Plaintext, &path).unwrap();
assert_eq!(kek, [0u8; 32]);
}
}
#[test]
fn raw_key_resolves_directly() {
#[cfg(not(target_arch = "wasm32"))]
{
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dummy.pagedb");
let raw = [0xABu8; 32];
let kek = resolve_kek_native(&Encryption::RawKey(raw), &path).unwrap();
assert_eq!(kek, raw);
}
}
#[test]
fn debug_does_not_leak_secrets() {
let passphrase_variant = Encryption::passphrase("my-secret-pass");
let debug_str = format!("{passphrase_variant:?}");
assert!(
!debug_str.contains("my-secret-pass"),
"passphrase leaked in Debug"
);
assert!(debug_str.contains("Passphrase"));
let raw_variant = Encryption::RawKey([0xDE; 32]);
let debug_str = format!("{raw_variant:?}");
assert!(!debug_str.contains("222"), "raw key bytes leaked in Debug");
assert!(debug_str.contains("RawKey"));
let plain = Encryption::Plaintext;
let debug_str = format!("{plain:?}");
assert!(debug_str.contains("Plaintext"));
}
}