use aes_gcm::aead::{AeadInOut, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use secrecy::zeroize::Zeroize;
use crate::Result;
use crate::export::{Item, algorithm_name, base64, hex, items, named_groups, type_name, uuid_text};
use crate::vault::{EntryKind, Vault};
const FORMAT_VERSION: u64 = 1;
const DATABASE_VERSION: u64 = 3;
const SLOT_PASSWORD: u64 = 1;
const KDF_COST: u64 = 1 << 15;
const KDF_LOG_N: u8 = 15;
const KDF_BLOCK_SIZE: u32 = 8;
const KDF_PARALLELISM: u32 = 1;
const SALT_LEN: usize = 32;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
pub fn write(vault: &Vault) -> Result<String> {
let mut json = Json::new();
json.begin_object();
json.key("version");
json.integer(FORMAT_VERSION);
json.key("header");
json.begin_object();
json.key("slots");
json.null();
json.key("params");
json.null();
json.end_object();
json.key("db");
database_into(&mut json, vault)?;
json.end_object();
Ok(json.into_text())
}
pub fn write_encrypted(vault: &Vault, password: &[u8]) -> Result<String> {
let mut database = Json::new();
database_into(&mut database, vault)?;
let mut salt = [0_u8; SALT_LEN];
let mut master = [0_u8; KEY_LEN];
let mut slot_uuid = [0_u8; 16];
for buffer in [&mut salt[..], &mut master[..], &mut slot_uuid[..]] {
crate::getrandom::fill(buffer).map_err(|_| crate::Error::RandomnessUnavailable)?;
}
let parameters =
scrypt::Params::new(KDF_LOG_N, KDF_BLOCK_SIZE, KDF_PARALLELISM).map_err(|_| {
crate::Error::ExportParameterRefused {
parameter: "scrypt cost, block size and parallelisation",
}
})?;
let mut derived = [0_u8; KEY_LEN];
scrypt::scrypt(password, &salt, ¶meters, &mut derived).map_err(|_| {
crate::Error::ExportParameterRefused {
parameter: "scrypt output length",
}
})?;
let slot = seal(&derived, &master)?;
derived.zeroize();
let sealed = seal(&master, database.into_text().as_bytes())?;
master.zeroize();
let (slot_nonce, slot_tag, slot_key) = slot;
let (db_nonce, db_tag, db_ciphertext) = sealed;
let mut json = Json::new();
json.begin_object();
json.key("version");
json.integer(FORMAT_VERSION);
json.key("header");
json.begin_object();
json.key("slots");
json.begin_array();
json.begin_object();
json.key("type");
json.integer(SLOT_PASSWORD);
json.key("uuid");
json.string(&uuid_text(&slot_uuid));
json.key("key");
json.string(&hex(&slot_key));
json.key("key_params");
json.begin_object();
json.key("nonce");
json.string(&hex(&slot_nonce));
json.key("tag");
json.string(&hex(&slot_tag));
json.end_object();
json.key("n");
json.integer(KDF_COST);
json.key("r");
json.integer(u64::from(KDF_BLOCK_SIZE));
json.key("p");
json.integer(u64::from(KDF_PARALLELISM));
json.key("salt");
json.string(&hex(&salt));
json.key("repaired");
json.boolean(false);
json.key("is_backup");
json.boolean(false);
json.end_object();
json.end_array();
json.key("params");
json.begin_object();
json.key("nonce");
json.string(&hex(&db_nonce));
json.key("tag");
json.string(&hex(&db_tag));
json.end_object();
json.end_object();
json.key("db");
json.string(&base64(&db_ciphertext));
json.end_object();
Ok(json.into_text())
}
fn seal(
key: &[u8; KEY_LEN],
plaintext: &[u8],
) -> Result<([u8; NONCE_LEN], [u8; TAG_LEN], Vec<u8>)> {
let mut nonce = [0_u8; NONCE_LEN];
crate::getrandom::fill(&mut nonce).map_err(|_| crate::Error::RandomnessUnavailable)?;
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
let mut buffer = plaintext.to_vec();
cipher
.encrypt_in_place(&Nonce::from(nonce), &[], &mut buffer)
.map_err(|_| crate::Error::ExportTooLarge)?;
let tag: [u8; TAG_LEN] = buffer
.split_off(buffer.len().saturating_sub(TAG_LEN))
.try_into()
.map_err(|_| crate::Error::ExportParameterRefused {
parameter: "AES-GCM tag length",
})?;
Ok((nonce, tag, buffer))
}
fn database_into(json: &mut Json, vault: &Vault) -> Result<()> {
json.begin_object();
json.key("version");
json.integer(DATABASE_VERSION);
json.key("entries");
json.begin_array();
for item in items(vault) {
entry_into(json, &item)?;
}
json.end_array();
json.key("groups");
json.begin_array();
for (id, group) in named_groups(vault) {
json.begin_object();
json.key("uuid");
json.string(&uuid_text(id.as_bytes()));
json.key("name");
json.string(&group.name);
json.end_object();
}
json.end_array();
json.key("icons_optimized");
json.boolean(false);
json.end_object();
Ok(())
}
fn entry_into(json: &mut Json, item: &Item<'_>) -> Result<()> {
let entry = item.entry;
json.begin_object();
json.key("type");
json.string(type_name(&entry.kind));
json.key("uuid");
json.string(&uuid_text(item.id.as_bytes()));
json.key("name");
json.string(&entry.name);
json.key("issuer");
match entry.issuer.as_deref() {
Some(issuer) => json.string(issuer),
None => json.null(),
}
json.key("note");
json.string(&entry.note);
json.key("favorite");
json.boolean(false);
json.key("icon");
match &entry.icon {
Some(icon) => json.string(&base64(&icon.bytes)),
None => json.null(),
}
if let Some(icon) = &entry.icon {
json.key("icon_mime");
json.string(icon.mime.media_type());
json.key("icon_hash");
json.string(&hex(&icon.hash()));
}
json.key("info");
json.begin_object();
json.key("secret");
json.string(&entry.secret.to_base32());
json.key("algo");
json.string(algorithm_name(&entry.kind, entry.algorithm));
json.key("digits");
json.integer(u64::from(entry.digits.count()));
match &entry.kind {
EntryKind::Totp { period } => {
json.key("period");
json.integer(*period);
}
EntryKind::Hotp { counter } => {
json.key("counter");
json.integer(*counter);
}
EntryKind::Steam => {
json.key("period");
json.integer(crate::otp::STEAM_PERIOD);
}
EntryKind::Motp { period, pin } => {
json.key("period");
json.integer(*period);
if let Some(pin) = pin {
json.key("pin");
json.string(pin.expose());
}
}
EntryKind::Yandex { period, pin } => {
json.key("period");
json.integer(*period);
if let Some(pin) = pin {
json.key("pin");
json.string(pin.expose());
}
}
}
json.end_object();
json.key("groups");
json.begin_array();
if let Some(group) = item.group {
json.string(&uuid_text(group.as_bytes()));
}
json.end_array();
json.end_object();
Ok(())
}
struct Json {
out: String,
open: Vec<usize>,
after_key: bool,
}
impl Json {
fn new() -> Self {
Self {
out: String::new(),
open: Vec::new(),
after_key: false,
}
}
fn into_text(self) -> String {
self.out
}
fn separate(&mut self) {
if self.after_key {
self.after_key = false;
return;
}
if let Some(count) = self.open.last_mut() {
if *count > 0 {
self.out.push(',');
}
*count += 1;
}
}
fn begin_object(&mut self) {
self.separate();
self.out.push('{');
self.open.push(0);
}
fn end_object(&mut self) {
self.open.pop();
self.out.push('}');
}
fn begin_array(&mut self) {
self.separate();
self.out.push('[');
self.open.push(0);
}
fn end_array(&mut self) {
self.open.pop();
self.out.push(']');
}
fn key(&mut self, name: &str) {
if let Some(count) = self.open.last_mut() {
if *count > 0 {
self.out.push(',');
}
*count += 1;
}
self.quote(name);
self.out.push(':');
self.after_key = true;
}
fn string(&mut self, value: &str) {
self.separate();
self.quote(value);
}
fn integer(&mut self, value: u64) {
self.separate();
self.out.push_str(&value.to_string());
}
fn boolean(&mut self, value: bool) {
self.separate();
self.out.push_str(if value { "true" } else { "false" });
}
fn null(&mut self) {
self.separate();
self.out.push_str("null");
}
fn quote(&mut self, value: &str) {
self.out.push('"');
for character in value.chars() {
match character {
'"' => self.out.push_str("\\\""),
'\\' => self.out.push_str("\\\\"),
'\n' => self.out.push_str("\\n"),
'\r' => self.out.push_str("\\r"),
'\t' => self.out.push_str("\\t"),
'\u{08}' => self.out.push_str("\\b"),
'\u{0c}' => self.out.push_str("\\f"),
control if (control as u32) < 0x20 => {
self.out.push_str(&format!("\\u{:04x}", control as u32));
}
other => self.out.push(other),
}
}
self.out.push('"');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::{ImportFormat, import};
use crate::otp::{Algorithm, Digits};
use crate::vault::{Entry, EntryId, GroupId};
fn entry(uri: &str, now: u64) -> Entry {
let parsed = crate::uri::parse(uri).expect("the uri is valid");
crate::vault::entry_from_parsed_uri(parsed, now).expect("the entry is valid")
}
const URIS: [&str; 5] = [
"otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&period=60",
"otpauth://hotp/Bank:bob?secret=JBSWY3DPEHPK3PXP&issuer=Bank&counter=7&digits=8",
"otpauth://steam/alice?secret=JBSWY3DPEHPK3PXP",
"motp:/Example:alice?secret=deadbeef&issuer=Example&pin=GEZDGNBVGY3Q",
"otpauth://yaotp/alice?secret=LA2V6KMCGYMWWVEW64RNP3JA3I&pin=GEZDGNBVGY3Q",
];
fn originals() -> Vec<Entry> {
URIS.iter()
.enumerate()
.map(|(index, uri)| {
let mut built = entry(uri, 1_600_000_000);
built.name = format!("entry {index}");
built
})
.collect()
}
fn vault() -> (Vault, Vec<EntryId>) {
let mut vault = Vault::new();
let ids: Vec<EntryId> = originals()
.into_iter()
.map(|built| vault.insert(built).expect("the vault accepts it"))
.collect();
let work = vault.create_group("Work".to_owned()).expect("a valid name");
let home = vault.create_group("Home".to_owned()).expect("a valid name");
vault
.assign_to_group(&ids[0], &work)
.expect("the entry is there");
vault
.assign_to_group(&ids[1], &work)
.expect("the entry is there");
vault
.assign_to_group(&ids[2], &home)
.expect("the entry is there");
(vault, ids)
}
fn find<'a>(
report: &'a crate::import::ImportReport,
name: &str,
) -> &'a crate::import::ImportedEntry {
report
.entries
.iter()
.find(|entry| entry.entry.name == name)
.unwrap_or_else(|| panic!("the export holds no entry named {name}"))
}
fn read_back(text: &str, password: Option<&[u8]>) -> crate::import::ImportReport {
import(text.as_bytes(), ImportFormat::Json, 1_700_000_000, password)
.unwrap_or_else(|error| panic!("the export must read back: {error}"))
}
#[test]
fn a_plaintext_export_round_trips_through_the_reader() {
let (vault, _) = vault();
let text = write(&vault).expect("the export is written");
let report = read_back(&text, None);
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), URIS.len());
for original in originals() {
let name = &original.name;
let read = &find(&report, name).entry;
assert_eq!(read.issuer, original.issuer, "{name}");
assert_eq!(read.algorithm, original.algorithm, "{name}");
assert_eq!(read.digits, original.digits, "{name}");
assert_eq!(read.kind, original.kind, "{name}");
assert_eq!(
read.secret.to_base32(),
original.secret.to_base32(),
"{name}"
);
assert!(
!find(&report, name).favorite,
"this application has no favourites, so none may be invented"
);
}
}
#[test]
fn the_export_order_is_the_display_order() {
let (vault, _) = vault();
let report = read_back(&write(&vault).expect("the export is written"), None);
let names: Vec<&str> = report
.entries
.iter()
.map(|entry| entry.entry.name.as_str())
.collect();
assert_eq!(
names,
vec!["entry 3", "entry 4", "entry 0", "entry 1", "entry 2"],
"the top group's entries first, then Work, then Home"
);
}
#[test]
fn the_groups_and_their_order_survive_the_round_trip() {
let (vault, _) = vault();
let text = write(&vault).expect("the export is written");
let report = read_back(&text, None);
let names: Vec<&str> = report
.groups
.iter()
.map(|group| group.name.as_str())
.collect();
assert_eq!(names, vec!["Work", "Home"], "the order is the vault's");
assert_eq!(find(&report, "entry 0").groups, vec![report.groups[0].id]);
assert_eq!(find(&report, "entry 1").groups, vec![report.groups[0].id]);
assert_eq!(find(&report, "entry 2").groups, vec![report.groups[1].id]);
assert!(
find(&report, "entry 3").groups.is_empty(),
"an entry in no named group names none"
);
assert!(find(&report, "entry 4").groups.is_empty());
}
#[test]
fn the_document_has_the_shape_of_the_format() {
let (vault, _) = vault();
let text = write(&vault).expect("the export is written");
let root: tinyjson::JsonValue = text.parse().expect("it is valid JSON");
let root = root
.get::<std::collections::HashMap<String, tinyjson::JsonValue>>()
.expect("the document is an object");
assert_eq!(
root.get("version")
.and_then(|value| value.get::<f64>())
.copied(),
Some(1.0)
);
let header = root
.get("header")
.and_then(|value| value.get::<std::collections::HashMap<String, tinyjson::JsonValue>>())
.expect("the header is an object");
assert!(
header
.get("slots")
.is_some_and(tinyjson::JsonValue::is_null),
"a plaintext export has no slots"
);
assert!(
header
.get("params")
.is_some_and(tinyjson::JsonValue::is_null)
);
let db = root
.get("db")
.and_then(|value| value.get::<std::collections::HashMap<String, tinyjson::JsonValue>>())
.expect("a plaintext database is an object");
assert_eq!(
db.get("version")
.and_then(|value| value.get::<f64>())
.copied(),
Some(3.0)
);
assert_eq!(
db.get("entries")
.and_then(|value| value.get::<Vec<tinyjson::JsonValue>>())
.map(Vec::len),
Some(URIS.len())
);
assert_eq!(
db.get("icons_optimized")
.and_then(|value| value.get::<bool>())
.copied(),
Some(false)
);
let first = db
.get("entries")
.and_then(|value| value.get::<Vec<tinyjson::JsonValue>>())
.and_then(|entries| entries.first())
.and_then(|value| value.get::<std::collections::HashMap<String, tinyjson::JsonValue>>())
.expect("an entry is an object");
let types: Vec<&str> = db
.get("entries")
.and_then(|value| value.get::<Vec<tinyjson::JsonValue>>())
.expect("the entries are an array")
.iter()
.filter_map(|value| {
value
.get::<std::collections::HashMap<String, tinyjson::JsonValue>>()?
.get("type")?
.get::<String>()
.map(String::as_str)
})
.collect();
assert_eq!(
types.len(),
URIS.len(),
"every scheme is written: {types:?}"
);
for scheme in ["totp", "hotp", "steam", "motp", "yandex"] {
assert!(types.contains(&scheme), "{scheme} is missing: {types:?}");
}
assert!(
first.get("icon").is_some_and(tinyjson::JsonValue::is_null),
"the icon field is written, and it is null"
);
let uuid = first
.get("uuid")
.and_then(|value| value.get::<String>())
.expect("every entry carries a UUID");
assert_eq!(
uuid.len(),
36,
"the UUID is written in canonical form: {uuid}"
);
}
#[test]
fn an_encrypted_export_round_trips_through_the_reader() {
let (vault, _) = vault();
let text = write_encrypted(&vault, b"correct horse").expect("the export is written");
let report = read_back(&text, Some(b"correct horse"));
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), URIS.len());
for original in originals() {
let name = &original.name;
let read = &find(&report, name).entry;
assert_eq!(read.kind, original.kind, "{name}");
assert_eq!(
read.secret.to_base32(),
original.secret.to_base32(),
"{name}"
);
}
assert!(
!text.contains("JBSWY3DPEHPK3PXP"),
"the secret must not survive in the file as it stands"
);
assert!(text.contains("\"db\":\""), "the database is a string");
let wrong = import(
text.as_bytes(),
ImportFormat::Json,
0,
Some(b"correct horsf"),
);
assert!(
matches!(wrong, Err(crate::Error::ImportWrongPassword)),
"actual {wrong:?}"
);
}
#[test]
fn the_kdf_parameters_are_valid() {
let parameters = scrypt::Params::new(KDF_LOG_N, KDF_BLOCK_SIZE, KDF_PARALLELISM)
.expect("the format's own parameters must be valid");
assert_eq!(u32::from(parameters.log_n()), 15);
assert_eq!(parameters.r(), 8);
assert_eq!(parameters.p(), 1);
assert_eq!(
KDF_COST,
1 << KDF_LOG_N,
"the cost is written as iterations"
);
}
#[test]
fn a_note_is_escaped_rather_than_written_as_it_stands() {
let mut vault = Vault::new();
let mut built = entry(URIS[0], 1_600_000_000);
built.note = "a \"quoted\" \\ line\nand a tab\there \u{1}".to_owned();
built.name = "名字".to_owned();
vault.insert(built).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(
text.parse::<tinyjson::JsonValue>().is_ok(),
"the escaping must produce valid JSON: {text}"
);
let report = read_back(&text, None);
assert_eq!(
report.entries[0].entry.note, "a \"quoted\" \\ line\nand a tab\there \u{1}",
"the note must come back exactly"
);
assert_eq!(report.entries[0].entry.name, "名字");
}
#[test]
fn a_large_counter_is_written_exactly() {
let counter = (1_u64 << 53) + 7;
let mut vault = Vault::new();
let mut built = entry(URIS[1], 1_600_000_000);
built.kind = EntryKind::Hotp { counter };
vault.insert(built).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(
text.contains(&format!("\"counter\":{counter}")),
"the counter must appear as it is: {text}"
);
}
#[test]
fn a_scheme_writes_the_algorithm_it_is_defined_with() {
let mut vault = Vault::new();
let mut motp = entry(URIS[3], 1_600_000_000);
motp.algorithm = Algorithm::Sha512;
motp.digits = Digits::EIGHT;
vault.insert(motp).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(text.contains("\"algo\":\"MD5\""), "mOTP is MD5: {text}");
let report = read_back(&text, None);
assert_eq!(report.entries[0].entry.kind, entry(URIS[3], 0).kind);
}
#[test]
fn an_encrypted_export_refuses_a_missing_or_empty_password() {
let file = crate::vault::VaultFile::create().expect("OS randomness available");
let missing =
crate::export::export(&file, crate::export::ExportFormat::EncryptedJson, None);
assert!(
matches!(missing, Err(crate::Error::ExportPasswordRequired)),
"actual {missing:?}"
);
let empty =
crate::export::export(&file, crate::export::ExportFormat::EncryptedJson, Some(b""));
assert!(
matches!(empty, Err(crate::Error::ExportPasswordEmpty)),
"actual {empty:?}"
);
}
#[test]
fn an_entry_in_the_top_group_names_no_group() {
let mut vault = Vault::new();
vault
.insert(entry(URIS[2], 1_600_000_000))
.expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(text.contains("\"groups\":[]"), "actual {text}");
assert!(
text.contains("\"groups\":[]"),
"the group list is empty too"
);
let read = read_back(&text, None);
assert!(read.groups.is_empty());
assert!(read.entries[0].groups.is_empty());
assert!(!text.contains(&uuid_text(GroupId::TOP.as_bytes())));
}
#[test]
fn an_icon_round_trips_through_the_export() {
let icon =
crate::vault::EntryIcon::new(crate::vault::IconMime::Png, vec![0x89, b'P', b'N', b'G'])
.expect("small icon");
let mut vault = Vault::new();
vault
.insert(entry(URIS[0], 1_600_000_000).with_icon(Some(icon.clone())))
.expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(
text.contains("\"icon_mime\":\"image/png\""),
"the media type is written: {text}"
);
assert!(
text.contains(&format!("\"icon_hash\":\"{}\"", hex(&icon.hash()))),
"the hash is computed from what is stored: {text}"
);
assert!(
text.contains("\"icon\":\"iVBORw==\""),
"the bytes are the original file, base64: {text}"
);
let report = read_back(&text, None);
assert_eq!(
report.entries[0].entry.icon,
Some(icon),
"the picture survives the round trip"
);
assert_eq!(report.dropped.icons, 0);
}
#[test]
fn an_entry_without_an_icon_writes_null_and_nothing_else() {
let mut vault = Vault::new();
vault
.insert(entry(URIS[0], 1_600_000_000))
.expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(text.contains("\"icon\":null"), "actual {text}");
assert!(!text.contains("icon_mime"), "actual {text}");
assert!(!text.contains("icon_hash"), "actual {text}");
}
}