mod codec;
pub(crate) mod crypto;
mod entry;
mod group;
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, EntryIcon, EntryId, EntryKind, IconMime, Timestamp, entry_from_parsed_uri};
pub use group::{Group, GroupChild, GroupId};
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 = 4;
pub const READABLE_VERSIONS: &[u8] = &[1, 2, 3, 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 ENTRY_KIND: u8 = 0x0E;
pub(super) const MOTP_PIN: u8 = 0x0F;
pub(super) const YANDEX_PIN: u8 = 0x10;
pub(super) const ICON: u8 = 0x11;
pub(super) const GROUP_ORDER: u8 = 0x11;
pub(super) const ENTRY_IDS: u8 = 0x12;
pub(super) const GROUP_CHILDREN: u8 = 0x13;
pub(super) const ENTRY_TAG_MAX: u8 = ICON;
pub(super) const GROUP_TAG_MAX: u8 = GROUP_CHILDREN;
pub(super) const ALG_SHA1: u8 = 1;
pub(super) const ALG_SHA256: u8 = 2;
pub(super) const ALG_SHA512: u8 = 3;
pub(super) const KIND_TOTP: u8 = 1;
pub(super) const KIND_HOTP: u8 = 2;
pub(super) const KIND_MOTP: u8 = 3;
pub(super) const KIND_YANDEX: u8 = 4;
pub(super) const KIND_STEAM: u8 = 5;
pub(super) const CHILD_ENTRY: u8 = 0x01;
pub(super) const CHILD_GROUP: u8 = 0x02;
}
#[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>,
groups: BTreeMap<GroupId, Group>,
}
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);
self.top_group_mut().children.push(GroupChild::Entry(id));
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);
self.top_group_mut().children.push(GroupChild::Entry(id));
Ok(())
}
pub(crate) fn insert_parsed(&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 insert_group_with_id(&mut self, id: GroupId, group: Group) -> Result<()> {
if self.groups.contains_key(&id) {
return Err(Error::VaultDuplicateGroupId);
}
self.groups.insert(id, group);
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.detach_everywhere(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()
}
pub fn forget_tombstone(&mut self, id: &EntryId) -> Option<Tombstone> {
self.tombstones.remove(id)
}
}
impl Vault {
pub const MAX_GROUP_DEPTH: usize = 3;
pub fn groups(&self) -> impl Iterator<Item = (&GroupId, &Group)> {
let mut ordered: Vec<(&GroupId, &Group)> = Vec::with_capacity(self.groups.len());
let mut visited: Vec<GroupId> = Vec::with_capacity(self.groups.len());
self.walk_pre_order(core::iter::once(GroupId::TOP), &mut visited, &mut ordered);
if visited.len() < self.groups.len() {
let mut remaining: Vec<GroupId> = self
.groups
.keys()
.filter(|id| !visited.contains(id))
.copied()
.collect();
remaining.sort_unstable();
self.walk_pre_order(remaining, &mut visited, &mut ordered);
}
ordered.into_iter()
}
fn walk_pre_order<'a>(
&'a self,
roots: impl IntoIterator<Item = GroupId>,
visited: &mut Vec<GroupId>,
out: &mut Vec<(&'a GroupId, &'a Group)>,
) {
let mut stack: Vec<GroupId> = roots.into_iter().collect();
stack.reverse();
while let Some(id) = stack.pop() {
if visited.contains(&id) {
continue;
}
let Some((key, group)) = self.groups.get_key_value(&id) else {
continue;
};
visited.push(id);
out.push((key, group));
for child in group.children.iter().rev() {
if let GroupChild::Group(child_id) = child {
stack.push(*child_id);
}
}
}
}
#[must_use]
pub fn group_count(&self) -> usize {
self.groups.len()
}
#[must_use]
pub fn group(&self, id: &GroupId) -> Option<&Group> {
self.groups.get(id)
}
#[must_use]
pub fn group_mut(&mut self, id: &GroupId) -> Option<&mut Group> {
self.groups.get_mut(id)
}
#[must_use]
pub fn children_of(&self, id: &GroupId) -> Option<&[GroupChild]> {
self.groups.get(id).map(|group| group.children.as_slice())
}
#[must_use]
pub fn parent_of(&self, id: &GroupId) -> Option<GroupId> {
if id.is_top() {
return None;
}
self.groups
.iter()
.find(|(_, group)| group.children.contains(&GroupChild::Group(*id)))
.map(|(parent, _)| *parent)
}
#[must_use]
pub fn depth_of(&self, id: &GroupId) -> Option<usize> {
if !self.groups.contains_key(id) {
return None;
}
if id.is_top() {
return Some(0);
}
let mut depth = 0;
let mut current = *id;
let mut seen: Vec<GroupId> = Vec::new();
loop {
if seen.contains(¤t) {
return None;
}
seen.push(current);
let parent = self.parent_of(¤t)?;
depth += 1;
if parent.is_top() {
return Some(depth);
}
current = parent;
}
}
#[must_use]
pub fn top_group(&self) -> Option<&Group> {
self.groups.get(&GroupId::TOP)
}
#[must_use]
pub fn ordering(&self, id: &GroupId) -> Option<Vec<EntryId>> {
self.groups.get(id).map(Group::entry_ids)
}
#[must_use]
pub fn group_of(&self, entry: &EntryId) -> Option<&GroupId> {
self.groups
.iter()
.find(|(_, group)| group.children.contains(&GroupChild::Entry(*entry)))
.map(|(id, _)| id)
}
pub fn entries_in(&self, id: &GroupId) -> impl Iterator<Item = (&EntryId, &VaultEntry)> {
self.groups
.get(id)
.into_iter()
.flat_map(|group| group.children.iter())
.filter_map(|child| match child {
GroupChild::Entry(entry_id) => {
self.entries.get(entry_id).map(|entry| (entry_id, entry))
}
GroupChild::Group(_) => None,
})
}
pub fn create_group(&mut self, name: String) -> Result<GroupId> {
self.create_group_in(&GroupId::TOP, name)
}
pub fn create_group_in(&mut self, parent: &GroupId, name: String) -> Result<GroupId> {
if parent.is_top() {
self.top_group_mut();
}
let depth = self.depth_of(parent).ok_or(Error::VaultGroupNotFound)?;
if depth + 1 > Self::MAX_GROUP_DEPTH {
return Err(Error::VaultGroupDepthExceeded {
limit: Self::MAX_GROUP_DEPTH,
actual: depth + 1,
});
}
let id = GroupId::generate()?;
let group = Group::new(name, Vec::new())?;
self.groups.insert(id, group);
self.groups
.get_mut(parent)
.expect("the parent was checked above")
.children
.push(GroupChild::Group(id));
Ok(id)
}
pub fn create_group_with_id(&mut self, id: GroupId, name: String) -> Result<()> {
if id.is_top() || self.groups.contains_key(&id) {
return Err(Error::VaultDuplicateGroupId);
}
let group = Group::new(name, Vec::new())?;
self.groups.insert(id, group);
self.top_group_mut().children.push(GroupChild::Group(id));
Ok(())
}
pub fn rename_group(&mut self, id: &GroupId, name: String) -> Result<()> {
if id.is_top() {
return Err(Error::VaultTopGroupImmutable);
}
if name.len() > Entry::MAX_FIELD_LEN {
return Err(Error::FieldTooLong {
field: "group name",
limit: Entry::MAX_FIELD_LEN,
actual: name.len(),
});
}
let group = self.groups.get_mut(id).ok_or(Error::VaultGroupNotFound)?;
group.name = name;
Ok(())
}
pub fn remove_group(&mut self, id: &GroupId) -> Result<Option<Group>> {
if id.is_top() {
return Err(Error::VaultTopGroupImmutable);
}
let parent = self.parent_of(id);
let Some(removed) = self.groups.remove(id) else {
return Ok(None);
};
let parent_id = match parent {
Some(parent) if self.groups.contains_key(&parent) => parent,
_ => {
self.top_group_mut();
GroupId::TOP
}
};
let parent_group = self
.groups
.get_mut(&parent_id)
.expect("the parent was checked above");
let at = parent_group
.children
.iter()
.position(|child| *child == GroupChild::Group(*id));
match at {
Some(at) => {
parent_group.children.remove(at);
for (offset, child) in removed.children.iter().enumerate() {
parent_group.children.insert(at + offset, *child);
}
}
None => parent_group
.children
.extend(removed.children.iter().copied()),
}
Ok(Some(removed))
}
pub fn set_children(&mut self, id: &GroupId, children: Vec<GroupChild>) -> Result<()> {
if !self.groups.contains_key(id) {
return Err(Error::VaultGroupNotFound);
}
for child in &children {
match child {
GroupChild::Entry(entry_id) => {
if !self.entries.contains_key(entry_id) {
return Err(Error::VaultGroupUnknownEntry);
}
}
GroupChild::Group(group_id) => {
if !self.groups.contains_key(group_id) {
return Err(Error::VaultGroupNotFound);
}
if self.is_within(*id, group_id) {
return Err(Error::VaultGroupCycle);
}
}
}
}
let replacement = Group::new(String::new(), children)?.children;
self.check_depth(id, &replacement)?;
let dropped: Vec<GroupChild> = self
.groups
.get(id)
.expect("checked above")
.children
.iter()
.filter(|child| !replacement.contains(child))
.copied()
.collect();
for child in &replacement {
self.detach_from_other_groups(child, id);
}
self.groups.get_mut(id).expect("checked above").children = replacement;
let parent = self.parent_of(id);
let parent_id = match parent {
Some(parent) if self.groups.contains_key(&parent) => parent,
_ => {
self.top_group_mut();
GroupId::TOP
}
};
for child in dropped {
match child {
GroupChild::Entry(entry_id) => {
if self.entries.contains_key(&entry_id) && self.group_of(&entry_id).is_none() {
self.top_group_mut().children.push(child);
}
}
GroupChild::Group(group_id) => {
if self.groups.contains_key(&group_id) {
self.groups
.get_mut(&parent_id)
.expect("the parent was created or checked above")
.children
.push(child);
}
}
}
}
Ok(())
}
fn is_within(&self, candidate: GroupId, ancestor: &GroupId) -> bool {
let mut current = candidate;
let mut seen: Vec<GroupId> = Vec::new();
loop {
if current == *ancestor {
return true;
}
if seen.contains(¤t) {
return false;
}
seen.push(current);
match self.parent_of(¤t) {
Some(parent) => current = parent,
None => return false,
}
}
}
fn subtree_height(&self, id: &GroupId) -> usize {
self.subtree_height_within(id, &mut Vec::new())
}
fn subtree_height_within(&self, id: &GroupId, seen: &mut Vec<GroupId>) -> usize {
if seen.contains(id) {
return 0;
}
seen.push(*id);
let mut height = 1;
if let Some(group) = self.groups.get(id) {
for child in &group.children {
if let GroupChild::Group(child_id) = child
&& self.groups.contains_key(child_id)
{
height = height.max(1 + self.subtree_height_within(child_id, seen));
}
}
}
height
}
fn check_depth(&self, id: &GroupId, children: &[GroupChild]) -> Result<()> {
let depth = self.depth_of(id).ok_or(Error::VaultGroupNotFound)?;
for child in children {
if let GroupChild::Group(child_id) = child {
let height = self.subtree_height(child_id);
let deepest = depth + height;
if deepest > Self::MAX_GROUP_DEPTH {
return Err(Error::VaultGroupDepthExceeded {
limit: Self::MAX_GROUP_DEPTH,
actual: deepest,
});
}
}
}
Ok(())
}
pub fn set_ordering(&mut self, id: &GroupId, entry_ids: Vec<EntryId>) -> Result<()> {
let group = self.groups.get(id).ok_or(Error::VaultGroupNotFound)?;
let existing = &group.children;
let mut children: Vec<GroupChild> = Vec::with_capacity(existing.len() + entry_ids.len());
let mut inserted = false;
for child in existing {
match child {
GroupChild::Entry(_) => {
if !inserted {
children.extend(
entry_ids
.iter()
.map(|entry_id| GroupChild::Entry(*entry_id)),
);
inserted = true;
}
}
GroupChild::Group(_) => children.push(*child),
}
}
if !inserted {
children.extend(
entry_ids
.iter()
.map(|entry_id| GroupChild::Entry(*entry_id)),
);
}
self.set_children(id, children)
}
pub fn assign_to_group(&mut self, entry: &EntryId, group: &GroupId) -> Result<()> {
if !self.entries.contains_key(entry) {
return Err(Error::VaultEntryNotFound);
}
if !self.groups.contains_key(group) {
return Err(Error::VaultGroupNotFound);
}
if self.group_of(entry) == Some(group) {
return Ok(());
}
let child = GroupChild::Entry(*entry);
self.detach_from_other_groups(&child, group);
self.groups
.get_mut(group)
.expect("checked above")
.children
.push(child);
Ok(())
}
pub fn adopt_unclaimed_entries(&mut self, order: &[EntryId]) {
for entry_id in order {
if self.entries.contains_key(entry_id) && self.group_of(entry_id).is_none() {
self.top_group_mut()
.children
.push(GroupChild::Entry(*entry_id));
}
}
}
fn detach_everywhere(&mut self, entry: &EntryId) {
let child = GroupChild::Entry(*entry);
for group in self.groups.values_mut() {
group.children.retain(|candidate| *candidate != child);
}
}
fn detach_from_other_groups(&mut self, child: &GroupChild, keep: &GroupId) {
for (id, group) in &mut self.groups {
if id == keep {
continue;
}
group.children.retain(|candidate| candidate != child);
}
}
fn top_group_mut(&mut self) -> &mut Group {
self.groups
.entry(GroupId::TOP)
.or_insert_with(Group::top_empty)
}
pub(crate) fn rebuild_tree(&mut self, entry_order: &[EntryId], group_order: &[GroupId]) {
let raw: BTreeMap<GroupId, Vec<GroupChild>> = self
.groups
.iter()
.map(|(id, group)| (*id, group.children.clone()))
.collect();
let has_named = raw.keys().any(|id| !id.is_top());
if self.entries.is_empty() && !has_named && !raw.contains_key(&GroupId::TOP) {
return;
}
self.groups
.entry(GroupId::TOP)
.or_insert_with(Group::top_empty);
let mut placed_groups: Vec<GroupId> = Vec::new();
let mut placed_entries: Vec<EntryId> = Vec::new();
let mut rebuilt: BTreeMap<GroupId, Vec<GroupChild>> = BTreeMap::new();
let place = |vault: &Vault,
root: GroupId,
start_depth: usize,
placed_groups: &mut Vec<GroupId>,
placed_entries: &mut Vec<EntryId>,
rebuilt: &mut BTreeMap<GroupId, Vec<GroupChild>>| {
let mut stack: Vec<(GroupId, usize)> = vec![(root, start_depth)];
while let Some((group_id, depth)) = stack.pop() {
if placed_groups.contains(&group_id) || !raw.contains_key(&group_id) {
continue;
}
placed_groups.push(group_id);
let mut kept: Vec<GroupChild> = Vec::new();
let mut shallow: Vec<(GroupId, usize)> = Vec::new();
for child in raw.get(&group_id).map(Vec::as_slice).unwrap_or_default() {
match child {
GroupChild::Entry(entry_id) => {
if vault.entries.contains_key(entry_id)
&& !placed_entries.contains(entry_id)
{
placed_entries.push(*entry_id);
kept.push(*child);
}
}
GroupChild::Group(child_id) => {
if child_id.is_top()
|| !raw.contains_key(child_id)
|| placed_groups.contains(child_id)
{
continue;
}
if depth + 1 > Vault::MAX_GROUP_DEPTH {
continue;
}
kept.push(*child);
shallow.push((*child_id, depth + 1));
}
}
}
rebuilt.insert(group_id, kept);
for entry in shallow.into_iter().rev() {
stack.push(entry);
}
}
};
if raw.contains_key(&GroupId::TOP) {
place(
self,
GroupId::TOP,
0,
&mut placed_groups,
&mut placed_entries,
&mut rebuilt,
);
}
let mut candidates: Vec<GroupId> = group_order
.iter()
.copied()
.filter(|id| !id.is_top() && raw.contains_key(id) && !placed_groups.contains(id))
.collect();
let mut rest: Vec<GroupId> = raw
.keys()
.copied()
.filter(|id| !id.is_top() && !placed_groups.contains(id) && !candidates.contains(id))
.collect();
rest.sort_unstable();
candidates.extend(rest);
for group_id in candidates {
if placed_groups.contains(&group_id) {
continue;
}
rebuilt
.entry(GroupId::TOP)
.or_default()
.push(GroupChild::Group(group_id));
place(
self,
group_id,
1,
&mut placed_groups,
&mut placed_entries,
&mut rebuilt,
);
}
let drop_unclaimed =
|vault: &Vault,
entries: &[EntryId],
placed_entries: &mut Vec<EntryId>,
rebuilt: &mut BTreeMap<GroupId, Vec<GroupChild>>| {
for entry_id in entries {
if vault.entries.contains_key(entry_id) && !placed_entries.contains(entry_id) {
placed_entries.push(*entry_id);
rebuilt
.entry(GroupId::TOP)
.or_default()
.push(GroupChild::Entry(*entry_id));
}
}
};
drop_unclaimed(self, entry_order, &mut placed_entries, &mut rebuilt);
let remaining: Vec<EntryId> = self.entries.keys().copied().collect();
drop_unclaimed(self, &remaining, &mut placed_entries, &mut rebuilt);
for (id, group) in &mut self.groups {
let children = rebuilt.remove(id).unwrap_or_default();
group.children = children;
}
}
}
#[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
}
#[must_use]
pub fn split_mut(&mut self) -> (&MasterKey, &mut Vault) {
(&self.master, &mut self.vault)
}
pub fn serialize(&self) -> Result<Vec<u8>> {
let total = self
.vault
.entries
.len()
.checked_add(self.vault.tombstones.len())
.and_then(|total| total.checked_add(self.vault.groups.len()))
.ok_or(Error::VaultTooManyRecords)?;
if total > MAX_RECORDS {
return Err(Error::VaultTooManyRecords);
}
#[expect(
clippy::cast_possible_truncation,
reason = "total was already compared against MAX_RECORDS (far below 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.as_bytes(),
&plaintext,
)?;
}
for (id, tombstone) in &self.vault.tombstones {
let plaintext = encode_tombstone(tombstone);
write_record(
&mut out,
&self.master,
FORMAT_VERSION,
RecordKind::Tombstone,
id.as_bytes(),
&plaintext,
)?;
}
for (id, group) in self.vault.groups() {
let plaintext = encode_group(group)?;
write_record(
&mut out,
&self.master,
FORMAT_VERSION,
RecordKind::Group,
id.as_bytes(),
&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();
let mut file_entry_order: Vec<EntryId> = Vec::new();
let mut file_group_order: Vec<(GroupId, Option<u32>)> = Vec::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.as_bytes(), version, &nonce, ciphertext)?;
match kind {
RecordKind::Entry => {
let entry = decode_entry(&plaintext)?;
vault.insert_parsed(id, entry)?;
file_entry_order.push(id);
}
RecordKind::Tombstone => {
let tombstone = decode_tombstone(id, &plaintext)?;
vault.record_tombstone(tombstone);
}
RecordKind::Group => {
let (group, order) = decode_group_with_order(&plaintext, version)?;
let group_id = GroupId::from_bytes(*id.as_bytes());
vault.insert_group_with_id(group_id, group)?;
file_group_order.push((group_id, order));
}
RecordKind::SyncEntry | RecordKind::SyncTombstone | RecordKind::SyncGroup => {
return Err(Error::VaultUnknownRecordKind { value: kind_byte });
}
}
}
if version < 4 {
file_group_order.sort_by_key(|(id, order)| (order.is_none(), order.unwrap_or(0), *id));
}
let group_order: Vec<GroupId> = file_group_order.iter().map(|(id, _)| *id).collect();
vault.rebuild_tree(&file_entry_order, &group_order);
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,
record_id: &[u8; EntryId::LEN],
plaintext: &[u8],
) -> Result<()> {
let (nonce, ciphertext) = crypto::seal(master, kind, record_id, format_version, plaintext)?;
out.push(kind.as_byte());
out.extend_from_slice(record_id);
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::ENTRY_KIND,
&[entry_kind_to_byte(&entry.kind)],
);
match &entry.kind {
EntryKind::Totp { period }
| EntryKind::Motp { period, .. }
| EntryKind::Yandex { period, .. } => {
put_node(&mut out, tag::PERIOD, &period.to_be_bytes());
}
EntryKind::Hotp { counter } => put_node(&mut out, tag::COUNTER, &counter.to_be_bytes()),
EntryKind::Steam => {
put_node(&mut out, tag::PERIOD, &otp::STEAM_PERIOD.to_be_bytes());
}
}
if let Some(pin) = entry.kind.motp_pin() {
put_node(&mut out, tag::MOTP_PIN, pin.expose().as_bytes());
}
if let Some(pin) = entry.kind.yandex_pin() {
put_node(&mut out, tag::YANDEX_PIN, pin.expose().as_bytes());
}
if let Some(icon) = &entry.icon {
if icon.bytes.len() > EntryIcon::MAX_BYTES {
return Err(Error::FieldTooLong {
field: "icon",
limit: EntryIcon::MAX_BYTES,
actual: icon.bytes.len(),
});
}
let mut payload = Vec::with_capacity(1 + icon.bytes.len());
payload.push(icon.mime.to_byte());
payload.extend_from_slice(&icon.bytes);
put_node(&mut out, tag::ICON, &payload);
}
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_group(group: &Group) -> Result<Vec<u8>> {
if group.children.len() > Group::MAX_CHILDREN {
return Err(Error::VaultGroupTooManyChildren {
limit: Group::MAX_CHILDREN,
actual: group.children.len(),
});
}
let mut out = Vec::new();
put_node(&mut out, tag::NAME, group.name.as_bytes());
let per_child = 1 + GroupId::LEN;
let mut children = Vec::with_capacity(group.children.len() * per_child);
for child in &group.children {
match child {
GroupChild::Entry(id) => {
children.push(tag::CHILD_ENTRY);
children.extend_from_slice(id.as_bytes());
}
GroupChild::Group(id) => {
children.push(tag::CHILD_GROUP);
children.extend_from_slice(id.as_bytes());
}
}
}
put_node(&mut out, tag::GROUP_CHILDREN, &children);
Ok(out)
}
pub(crate) fn decode_group(data: &[u8], version: u8) -> Result<Group> {
decode_group_with_order(data, version).map(|(group, _)| group)
}
pub(crate) fn decode_group_with_order(data: &[u8], version: u8) -> Result<(Group, Option<u32>)> {
let mut reader = Reader::new(data);
let mut name = String::new();
let mut group_order = None;
let mut entry_ids = None;
let mut children = None;
let mut seen: u32 = 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: "group node",
limit: Entry::MAX_FIELD_LEN,
actual: len,
});
}
let payload = reader.take(len).ok_or(Error::VaultTruncated)?;
if node_tag <= tag::GROUP_TAG_MAX {
let bit = 1_u32 << node_tag;
if seen & bit != 0 {
return Err(Error::VaultDuplicateField { tag: node_tag });
}
seen |= bit;
}
match node_tag {
tag::NAME => name = utf8(payload)?,
tag::GROUP_ORDER => {
let bytes: [u8; 4] = payload.try_into().map_err(|_| Error::VaultCorrupt)?;
group_order = Some(u32::from_be_bytes(bytes));
}
tag::ENTRY_IDS => {
if !payload.len().is_multiple_of(EntryId::LEN) {
return Err(Error::VaultCorrupt);
}
let mut ids = Vec::with_capacity(payload.len() / EntryId::LEN);
for start in (0..payload.len()).step_by(EntryId::LEN) {
let end = start.checked_add(EntryId::LEN).ok_or(Error::VaultCorrupt)?;
let chunk = payload.get(start..end).ok_or(Error::VaultCorrupt)?;
let bytes: [u8; EntryId::LEN] =
chunk.try_into().map_err(|_| Error::VaultCorrupt)?;
ids.push(GroupChild::Entry(EntryId::from_bytes(bytes)));
}
entry_ids = Some(ids);
}
tag::GROUP_CHILDREN => {
children = Some(decode_children(payload)?);
}
_ => {}
}
}
if version >= 4 {
let children = children.ok_or(Error::VaultCorrupt)?;
return Ok((Group::new(name, children)?, None));
}
let order = group_order.ok_or(Error::VaultCorrupt)?;
let children = entry_ids.ok_or(Error::VaultCorrupt)?;
Ok((Group::new(name, children)?, Some(order)))
}
fn decode_children(payload: &[u8]) -> Result<Vec<GroupChild>> {
const PER_CHILD: usize = 1 + GroupId::LEN;
if !payload.len().is_multiple_of(PER_CHILD) {
return Err(Error::VaultCorrupt);
}
let mut children = Vec::with_capacity(payload.len() / PER_CHILD);
let mut at = 0;
while at < payload.len() {
let chunk = payload.get(at..at + PER_CHILD).ok_or(Error::VaultCorrupt)?;
let id: [u8; GroupId::LEN] = chunk[1..].try_into().map_err(|_| Error::VaultCorrupt)?;
let child = match chunk[0] {
tag::CHILD_ENTRY => GroupChild::Entry(EntryId::from_bytes(id)),
tag::CHILD_GROUP => GroupChild::Group(GroupId::from_bytes(id)),
_ => return Err(Error::VaultCorrupt),
};
children.push(child);
at += PER_CHILD;
}
Ok(children)
}
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
}
#[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>,
kind: Option<u8>,
period: Option<u64>,
counter: Option<u64>,
motp_pin: Option<String>,
yandex_pin: Option<String>,
note: String,
created_at: Option<u64>,
updated_at: Option<u64>,
icon: Option<EntryIcon>,
}
pub(crate) fn decode_entry(data: &[u8]) -> Result<VaultEntry> {
let mut reader = Reader::new(data);
let mut f = Fields::default();
let mut seen: u32 = 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::ENTRY_TAG_MAX {
let bit = 1_u32 << 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::ENTRY_KIND => {
let byte = single_byte(payload)?;
if entry_kind_byte_is_known(byte) {
f.kind = Some(byte);
} else {
return Err(Error::VaultCorrupt);
}
}
tag::PERIOD => f.period = Some(u64_from(payload)?),
tag::COUNTER => f.counter = Some(u64_from(payload)?),
tag::MOTP_PIN => f.motp_pin = Some(utf8(payload)?),
tag::YANDEX_PIN => f.yandex_pin = Some(utf8(payload)?),
tag::ICON => {
let (mime_byte, bytes) = payload.split_first().ok_or(Error::VaultCorrupt)?;
let mime = IconMime::from_byte(*mime_byte).ok_or(Error::VaultCorrupt)?;
f.icon = Some(EntryIcon::new(mime, bytes.to_vec())?);
}
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 kind = assemble_entry_kind(&mut f)?;
let created_at = f.created_at.ok_or(Error::VaultCorrupt)?;
let entry = 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)?,
kind,
f.note,
created_at,
f.updated_at.unwrap_or(created_at),
)?;
Ok(entry.with_icon(f.icon))
}
fn assemble_entry_kind(f: &mut Fields) -> Result<EntryKind> {
let pin_fields_are_empty = f.motp_pin.is_none() && f.yandex_pin.is_none();
let Some(byte) = f.kind else {
if !pin_fields_are_empty {
return Err(Error::VaultCorrupt);
}
return match f.counter {
Some(counter) => Ok(EntryKind::Hotp { counter }),
None => Ok(EntryKind::Totp {
period: f.period.ok_or(Error::VaultCorrupt)?,
}),
};
};
let pin_only_where_meaningful = |pin: &Option<String>| pin.is_some();
match byte {
tag::KIND_TOTP => {
if !pin_fields_are_empty {
return Err(Error::VaultCorrupt);
}
Ok(EntryKind::Totp {
period: f.period.ok_or(Error::VaultCorrupt)?,
})
}
tag::KIND_HOTP => {
if !pin_fields_are_empty {
return Err(Error::VaultCorrupt);
}
Ok(EntryKind::Hotp {
counter: f.counter.ok_or(Error::VaultCorrupt)?,
})
}
tag::KIND_MOTP => {
if pin_only_where_meaningful(&f.yandex_pin) {
return Err(Error::VaultCorrupt);
}
Ok(EntryKind::Motp {
period: f.period.ok_or(Error::VaultCorrupt)?,
pin: f
.motp_pin
.take()
.filter(|text| !text.is_empty())
.map(crate::motp::MotpPin::new),
})
}
tag::KIND_YANDEX => {
if pin_only_where_meaningful(&f.motp_pin) {
return Err(Error::VaultCorrupt);
}
Ok(EntryKind::Yandex {
period: f.period.ok_or(Error::VaultCorrupt)?,
pin: f
.yandex_pin
.take()
.filter(|text| !text.is_empty())
.map(crate::yaotp::YandexPin::new),
})
}
tag::KIND_STEAM => {
if !pin_fields_are_empty {
return Err(Error::VaultCorrupt);
}
Ok(EntryKind::Steam)
}
_ => Err(Error::VaultCorrupt),
}
}
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 {
digits.as_byte()
}
fn digits_from_byte(byte: u8) -> Option<otp::Digits> {
otp::Digits::try_from(byte).ok()
}
const fn entry_kind_to_byte(kind: &EntryKind) -> u8 {
match kind {
EntryKind::Totp { .. } => tag::KIND_TOTP,
EntryKind::Hotp { .. } => tag::KIND_HOTP,
EntryKind::Motp { .. } => tag::KIND_MOTP,
EntryKind::Yandex { .. } => tag::KIND_YANDEX,
EntryKind::Steam => tag::KIND_STEAM,
}
}
const fn entry_kind_byte_is_known(byte: u8) -> bool {
matches!(
byte,
tag::KIND_TOTP | tag::KIND_HOTP | tag::KIND_MOTP | tag::KIND_YANDEX | tag::KIND_STEAM
)
}
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("valid secret")
}
fn sample_entry(name: &str) -> VaultEntry {
Entry::new(
name.into(),
Some("Example".into()),
"alice".into(),
secret(),
otp::Algorithm::Sha256,
otp::Digits::EIGHT,
EntryKind::Totp { period: 30 },
"note".into(),
1_700_000_000,
1_700_000_123,
)
.expect("valid entry")
}
fn sample_hotp_entry() -> VaultEntry {
Entry::new(
"HOTP".into(),
None,
"bob".into(),
secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
EntryKind::Hotp { counter: 42 },
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("valid entry")
}
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.kind, b.kind, "kind");
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 randomness available");
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 randomness available");
let id = file
.vault_mut()
.insert(sample_entry("GitHub"))
.expect("insertion succeeds");
let bytes = file.serialize().expect("serialization succeeds");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
)
.expect("parsing succeeds");
assert_eq!(reopened.vault().len(), 1);
let restored = reopened.vault().get(&id).expect("the entry must exist");
assert_entry_eq(&sample_entry("GitHub"), restored);
}
#[test]
fn roundtrip_preserves_hotp_counter() {
let mut file = VaultFile::create().expect("OS randomness available");
let id = file
.vault_mut()
.insert(sample_hotp_entry())
.expect("insertion succeeds");
let bytes = file.serialize().expect("serialization succeeds");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
)
.expect("parsing succeeds");
let restored = reopened.vault().get(&id).expect("the entry must exist");
assert_eq!(restored.kind, EntryKind::Hotp { counter: 42 });
assert_eq!(
restored.uri_type(),
crate::uri::UriType::Hotp,
"the restored entry is still HOTP"
);
}
#[test]
fn roundtrip_preserves_tombstones() {
let mut file = VaultFile::create().expect("OS randomness available");
let id = file
.vault_mut()
.insert(sample_entry("Doomed"))
.expect("insertion succeeds");
file.vault_mut().remove(&id, 1_800_000_000);
let bytes = file.serialize().expect("serialization succeeds");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
)
.expect("parsing succeeds");
assert!(
reopened.vault().is_empty(),
"the entry must have been deleted"
);
assert_eq!(reopened.vault().tombstone_count(), 1);
let tombstone = reopened
.vault()
.get_tombstone(&id)
.expect("the tombstone must exist");
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("insertion succeeds");
vault.remove(&id, 100);
assert!(vault.remove(&id, 999).is_none());
assert_eq!(
vault
.get_tombstone(&id)
.expect("tombstone exists")
.deleted_at,
100
);
}
#[test]
fn roundtrip_preserves_many_entries() {
let mut file = VaultFile::create().expect("OS randomness available");
let mut ids = Vec::new();
for i in 0..64 {
ids.push(
file.vault_mut()
.insert(sample_entry(&format!("entry-{i}")))
.expect("insertion succeeds"),
);
}
let bytes = file.serialize().expect("serialization succeeds");
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
)
.expect("parsing succeeds");
assert_eq!(reopened.vault().len(), 64);
for id in ids {
assert!(reopened.vault().get(&id).is_some(), "missing entry {id}");
}
}
#[test]
fn serialization_is_deterministic_in_structure() {
let mut file = VaultFile::create().expect("OS randomness available");
for i in 0..16 {
file.vault_mut()
.insert(sample_entry(&format!("e{i}")))
.expect("insertion succeeds");
}
let a = file.serialize().expect("serialization succeeds");
let b = file.serialize().expect("serialization succeeds");
assert_eq!(a.len(), b.len(), "two serializations differ in length");
assert_eq!(a[..9], b[..9], "magic and version must match");
assert_eq!(a[9], b[9], "the reserved byte must match");
let names = |bytes: &[u8]| -> Vec<String> {
let f = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key()))
.expect("correct length"),
bytes,
)
.expect("parsing succeeds");
f.vault().iter().map(|(_, e)| e.name.clone()).collect()
};
assert_eq!(names(&a), names(&b), "the entry order is nondeterministic");
}
#[test]
fn wrong_master_key_cannot_open() {
let mut file = VaultFile::create().expect("OS randomness available");
file.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
let bytes = file.serialize().expect("serialization succeeds");
let other = MasterKey::generate().expect("OS randomness available");
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 randomness available");
file.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
file.vault_mut()
.insert(sample_hotp_entry())
.expect("insertion succeeds");
let bytes = file.serialize().expect("serialization succeeds");
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("correct length"),
&tampered,
);
assert!(
result.is_err(),
"byte {index} was flipped ({:#04x} -> {:#04x}) yet still accepted",
bytes[index],
tampered[index]
);
}
}
#[test]
fn truncation_at_any_point_is_rejected() {
let mut file = VaultFile::create().expect("OS randomness available");
file.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
file.vault_mut()
.insert(sample_hotp_entry())
.expect("insertion succeeds");
let first_id = *file.vault().iter().next().expect("has an entry").0;
file.vault_mut().remove(&first_id, 1);
let bytes = file.serialize().expect("serialization succeeds");
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("correct length"),
&bytes[..cut],
);
assert!(
result.is_err(),
"truncating to {cut} bytes was still accepted"
);
}
}
#[test]
fn trailing_bytes_are_rejected() {
let mut file = VaultFile::create().expect("OS randomness available");
file.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
let mut bytes = file.serialize().expect("serialization succeeds");
bytes.push(0x00);
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
);
assert!(matches!(err, Err(Error::VaultTrailingBytes { count: 1 })));
}
#[test]
fn bad_magic_is_rejected() {
let file = VaultFile::create().expect("OS randomness available");
let mut bytes = file.serialize().expect("serialization succeeds");
bytes[0] = b'X';
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
);
assert!(matches!(err, Err(Error::VaultBadMagic)));
}
#[test]
fn unsupported_version_is_rejected() {
let file = VaultFile::create().expect("OS randomness available");
let mut bytes = file.serialize().expect("serialization succeeds");
bytes[8] = 99;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
);
match err {
Err(Error::VaultUnsupportedVersion { version }) => assert_eq!(version, 99),
other => panic!(
"an unknown version must be rejected, actual ok={}",
other.is_ok()
),
}
}
#[test]
fn nonzero_reserved_byte_is_rejected() {
let file = VaultFile::create().expect("OS randomness available");
let mut bytes = file.serialize().expect("serialization succeeds");
bytes[9] = 1;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
);
assert!(matches!(
err,
Err(Error::VaultReservedByteNotZero { value: 1 })
));
}
#[test]
fn unknown_record_kind_is_rejected() {
let mut file = VaultFile::create().expect("OS randomness available");
file.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
let mut bytes = file.serialize().expect("serialization succeeds");
bytes[14] = 0x7F;
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
);
assert!(matches!(
err,
Err(Error::VaultUnknownRecordKind { value: 0x7F })
));
}
#[test]
fn duplicate_entry_id_is_rejected() {
let mut file = VaultFile::create().expect("OS randomness available");
let id = file
.vault_mut()
.insert(sample_entry("x"))
.expect("insertion succeeds");
let mut vault = Vault::new();
vault
.insert_with_id(id, sample_entry("a"))
.expect("first insertion");
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("entry exists").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("insertion succeeds");
let created = vault.get(&id).expect("entry exists").created_at;
let mut renamed = sample_entry("renamed");
renamed.created_at = created;
vault
.replace(id, renamed)
.expect("replacement must succeed");
let entry = vault
.get(&id)
.expect("the identifier must still point at that entry");
assert_eq!(entry.name, "renamed");
assert_eq!(
entry.created_at, created,
"the creation time must be preserved"
);
assert_eq!(vault.len(), 1, "a replacement must not add an entry");
assert_eq!(
vault.tombstone_count(),
0,
"a rename is not a deletion and must not leave a tombstone: the tombstone gets synced out and the other side may really delete the entry because of it"
);
}
#[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(), "a failure must not conjure up an entry");
}
#[test]
fn empty_vault_roundtrips() {
let file = VaultFile::create().expect("OS randomness available");
let bytes = file.serialize().expect("serialization succeeds");
assert_eq!(
bytes.len(),
14 + crypto::KEY_CHECK_LEN,
"an empty file must be the header plus the master key check value"
);
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length"),
&bytes,
)
.expect("parsing succeeds");
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 randomness available");
assert!(
file.vault().is_empty(),
"precondition: this vault really is empty"
);
let bytes = file.serialize().expect("serialization succeeds");
let wrong = MasterKey::generate().expect("OS randomness available");
assert_ne!(
wrong.expose(),
file.master_key().expose(),
"the two keys must differ"
);
let err = VaultFile::parse(wrong, &bytes);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"an empty vault must be rejected with the wrong key too, actual ok={}",
err.is_ok()
);
}
#[test]
fn version_1_files_are_still_readable() {
let master = MasterKey::generate().expect("OS randomness available");
let id = EntryId::generate().expect("OS randomness available");
let entry = sample_entry("old-format");
let plaintext = encode_entry(&entry).expect("encoding succeeds");
let (nonce, ciphertext) =
crypto::seal(&master, RecordKind::Entry, id.as_bytes(), 1, &plaintext)
.expect("sealing succeeds");
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("the test data is far below u32::MAX"),
);
out.extend_from_slice(&ciphertext);
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("correct length"),
&out,
)
.expect("a version 1 file must still parse");
assert_eq!(reopened.vault().len(), 1, "the entry must be read back");
assert_eq!(
reopened.vault().iter().next().expect("one entry").1.name,
"old-format"
);
let wrong = MasterKey::generate().expect("OS randomness available");
assert!(
VaultFile::parse(wrong, &out).is_err(),
"a version 1 file with content must fail under the wrong key"
);
}
fn entry_with_kind(kind: EntryKind) -> VaultEntry {
Entry::new(
"kind".into(),
Some("Example".into()),
"alice".into(),
secret(),
otp::Algorithm::Sha1,
otp::Digits::SIX,
kind,
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("valid entry")
}
fn version_2_plaintext(with_counter: bool) -> Vec<u8> {
let mut out = Vec::new();
put_node(&mut out, tag::NAME, b"legacy");
put_node(&mut out, tag::ACCOUNT, b"alice");
put_node(&mut out, tag::SECRET, b"JBSWY3DPEHPK3PXP");
put_node(&mut out, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut out, tag::DIGITS, &[otp::Digits::SIX.as_byte()]);
put_node(&mut out, tag::PERIOD, &30_u64.to_be_bytes());
if with_counter {
put_node(&mut out, tag::COUNTER, &7_u64.to_be_bytes());
}
put_node(&mut out, tag::NOTE, b"");
put_node(&mut out, tag::CREATED_AT, &0_u64.to_be_bytes());
put_node(&mut out, tag::UPDATED_AT, &0_u64.to_be_bytes());
out
}
#[test]
fn version_2_files_are_still_readable() {
let master = MasterKey::generate().expect("OS randomness available");
let id = EntryId::generate().expect("OS randomness available");
let plaintext = version_2_plaintext(false);
let (nonce, ciphertext) =
crypto::seal(&master, RecordKind::Entry, id.as_bytes(), 2, &plaintext)
.expect("sealing succeeds");
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(2);
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("the test data is far below u32::MAX"),
);
out.extend_from_slice(&ciphertext);
out.extend_from_slice(&crypto::key_check(&master));
let reopened = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("correct length"),
&out,
)
.expect("a version 2 file must still parse");
assert_eq!(reopened.vault().len(), 1, "the entry must be read back");
let (restored_id, restored) = reopened.vault().iter().next().expect("one entry");
assert_eq!(*restored_id, id, "the identifier survives");
assert_eq!(restored.name, "legacy");
assert_eq!(
restored.kind,
EntryKind::Totp { period: 30 },
"a version 2 plaintext has no kind node, so the old rule decides"
);
let wrong = MasterKey::generate().expect("OS randomness available");
assert!(
VaultFile::parse(wrong, &out).is_err(),
"a version 2 file must fail under the wrong key"
);
}
#[test]
fn a_plaintext_without_a_kind_node_falls_back_to_the_old_rule() {
let totp = decode_entry(&version_2_plaintext(false)).expect("a version 2 plaintext parses");
assert_eq!(totp.kind, EntryKind::Totp { period: 30 });
let hotp = decode_entry(&version_2_plaintext(true)).expect("a version 2 plaintext parses");
assert_eq!(hotp.kind, EntryKind::Hotp { counter: 7 });
}
#[test]
fn every_entry_kind_round_trips() {
let kinds = [
EntryKind::Totp { period: 45 },
EntryKind::Hotp { counter: 9_999 },
EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned())),
},
EntryKind::Motp {
period: 10,
pin: None,
},
EntryKind::Yandex {
period: 30,
pin: Some(crate::yaotp::YandexPin::new("7586".to_owned())),
},
EntryKind::Yandex {
period: 60,
pin: None,
},
EntryKind::Steam,
];
for kind in kinds {
let entry = entry_with_kind(kind);
let encoded = encode_entry(&entry).expect("encoding succeeds");
let decoded = decode_entry(&encoded).expect("the encoded entry must decode");
assert_entry_eq(&entry, &decoded);
}
}
#[test]
fn a_pin_node_on_a_scheme_without_a_pin_is_rejected() {
for kind in [tag::KIND_TOTP, tag::KIND_HOTP, tag::KIND_STEAM] {
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"secret");
put_node(&mut plaintext, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut plaintext, tag::DIGITS, &[otp::Digits::SIX.as_byte()]);
put_node(&mut plaintext, tag::ENTRY_KIND, &[kind]);
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
put_node(&mut plaintext, tag::MOTP_PIN, b"1234");
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
assert!(
matches!(decode_entry(&plaintext), Err(Error::VaultCorrupt)),
"a PIN on a scheme without one must be rejected"
);
}
}
#[test]
fn an_unknown_or_incomplete_kind_is_rejected() {
let base = |kind: Option<u8>, period: bool| {
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"secret");
put_node(&mut plaintext, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut plaintext, tag::DIGITS, &[otp::Digits::SIX.as_byte()]);
if let Some(kind) = kind {
put_node(&mut plaintext, tag::ENTRY_KIND, &[kind]);
}
if period {
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
}
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
plaintext
};
assert!(
matches!(decode_entry(&base(Some(0), true)), Err(Error::VaultCorrupt)),
"kind byte 0 is not a scheme"
);
assert!(
matches!(
decode_entry(&base(Some(tag::KIND_STEAM + 1), true)),
Err(Error::VaultCorrupt)
),
"a kind byte from a newer version must be rejected, not guessed"
);
assert!(
matches!(
decode_entry(&base(Some(tag::KIND_MOTP), false)),
Err(Error::VaultCorrupt)
),
"a kind carrying a step must have one"
);
assert!(
matches!(
decode_entry(&base(Some(tag::KIND_HOTP), true)),
Err(Error::VaultCorrupt)
),
"HOTP needs a counter, not a step"
);
assert!(
decode_entry(&base(Some(tag::KIND_TOTP), true)).is_ok(),
"a complete TOTP entry is accepted"
);
}
#[test]
fn duplicate_nodes_are_rejected_for_the_new_tags_too() {
for duplicated in [tag::ENTRY_KIND, tag::MOTP_PIN, tag::YANDEX_PIN] {
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"secret");
put_node(&mut plaintext, tag::ALGORITHM, &[tag::ALG_SHA1]);
put_node(&mut plaintext, tag::DIGITS, &[otp::Digits::SIX.as_byte()]);
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
for _ in 0..2 {
let payload: &[u8] = match duplicated {
tag::ENTRY_KIND => &[tag::KIND_TOTP],
_ => b"1234",
};
put_node(&mut plaintext, duplicated, payload);
}
match decode_entry(&plaintext) {
Err(Error::VaultDuplicateField { tag }) => assert_eq!(tag, duplicated),
other => panic!(
"tag {duplicated:#04x} appearing twice must be rejected, actual {}",
if other.is_ok() { "ok" } else { "another error" }
),
}
}
}
#[test]
fn every_previous_format_version_stays_readable() {
assert!(READABLE_VERSIONS.contains(&1), "version 1 was dropped");
assert!(READABLE_VERSIONS.contains(&2), "version 2 was dropped");
assert!(
READABLE_VERSIONS.contains(&FORMAT_VERSION),
"the current version is not in the readable list"
);
}
#[test]
fn serialization_always_writes_the_current_version() {
let empty = VaultFile::create().expect("OS randomness available");
let bytes = empty.serialize().expect("serialization succeeds");
assert_eq!(bytes[8], FORMAT_VERSION, "an empty vault");
assert_eq!(bytes[8], 4, "the format version changed");
let mut totp_only = VaultFile::create().expect("OS randomness available");
totp_only
.vault_mut()
.insert(sample_entry("totp"))
.expect("insertion succeeds");
let bytes = totp_only.serialize().expect("serialization succeeds");
assert_eq!(bytes[8], 4, "a vault holding only a TOTP entry");
let mut hotp = VaultFile::create().expect("OS randomness available");
hotp.vault_mut()
.insert(sample_hotp_entry())
.expect("insertion succeeds");
let bytes = hotp.serialize().expect("serialization succeeds");
assert_eq!(bytes[8], 4, "a vault holding only an HOTP entry");
}
#[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 randomness available");
let parsed = VaultFile::parse(wrong, &out);
assert!(
parsed.is_ok(),
"an empty version 1 file has no check value, so any key parses it; that is a known limitation of that format"
);
}
#[test]
fn a_tampered_key_check_is_rejected() {
let file = VaultFile::create().expect("OS randomness available");
let master =
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length");
let bytes = file.serialize().expect("serialization succeeds");
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("correct length"),
&tampered,
);
assert!(
matches!(err, Err(Error::VaultDecryptionFailed)),
"check value byte {index} was flipped yet still accepted"
);
}
}
#[test]
fn a_truncated_key_check_is_a_structural_error() {
let file = VaultFile::create().expect("OS randomness available");
let master =
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length");
let bytes = file.serialize().expect("serialization succeeds");
let mut cut = bytes.clone();
cut.truncate(cut.len() - 3);
let err = VaultFile::parse(
MasterKey::from_bytes(master_key_to_bytes(&master)).expect("correct length"),
&cut,
);
assert!(
matches!(err, Err(Error::VaultTruncated)),
"a truncated check value must report truncation, actual ok={}",
err.is_ok()
);
let err = crypto::verify_key_check(&master, &[0_u8; 8]);
assert!(
matches!(err, Err(Error::VaultBadKeyCheck)),
"a length mismatch must report VaultBadKeyCheck"
);
}
#[test]
fn file_header_layout_is_stable() {
let file = VaultFile::create().expect("OS randomness available");
let bytes = file.serialize().expect("serialization succeeds");
assert_eq!(&bytes[..8], MAGIC);
assert_eq!(bytes[8], FORMAT_VERSION);
assert_eq!(bytes[9], 0, "reserved byte");
assert_eq!(
u32::from_be_bytes(bytes[10..14].try_into().expect("4 bytes")),
0
);
}
#[test]
fn unknown_entry_node_is_skipped() {
let entry = sample_entry("x");
let mut plaintext = encode_entry(&entry).expect("encoding succeeds");
plaintext.push(0xEE);
put_bytes(&mut plaintext, b"future field");
let decoded = decode_entry(&plaintext).expect("unknown nodes must be skipped");
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("encoding succeeds"));
let decoded = decode_entry(&plaintext).expect("must skip 100 bytes");
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, &[otp::Digits::SIX.as_byte()]);
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, &[otp::Digits::SIX.as_byte()]);
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 count in otp::Digits::MIN..=otp::Digits::MAX {
let digits = otp::Digits::new(count).expect("1..=9 is the supported range");
assert_eq!(
digits_from_byte(digits_to_byte(digits)),
Some(digits),
"count {count}"
);
}
assert_eq!(algorithm_from_byte(0), None);
assert_eq!(algorithm_from_byte(99), None);
assert_eq!(digits_from_byte(0), None);
assert_eq!(digits_from_byte(10), 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("encoding succeeds"))
.expect("decoding succeeds");
assert_eq!(decoded.issuer, None);
entry.issuer = Some(String::new());
let decoded = decode_entry(&encode_entry(&entry).expect("encoding succeeds"))
.expect("decoding succeeds");
assert_eq!(
decoded.issuer, None,
"an empty string must normalize to 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("directory creation succeeds");
let path = dir.join("vault.bin");
let data = b"hello atomic world";
write_file_atomically(&path, data).expect("writing succeeds");
assert_eq!(std::fs::read(&path).expect("reading succeeds"), data);
let longer = b"a much longer replacement payload";
write_file_atomically(&path, longer).expect("writing succeeds");
assert_eq!(std::fs::read(&path).expect("reading succeeds"), longer);
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("listing the directory succeeds")
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"leftover temporary files: {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("directory creation succeeds");
let path = dir.join("vault.bin");
let mut file = VaultFile::create().expect("OS randomness available");
let id = file
.vault_mut()
.insert(sample_entry("GitHub"))
.expect("insertion succeeds");
let bytes = file.serialize().expect("serialization succeeds");
write_file_atomically(&path, &bytes).expect("writing succeeds");
let key =
MasterKey::from_bytes(master_key_to_bytes(file.master_key())).expect("correct length");
let reopened = read_file(key, &path).expect("reading back succeeds");
assert_eq!(reopened.vault().len(), 1);
assert_entry_eq(
&sample_entry("GitHub"),
reopened.vault().get(&id).expect("entry exists"),
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reading_missing_file_reports_io_error() {
let key = MasterKey::generate().expect("OS randomness available");
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 randomness available");
assert!(matches!(
VaultFile::parse(key, &[]),
Err(Error::VaultTruncated)
));
}
#[test]
fn absurd_record_count_is_rejected() {
let key = MasterKey::generate().expect("OS randomness available");
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 randomness available");
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!(
"an over-long record must be rejected, actual ok={}",
other.is_ok()
),
}
}
#[test]
fn garbage_with_correct_magic_is_rejected() {
let key = MasterKey::generate().expect("OS randomness available");
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)));
}
fn file_from_records(
master: &MasterKey,
records: &[(RecordKind, [u8; EntryId::LEN], Vec<u8>)],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(FORMAT_VERSION);
out.push(0);
put_u32(
&mut out,
u32::try_from(records.len()).expect("the test writes a handful of records"),
);
for (kind, id, plaintext) in records {
write_record(&mut out, master, FORMAT_VERSION, *kind, id, plaintext)
.expect("sealing succeeds");
}
out.extend_from_slice(&crypto::key_check(master));
out
}
fn copy_key(master: &MasterKey) -> MasterKey {
MasterKey::from_bytes(master_key_to_bytes(master)).expect("the length is correct")
}
fn record_count(filedata: &[u8]) -> u32 {
let bytes: [u8; 4] = filedata[10..14].try_into().expect("the header is 14 bytes");
u32::from_be_bytes(bytes)
}
fn entry_record(
master: &MasterKey,
id: EntryId,
name: &str,
) -> (RecordKind, [u8; 16], Vec<u8>) {
let plaintext = encode_entry(&sample_entry(name)).expect("encoding succeeds");
let _ = master;
(RecordKind::Entry, *id.as_bytes(), plaintext)
}
fn file_from_records_with_version(
master: &MasterKey,
version: u8,
records: &[(RecordKind, [u8; EntryId::LEN], Vec<u8>)],
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.push(version);
out.push(0);
put_u32(
&mut out,
u32::try_from(records.len()).expect("the test writes a handful of records"),
);
for (kind, id, plaintext) in records {
write_record(&mut out, master, version, *kind, id, plaintext)
.expect("sealing succeeds");
}
if version >= 2 {
out.extend_from_slice(&crypto::key_check(master));
}
out
}
fn group_record(id: GroupId, group: &Group) -> (RecordKind, [u8; EntryId::LEN], Vec<u8>) {
(
RecordKind::Group,
*id.as_bytes(),
encode_group(group).expect("encoding succeeds"),
)
}
fn legacy_group_plaintext(name: &str, order: u32, entry_ids: &[EntryId]) -> Vec<u8> {
let mut out = Vec::new();
put_node(&mut out, tag::NAME, name.as_bytes());
put_node(&mut out, tag::GROUP_ORDER, &order.to_be_bytes());
let mut ids = Vec::with_capacity(entry_ids.len() * EntryId::LEN);
for id in entry_ids {
ids.extend_from_slice(id.as_bytes());
}
put_node(&mut out, tag::ENTRY_IDS, &ids);
out
}
fn node_tags(plaintext: &[u8]) -> Vec<u8> {
let mut tags = Vec::new();
let mut at = 0;
while at + 5 <= plaintext.len() {
let node_tag = plaintext[at];
let len = u32::from_be_bytes(
plaintext[at + 1..at + 5]
.try_into()
.expect("four bytes were sliced"),
);
let len = usize::try_from(len).expect("the test writes small nodes");
tags.push(node_tag);
at += 5 + len;
}
tags
}
#[test]
fn every_readable_version_parses_and_version_4_is_written() {
let master = MasterKey::generate().expect("OS randomness available");
let id = EntryId::generate().expect("OS randomness available");
for version in READABLE_VERSIONS {
let records = [(
RecordKind::Entry,
*id.as_bytes(),
encode_entry(&sample_entry("x")).expect("encodes"),
)];
let bytes = file_from_records_with_version(&master, *version, &records);
let parsed = VaultFile::parse(copy_key(&master), &bytes)
.unwrap_or_else(|error| panic!("version {version} must parse: {error}"));
assert!(parsed.vault().get(&id).is_some(), "version {version}");
assert_eq!(*version, bytes[8]);
}
let file = VaultFile::create().expect("OS randomness available");
let written = file.serialize().expect("serialization succeeds");
assert_eq!(written[8], 4, "the current build writes format version 4");
assert_eq!(FORMAT_VERSION, 4);
assert_eq!(READABLE_VERSIONS, &[1, 2, 3, 4]);
}
#[test]
fn a_legacy_file_lies_flat_under_the_top_group_in_its_declared_order() {
let master = MasterKey::generate().expect("OS randomness available");
let first = EntryId::generate().expect("OS randomness available");
let second = EntryId::generate().expect("OS randomness available");
let later = GroupId::generate().expect("OS randomness available");
let earlier = GroupId::generate().expect("OS randomness available");
let bytes = file_from_records_with_version(
&master,
3,
&[
entry_record(&master, first, "first"),
entry_record(&master, second, "second"),
(
RecordKind::Group,
*later.as_bytes(),
legacy_group_plaintext("Later", 1, &[second, first]),
),
(
RecordKind::Group,
*earlier.as_bytes(),
legacy_group_plaintext("Earlier", 0, &[]),
),
],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
let order: Vec<&str> = parsed
.vault()
.groups()
.map(|(_, group)| group.name.as_str())
.collect();
assert_eq!(
order,
vec!["", "Earlier", "Later"],
"the top group comes first, then the named groups by their flat position"
);
assert_eq!(
parsed.vault().ordering(&later),
Some(vec![second, first]),
"the entry list is read in its own order"
);
assert_eq!(parsed.vault().parent_of(&later), Some(GroupId::TOP));
}
#[test]
fn a_version_4_group_record_needs_its_child_list() {
let master = MasterKey::generate().expect("OS randomness available");
let group_id = GroupId::generate().expect("OS randomness available");
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"Work");
let bytes = file_from_records_with_version(
&master,
4,
&[(RecordKind::Group, *group_id.as_bytes(), plaintext)],
);
let err = VaultFile::parse(copy_key(&master), &bytes);
assert!(matches!(err, Err(Error::VaultCorrupt)), "actual {err:?}");
}
#[test]
fn an_unknown_child_kind_is_rejected() {
let master = MasterKey::generate().expect("OS randomness available");
let group_id = GroupId::generate().expect("OS randomness available");
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"Work");
let mut children = Vec::new();
children.push(0x7F_u8);
children.extend_from_slice(&[0x11; GroupId::LEN]);
put_node(&mut plaintext, tag::GROUP_CHILDREN, &children);
let bytes = file_from_records_with_version(
&master,
4,
&[(RecordKind::Group, *group_id.as_bytes(), plaintext)],
);
let err = VaultFile::parse(copy_key(&master), &bytes);
assert!(matches!(err, Err(Error::VaultCorrupt)), "actual {err:?}");
}
#[test]
fn a_version_4_tree_round_trips() {
let mut file = VaultFile::create().expect("OS randomness available");
let a = file
.vault_mut()
.insert(sample_entry("a"))
.expect("insertion");
let b = file
.vault_mut()
.insert(sample_entry("b"))
.expect("insertion");
let c = file
.vault_mut()
.insert(sample_entry("c"))
.expect("insertion");
let work = file
.vault_mut()
.create_group("Work".to_owned())
.expect("creation");
let sub = file
.vault_mut()
.create_group_in(&work, "Sub".to_owned())
.expect("creation");
let empty = file
.vault_mut()
.create_group("Empty".to_owned())
.expect("creation");
file.vault_mut()
.set_children(
&work,
vec![
GroupChild::Entry(b),
GroupChild::Group(sub),
GroupChild::Entry(a),
],
)
.expect("the list is valid");
file.vault_mut()
.assign_to_group(&c, &sub)
.expect("moving succeeds");
let bytes = file.serialize().expect("serialization succeeds");
let parsed = VaultFile::parse(copy_key(file.master_key()), &bytes).expect("it parses back");
assert_eq!(
parsed.vault().children_of(&work),
Some(
[
GroupChild::Entry(b),
GroupChild::Group(sub),
GroupChild::Entry(a)
]
.as_slice()
),
"the mixed order survives the file"
);
assert_eq!(
parsed.vault().ordering(&sub),
Some(vec![c]),
"the nested group keeps its entry"
);
assert_eq!(
parsed.vault().ordering(&empty),
Some(Vec::new()),
"an empty group is a group"
);
assert_eq!(parsed.vault().depth_of(&sub), Some(2));
assert_eq!(parsed.vault().parent_of(&sub), Some(work));
assert_eq!(parsed.vault().parent_of(&work), Some(GroupId::TOP));
assert_eq!(
parsed.vault().group_of(&a),
Some(&work),
"the membership follows the child list"
);
let names: Vec<&str> = parsed
.vault()
.groups()
.map(|(_, group)| group.name.as_str())
.collect();
assert_eq!(
names,
vec!["", "Work", "Sub", "Empty"],
"groups() is a pre-order walk: the top group first, then its children depth-first"
);
let top = parsed.vault().top_group().expect("a top group exists");
assert_eq!(
top.children,
vec![GroupChild::Group(work), GroupChild::Group(empty)],
"c moved into Sub; the two groups stay in the order they were created"
);
}
#[test]
fn a_written_group_holds_one_child_list_and_no_flat_order() {
let group = Group::new(
"Work".to_owned(),
vec![
GroupChild::Entry(EntryId::from_bytes([1; EntryId::LEN])),
GroupChild::Group(GroupId::from_bytes([2; GroupId::LEN])),
],
)
.expect("valid group");
let encoded = encode_group(&group).expect("it encodes");
assert_eq!(
node_tags(&encoded),
vec![tag::NAME, tag::GROUP_CHILDREN],
"the flat position and the entry list belong to older versions"
);
}
#[test]
fn a_file_without_group_records_reads_as_one_top_group_in_file_order() {
let master = MasterKey::generate().expect("OS randomness available");
let first = EntryId::generate().expect("OS randomness available");
let second = EntryId::generate().expect("OS randomness available");
let third = EntryId::generate().expect("OS randomness available");
let bytes = file_from_records(
&master,
&[
entry_record(&master, first, "first"),
entry_record(&master, second, "second"),
entry_record(&master, third, "third"),
],
);
assert_eq!(record_count(&bytes), 3, "three records, no group record");
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(
parsed.vault().group_count(),
1,
"the top group is synthesized"
);
let top = parsed.vault().top_group().expect("a top group exists");
assert_eq!(
top.children,
vec![
GroupChild::Entry(first),
GroupChild::Entry(second),
GroupChild::Entry(third)
],
"the top group is filled from the file order, since no record said otherwise"
);
assert_eq!(
parsed.vault().group_of(&second),
Some(&GroupId::TOP),
"every entry is in the top group"
);
let rewritten = parsed.serialize().expect("it serializes");
assert_eq!(record_count(&rewritten), 4, "three entries and one group");
let reparsed = VaultFile::parse(copy_key(&master), &rewritten).expect("it parses");
let ordering: Vec<EntryId> = reparsed
.vault()
.entries_in(&GroupId::TOP)
.map(|(id, _)| *id)
.collect();
assert_eq!(ordering, vec![first, second, third], "the order survives");
}
#[test]
fn a_dangling_reference_is_skipped() {
let master = MasterKey::generate().expect("OS randomness available");
let present = EntryId::generate().expect("OS randomness available");
let missing = EntryId::generate().expect("OS randomness available");
let group_id = GroupId::generate().expect("OS randomness available");
let group = Group::new(
"Work".to_owned(),
vec![GroupChild::Entry(missing), GroupChild::Entry(present)],
)
.expect("valid group");
let bytes = file_from_records(
&master,
&[
entry_record(&master, present, "present"),
group_record(group_id, &group),
],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(
parsed.vault().ordering(&group_id),
Some(vec![present]),
"the reference to a missing entry is skipped, the rest is kept"
);
}
#[test]
fn the_first_list_wins_a_shared_entry() {
let master = MasterKey::generate().expect("OS randomness available");
let shared = EntryId::generate().expect("OS randomness available");
let first_group = GroupId::generate().expect("OS randomness available");
let second_group = GroupId::generate().expect("OS randomness available");
let first =
Group::new("First".to_owned(), vec![GroupChild::Entry(shared)]).expect("valid group");
let second =
Group::new("Second".to_owned(), vec![GroupChild::Entry(shared)]).expect("valid group");
let bytes = file_from_records(
&master,
&[
group_record(second_group, &second),
entry_record(&master, shared, "shared"),
group_record(first_group, &first),
],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(
parsed.vault().ordering(&second_group),
Some(vec![shared]),
"the second record came first in the file, so its claim stands"
);
assert_eq!(
parsed.vault().ordering(&first_group),
Some(Vec::new()),
"the later list loses the reference"
);
assert_eq!(parsed.vault().group_of(&shared), Some(&second_group));
}
#[test]
fn a_group_without_a_parent_is_attached_to_the_top() {
let master = MasterKey::generate().expect("OS randomness available");
let group_id = GroupId::generate().expect("OS randomness available");
let group = Group::new("Work".to_owned(), Vec::new()).expect("valid group");
let bytes = file_from_records(&master, &[group_record(group_id, &group)]);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(
parsed.vault().top_group().map(|top| top.children.clone()),
Some(vec![GroupChild::Group(group_id)]),
"a group with no parent list hangs from the top"
);
assert_eq!(parsed.vault().parent_of(&group_id), Some(GroupId::TOP));
}
#[test]
fn a_cycle_is_broken_without_dropping_a_group() {
let master = MasterKey::generate().expect("OS randomness available");
let a = GroupId::generate().expect("OS randomness available");
let b = GroupId::generate().expect("OS randomness available");
let group_a = Group::new("A".to_owned(), vec![GroupChild::Group(b)]).expect("valid");
let group_b = Group::new("B".to_owned(), vec![GroupChild::Group(a)]).expect("valid");
let bytes = file_from_records(
&master,
&[group_record(a, &group_a), group_record(b, &group_b)],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(parsed.vault().group_count(), 3, "top and two named groups");
assert!(parsed.vault().depth_of(&a).is_some());
assert!(parsed.vault().depth_of(&b).is_some());
assert!(
parsed.vault().parent_of(&a) == Some(GroupId::TOP)
|| parsed.vault().parent_of(&b) == Some(GroupId::TOP),
"at least one end of the cycle must hang from the top"
);
}
#[test]
fn a_group_nested_too_deeply_is_attached_to_the_top() {
let master = MasterKey::generate().expect("OS randomness available");
let a = GroupId::generate().expect("OS randomness available");
let b = GroupId::generate().expect("OS randomness available");
let c = GroupId::generate().expect("OS randomness available");
let d = GroupId::generate().expect("OS randomness available");
let group_a = Group::new("A".to_owned(), vec![GroupChild::Group(b)]).expect("valid");
let group_b = Group::new("B".to_owned(), vec![GroupChild::Group(c)]).expect("valid");
let group_c = Group::new("C".to_owned(), vec![GroupChild::Group(d)]).expect("valid");
let group_d = Group::new("D".to_owned(), Vec::new()).expect("valid");
let bytes = file_from_records(
&master,
&[
group_record(a, &group_a),
group_record(b, &group_b),
group_record(c, &group_c),
group_record(d, &group_d),
],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(parsed.vault().depth_of(&a), Some(1));
assert_eq!(parsed.vault().depth_of(&b), Some(2));
assert_eq!(parsed.vault().depth_of(&c), Some(3));
assert_eq!(
parsed.vault().depth_of(&d),
Some(1),
"the fourth level is attached to the top, not dropped"
);
}
#[test]
fn an_entry_no_list_names_lands_in_the_top_group() {
let master = MasterKey::generate().expect("OS randomness available");
let listed = EntryId::generate().expect("OS randomness available");
let unlisted = EntryId::generate().expect("OS randomness available");
let group_id = GroupId::generate().expect("OS randomness available");
let group =
Group::new("Work".to_owned(), vec![GroupChild::Entry(listed)]).expect("valid group");
let bytes = file_from_records(
&master,
&[
entry_record(&master, unlisted, "unlisted"),
entry_record(&master, listed, "listed"),
group_record(group_id, &group),
],
);
let parsed = VaultFile::parse(copy_key(&master), &bytes).expect("it parses");
assert_eq!(parsed.vault().ordering(&group_id), Some(vec![listed]));
assert_eq!(
parsed.vault().ordering(&GroupId::TOP),
Some(vec![unlisted]),
"what no list claims belongs to the top group"
);
}
#[test]
fn the_top_group_cannot_be_renamed_or_removed() {
let mut vault = Vault::new();
let id = vault.insert(sample_entry("x")).expect("insertion succeeds");
assert!(matches!(
vault.rename_group(&GroupId::TOP, "Home".to_owned()),
Err(Error::VaultTopGroupImmutable)
));
assert!(matches!(
vault.remove_group(&GroupId::TOP),
Err(Error::VaultTopGroupImmutable)
));
vault
.set_ordering(&GroupId::TOP, vec![id])
.expect("the ordering of the top group is what it is for");
assert_eq!(vault.ordering(&GroupId::TOP), Some(vec![id]));
}
#[test]
fn insertion_and_deletion_keep_the_top_group_consistent() {
let mut vault = Vault::new();
let first = vault.insert(sample_entry("a")).expect("insertion succeeds");
let second = vault.insert(sample_entry("b")).expect("insertion succeeds");
assert_eq!(
vault.ordering(&GroupId::TOP),
Some(vec![first, second]),
"insertion appends"
);
vault.remove(&first, 1_700_000_000);
assert_eq!(
vault.ordering(&GroupId::TOP),
Some(vec![second]),
"a deleted entry must not stay in the ordering"
);
assert!(vault.group_of(&first).is_none());
}
#[test]
fn moving_an_entry_between_groups_leaves_one_reference() {
let mut vault = Vault::new();
let entry = vault.insert(sample_entry("x")).expect("insertion succeeds");
let work = vault
.create_group("Work".to_owned())
.expect("creation succeeds");
let private = vault
.create_group("Private".to_owned())
.expect("creation succeeds");
vault
.assign_to_group(&entry, &work)
.expect("the move succeeds");
assert_eq!(vault.ordering(&work), Some(vec![entry]));
assert_eq!(
vault.ordering(&GroupId::TOP),
Some(Vec::new()),
"it left the top group"
);
vault
.assign_to_group(&entry, &private)
.expect("the second move succeeds");
assert_eq!(vault.ordering(&private), Some(vec![entry]));
assert_eq!(vault.ordering(&work), Some(Vec::new()));
vault
.assign_to_group(&entry, &work)
.expect("moving back succeeds");
assert_eq!(vault.ordering(&work), Some(vec![entry]));
assert_eq!(vault.ordering(&private), Some(Vec::new()));
assert_eq!(vault.group_of(&entry), Some(&work));
}
#[test]
fn setting_an_ordering_validates_its_input() {
let mut vault = Vault::new();
let entry = vault.insert(sample_entry("x")).expect("insertion succeeds");
let group = vault
.create_group("Work".to_owned())
.expect("creation succeeds");
let missing = EntryId::generate().expect("OS randomness available");
assert!(matches!(
vault.set_ordering(&group, vec![missing]),
Err(Error::VaultGroupUnknownEntry)
));
assert!(matches!(
vault.set_ordering(&group, vec![entry, entry]),
Err(Error::VaultGroupDuplicateChild)
));
assert!(matches!(
vault.set_ordering(&missing_group_id(), vec![]),
Err(Error::VaultGroupNotFound)
));
assert!(matches!(
vault.assign_to_group(&missing, &group),
Err(Error::VaultEntryNotFound)
));
assert!(matches!(
vault.assign_to_group(&entry, &missing_group_id()),
Err(Error::VaultGroupNotFound)
));
assert_eq!(
vault.ordering(&group),
Some(Vec::new()),
"a refused call must not have changed anything"
);
}
#[test]
fn setting_children_builds_and_repairs_the_tree() {
let mut vault = Vault::new();
let a = vault.insert(sample_entry("a")).expect("insertion");
let b = vault.insert(sample_entry("b")).expect("insertion");
let work = vault
.create_group("Work".to_owned())
.expect("creation succeeds");
let sub = vault
.create_group_in(&work, "Sub".to_owned())
.expect("creation succeeds");
vault
.set_children(
&work,
vec![
GroupChild::Entry(b),
GroupChild::Group(sub),
GroupChild::Entry(a),
],
)
.expect("the list is valid");
assert_eq!(
vault.children_of(&work),
Some(
[
GroupChild::Entry(b),
GroupChild::Group(sub),
GroupChild::Entry(a)
]
.as_slice()
)
);
assert_eq!(
vault.children_of(&GroupId::TOP),
Some([GroupChild::Group(work)].as_slice()),
"the entries moved out of the top group"
);
assert_eq!(vault.group_of(&b), Some(&work));
vault
.set_children(
&GroupId::TOP,
vec![GroupChild::Group(work), GroupChild::Group(sub)],
)
.expect("the list is valid");
assert_eq!(vault.parent_of(&sub), Some(GroupId::TOP));
assert_eq!(
vault.children_of(&work),
Some([GroupChild::Entry(b), GroupChild::Entry(a)].as_slice()),
"moving the child group out leaves the entries where they were"
);
vault
.set_children(&work, vec![GroupChild::Group(sub)])
.expect("valid again");
let err = vault.set_children(&sub, vec![GroupChild::Group(work)]);
assert!(matches!(err, Err(Error::VaultGroupCycle)), "actual {err:?}");
vault
.set_children(&work, vec![GroupChild::Group(sub)])
.expect("valid");
assert_eq!(vault.group_of(&a), Some(&GroupId::TOP));
assert_eq!(vault.group_of(&b), Some(&GroupId::TOP));
}
#[test]
fn removing_a_group_moves_its_children_to_its_parent() {
let mut vault = Vault::new();
let entry = vault.insert(sample_entry("a")).expect("insertion");
let outer = vault
.create_group("Outer".to_owned())
.expect("creation succeeds");
let inner = vault
.create_group_in(&outer, "Inner".to_owned())
.expect("creation succeeds");
vault
.assign_to_group(&entry, &inner)
.expect("moving succeeds");
let removed = vault
.remove_group(&outer)
.expect("removal succeeds")
.expect("the group existed");
assert_eq!(removed.name, "Outer");
assert_eq!(
vault.parent_of(&inner),
Some(GroupId::TOP),
"the child group moves to where its parent was (the top)"
);
assert_eq!(vault.group_of(&entry), Some(&inner), "the entry stays put");
assert_eq!(
vault.top_group().map(|top| top.children.clone()),
Some(vec![GroupChild::Group(inner)]),
"and takes the removed group's place in the parent list"
);
assert!(
vault
.remove_group(&inner)
.expect("removal succeeds")
.is_some()
);
assert_eq!(
vault.group_of(&entry),
Some(&GroupId::TOP),
"its entry falls back to the top group"
);
assert!(
vault
.remove_group(&inner)
.expect("removal succeeds")
.is_none()
);
}
#[test]
fn creating_a_child_group_respects_the_depth_limit() {
let mut vault = Vault::new();
let first = vault
.create_group("A".to_owned())
.expect("creation succeeds");
let second = vault
.create_group_in(&first, "B".to_owned())
.expect("creation succeeds");
let third = vault
.create_group_in(&second, "C".to_owned())
.expect("creation succeeds");
assert_eq!(vault.depth_of(&third), Some(Vault::MAX_GROUP_DEPTH));
let err = vault.create_group_in(&third, "D".to_owned());
assert!(
matches!(err, Err(Error::VaultGroupDepthExceeded { .. })),
"actual {err:?}"
);
assert!(matches!(
vault.create_group_in(&missing_group_id(), "X".to_owned()),
Err(Error::VaultGroupNotFound)
));
assert_eq!(vault.parent_of(&first), Some(GroupId::TOP));
}
#[test]
fn moving_a_tall_subtree_past_the_limit_is_refused() {
let mut vault = Vault::new();
let deep = vault.create_group("Deep".to_owned()).expect("creation");
let child = vault
.create_group_in(&deep, "Child".to_owned())
.expect("creation");
let middle = vault.create_group("Middle".to_owned()).expect("creation");
let target = vault
.create_group_in(&middle, "Target".to_owned())
.expect("creation");
assert_eq!(vault.depth_of(&target), Some(2));
let err = vault.set_children(&target, vec![GroupChild::Group(deep)]);
assert!(
matches!(err, Err(Error::VaultGroupDepthExceeded { .. })),
"placing a two-level subtree at depth two would reach four, actual {err:?}"
);
let _ = child;
}
#[test]
fn an_icon_survives_the_file_and_takes_part_in_equality() {
let icon = EntryIcon::new(IconMime::Png, vec![0x89, b'P', b'N', b'G']).expect("small icon");
let plain = sample_entry("GitHub");
let with_icon = sample_entry("GitHub").with_icon(Some(icon.clone()));
assert_ne!(
plain, with_icon,
"two entries that differ only in their icon are different entries"
);
assert_eq!(
with_icon.duplicate().expect("it duplicates").icon,
Some(icon.clone())
);
let debug = format!("{icon:?}");
assert!(debug.contains("Png"), "the format is visible: {debug}");
assert!(
!debug.contains("137"),
"the bytes are not printed into diagnostics: {debug}"
);
let mut file = VaultFile::create().expect("OS randomness available");
let id = file.vault_mut().insert(with_icon).expect("insertion");
let bytes = file.serialize().expect("serialization succeeds");
let parsed = VaultFile::parse(copy_key(file.master_key()), &bytes).expect("it parses");
let restored = parsed.vault().get(&id).expect("the entry exists");
assert_eq!(restored.icon, Some(icon));
}
#[test]
fn an_unknown_icon_media_type_is_rejected() {
let master = MasterKey::generate().expect("OS randomness available");
let id = EntryId::generate().expect("OS randomness available");
let mut plaintext = encode_entry(&sample_entry("x")).expect("encodes");
let mut payload = vec![0x7F_u8];
payload.extend_from_slice(&[0xAA; 4]);
put_node(&mut plaintext, tag::ICON, &payload);
let bytes = file_from_records(&master, &[(RecordKind::Entry, *id.as_bytes(), plaintext)]);
let err = VaultFile::parse(copy_key(&master), &bytes);
assert!(matches!(err, Err(Error::VaultCorrupt)), "actual {err:?}");
}
#[test]
fn an_icon_without_a_media_type_is_rejected() {
let master = MasterKey::generate().expect("OS randomness available");
let id = EntryId::generate().expect("OS randomness available");
let mut plaintext = Vec::new();
put_node(&mut plaintext, tag::NAME, b"x");
put_node(&mut plaintext, tag::ISSUER, b"");
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, &[6]);
put_node(&mut plaintext, tag::ENTRY_KIND, &[tag::KIND_TOTP]);
put_node(&mut plaintext, tag::PERIOD, &30_u64.to_be_bytes());
put_node(&mut plaintext, tag::CREATED_AT, &0_u64.to_be_bytes());
put_node(&mut plaintext, tag::ICON, &[]);
let bytes = file_from_records(&master, &[(RecordKind::Entry, *id.as_bytes(), plaintext)]);
let err = VaultFile::parse(copy_key(&master), &bytes);
assert!(matches!(err, Err(Error::VaultCorrupt)), "actual {err:?}");
}
#[test]
fn an_oversized_icon_is_refused_before_it_is_written() {
let mut entry = sample_entry("x");
entry.icon = Some(EntryIcon {
mime: IconMime::Png,
bytes: vec![0_u8; EntryIcon::MAX_BYTES + 1],
});
let err = encode_entry(&entry);
assert!(matches!(
err,
Err(Error::FieldTooLong { field: "icon", .. })
));
}
#[test]
fn the_largest_icon_fits_its_node() {
let icon = EntryIcon::new(IconMime::Jpeg, vec![0x42; EntryIcon::MAX_BYTES])
.expect("the boundary is allowed");
let entry = sample_entry("x").with_icon(Some(icon.clone()));
let encoded = encode_entry(&entry).expect("it encodes");
let decoded = decode_entry(&encoded).expect("it decodes");
assert_eq!(decoded.icon, Some(icon));
}
fn missing_group_id() -> GroupId {
GroupId::from_bytes([0xAB; GroupId::LEN])
}
}