pub mod html;
pub mod json;
pub(crate) mod png;
pub mod text;
use std::collections::HashSet;
use crate::vault::{Entry, EntryId, Group, GroupId, Vault, VaultFile};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Vault,
EncryptedVault,
Text,
Json,
EncryptedJson,
Html,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupCarrying {
None,
FlatGroups,
FullTree,
}
impl GroupCarrying {
#[must_use]
pub const fn carries_anything(self) -> bool {
!matches!(self, Self::None)
}
#[must_use]
pub const fn carries_nesting(self) -> bool {
matches!(self, Self::FullTree)
}
}
impl ExportFormat {
pub const ALL: [Self; 6] = [
Self::Vault,
Self::EncryptedVault,
Self::Text,
Self::Json,
Self::EncryptedJson,
Self::Html,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Vault => "vault",
Self::EncryptedVault => "encrypted vault",
Self::Text => "text",
Self::Json => "JSON",
Self::EncryptedJson => "encrypted JSON",
Self::Html => "HTML",
}
}
#[must_use]
pub const fn extension(self) -> &'static str {
match self {
Self::Vault | Self::EncryptedVault => "q2fa",
Self::Text => "txt",
Self::Json | Self::EncryptedJson => "json",
Self::Html => "html",
}
}
#[must_use]
pub const fn needs_password(self) -> bool {
matches!(self, Self::EncryptedVault | Self::EncryptedJson)
}
#[must_use]
pub const fn group_carrying(self) -> GroupCarrying {
match self {
Self::Vault | Self::EncryptedVault => GroupCarrying::FullTree,
Self::Json | Self::EncryptedJson => GroupCarrying::FlatGroups,
Self::Text | Self::Html => GroupCarrying::None,
}
}
#[must_use]
pub const fn carries_groups(self) -> bool {
self.group_carrying().carries_anything()
}
}
pub fn export(file: &VaultFile, format: ExportFormat, password: Option<&[u8]>) -> Result<Vec<u8>> {
let vault = file.vault();
let bytes = match format {
ExportFormat::Vault => {
portable_bytes(file)?
}
ExportFormat::EncryptedVault => {
let password = password.ok_or(Error::ExportPasswordRequired)?;
if password.is_empty() {
return Err(Error::ExportPasswordEmpty);
}
let passphrase =
core::str::from_utf8(password).map_err(|_| Error::ExportParameterRefused {
parameter: "the passphrase is not UTF-8",
})?;
let recovery = crate::recovery::export(
file.master_key(),
&crate::SecretString::from(passphrase.to_owned()),
&crate::recovery::KdfParams::recommended(),
)?;
let data = file.serialize()?;
crate::portable::pack_encrypted(&recovery, &data)?
}
ExportFormat::Text => text::write(vault)?.into_bytes(),
ExportFormat::Json => json::write(vault)?.into_bytes(),
ExportFormat::Html => html::write(vault)?.into_bytes(),
ExportFormat::EncryptedJson => {
let password = password.ok_or(Error::ExportPasswordRequired)?;
if password.is_empty() {
return Err(Error::ExportPasswordEmpty);
}
json::write_encrypted(vault, password)?.into_bytes()
}
};
Ok(bytes)
}
fn portable_bytes(file: &VaultFile) -> Result<Vec<u8>> {
let data = file.serialize()?;
crate::portable::pack_plain(file.master_key(), &data)
}
pub(crate) struct Item<'a> {
pub(crate) id: EntryId,
pub(crate) entry: &'a Entry,
pub(crate) group: Option<GroupId>,
pub(crate) position: usize,
}
pub(crate) fn items(vault: &Vault) -> Vec<Item<'_>> {
let mut out: Vec<Item<'_>> = Vec::with_capacity(vault.len());
let mut seen: HashSet<EntryId> = HashSet::with_capacity(vault.len());
for (group_id, _) in vault.groups() {
let named = (!group_id.is_top()).then_some(*group_id);
for (id, entry) in vault.entries_in(group_id) {
if !seen.insert(*id) {
continue;
}
out.push(Item {
id: *id,
entry,
group: named,
position: out.len() + 1,
});
}
}
for (id, entry) in vault.iter() {
if !seen.insert(*id) {
continue;
}
out.push(Item {
id: *id,
entry,
group: None,
position: out.len() + 1,
});
}
out
}
pub(crate) fn named_groups(vault: &Vault) -> Vec<(GroupId, &Group)> {
vault
.groups()
.filter(|(id, _)| !id.is_top())
.map(|(id, group)| (*id, group))
.collect()
}
pub(crate) fn uri_of(entry: &Entry) -> Result<String> {
let Entry {
name,
issuer,
account,
secret,
algorithm,
digits,
kind,
..
} = entry.duplicate()?;
let label = if account.is_empty() { name } else { account };
crate::uri::serialize(&crate::uri::OtpAuth {
kind,
secret,
issuer,
account: label,
algorithm,
digits,
})
}
pub(crate) const fn type_name(kind: &crate::vault::EntryKind) -> &'static str {
use crate::vault::EntryKind;
match kind {
EntryKind::Totp { .. } => "totp",
EntryKind::Hotp { .. } => "hotp",
EntryKind::Steam => "steam",
EntryKind::Motp { .. } => "motp",
EntryKind::Yandex { .. } => "yandex",
}
}
pub(crate) const fn algorithm_name(
kind: &crate::vault::EntryKind,
algorithm: crate::otp::Algorithm,
) -> &'static str {
use crate::otp::Algorithm;
use crate::vault::EntryKind;
match kind {
EntryKind::Motp { .. } => "MD5",
EntryKind::Yandex { .. } => "SHA256",
EntryKind::Steam => "SHA1",
EntryKind::Totp { .. } | EntryKind::Hotp { .. } => match algorithm {
Algorithm::Sha1 => "SHA1",
Algorithm::Sha256 => "SHA256",
Algorithm::Sha512 => "SHA512",
},
}
}
pub(crate) fn uuid_text(bytes: &[u8; 16]) -> String {
let mut out = String::with_capacity(36);
for (index, byte) in bytes.iter().enumerate() {
if matches!(index, 4 | 6 | 8 | 10) {
out.push('-');
}
out.push_str(&format!("{byte:02x}"));
}
out
}
pub(crate) fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub(crate) fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let mut block = [0_u8; 3];
block[..chunk.len()].copy_from_slice(chunk);
let bits = (u32::from(block[0]) << 16) | (u32::from(block[1]) << 8) | u32::from(block[2]);
let letters = [
ALPHABET[((bits >> 18) & 0x3F) as usize],
ALPHABET[((bits >> 12) & 0x3F) as usize],
ALPHABET[((bits >> 6) & 0x3F) as usize],
ALPHABET[(bits & 0x3F) as usize],
];
let keep = chunk.len() + 1;
for (index, letter) in letters.iter().enumerate() {
out.push(if index < keep {
char::from(*letter)
} else {
'='
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_identifier_is_written_as_a_canonical_uuid() {
let mut bytes = [0_u8; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::try_from(index).expect("the index is small");
}
let text = uuid_text(&bytes);
assert_eq!(text, "00010203-0405-0607-0809-0a0b0c0d0e0f");
assert_eq!(text.len(), 36);
}
#[test]
fn base64_is_the_standard_alphabet_with_padding() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"A"), "QQ==");
assert_eq!(base64(b"AB"), "QUI=");
assert_eq!(base64(b"ABC"), "QUJD");
assert_eq!(base64(&[0xFB, 0xFF]), "+/8=");
}
#[test]
fn hexadecimal_is_lower_case_and_two_digits_per_byte() {
assert_eq!(hex(&[0x00, 0x0F, 0xFF]), "000fff");
assert_eq!(hex(&[]), "");
}
#[test]
fn the_formats_describe_themselves() {
let formats = ExportFormat::ALL;
let names: Vec<&str> = formats.iter().map(|format| format.name()).collect();
assert_eq!(
names,
vec![
"vault",
"encrypted vault",
"text",
"JSON",
"encrypted JSON",
"HTML"
],
"the order is the interface's fixed order"
);
let extensions: Vec<&str> = formats.iter().map(|format| format.extension()).collect();
assert_eq!(
extensions,
vec!["q2fa", "q2fa", "txt", "json", "json", "html"]
);
let encrypted: Vec<bool> = formats
.iter()
.map(|format| format.needs_password())
.collect();
assert_eq!(
encrypted,
vec![false, true, false, false, true, false],
"exactly the two encrypted forms ask for a password"
);
assert_eq!(
ExportFormat::Vault.group_carrying(),
GroupCarrying::FullTree
);
assert_eq!(
ExportFormat::EncryptedVault.group_carrying(),
GroupCarrying::FullTree
);
assert_eq!(
ExportFormat::Json.group_carrying(),
GroupCarrying::FlatGroups
);
assert_eq!(
ExportFormat::EncryptedJson.group_carrying(),
GroupCarrying::FlatGroups,
"the JSON forms keep the group names and memberships, not the nesting"
);
assert!(!ExportFormat::Json.group_carrying().carries_nesting());
assert_eq!(ExportFormat::Text.group_carrying(), GroupCarrying::None);
assert_eq!(
ExportFormat::Html.group_carrying(),
GroupCarrying::None,
"a list of URIs and a table have nowhere to put a group"
);
}
#[test]
fn every_format_writes_an_empty_vault() {
let file = VaultFile::create().expect("OS randomness available");
for format in ExportFormat::ALL {
let password = format.needs_password().then_some(&b"password"[..]);
let written = export(&file, format, password)
.unwrap_or_else(|error| panic!("{} must write: {error}", format.name()));
if format == ExportFormat::Text {
assert!(written.is_empty(), "there are no entries to write");
} else {
assert!(
!written.is_empty(),
"{} must still produce a container",
format.name()
);
}
}
}
#[test]
fn the_portable_formats_carry_the_key_and_the_vault() {
let mut file = VaultFile::create().expect("OS randomness available");
file.vault_mut()
.insert(
crate::vault::Entry::new(
"GitHub".into(),
Some("GitHub".into()),
"alice".into(),
crate::otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid secret"),
crate::otp::Algorithm::Sha1,
crate::otp::Digits::SIX,
crate::vault::EntryKind::Totp { period: 30 },
String::new(),
1_700_000_000,
1_700_000_000,
)
.expect("valid entry"),
)
.expect("insertion succeeds");
let plain =
export(&file, ExportFormat::Vault, None).expect("the plain form needs no password");
assert_eq!(&plain[..8], crate::portable::PLAIN_MAGIC);
let parsed = crate::portable::parse(&plain).expect("it parses");
assert_eq!(parsed.kind(), crate::portable::PortableKind::PlainVault);
let vault_bytes = parsed.vault().expect("it carries the vault");
let reopened = VaultFile::parse(
crate::vault::MasterKey::from_bytes(crate::vault::master_key_to_bytes(
file.master_key(),
))
.expect("the length is correct"),
vault_bytes,
)
.expect("the vault opens with the key the container carried");
assert_eq!(reopened.vault().len(), 1);
let encrypted = export(&file, ExportFormat::EncryptedVault, Some(b"password"))
.expect("the encrypted form takes a password");
assert_eq!(&encrypted[..8], crate::portable::ENCRYPTED_MAGIC);
let parsed = crate::portable::parse(&encrypted).expect("it parses");
assert_eq!(parsed.kind(), crate::portable::PortableKind::EncryptedVault);
assert!(parsed.vault().is_some());
assert!(matches!(
export(&file, ExportFormat::EncryptedVault, None),
Err(Error::ExportPasswordRequired)
));
assert!(matches!(
export(&file, ExportFormat::EncryptedVault, Some(b"")),
Err(Error::ExportPasswordEmpty)
));
}
}