use std::fs;
use std::path::{Path, PathBuf};
use crate::crypto::{self, Encrypted, KdfParams, MasterKey};
use crate::db::Database;
use crate::error::{Error, Result};
pub const MAGIC: [u8; 4] = *b"ZKV1";
pub const HEADER_LEN: usize = 58;
pub const VERSION: u8 = 1;
#[derive(Debug, Clone)]
pub struct VaultHeader {
pub version: u8,
pub flags: u8,
pub kdf: KdfParams,
pub salt: [u8; 16],
pub nonce: [u8; 24],
}
impl VaultHeader {
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN);
out.extend_from_slice(&MAGIC);
out.push(self.version);
out.push(self.flags);
out.extend_from_slice(&self.kdf.m_kib.to_le_bytes());
out.extend_from_slice(&self.kdf.t_cost.to_le_bytes());
out.extend_from_slice(&self.kdf.p_cost.to_le_bytes());
out.extend_from_slice(&self.salt);
out.extend_from_slice(&self.nonce);
debug_assert_eq!(out.len(), HEADER_LEN);
out
}
pub fn parse(bytes: &[u8]) -> Result<VaultHeader> {
if bytes.len() < HEADER_LEN {
return Err(Error::CorruptFile(format!(
"header too short: {} < {HEADER_LEN}",
bytes.len()
)));
}
if bytes[0..4] != MAGIC {
return Err(Error::CorruptFile("bad magic".to_string()));
}
let version = bytes[4];
if version != VERSION {
return Err(Error::CorruptFile(format!(
"unsupported version: {version}"
)));
}
let flags = bytes[5];
let m_kib = u32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]);
let t_cost = u32::from_le_bytes([bytes[10], bytes[11], bytes[12], bytes[13]]);
let p_cost = u32::from_le_bytes([bytes[14], bytes[15], bytes[16], bytes[17]]);
let mut salt = [0u8; 16];
salt.copy_from_slice(&bytes[18..34]);
let mut nonce = [0u8; 24];
nonce.copy_from_slice(&bytes[34..58]);
Ok(VaultHeader {
version,
flags,
kdf: KdfParams {
m_kib,
t_cost,
p_cost,
},
salt,
nonce,
})
}
}
pub fn create(path: &Path, passphrase: &str) -> Result<()> {
create_with_params(path, passphrase, &KdfParams::default())
}
pub fn create_with_params(path: &Path, passphrase: &str, kdf: &KdfParams) -> Result<()> {
let db = Database::open_in_memory()?;
let plaintext = db.dump_bytes()?;
let salt = crypto::gen_salt();
let key = crypto::derive_key(passphrase.as_bytes(), &salt, kdf)?;
encrypt_and_write(path, &key, kdf, salt, plaintext)
}
pub fn unlock(path: &Path, passphrase: &str) -> Result<Database> {
let (db, _key, _kdf, _salt) = unlock_full(path, passphrase)?;
Ok(db)
}
pub fn unlock_full(
path: &Path,
passphrase: &str,
) -> Result<(Database, MasterKey, KdfParams, [u8; 16])> {
let file = fs::read(path)?;
let header = VaultHeader::parse(&file)?;
let key = crypto::derive_key(passphrase.as_bytes(), &header.salt, &header.kdf)?;
let (db, kdf, salt) = decrypt_body(&file, &key)?;
Ok((db, key, kdf, salt))
}
pub fn unlock_with_key(
path: &Path,
key: MasterKey,
) -> Result<(Database, MasterKey, KdfParams, [u8; 16])> {
let file = fs::read(path)?;
let (db, kdf, salt) = decrypt_body(&file, &key)?;
Ok((db, key, kdf, salt))
}
fn decrypt_body(file: &[u8], key: &MasterKey) -> Result<(Database, KdfParams, [u8; 16])> {
let header = VaultHeader::parse(file)?;
let enc = Encrypted {
nonce: header.nonce,
ciphertext: file[HEADER_LEN..].to_vec(),
};
let plaintext = crypto::decrypt(key, &enc)?;
let db = Database::from_bytes(&plaintext)?;
Ok((db, header.kdf, header.salt))
}
pub fn unlock_with_params(path: &Path, passphrase: &str, _kdf: &KdfParams) -> Result<Database> {
unlock(path, passphrase)
}
pub fn save(path: &Path, passphrase: &str, db: &Database) -> Result<()> {
save_with_params(path, passphrase, db, &KdfParams::default())
}
pub fn save_with_params(
path: &Path,
passphrase: &str,
db: &Database,
kdf: &KdfParams,
) -> Result<()> {
let plaintext = db.dump_bytes()?;
let salt = match fs::read(path) {
Ok(file) => match VaultHeader::parse(&file) {
Ok(h) => h.salt,
Err(_) => crypto::gen_salt(),
},
Err(_) => crypto::gen_salt(),
};
let key = crypto::derive_key(passphrase.as_bytes(), &salt, kdf)?;
encrypt_and_write(path, &key, kdf, salt, plaintext)
}
pub fn save_with_key(
path: &Path,
key: &MasterKey,
kdf: &KdfParams,
salt: [u8; 16],
db: &Database,
) -> Result<()> {
let plaintext = db.dump_bytes()?;
encrypt_and_write(path, key, kdf, salt, plaintext)
}
pub fn change_passphrase(path: &Path, old_pass: &str, new_pass: &str) -> Result<()> {
let (db, _old_key, kdf, _old_salt) = unlock_full(path, old_pass)?;
let new_salt = crypto::gen_salt();
let new_key = crypto::derive_key(new_pass.as_bytes(), &new_salt, &kdf)?;
let plaintext = db.dump_bytes()?;
encrypt_and_write(path, &new_key, &kdf, new_salt, plaintext)
}
fn encrypt_and_write(
path: &Path,
key: &MasterKey,
kdf: &KdfParams,
salt: [u8; 16],
plaintext: Vec<u8>,
) -> Result<()> {
let enc = crypto::encrypt(key, &plaintext)?;
let header = VaultHeader {
version: VERSION,
flags: 0,
kdf: *kdf,
salt,
nonce: enc.nonce,
};
let mut out = header.to_bytes();
out.extend_from_slice(&enc.ciphertext);
atomic_write(path, &out)
}
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
{
let res = (|| -> Result<()> {
let mut f = open_secure(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
Ok(())
})();
if res.is_err() {
let _ = fs::remove_file(&tmp);
return res;
}
}
set_mode_0600(&tmp);
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(Error::Io(e));
}
set_mode_0600(path);
fsync_parent(path);
Ok(())
}
#[cfg(unix)]
fn open_secure(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
Ok(std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.mode(0o600)
.open(path)?)
}
#[cfg(windows)]
fn open_secure(path: &Path) -> Result<std::fs::File> {
crate::db::win_security::open_secure_file(path)
}
#[cfg(not(any(unix, windows)))]
fn open_secure(path: &Path) -> Result<std::fs::File> {
Ok(std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(path)?)
}
#[cfg(unix)]
fn set_mode_0600(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn set_mode_0600(_path: &Path) {}
#[cfg(unix)]
fn fsync_parent(path: &Path) {
let Some(parent) = path.parent() else {
return;
};
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
fn fsync_parent(_path: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
fn fast_kdf() -> KdfParams {
KdfParams {
m_kib: 4_096,
t_cost: 1,
p_cost: 1,
}
}
fn tmp_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("zkv_test_{tag}_{}_{}", std::process::id(), n));
p
}
fn cleanup(p: &std::path::Path) {
let _ = std::fs::remove_file(p);
let mut t = p.as_os_str().to_owned();
t.push(".tmp");
let _ = std::fs::remove_file(std::path::PathBuf::from(t));
}
#[test]
fn header_roundtrip_and_constants() {
let kdf = fast_kdf();
let h = VaultHeader {
version: VERSION,
flags: 0,
kdf,
salt: [0xA0u8; 16],
nonce: [0xB1u8; 24],
};
let b = h.to_bytes();
assert_eq!(b.len(), HEADER_LEN);
assert_eq!(&b[0..4], &MAGIC);
let h2 = VaultHeader::parse(&b).unwrap();
assert_eq!(h2.version, VERSION);
assert_eq!(h2.flags, 0);
assert_eq!(h2.kdf, kdf);
assert_eq!(h2.salt, [0xA0u8; 16]);
assert_eq!(h2.nonce, [0xB1u8; 24]);
}
#[test]
fn header_parse_rejects_bad_magic_and_short() {
assert!(matches!(
VaultHeader::parse(&[0u8; 10]),
Err(Error::CorruptFile(_))
));
let mut bad = vec![0u8; HEADER_LEN];
bad[0..4].copy_from_slice(b"XXXX");
assert!(matches!(
VaultHeader::parse(&bad),
Err(Error::CorruptFile(_))
));
}
#[test]
fn create_unlock_roundtrip() {
let p = tmp_path("cu");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "hunter2", &kdf).expect("create");
let db = unlock(&p, "hunter2").expect("unlock");
let cnt: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt, 0);
cleanup(&p);
}
#[test]
fn save_then_unlock_preserves_changes() {
let p = tmp_path("save");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "pw", &kdf).unwrap();
{
let db = unlock(&p, "pw").unwrap();
db.conn()
.execute(
"INSERT INTO items(type,title,data,search_text,created_at,updated_at)
VALUES ('note','T','{}','s',1,1)",
[],
)
.unwrap();
save_with_params(&p, "pw", &db, &kdf).unwrap();
}
let db2 = unlock(&p, "pw").unwrap();
let cnt: i64 = db2
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt, 1);
cleanup(&p);
}
#[test]
fn wrong_passphrase_yields_bad_passphrase() {
let p = tmp_path("wrong");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "correct", &kdf).unwrap();
let res = unlock(&p, "incorrect");
assert!(
matches!(res, Err(Error::BadPassphrase)),
"expected BadPassphrase, got {:?}",
res
);
cleanup(&p);
}
#[test]
fn corrupted_magic_yields_corrupt_file() {
let p = tmp_path("corrupt");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "pw", &kdf).unwrap();
let mut data = std::fs::read(&p).unwrap();
data[0] = 0x00;
std::fs::write(&p, &data).unwrap();
let res = unlock(&p, "pw");
assert!(matches!(res, Err(Error::CorruptFile(_))));
cleanup(&p);
}
#[test]
fn save_each_time_new_nonce() {
let p = tmp_path("nonce");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "pw", &kdf).unwrap();
let f1 = std::fs::read(&p).unwrap();
let h1 = VaultHeader::parse(&f1).unwrap();
{
let db = unlock(&p, "pw").unwrap();
save_with_params(&p, "pw", &db, &kdf).unwrap();
}
let f2 = std::fs::read(&p).unwrap();
let h2 = VaultHeader::parse(&f2).unwrap();
assert_ne!(h1.nonce, h2.nonce, "每次保存应生成新 nonce");
assert_eq!(h1.salt, h2.salt);
cleanup(&p);
}
#[test]
fn change_passphrase_roundtrip() {
let p = tmp_path("change");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "old", &kdf).unwrap();
{
let db = unlock(&p, "old").unwrap();
db.conn()
.execute(
"INSERT INTO items(type,title,data,search_text,created_at,updated_at)
VALUES ('password','GitHub','{\"password\":\"s3cret\"}','s3cret',1,1)",
[],
)
.unwrap();
save_with_params(&p, "old", &db, &kdf).unwrap();
}
let before = std::fs::read(&p).unwrap();
let h_before = VaultHeader::parse(&before).unwrap();
change_passphrase(&p, "old", "new").unwrap();
assert!(
matches!(unlock(&p, "old"), Err(Error::BadPassphrase)),
"old passphrase must no longer unlock after change"
);
let db = unlock(&p, "new").unwrap();
let cnt: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt, 1, "data should survive passphrase change");
let title: String = db
.conn()
.query_row("SELECT title FROM items", [], |r| r.get::<_, String>(0))
.unwrap();
assert_eq!(title, "GitHub");
let after = std::fs::read(&p).unwrap();
let h_after = VaultHeader::parse(&after).unwrap();
assert_ne!(h_before.salt, h_after.salt, "salt must rotate on change");
cleanup(&p);
}
#[test]
fn change_passphrase_wrong_old_yields_bad_passphrase() {
let p = tmp_path("change_wrong");
cleanup(&p);
let kdf = fast_kdf();
create_with_params(&p, "correct", &kdf).unwrap();
let res = change_passphrase(&p, "incorrect", "whatever");
assert!(
matches!(res, Err(Error::BadPassphrase)),
"wrong old passphrase must yield BadPassphrase, got {:?}",
res
);
assert!(unlock(&p, "correct").is_ok());
cleanup(&p);
}
}