use crate::sync::{FORMAT_VERSION, InnerRecordKind, Record};
use crate::vault::crypto;
use crate::vault::{Entry as VaultEntry, EntryId, Group, GroupId, Tombstone};
use crate::{Error, Result};
const ENTRY_HEADER_LEN: usize = 4 + EntryId::LEN + 4;
const ENTRY_MAGIC: &[u8; 4] = b"Q2FE";
const GROUP_MAGIC: &[u8; 4] = b"Q2FG";
#[derive(Debug, PartialEq, Eq)]
pub enum OpenedRecord {
Entry {
id: EntryId,
entry: Box<VaultEntry>,
},
Tombstone(Box<Tombstone>),
Group {
id: GroupId,
group: Box<Group>,
},
}
impl OpenedRecord {
#[must_use]
pub fn entry_id(&self) -> EntryId {
match self {
Self::Entry { id, .. } => *id,
Self::Tombstone(tombstone) => tombstone.entry_id,
Self::Group { id, .. } => EntryId::from_bytes(*id.as_bytes()),
}
}
#[must_use]
pub fn group_id(&self) -> Option<GroupId> {
match self {
Self::Group { id, .. } => Some(*id),
_ => None,
}
}
#[must_use]
pub fn into_merged(self) -> crate::sync::MergedRecord {
match self {
Self::Entry { id, entry } => crate::sync::MergedRecord::Entry { id, entry },
Self::Tombstone(tombstone) => crate::sync::MergedRecord::Tombstone(tombstone),
Self::Group { id, group } => crate::sync::MergedRecord::Group { id, group },
}
}
}
pub fn seal_entry(key: &[u8; 32], id: &EntryId, entry: &VaultEntry) -> Result<Record> {
let body = crate::vault::encode_entry(entry)?;
let mut plaintext = Vec::with_capacity(ENTRY_HEADER_LEN + body.len());
plaintext.extend_from_slice(ENTRY_MAGIC);
plaintext.extend_from_slice(id.as_bytes());
let len = u32::try_from(body.len()).map_err(|_| Error::SyncMalformed {
reason: "a single entry encodes to more than 4294967295 bytes",
})?;
plaintext.extend_from_slice(&len.to_be_bytes());
plaintext.extend_from_slice(&body);
seal_plaintext(key, InnerRecordKind::SyncEntry, id, &plaintext)
}
pub fn seal_tombstone(key: &[u8; 32], tombstone: &Tombstone) -> Result<Record> {
let plaintext = crate::vault::encode_tombstone(tombstone);
seal_plaintext(
key,
InnerRecordKind::SyncTombstone,
&tombstone.entry_id,
&plaintext,
)
}
pub fn seal_group(key: &[u8; 32], id: &GroupId, group: &Group) -> Result<Record> {
let body = crate::vault::encode_group(group)?;
let mut plaintext = Vec::with_capacity(ENTRY_HEADER_LEN + body.len());
plaintext.extend_from_slice(GROUP_MAGIC);
plaintext.extend_from_slice(id.as_bytes());
let len = u32::try_from(body.len()).map_err(|_| Error::SyncMalformed {
reason: "a single group encodes to more than 4294967295 bytes",
})?;
plaintext.extend_from_slice(&len.to_be_bytes());
plaintext.extend_from_slice(&body);
let record_id = EntryId::from_bytes(*id.as_bytes());
seal_plaintext(key, InnerRecordKind::SyncGroup, &record_id, &plaintext)
}
fn seal_plaintext(
key: &[u8; 32],
kind: InnerRecordKind,
id: &EntryId,
plaintext: &[u8],
) -> Result<Record> {
let (nonce, ciphertext) =
crypto::seal_with_key(key, kind, id.as_bytes(), FORMAT_VERSION, plaintext)?;
Ok(Record {
entry_id: *id,
nonce,
ciphertext,
})
}
pub fn open(key: &[u8; 32], record: &Record) -> Result<OpenedRecord> {
for kind in [
InnerRecordKind::SyncEntry,
InnerRecordKind::SyncTombstone,
InnerRecordKind::SyncGroup,
] {
if let Ok(opened) = try_open_as(key, kind, record) {
return Ok(opened);
}
}
Err(Error::SyncAuthenticationFailed)
}
fn try_open_as(key: &[u8; 32], kind: InnerRecordKind, record: &Record) -> Result<OpenedRecord> {
let plaintext = crypto::open_with_key(
key,
kind,
record.entry_id.as_bytes(),
FORMAT_VERSION,
&record.nonce,
&record.ciphertext,
)?;
match kind {
InnerRecordKind::SyncEntry => open_entry(&record.entry_id, &plaintext),
InnerRecordKind::SyncTombstone => open_tombstone(&record.entry_id, &plaintext),
InnerRecordKind::SyncGroup => open_group(&record.entry_id, &plaintext),
_ => Err(Error::SyncMalformed {
reason: "the decoding path received a non-sync record kind",
}),
}
}
fn open_entry(id: &EntryId, plaintext: &[u8]) -> Result<OpenedRecord> {
if plaintext.len() < ENTRY_HEADER_LEN {
return Err(Error::SyncTruncated);
}
if &plaintext[..4] != ENTRY_MAGIC {
return Err(Error::SyncMalformed {
reason: "the entry plaintext is missing its magic",
});
}
let embedded: [u8; EntryId::LEN] = plaintext[4..4 + EntryId::LEN]
.try_into()
.map_err(|_| Error::SyncTruncated)?;
if &embedded != id.as_bytes() {
return Err(Error::SyncMalformed {
reason: "the identifier in the entry plaintext differs from the record identifier",
});
}
let len_bytes: [u8; 4] = plaintext[4 + EntryId::LEN..ENTRY_HEADER_LEN]
.try_into()
.map_err(|_| Error::SyncTruncated)?;
let declared =
usize::try_from(u32::from_be_bytes(len_bytes)).map_err(|_| Error::SyncTruncated)?;
let body = plaintext
.get(ENTRY_HEADER_LEN..ENTRY_HEADER_LEN + declared)
.ok_or(Error::SyncTruncated)?;
if plaintext.len() != ENTRY_HEADER_LEN + declared {
return Err(Error::SyncMalformed {
reason: "the entry plaintext holds content beyond its declared length",
});
}
Ok(OpenedRecord::Entry {
id: *id,
entry: Box::new(crate::vault::decode_entry(body)?),
})
}
fn open_tombstone(id: &EntryId, plaintext: &[u8]) -> Result<OpenedRecord> {
let tombstone = crate::vault::decode_tombstone(*id, plaintext)?;
Ok(OpenedRecord::Tombstone(Box::new(tombstone)))
}
fn open_group(id: &EntryId, plaintext: &[u8]) -> Result<OpenedRecord> {
if plaintext.len() < ENTRY_HEADER_LEN {
return Err(Error::SyncTruncated);
}
if &plaintext[..4] != GROUP_MAGIC {
return Err(Error::SyncMalformed {
reason: "the group plaintext is missing its magic",
});
}
let embedded: [u8; GroupId::LEN] = plaintext[4..4 + GroupId::LEN]
.try_into()
.map_err(|_| Error::SyncTruncated)?;
if &embedded != id.as_bytes() {
return Err(Error::SyncMalformed {
reason: "the identifier in the group plaintext differs from the record identifier",
});
}
let len_bytes: [u8; 4] = plaintext[4 + GroupId::LEN..ENTRY_HEADER_LEN]
.try_into()
.map_err(|_| Error::SyncTruncated)?;
let declared =
usize::try_from(u32::from_be_bytes(len_bytes)).map_err(|_| Error::SyncTruncated)?;
let body = plaintext
.get(ENTRY_HEADER_LEN..ENTRY_HEADER_LEN + declared)
.ok_or(Error::SyncTruncated)?;
if plaintext.len() != ENTRY_HEADER_LEN + declared {
return Err(Error::SyncMalformed {
reason: "the group plaintext holds content beyond its declared length",
});
}
Ok(OpenedRecord::Group {
id: GroupId::from_bytes(embedded),
group: Box::new(crate::vault::decode_group(body, FORMAT_VERSION)?),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::crypto::RecordKind;
use crate::vault::{EntryKind, Timestamp};
fn key() -> [u8; 32] {
[0x5A; 32]
}
fn sample_entry() -> VaultEntry {
VaultEntry::new(
"ACME".to_owned(),
Some("ACME 公司".to_owned()),
"alice@example.com".to_owned(),
crate::otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid secret"),
crate::otp::Algorithm::Sha1,
crate::otp::Digits::SIX,
EntryKind::Totp { period: 30 },
"工作账号".to_owned(),
Timestamp::from(1_700_000_000_u64),
Timestamp::from(1_700_000_100_u64),
)
.expect("valid entry")
}
#[test]
fn an_entry_roundtrips() {
let id = EntryId::from_bytes([1; EntryId::LEN]);
let entry = sample_entry();
let record = seal_entry(&key(), &id, &entry).expect("sealing succeeds");
assert_eq!(record.entry_id, id);
let opened = open(&key(), &record).expect("opening succeeds");
match opened {
OpenedRecord::Entry {
id: got_id,
entry: got,
} => {
assert_eq!(got_id, id);
assert_eq!(
*got, entry,
"the entry content differs after the round trip"
);
}
other => panic!("expected an entry, actual {other:?}"),
}
}
#[test]
fn a_tombstone_roundtrips() {
let id = EntryId::from_bytes([2; EntryId::LEN]);
let tombstone = Tombstone {
entry_id: id,
deleted_at: 1_700_000_200,
deleted_by: Some(EntryId::from_bytes([3; EntryId::LEN])),
};
let record = seal_tombstone(&key(), &tombstone).expect("sealing succeeds");
let opened = open(&key(), &record).expect("opening succeeds");
match opened {
OpenedRecord::Tombstone(got) => assert_eq!(
*got, tombstone,
"the tombstone differs after the round trip"
),
other => panic!("expected a tombstone, actual {other:?}"),
}
}
#[test]
fn tombstone_deleted_by_states_roundtrip() {
let id = EntryId::from_bytes([4; EntryId::LEN]);
for deleted_by in [None, Some(EntryId::from_bytes([5; EntryId::LEN]))] {
let tombstone = Tombstone {
entry_id: id,
deleted_at: 1,
deleted_by,
};
let record = seal_tombstone(&key(), &tombstone).expect("sealing succeeds");
let opened = open(&key(), &record).expect("opening succeeds");
match opened {
OpenedRecord::Tombstone(got) => assert_eq!(got.deleted_by, deleted_by),
other => panic!("expected a tombstone, actual {other:?}"),
}
}
}
#[test]
fn a_wrong_key_is_rejected() {
let id = EntryId::from_bytes([6; EntryId::LEN]);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
let err = open(&[0x5B; 32], &record);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"actual {err:?}"
);
}
#[test]
fn moving_a_ciphertext_to_another_id_is_rejected() {
let id_a = EntryId::from_bytes([7; EntryId::LEN]);
let id_b = EntryId::from_bytes([8; EntryId::LEN]);
let mut record = seal_entry(&key(), &id_a, &sample_entry()).expect("sealing succeeds");
record.entry_id = id_b;
let err = open(&key(), &record);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"an entry ciphertext moved to another ID was still accepted, actual {err:?}"
);
}
#[test]
fn a_tombstone_cannot_be_read_as_an_entry() {
let id = EntryId::from_bytes([9; EntryId::LEN]);
let tombstone = Tombstone {
entry_id: id,
deleted_at: 1_700_000_300,
deleted_by: None,
};
let record = seal_tombstone(&key(), &tombstone).expect("sealing succeeds");
match open(&key(), &record).expect("opening succeeds") {
OpenedRecord::Tombstone(_) => {}
other => panic!("a tombstone was misread as {other:?}"),
}
}
#[test]
fn flipping_any_ciphertext_byte_is_rejected() {
let id = EntryId::from_bytes([10; EntryId::LEN]);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
for index in 0..record.ciphertext.len() {
let mut tampered = record.clone();
tampered.ciphertext[index] ^= 0x01;
let err = open(&key(), &tampered);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"byte {index} was flipped yet still accepted, actual {err:?}"
);
}
}
#[test]
fn a_tampered_nonce_is_rejected() {
let id = EntryId::from_bytes([11; EntryId::LEN]);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
for index in 0..record.nonce.len() {
let mut tampered = record.clone();
tampered.nonce[index] ^= 0x80;
let err = open(&key(), &tampered);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"nonce byte {index} was flipped yet still accepted, actual {err:?}"
);
}
}
#[test]
fn a_truncated_ciphertext_is_rejected() {
let id = EntryId::from_bytes([12; EntryId::LEN]);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
for cut in 1..record.ciphertext.len().min(8) {
let mut short = record.clone();
short.ciphertext.truncate(record.ciphertext.len() - cut);
assert!(
open(&key(), &short).is_err(),
"truncating {cut} bytes was still accepted"
);
}
}
#[test]
fn nonces_differ_across_records() {
let id = EntryId::from_bytes([13; EntryId::LEN]);
let entry = sample_entry();
let first = seal_entry(&key(), &id, &entry).expect("sealing succeeds");
let second = seal_entry(&key(), &id, &entry).expect("sealing succeeds");
assert_ne!(
first.nonce, second.nonce,
"two encryptions used the same nonce"
);
assert_ne!(
first.ciphertext, second.ciphertext,
"encrypting the same content twice produced the same ciphertext, randomness is broken"
);
}
#[test]
fn sync_and_vault_keys_are_different() {
let master = crate::vault::MasterKey::generate().expect("randomness available");
let sync_key = crate::sync::derive_record_key(&master).expect("derivation succeeds");
let vault_key = crypto::entry_key(&master).expect("derivation succeeds");
assert_ne!(
sync_key, vault_key,
"the sync record key and the vault record key are the same subkey, domain separation failed"
);
let envelope_key = crate::sync::derive_envelope_key(&master).expect("derivation succeeds");
assert_ne!(
sync_key, envelope_key,
"the record key equals the envelope key"
);
assert_ne!(
vault_key, envelope_key,
"the vault key equals the envelope key"
);
let again = crate::sync::derive_record_key(&master).expect("derivation succeeds");
assert_eq!(
sync_key, again,
"the same master key derived two different results"
);
}
#[test]
fn the_domain_separation_constants_are_stable() {
assert_eq!(
crate::sync::HKDF_SALT_SYNC,
*b"Qualia2FA sync salt v1 (records)",
"the sync HKDF salt changed, every existing payload will fail to open"
);
assert_eq!(
RecordKind::SyncEntry.as_byte(),
0x81,
"the sync entry kind byte changed"
);
assert_eq!(
RecordKind::SyncTombstone.as_byte(),
0x82,
"the sync tombstone kind byte changed"
);
assert_eq!(FORMAT_VERSION, 4, "the payload format version changed");
}
#[test]
fn the_two_domains_kinds_do_not_overlap() {
assert!(RecordKind::from_byte(0x01).is_some());
assert!(RecordKind::from_byte(0x02).is_some());
assert!(
RecordKind::from_byte(0x81).is_none(),
"the file parser accepted the sync entry kind: a sync payload would be taken in as a vault file"
);
assert!(
RecordKind::from_byte(0x82).is_none(),
"the file parser accepted the sync tombstone kind"
);
assert!(RecordKind::from_sync_byte(0x81).is_some());
assert!(RecordKind::from_sync_byte(0x82).is_some());
assert!(
RecordKind::from_sync_byte(0x01).is_none(),
"the sync parser accepted the file entry kind"
);
assert!(RecordKind::from_sync_byte(0x02).is_none());
assert!(RecordKind::from_sync_byte(0x00).is_none());
assert!(RecordKind::from_sync_byte(0xFF).is_none());
}
#[test]
fn encoded_len_matches_the_wire_format() {
let id = EntryId::from_bytes([14; EntryId::LEN]);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
assert_eq!(record.encoded_len(), 16 + 24 + 4 + record.ciphertext.len());
}
#[test]
fn entry_ids_survive_the_roundtrip_byte_for_byte() {
for pattern in [0x00_u8, 0xFF, 0x80] {
for position in 0..EntryId::LEN {
let mut bytes = [0_u8; EntryId::LEN];
bytes[position] = pattern;
let id = EntryId::from_bytes(bytes);
let record = seal_entry(&key(), &id, &sample_entry()).expect("sealing succeeds");
match open(&key(), &record).expect("opening succeeds") {
OpenedRecord::Entry { id: got, .. } => {
assert_eq!(
got.as_bytes(),
&bytes,
"identifier {bytes:?} differs after the round trip"
);
}
other => panic!("expected an entry, actual {other:?}"),
}
}
}
}
#[test]
fn timestamp_boundaries_survive_the_roundtrip() {
let id = EntryId::from_bytes([15; EntryId::LEN]);
for deleted_at in [1_u64, u32::MAX.into(), u64::MAX] {
let tombstone = Tombstone {
entry_id: id,
deleted_at: Timestamp::from(deleted_at),
deleted_by: None,
};
let record = seal_tombstone(&key(), &tombstone).expect("sealing succeeds");
match open(&key(), &record).expect("opening succeeds") {
OpenedRecord::Tombstone(got) => assert_eq!(
got.deleted_at, deleted_at,
"deletion time {deleted_at} differs after the round trip"
),
other => panic!("expected a tombstone, actual {other:?}"),
}
}
}
}