mod codec;
pub(crate) mod crypto;
mod entry;
pub use crypto::{
KEY_CHECK_LEN, MASTER_KEY_LEN, MasterKey, NONCE_LEN, TAG_LEN, key_check, master_key_to_bytes,
verify_key_check,
};
pub use entry::{Entry, EntryId, Timestamp, entry_from_parsed_uri};
pub(crate) use crypto::RecordKind;
use std::collections::BTreeMap;
use std::path::Path;
use crate::{Error, Result, otp};
use codec::{Reader, put_bytes, put_u32};
use entry::Entry as VaultEntry;
pub const MAGIC: &[u8; 8] = b"Q2FAVLT\0";
pub const FORMAT_VERSION: u8 = 2;
pub const READABLE_VERSIONS: &[u8] = &[1, FORMAT_VERSION];
pub const MAX_RECORD_LEN: usize = 1024 * 1024;
pub const MAX_RECORDS: usize = 1_000_000;
mod tag {
pub(super) const NAME: u8 = 0x01;
pub(super) const ISSUER: u8 = 0x02;
pub(super) const ACCOUNT: u8 = 0x03;
pub(super) const SECRET: u8 = 0x04;
pub(super) const ALGORITHM: u8 = 0x05;
pub(super) const DIGITS: u8 = 0x06;
pub(super) const PERIOD: u8 = 0x07;
pub(super) const COUNTER: u8 = 0x08;
pub(super) const NOTE: u8 = 0x09;
pub(super) const CREATED_AT: u8 = 0x0A;
pub(super) const UPDATED_AT: u8 = 0x0B;
pub(super) const DELETED_AT: u8 = 0x0C;
pub(super) const DELETED_BY: u8 = 0x0D;
pub(super) const ALG_SHA1: u8 = 1;
pub(super) const ALG_SHA256: u8 = 2;
pub(super) const ALG_SHA512: u8 = 3;
pub(super) const DIGITS_SIX: u8 = 6;
pub(super) const DIGITS_SEVEN: u8 = 7;
pub(super) const DIGITS_EIGHT: u8 = 8;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tombstone {
pub entry_id: EntryId,
pub deleted_at: Timestamp,
pub deleted_by: Option<EntryId>,
}
#[derive(Debug, Default)]
pub struct Vault {
entries: BTreeMap<EntryId, VaultEntry>,
tombstones: BTreeMap<EntryId, Tombstone>,
}
impl Vault {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn tombstone_count(&self) -> usize {
self.tombstones.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&EntryId, &VaultEntry)> {
self.entries.iter()
}
#[must_use]
pub fn get(&self, id: &EntryId) -> Option<&VaultEntry> {
self.entries.get(id)
}
#[must_use]
pub fn get_tombstone(&self, id: &EntryId) -> Option<&Tombstone> {
self.tombstones.get(id)
}
pub fn insert(&mut self, entry: VaultEntry) -> Result<EntryId> {
let id = EntryId::generate()?;
self.tombstones.remove(&id);
self.entries.insert(id, entry);
Ok(id)
}
pub fn insert_with_id(&mut self, id: EntryId, entry: VaultEntry) -> Result<()> {
if self.entries.contains_key(&id) {
return Err(Error::VaultDuplicateEntryId);
}
self.entries.insert(id, entry);
Ok(())
}
pub fn replace(&mut self, id: EntryId, entry: VaultEntry) -> Result<()> {
if !self.entries.contains_key(&id) {
return Err(Error::VaultEntryNotFound);
}
self.entries.insert(id, entry);
Ok(())
}
pub fn remove(&mut self, id: &EntryId, now: Timestamp) -> Option<VaultEntry> {
let removed = self.entries.remove(id)?;
self.tombstones.entry(*id).or_insert_with(|| Tombstone {
entry_id: *id,
deleted_at: now,
deleted_by: None,
});
Some(removed)
}
pub fn record_tombstone(&mut self, tombstone: Tombstone) {
self.tombstones.insert(tombstone.entry_id, tombstone);
}
pub fn iter_tombstones(&self) -> impl Iterator<Item = (&EntryId, &Tombstone)> {
self.tombstones.iter()
}
}
#[derive(Debug)]
pub struct VaultFile {
master: MasterKey,
vault: Vault,
}
impl VaultFile {
pub fn create() -> Result<Self> {
Ok(Self {
master: MasterKey::generate()?,
vault: Vault::new(),
})
}
#[must_use]
pub fn with_master_key(master: MasterKey) -> Self {
Self {
master,
vault: Vault::new(),
}
}
#[must_use]
pub const fn master_key(&self) -> &MasterKey {
&self.master
}
#[must_use]
pub fn into_parts(self) -> (MasterKey, Vault) {
(self.master, self.vault)
}
#[must_use]
pub const fn vault(&self) -> &Vault {
&self.vault
}
pub const fn vault_mut(&mut self) -> &mut Vault {
&mut self.vault
}
pub fn serialize(&self) -> Result<Vec<u8>> {
let total = self
.vault
.entries
.len()
.checked_add(self.vault.tombstones.len())
.ok_or(Error::VaultTooManyRecords)?;
if total > MAX_RECORDS {
return Err(Error::VaultTooManyRecords);
}
#[expect(
clippy::cast_possible_truncation,
reason = "total 已与 MAX_RECORDS(远小于 u32::MAX)比较过"
)]
let count = total as u32;
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(FORMAT_VERSION);
out.push(0);
put_u32(&mut out, count);
for (id, entry) in &self.vault.entries {
let plaintext = encode_entry(entry)?;
write_record(
&mut out,
&self.master,
FORMAT_VERSION,
RecordKind::Entry,
id,
&plaintext,
)?;
}
for (id, tombstone) in &self.vault.tombstones {
let plaintext = encode_tombstone(tombstone);
write_record(
&mut out,
&self.master,
FORMAT_VERSION,
RecordKind::Tombstone,
id,
&plaintext,
)?;
}
out.extend_from_slice(&crypto::key_check(&self.master));
Ok(out)
}
pub fn parse(master: MasterKey, data: &[u8]) -> Result<Self> {
let mut reader = Reader::new(data);
let magic = reader.take(MAGIC.len()).ok_or(Error::VaultTruncated)?;
if magic != MAGIC {
return Err(Error::VaultBadMagic);
}
let version = reader.u8().ok_or(Error::VaultTruncated)?;
if !READABLE_VERSIONS.contains(&version) {
return Err(Error::VaultUnsupportedVersion { version });
}
let reserved = reader.u8().ok_or(Error::VaultTruncated)?;
if reserved != 0 {
return Err(Error::VaultReservedByteNotZero { value: reserved });
}
let count = reader.u32().ok_or(Error::VaultTruncated)? as usize;
if count > MAX_RECORDS {
return Err(Error::VaultTooManyRecords);
}
let mut vault = Vault::new();
for _ in 0..count {
let kind_byte = reader.u8().ok_or(Error::VaultTruncated)?;
let kind = RecordKind::from_byte(kind_byte)
.ok_or(Error::VaultUnknownRecordKind { value: kind_byte })?;
let id = reader.entry_id().ok_or(Error::VaultTruncated)?;
let nonce = reader.nonce().ok_or(Error::VaultTruncated)?;
let len = reader.u32().ok_or(Error::VaultTruncated)? as usize;
if len > MAX_RECORD_LEN {
return Err(Error::VaultRecordTooLong {
limit: MAX_RECORD_LEN,
actual: len,
});
}
let ciphertext = reader.take(len).ok_or(Error::VaultTruncated)?;
let plaintext = crypto::open(&master, kind, &id, version, &nonce, ciphertext)?;
match kind {
RecordKind::Entry => {
let entry = decode_entry(&plaintext)?;
vault.insert_with_id(id, entry)?;
}
RecordKind::Tombstone => {
let tombstone = decode_tombstone(id, &plaintext)?;
vault.record_tombstone(tombstone);
}
RecordKind::SyncEntry | RecordKind::SyncTombstone => {
return Err(Error::VaultUnknownRecordKind { value: kind_byte });
}
}
}
if version >= 2 {
let stored = reader
.take(crypto::KEY_CHECK_LEN)
.ok_or(Error::VaultTruncated)?;
crypto::verify_key_check(&master, stored)?;
}
if !reader.is_empty() {
return Err(Error::VaultTrailingBytes {
count: reader.remaining(),
});
}
Ok(Self { master, vault })
}
}
fn write_record(
out: &mut Vec<u8>,
master: &MasterKey,
format_version: u8,
kind: RecordKind,
id: &EntryId,
plaintext: &[u8],
) -> Result<()> {
let (nonce, ciphertext) = crypto::seal(master, kind, id, format_version, plaintext)?;
out.push(kind.as_byte());
out.extend_from_slice(id.as_bytes());
out.extend_from_slice(&nonce);
put_bytes(out, &ciphertext);
Ok(())
}
pub(crate) fn encode_entry(entry: &VaultEntry) -> Result<Vec<u8>> {
let mut out = Vec::new();
put_node(&mut out, tag::NAME, entry.name.as_bytes());
put_node(
&mut out,
tag::ISSUER,
entry.issuer.as_deref().unwrap_or_default().as_bytes(),
);
put_node(&mut out, tag::ACCOUNT, entry.account.as_bytes());
put_node(&mut out, tag::SECRET, entry.secret.expose());
put_node(
&mut out,
tag::ALGORITHM,
&[algorithm_to_byte(entry.algorithm)],
);
put_node(&mut out, tag::DIGITS, &[digits_to_byte(entry.digits)]);
put_node(&mut out, tag::PERIOD, &entry.period.to_be_bytes());
if let Some(counter) = entry.counter {
put_node(&mut out, tag::COUNTER, &counter.to_be_bytes());
}
put_node(&mut out, tag::NOTE, entry.note.as_bytes());
put_node(&mut out, tag::CREATED_AT, &entry.created_at.to_be_bytes());
put_node(&mut out, tag::UPDATED_AT, &entry.updated_at.to_be_bytes());
Ok(out)
}
fn put_node(out: &mut Vec<u8>, tag: u8, payload: &[u8]) {
out.push(tag);
put_bytes(out, payload);
}
pub(crate) fn encode_tombstone(tombstone: &Tombstone) -> Vec<u8> {
let mut out = Vec::new();
put_node(
&mut out,
tag::DELETED_AT,
&tombstone.deleted_at.to_be_bytes(),
);
if let Some(by) = &tombstone.deleted_by {
put_node(&mut out, tag::DELETED_BY, by.as_bytes());
}
out
}
pub(crate) fn decode_entry(data: &[u8]) -> Result<VaultEntry> {
#[derive(Default)]
struct Fields {
name: Option<String>,
issuer: Option<String>,
account: Option<String>,
secret: Option<Vec<u8>>,
algorithm: Option<otp::Algorithm>,
digits: Option<otp::Digits>,
period: Option<u64>,
counter: Option<u64>,
note: String,
created_at: Option<u64>,
updated_at: Option<u64>,
}
let mut reader = Reader::new(data);
let mut f = Fields::default();
let mut seen: u16 = 0;
let mut guard = data.len() + 1;
while !reader.is_empty() {
guard = guard.checked_sub(1).ok_or(Error::VaultCorrupt)?;
let node_tag = reader.u8().ok_or(Error::VaultTruncated)?;
let len = reader.u32().ok_or(Error::VaultTruncated)? as usize;
if len > Entry::MAX_FIELD_LEN {
return Err(Error::FieldTooLong {
field: "vault node",
limit: Entry::MAX_FIELD_LEN,
actual: len,
});
}
let payload = reader.take(len).ok_or(Error::VaultTruncated)?;
if node_tag <= tag::DELETED_BY {
let bit = 1_u16 << node_tag;
if seen & bit != 0 {
return Err(Error::VaultDuplicateField { tag: node_tag });
}
seen |= bit;
}
match node_tag {
tag::NAME => f.name = Some(utf8(payload)?),
tag::ISSUER => {
let text = utf8(payload)?;
f.issuer = if text.is_empty() { None } else { Some(text) };
}
tag::ACCOUNT => f.account = Some(utf8(payload)?),
tag::SECRET => {
f.secret = Some(payload.to_vec());
}
tag::ALGORITHM => {
f.algorithm =
Some(algorithm_from_byte(single_byte(payload)?).ok_or(Error::VaultCorrupt)?);
}
tag::DIGITS => {
f.digits =
Some(digits_from_byte(single_byte(payload)?).ok_or(Error::VaultCorrupt)?);
}
tag::PERIOD => f.period = Some(u64_from(payload)?),
tag::COUNTER => f.counter = Some(u64_from(payload)?),
tag::NOTE => f.note = utf8(payload)?,
tag::CREATED_AT => f.created_at = Some(u64_from(payload)?),
tag::UPDATED_AT => f.updated_at = Some(u64_from(payload)?),
_ => {}
}
}
let created_at = f.created_at.ok_or(Error::VaultCorrupt)?;
Entry::new(
f.name.ok_or(Error::VaultCorrupt)?,
f.issuer,
f.account.ok_or(Error::VaultCorrupt)?,
otp::OtpSecret::from_bytes(f.secret.ok_or(Error::VaultCorrupt)?)?,
f.algorithm.ok_or(Error::VaultCorrupt)?,
f.digits.ok_or(Error::VaultCorrupt)?,
f.period.ok_or(Error::VaultCorrupt)?,
f.counter,
f.note,
created_at,
f.updated_at.unwrap_or(created_at),
)
}
pub(crate) fn decode_tombstone(id: EntryId, data: &[u8]) -> Result<Tombstone> {
let mut reader = Reader::new(data);
let mut deleted_at = None;
let mut deleted_by = None;
let mut guard = data.len() + 1;
while !reader.is_empty() {
guard = guard.checked_sub(1).ok_or(Error::VaultCorrupt)?;
let node_tag = reader.u8().ok_or(Error::VaultTruncated)?;
let len = reader.u32().ok_or(Error::VaultTruncated)? as usize;
if len > Entry::MAX_FIELD_LEN {
return Err(Error::VaultCorrupt);
}
let payload = reader.take(len).ok_or(Error::VaultTruncated)?;
match node_tag {
tag::DELETED_AT => deleted_at = Some(u64_from(payload)?),
tag::DELETED_BY => {
let bytes: [u8; EntryId::LEN] =
payload.try_into().map_err(|_| Error::VaultCorrupt)?;
deleted_by = Some(EntryId::from_bytes(bytes));
}
_ => {}
}
}
Ok(Tombstone {
entry_id: id,
deleted_at: deleted_at.ok_or(Error::VaultCorrupt)?,
deleted_by,
})
}
fn utf8(bytes: &[u8]) -> Result<String> {
String::from_utf8(bytes.to_vec()).map_err(|_| Error::VaultInvalidUtf8)
}
fn single_byte(payload: &[u8]) -> Result<u8> {
let bytes: [u8; 1] = payload.try_into().map_err(|_| Error::VaultCorrupt)?;
Ok(bytes[0])
}
fn u64_from(payload: &[u8]) -> Result<u64> {
let bytes: [u8; 8] = payload.try_into().map_err(|_| Error::VaultCorrupt)?;
Ok(u64::from_be_bytes(bytes))
}
const fn algorithm_to_byte(algorithm: otp::Algorithm) -> u8 {
match algorithm {
otp::Algorithm::Sha1 => tag::ALG_SHA1,
otp::Algorithm::Sha256 => tag::ALG_SHA256,
otp::Algorithm::Sha512 => tag::ALG_SHA512,
}
}
const fn algorithm_from_byte(byte: u8) -> Option<otp::Algorithm> {
match byte {
tag::ALG_SHA1 => Some(otp::Algorithm::Sha1),
tag::ALG_SHA256 => Some(otp::Algorithm::Sha256),
tag::ALG_SHA512 => Some(otp::Algorithm::Sha512),
_ => None,
}
}
const fn digits_to_byte(digits: otp::Digits) -> u8 {
match digits {
otp::Digits::Six => tag::DIGITS_SIX,
otp::Digits::Seven => tag::DIGITS_SEVEN,
otp::Digits::Eight => tag::DIGITS_EIGHT,
}
}
const fn digits_from_byte(byte: u8) -> Option<otp::Digits> {
match byte {
tag::DIGITS_SIX => Some(otp::Digits::Six),
tag::DIGITS_SEVEN => Some(otp::Digits::Seven),
tag::DIGITS_EIGHT => Some(otp::Digits::Eight),
_ => None,
}
}
pub fn write_file_atomically(path: &Path, data: &[u8]) -> Result<()> {
use std::io::Write;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
tmp_name.push(format!(".tmp{}", std::process::id()));
let tmp_path = parent.join(tmp_name);
let cleanup = |err| {
let _ = std::fs::remove_file(&tmp_path);
err
};
let mut file = std::fs::File::create(&tmp_path).map_err(|e| Error::VaultIo {
reason: e.to_string(),
})?;
file.write_all(data).map_err(|e| {
cleanup(Error::VaultIo {
reason: e.to_string(),
})
})?;
file.sync_all().map_err(|e| {
cleanup(Error::VaultIo {
reason: e.to_string(),
})
})?;
drop(file);
std::fs::rename(&tmp_path, path).map_err(|e| {
cleanup(Error::VaultIo {
reason: e.to_string(),
})
})?;
Ok(())
}
pub fn read_file(master: MasterKey, path: &Path) -> Result<VaultFile> {
let data = std::fs::read(path).map_err(|e| Error::VaultIo {
reason: e.to_string(),
})?;
VaultFile::parse(master, &data)
}
#[cfg(test)]
mod tests {
use super::*;
fn secret() -> otp::OtpSecret {
otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("合法种子")
}
fn sample_entry(name: &str) -> VaultEntry {
Entry::new(
name.into(),
Some("Example".into()),
"alice".into(),
secret(),
otp::Algorithm::Sha256,
otp::Digits::Eight,
30,
None,
"note".into(),
1_700_000_000,
1_700_000_123,
)
.expect("合法条目")
}
fn sample_hotp_entry() -> VaultEntry {
Entry::new(
"HOTP".into(),
None,
"bob".into(),
secret(),
otp::Algorithm::Sha1,
otp::Digits::Six,
30,
Some(42),
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("合法条目")
}
fn assert_entry_eq(a: &VaultEntry, b: &VaultEntry) {
assert_eq!(a.name, b.name, "name");
assert_eq!(a.issuer, b.issuer, "issuer");
assert_eq!(a.account, b.account, "account");
assert_eq!(a.secret.to_base32(), b.secret.to_base32(), "secret");
assert_eq!(a.algorithm, b.algorithm, "algorithm");
assert_eq!(a.digits, b.digits, "digits");
assert_eq!(a.period, b.period, "period");
assert_eq!(a.counter, b.counter, "counter");
assert_eq!(a.note, b.note, "note");
assert_eq!(a.created_at, b.created_at, "created_at");
assert_eq!(a.updated_at, b.updated_at, "updated_at");
}
#[test]
fn create_starts_empty() {
let file = VaultFile::create().expect("OS 随机源可用");
assert!(file.vault().is_empty());
assert_eq!(file.vault().len(), 0);
assert_eq!(file.vault().tombstone_count(), 0);
}
#[test]
fn roundtrip_preserves_every_field() {
let mut file = VaultFile::create().expect("OS 随机源可用");
let id = file
.vault_mut()
.insert(sample_entry("GitHub"))
.expect("可插入");
let bytes = file.serialize().expect("可序列化");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
)
.expect("可解析");
assert_eq!(reopened.vault().len(), 1);
let restored = reopened.vault().get(&id).expect("条目应存在");
assert_entry_eq(&sample_entry("GitHub"), restored);
}
#[test]
fn roundtrip_preserves_hotp_counter() {
let mut file = VaultFile::create().expect("OS 随机源可用");
let id = file
.vault_mut()
.insert(sample_hotp_entry())
.expect("可插入");
let bytes = file.serialize().expect("可序列化");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
)
.expect("可解析");
let restored = reopened.vault().get(&id).expect("条目应存在");
assert_eq!(restored.counter, Some(42));
assert_eq!(restored.uri_type(), crate::uri::UriType::Hotp);
}
#[test]
fn roundtrip_preserves_tombstones() {
let mut file = VaultFile::create().expect("OS 随机源可用");
let id = file
.vault_mut()
.insert(sample_entry("Doomed"))
.expect("可插入");
file.vault_mut().remove(&id, 1_800_000_000);
let bytes = file.serialize().expect("可序列化");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
)
.expect("可解析");
assert!(reopened.vault().is_empty(), "条目应已被删除");
assert_eq!(reopened.vault().tombstone_count(), 1);
let tombstone = reopened.vault().get_tombstone(&id).expect("墓碑应存在");
assert_eq!(tombstone.entry_id, id);
assert_eq!(tombstone.deleted_at, 1_800_000_000);
assert_eq!(tombstone.deleted_by, None);
}
#[test]
fn repeated_removal_keeps_original_timestamp() {
let mut vault = Vault::new();
let id = vault.insert(sample_entry("x")).expect("可插入");
vault.remove(&id, 100);
assert!(vault.remove(&id, 999).is_none());
assert_eq!(vault.get_tombstone(&id).expect("墓碑存在").deleted_at, 100);
}
#[test]
fn roundtrip_preserves_many_entries() {
let mut file = VaultFile::create().expect("OS 随机源可用");
let mut ids = Vec::new();
for i in 0..64 {
ids.push(
file.vault_mut()
.insert(sample_entry(&format!("entry-{i}")))
.expect("可插入"),
);
}
let bytes = file.serialize().expect("可序列化");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
)
.expect("可解析");
assert_eq!(reopened.vault().len(), 64);
for id in ids {
assert!(reopened.vault().get(&id).is_some(), "缺少条目 {id}");
}
}
#[test]
fn serialization_is_deterministic_in_structure() {
let mut file = VaultFile::create().expect("OS 随机源可用");
for i in 0..16 {
file.vault_mut()
.insert(sample_entry(&format!("e{i}")))
.expect("可插入");
}
let a = file.serialize().expect("可序列化");
let b = file.serialize().expect("可序列化");
assert_eq!(a.len(), b.len(), "两次序列化长度不同");
assert_eq!(a[..9], b[..9], "魔数与版本应一致");
assert_eq!(a[9], b[9], "保留字节应一致");
let names = |bytes: &[u8]| -> Vec<String> {
let f = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
bytes,
)
.expect("可解析");
f.vault().iter().map(|(_, e)| e.name.clone()).collect()
};
assert_eq!(names(&a), names(&b), "条目顺序不确定");
}
#[test]
fn wrong_master_key_cannot_open() {
let mut file = VaultFile::create().expect("OS 随机源可用");
file.vault_mut().insert(sample_entry("x")).expect("可插入");
let bytes = file.serialize().expect("可序列化");
let other = MasterKey::generate().expect("OS 随机源可用");
let err = VaultFile::parse(other, &bytes);
assert!(matches!(err, Err(Error::VaultDecryptionFailed)));
}
#[test]
fn tampering_any_byte_is_rejected() {
let mut file = VaultFile::create().expect("OS 随机源可用");
file.vault_mut().insert(sample_entry("x")).expect("可插入");
file.vault_mut()
.insert(sample_hotp_entry())
.expect("可插入");
let bytes = file.serialize().expect("可序列化");
let key_bytes = master_key_to_bytes(file.master_key());
for index in 0..bytes.len() {
let mut tampered = bytes.clone();
tampered[index] ^= 0x01;
let result = VaultFile::parse(
MasterKey::from_bytes(key_bytes.clone()).expect("长度正确"),
&tampered,
);
assert!(
result.is_err(),
"第 {index} 字节被翻转({:#04x} → {:#04x})却仍被接受",
bytes[index],
tampered[index]
);
}
}
#[test]
fn truncation_at_any_point_is_rejected() {
let mut file = VaultFile::create().expect("OS 随机源可用");
file.vault_mut().insert(sample_entry("x")).expect("可插入");
file.vault_mut()
.insert(sample_hotp_entry())
.expect("可插入");
let first_id = *file.vault().iter().next().expect("有条目").0;
file.vault_mut().remove(&first_id, 1);
let bytes = file.serialize().expect("可序列化");
let key_bytes = master_key_to_bytes(file.master_key());
for cut in 1..bytes.len() {
let result = VaultFile::parse(
MasterKey::from_bytes(key_bytes.clone()).expect("长度正确"),
&bytes[..cut],
);
assert!(result.is_err(), "截断到 {cut} 字节却仍被接受");
}
}
#[test]
fn trailing_bytes_are_rejected() {
let mut file = VaultFile::create().expect("OS 随机源可用");
file.vault_mut().insert(sample_entry("x")).expect("可插入");
let mut bytes = file.serialize().expect("可序列化");
bytes.push(0x00);
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
);
assert!(matches!(err, Err(Error::VaultTrailingBytes { count: 1 })));
}
#[test]
fn bad_magic_is_rejected() {
let file = VaultFile::create().expect("OS 随机源可用");
let mut bytes = file.serialize().expect("可序列化");
bytes[0] = b'X';
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
);
assert!(matches!(err, Err(Error::VaultBadMagic)));
}
#[test]
fn unsupported_version_is_rejected() {
let file = VaultFile::create().expect("OS 随机源可用");
let mut bytes = file.serialize().expect("可序列化");
bytes[8] = 99;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
);
match err {
Err(Error::VaultUnsupportedVersion { version }) => assert_eq!(version, 99),
other => panic!("应拒绝未知版本,实际 ok={}", other.is_ok()),
}
}
#[test]
fn nonzero_reserved_byte_is_rejected() {
let file = VaultFile::create().expect("OS 随机源可用");
let mut bytes = file.serialize().expect("可序列化");
bytes[9] = 1;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
);
assert!(matches!(
err,
Err(Error::VaultReservedByteNotZero { value: 1 })
));
}
#[test]
fn unknown_record_kind_is_rejected() {
let mut file = VaultFile::create().expect("OS 随机源可用");
file.vault_mut().insert(sample_entry("x")).expect("可插入");
let mut bytes = file.serialize().expect("可序列化");
bytes[14] = 0x7F;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
);
assert!(matches!(
err,
Err(Error::VaultUnknownRecordKind { value: 0x7F })
));
}
#[test]
fn duplicate_entry_id_is_rejected() {
let mut file = VaultFile::create().expect("OS 随机源可用");
let id = file.vault_mut().insert(sample_entry("x")).expect("可插入");
let mut vault = Vault::new();
vault
.insert_with_id(id, sample_entry("a"))
.expect("首次插入");
let err = vault.insert_with_id(id, sample_entry("b"));
assert!(matches!(err, Err(Error::VaultDuplicateEntryId)));
assert_eq!(vault.len(), 1);
assert_eq!(vault.get(&id).expect("条目存在").name, "a");
}
#[test]
fn replace_keeps_the_id_and_leaves_no_tombstone() {
let mut vault = Vault::new();
let id = vault.insert(sample_entry("original")).expect("可插入");
let created = vault.get(&id).expect("条目存在").created_at;
let mut renamed = sample_entry("renamed");
renamed.created_at = created;
vault.replace(id, renamed).expect("替换应当成功");
let entry = vault.get(&id).expect("标识必须仍然指向该条目");
assert_eq!(entry.name, "renamed");
assert_eq!(entry.created_at, created, "创建时间应当被保留");
assert_eq!(vault.len(), 1, "替换不该多出一条");
assert_eq!(
vault.tombstone_count(),
0,
"改名不是删除,不该留下墓碑——墓碑会被同步出去,另一端可能据此真的删掉条目"
);
}
#[test]
fn replace_of_a_missing_entry_is_rejected() {
let mut vault = Vault::new();
let missing = EntryId::from_bytes([0x5A; EntryId::LEN]);
let err = vault.replace(missing, sample_entry("ghost"));
assert!(matches!(err, Err(Error::VaultEntryNotFound)));
assert!(vault.is_empty(), "失败不得凭空插入条目");
}
#[test]
fn empty_vault_roundtrips() {
let file = VaultFile::create().expect("OS 随机源可用");
let bytes = file.serialize().expect("可序列化");
assert_eq!(
bytes.len(),
14 + crypto::KEY_CHECK_LEN,
"空文件应是文件头加主密钥检查值"
);
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确"),
&bytes,
)
.expect("可解析");
assert!(reopened.vault().is_empty());
assert_eq!(reopened.vault().tombstone_count(), 0);
}
#[test]
fn an_empty_vault_detects_a_wrong_master_key() {
let file = VaultFile::create().expect("OS 随机源可用");
assert!(file.vault().is_empty(), "前提:这个保险库确实是空的");
let bytes = file.serialize().expect("可序列化");
let wrong = MasterKey::generate().expect("OS 随机源可用");
assert_ne!(
wrong.expose(),
file.master_key().expose(),
"两把密钥必须不同"
);
let err = VaultFile::parse(wrong, &bytes);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"空保险库用错误的密钥也必须被拒绝,实际 ok={}",
err.is_ok()
);
}
#[test]
fn version_1_files_are_still_readable() {
let master = MasterKey::generate().expect("OS 随机源可用");
let id = EntryId::generate().expect("OS 随机源可用");
let entry = sample_entry("old-format");
let plaintext = encode_entry(&entry).expect("可编码");
let (nonce, ciphertext) =
crypto::seal(&master, RecordKind::Entry, &id, 1, &plaintext).expect("可加密");
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(1);
out.push(0);
put_u32(&mut out, 1);
out.push(RecordKind::Entry.as_byte());
out.extend_from_slice(id.as_bytes());
out.extend_from_slice(&nonce);
put_u32(
&mut out,
u32::try_from(ciphertext.len()).expect("测试数据远小于 u32::MAX"),
);
out.extend_from_slice(&ciphertext);
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("长度正确"),
&out,
)
.expect("版本 1 的文件必须仍可解析");
assert_eq!(reopened.vault().len(), 1, "条目应当被读出来");
assert_eq!(
reopened.vault().iter().next().expect("一条").1.name,
"old-format"
);
let wrong = MasterKey::generate().expect("OS 随机源可用");
assert!(
VaultFile::parse(wrong, &out).is_err(),
"有内容的版本 1 文件用错误密钥必须失败"
);
}
#[test]
fn a_version_1_empty_file_still_cannot_detect_a_wrong_key() {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(1);
out.push(0);
put_u32(&mut out, 0);
let wrong = MasterKey::generate().expect("OS 随机源可用");
let parsed = VaultFile::parse(wrong, &out);
assert!(
parsed.is_ok(),
"版本 1 的空文件没有检查值,因此任何密钥都能解析——这是该格式的已知限制"
);
}
#[test]
fn a_tampered_key_check_is_rejected() {
let file = VaultFile::create().expect("OS 随机源可用");
let master =
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确");
let bytes = file.serialize().expect("可序列化");
for index in 0..crypto::KEY_CHECK_LEN {
let mut tampered = bytes.clone();
let last = tampered.len() - crypto::KEY_CHECK_LEN + index;
tampered[last] ^= 0x01;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("长度正确"),
&tampered,
);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"检查值第 {index} 字节被翻转却仍被接受"
);
}
}
#[test]
fn a_truncated_key_check_is_a_structural_error() {
let file = VaultFile::create().expect("OS 随机源可用");
let master =
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确");
let bytes = file.serialize().expect("可序列化");
let mut cut = bytes.clone();
cut.truncate(cut.len() - 3);
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("长度正确"),
&cut,
);
assert!(
matches!(err, Err(Error::VaultTruncated)),
"截断的检查值应报截断,实际 ok={}",
err.is_ok()
);
let err = crypto::verify_key_check(&master, &[0_u8; 8]);
assert!(
matches!(err, Err(Error::VaultBadKeyCheck)),
"长度不符应报 VaultBadKeyCheck"
);
}
#[test]
fn file_header_layout_is_stable() {
let file = VaultFile::create().expect("OS 随机源可用");
let bytes = file.serialize().expect("可序列化");
assert_eq!(&bytes[..8], MAGIC);
assert_eq!(bytes[8], FORMAT_VERSION);
assert_eq!(bytes[9], 0, "保留字节");
assert_eq!(
u32::from_be_bytes(bytes[10..14].try_into().expect("4 字节")),
0
);
}
#[test]
fn unknown_entry_node_is_skipped() {
let entry = sample_entry("x");
let mut plaintext = encode_entry(&entry).expect("可编码");
plaintext.push(0xEE);
put_bytes(&mut plaintext, b"future field");
let decoded = decode_entry(&plaintext).expect("未知节点应被跳过");
assert_entry_eq(&entry, &decoded);
}
#[test]
fn skipping_unknown_node_uses_its_length() {
let entry = sample_entry("x");
let mut plaintext = Vec::new();
plaintext.push(0xEE);
put_bytes(&mut plaintext, &[0xAA; 100]);
plaintext.extend_from_slice(&encode_entry(&entry).expect("可编码"));
let decoded = decode_entry(&plaintext).expect("应跳过 100 字节");
assert_entry_eq(&entry, &decoded);
}
#[test]
fn unknown_node_with_overlong_length_is_rejected() {
let mut plaintext = Vec::new();
plaintext.push(0xEE);
put_u32(&mut plaintext, 9999);
plaintext.extend_from_slice(b"short");
let err = decode_entry(&plaintext);
assert!(matches!(err, Err(Error::VaultTruncated)));
}
#[test]
fn duplicate_known_node_is_rejected() {
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"first");
put_node(&mut plaintext, tag::NAME, b"second");
let err = decode_entry(&plaintext);
assert!(matches!(err, Err(Error::VaultDuplicateField { .. })));
}
#[test]
fn missing_required_node_is_rejected() {
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"only a name");
let err = decode_entry(&plaintext);
assert!(matches!(err, Err(Error::VaultCorrupt)));
}
#[test]
fn empty_secret_node_is_rejected() {
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"n");
put_node(&mut plaintext, tag::ACCOUNT, b"a");
put_node(&mut plaintext, tag::SECRET, b"");
put_node(&mut plaintext, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut plaintext, tag::DIGITS, &[tag::DIGITS_SIX]);
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
let err = decode_entry(&plaintext);
assert!(matches!(err, Err(Error::SecretEmpty)));
}
#[test]
fn invalid_utf8_is_rejected() {
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, &[0xFF, 0xFE]);
put_node(&mut plaintext, tag::ACCOUNT, b"a");
put_node(&mut plaintext, tag::SECRET, b"secret");
put_node(&mut plaintext, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut plaintext, tag::DIGITS, &[tag::DIGITS_SIX]);
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
let err = decode_entry(&plaintext);
assert!(matches!(err, Err(Error::VaultInvalidUtf8)));
}
#[test]
fn algorithm_and_digits_bytes_roundtrip() {
for algorithm in [
otp::Algorithm::Sha1,
otp::Algorithm::Sha256,
otp::Algorithm::Sha512,
] {
assert_eq!(
algorithm_from_byte(algorithm_to_byte(algorithm)),
Some(algorithm)
);
}
for digits in [otp::Digits::Six, otp::Digits::Seven, otp::Digits::Eight] {
assert_eq!(digits_from_byte(digits_to_byte(digits)), Some(digits));
}
assert_eq!(algorithm_from_byte(0), None);
assert_eq!(algorithm_from_byte(99), None);
assert_eq!(digits_from_byte(5), None);
assert_eq!(digits_from_byte(9), None);
}
#[test]
fn issuer_none_and_empty_normalize() {
let mut entry = sample_entry("x");
entry.issuer = None;
let decoded = decode_entry(&encode_entry(&entry).expect("可编码")).expect("可解码");
assert_eq!(decoded.issuer, None);
entry.issuer = Some(String::new());
let decoded = decode_entry(&encode_entry(&entry).expect("可编码")).expect("可解码");
assert_eq!(decoded.issuer, None, "空串应归一为 None");
}
#[test]
fn atomic_write_produces_exact_content() {
let dir = std::env::temp_dir().join(format!("q2fa-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("可建目录");
let path = dir.join("vault.bin");
let data = b"hello atomic world";
write_file_atomically(&path, data).expect("可写入");
assert_eq!(std::fs::read(&path).expect("可读"), data);
let longer = b"a much longer replacement payload";
write_file_atomically(&path, longer).expect("可写入");
assert_eq!(std::fs::read(&path).expect("可读"), longer);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("可读目录")
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "残留临时文件: {leftovers:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_then_read_file_roundtrip() {
let dir = std::env::temp_dir().join(format!("q2fa-rt-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("可建目录");
let path = dir.join("vault.bin");
let mut file = VaultFile::create().expect("OS 随机源可用");
let id = file
.vault_mut()
.insert(sample_entry("GitHub"))
.expect("可插入");
let bytes = file.serialize().expect("可序列化");
write_file_atomically(&path, &bytes).expect("可写入");
let key = MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("长度正确");
let reopened = read_file(key, &path).expect("可读回");
assert_eq!(reopened.vault().len(), 1);
assert_entry_eq(
&sample_entry("GitHub"),
reopened.vault().get(&id).expect("条目存在"),
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reading_missing_file_reports_io_error() {
let key = MasterKey::generate().expect("OS 随机源可用");
let err = read_file(key, Path::new("definitely/not/here/vault.bin"));
assert!(matches!(err, Err(Error::VaultIo { .. })));
}
#[test]
fn empty_input_is_rejected() {
let key = MasterKey::generate().expect("OS 随机源可用");
assert!(matches!(
VaultFile::parse(key, &[]),
Err(Error::VaultTruncated)
));
}
#[test]
fn absurd_record_count_is_rejected() {
let key = MasterKey::generate().expect("OS 随机源可用");
let mut bytes = Vec::new();
bytes.extend_from_slice(MAGIC);
bytes.push(FORMAT_VERSION);
bytes.push(0);
put_u32(&mut bytes, u32::MAX);
let err = VaultFile::parse(key, &bytes);
assert!(matches!(err, Err(Error::VaultTooManyRecords)));
}
#[test]
fn absurd_record_length_is_rejected() {
let key = MasterKey::generate().expect("OS 随机源可用");
let mut bytes = Vec::new();
bytes.extend_from_slice(MAGIC);
bytes.push(FORMAT_VERSION);
bytes.push(0);
put_u32(&mut bytes, 1);
bytes.push(0x01);
bytes.extend_from_slice(&[0_u8; EntryId::LEN]);
bytes.extend_from_slice(&[0_u8; NONCE_LEN]);
put_u32(&mut bytes, u32::MAX);
let err = VaultFile::parse(key, &bytes);
match err {
Err(Error::VaultRecordTooLong { limit, actual }) => {
assert_eq!(limit, MAX_RECORD_LEN);
assert_eq!(actual, u32::MAX as usize);
}
other => panic!("应拒绝超长记录,实际 ok={}", other.is_ok()),
}
}
#[test]
fn garbage_with_correct_magic_is_rejected() {
let key = MasterKey::generate().expect("OS 随机源可用");
let mut bytes = Vec::new();
bytes.extend_from_slice(MAGIC);
bytes.push(FORMAT_VERSION);
bytes.push(0);
put_u32(&mut bytes, 1);
bytes.push(0x01);
bytes.extend_from_slice(&[0x11; EntryId::LEN]);
bytes.extend_from_slice(&[0x22; NONCE_LEN]);
put_u32(&mut bytes, 32);
bytes.extend_from_slice(&[0x33; 32]);
let err = VaultFile::parse(key, &bytes);
assert!(matches!(err, Err(Error::VaultDecryptionFailed)));
}
}