use crate::sync::{FORMAT_VERSION, InnerRecordKind, Record};
use crate::vault::crypto;
use crate::vault::{Entry as VaultEntry, EntryId, Tombstone};
use crate::{Error, Result};
const ENTRY_HEADER_LEN: usize = 4 + EntryId::LEN + 2;
const ENTRY_MAGIC: &[u8; 4] = b"Q2FE";
#[derive(Debug, PartialEq, Eq)]
pub enum OpenedRecord {
Entry {
id: EntryId,
entry: Box<VaultEntry>,
},
Tombstone(Box<Tombstone>),
}
impl OpenedRecord {
#[must_use]
pub fn entry_id(&self) -> EntryId {
match self {
Self::Entry { id, .. } => *id,
Self::Tombstone(tombstone) => tombstone.entry_id,
}
}
#[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),
}
}
}
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 = u16::try_from(body.len()).map_err(|_| Error::SyncMalformed {
reason: "单条条目的编码超过 65535 字节",
})?;
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,
)
}
fn seal_plaintext(
key: &[u8; 32],
kind: InnerRecordKind,
id: &EntryId,
plaintext: &[u8],
) -> Result<Record> {
let (nonce, ciphertext) = crypto::seal_with_key(key, kind, id, FORMAT_VERSION, plaintext)?;
Ok(Record {
entry_id: *id,
nonce,
ciphertext,
})
}
pub fn open(key: &[u8; 32], record: &Record) -> Result<OpenedRecord> {
let mut last_error = None;
for kind in [InnerRecordKind::SyncEntry, InnerRecordKind::SyncTombstone] {
match try_open_as(key, kind, record) {
Ok(opened) => return Ok(opened),
Err(err) => last_error = Some(err),
}
}
Err(last_error.map_or(Error::SyncAuthenticationFailed, |_| {
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,
FORMAT_VERSION,
&record.nonce,
&record.ciphertext,
)?;
match kind {
InnerRecordKind::SyncEntry => open_entry(&record.entry_id, &plaintext),
InnerRecordKind::SyncTombstone => open_tombstone(&record.entry_id, &plaintext),
_ => Err(Error::SyncMalformed {
reason: "解码路径收到了非同步的记录种类",
}),
}
}
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: "条目明文缺少魔数",
});
}
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: "条目明文里的标识与记录标识不一致",
});
}
let len_bytes: [u8; 2] = plaintext[4 + EntryId::LEN..ENTRY_HEADER_LEN]
.try_into()
.map_err(|_| Error::SyncTruncated)?;
let declared = usize::from(u16::from_be_bytes(len_bytes));
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: "条目明文含超出声明长度的内容",
});
}
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)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Timestamp;
use crate::vault::crypto::RecordKind;
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("种子合法"),
crate::otp::Algorithm::Sha1,
crate::otp::Digits::Six,
30,
None,
"工作账号".to_owned(),
Timestamp::from(1_700_000_000_u64),
Timestamp::from(1_700_000_100_u64),
)
.expect("条目合法")
}
#[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("可加密");
assert_eq!(record.entry_id, id);
let opened = open(&key(), &record).expect("可解密");
match opened {
OpenedRecord::Entry {
id: got_id,
entry: got,
} => {
assert_eq!(got_id, id);
assert_eq!(*got, entry, "往返后条目内容不一致");
}
other => panic!("期望是条目,实际 {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("可加密");
let opened = open(&key(), &record).expect("可解密");
match opened {
OpenedRecord::Tombstone(got) => assert_eq!(*got, tombstone, "往返后墓碑不一致"),
other => panic!("期望是墓碑,实际 {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("可加密");
let opened = open(&key(), &record).expect("可解密");
match opened {
OpenedRecord::Tombstone(got) => assert_eq!(got.deleted_by, deleted_by),
other => panic!("期望是墓碑,实际 {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("可加密");
let err = open(&[0x5B; 32], &record);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"实际 {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("可加密");
record.entry_id = id_b;
let err = open(&key(), &record);
assert!(
matches!(err, Err(Error::SyncAuthenticationFailed)),
"条目密文被搬到另一个 ID 后仍被接受,实际 {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("可加密");
match open(&key(), &record).expect("可解密") {
OpenedRecord::Tombstone(_) => {}
other => panic!("墓碑被误判成 {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("可加密");
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)),
"第 {index} 字节被翻转后仍被接受,实际 {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("可加密");
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 第 {index} 字节被翻转后仍被接受,实际 {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("可加密");
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(), "截断 {cut} 字节后仍被接受");
}
}
#[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("可加密");
let second = seal_entry(&key(), &id, &entry).expect("可加密");
assert_ne!(first.nonce, second.nonce, "两次加密用了同一个 nonce");
assert_ne!(
first.ciphertext, second.ciphertext,
"同一内容两次加密产生了相同密文,随机性有问题"
);
}
#[test]
fn sync_and_vault_keys_are_different() {
let master = crate::vault::MasterKey::generate().expect("随机源可用");
let sync_key = crate::sync::derive_record_key(&master).expect("可派生");
let vault_key = crypto::entry_key(&master).expect("可派生");
assert_ne!(
sync_key, vault_key,
"同步记录与保险库记录派生了同一把子密钥,域分离失效"
);
let envelope_key = crate::sync::derive_envelope_key(&master).expect("可派生");
assert_ne!(sync_key, envelope_key, "记录密钥与信封密钥相同");
assert_ne!(vault_key, envelope_key, "保险库密钥与信封密钥相同");
let again = crate::sync::derive_record_key(&master).expect("可派生");
assert_eq!(sync_key, again, "同一主密钥两次派生的结果不同");
}
#[test]
fn the_domain_separation_constants_are_stable() {
assert_eq!(
crate::sync::HKDF_SALT_SYNC,
*b"Qualia2FA sync salt v1 (records)",
"同步的 HKDF salt 变了,既有载荷会全部解不开"
);
assert_eq!(
RecordKind::SyncEntry.as_byte(),
0x81,
"同步条目的种类字节变了"
);
assert_eq!(
RecordKind::SyncTombstone.as_byte(),
0x82,
"同步墓碑的种类字节变了"
);
assert_eq!(FORMAT_VERSION, 2, "载荷格式版本变了");
}
#[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(),
"文件解析器接受了同步条目种类——一份同步载荷会被当成保险库文件收下"
);
assert!(
RecordKind::from_byte(0x82).is_none(),
"文件解析器接受了同步墓碑种类"
);
assert!(RecordKind::from_sync_byte(0x81).is_some());
assert!(RecordKind::from_sync_byte(0x82).is_some());
assert!(
RecordKind::from_sync_byte(0x01).is_none(),
"同步解析器接受了文件条目种类"
);
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("可加密");
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("可加密");
match open(&key(), &record).expect("可解密") {
OpenedRecord::Entry { id: got, .. } => {
assert_eq!(got.as_bytes(), &bytes, "标识 {bytes:?} 往返后不同");
}
other => panic!("期望是条目,实际 {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("可加密");
match open(&key(), &record).expect("可解密") {
OpenedRecord::Tombstone(got) => assert_eq!(
got.deleted_at, deleted_at,
"删除时间 {deleted_at} 往返后不同"
),
other => panic!("期望是墓碑,实际 {other:?}"),
}
}
}
}