use crate::crypto::{CipherId, Fek, Kek, ZERO_NONCE, header_mac};
use crate::error::{Error, Result};
use crate::header::{
FIXED_HEADER_LEN, FixedHeader, HEADER_MAC_LEN, SLOT_AREA_LEN, SLOT_AREA_OFFSET, SLOT_COUNT,
SLOT_LEN, TLV_AREA_OFFSET,
};
use crate::util::Writer;
#[derive(Debug)]
pub struct RewriteOutcome {
pub bytes: Vec<u8>,
pub opened_slot: u16,
pub slot_used: usize,
pub carried_opaque: usize,
pub may_have_evicted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtherSlots {
Carry,
Discard,
}
pub fn rewrite_slots(
data: &[u8],
unlock: &[Kek],
keep: &[Kek],
others: OtherSlots,
) -> Result<RewriteOutcome> {
if keep.is_empty() {
return Err(Error::MalformedHeader {
reason: "refusing to leave a file with zero key slots; it could never be opened again",
});
}
if keep.len() > SLOT_COUNT {
return Err(Error::TooManySlots { got: keep.len(), max: SLOT_COUNT });
}
let opened = crate::file::open(data, unlock)?;
let header = FixedHeader::parse(data)?;
let old_area = data.get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN))
.ok_or(Error::Truncated {
context: "key slot area",
need: SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN),
got: data.len(),
})?;
let (slot_area, carried_opaque) = build_area(
keep,
opened.fek(),
&header.file_uuid,
header.cipher_id,
old_area,
others,
)?;
let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
context: "tlv area",
need: tlv_end,
got: data.len(),
})?;
let fixed = data.get(..FIXED_HEADER_LEN).ok_or(Error::Truncated {
context: "fixed header",
need: FIXED_HEADER_LEN,
got: data.len(),
})?;
let payload_start = tlv_end.saturating_add(HEADER_MAC_LEN);
let payload = data.get(payload_start..).ok_or(Error::Truncated {
context: "payload",
need: payload_start,
got: data.len(),
})?;
let mut covered = Writer::with_capacity(header.header_len as usize);
covered.bytes(fixed).bytes(&slot_area).bytes(tlv_blob);
let mac_key = opened.fek().derive_header_mac_key(&header.file_uuid);
let mac = header_mac(&mac_key, covered.as_slice());
let mut out =
Writer::with_capacity((header.header_len as usize).saturating_add(payload.len()));
out.bytes(covered.as_slice()).bytes(&mac).bytes(payload);
let bytes = out.into_vec();
debug_assert_eq!(bytes.len(), data.len(), "slot 改写不得改变文件长度");
Ok(RewriteOutcome {
bytes,
opened_slot: opened.slot_index,
slot_used: keep.len(),
carried_opaque,
may_have_evicted: others == OtherSlots::Carry && keep.len() > 1,
})
}
fn build_area(
keks: &[Kek],
fek: &Fek,
file_uuid: &[u8; 16],
cipher: CipherId,
old_area: &[u8],
others: OtherSlots,
) -> Result<(Vec<u8>, usize)> {
let mut w = Writer::with_capacity(SLOT_AREA_LEN);
for (i, kek) in keks.iter().enumerate() {
let idx = u16::try_from(i)
.map_err(|_| Error::TooManySlots { got: keks.len(), max: SLOT_COUNT })?;
let wrap_key = kek.derive_slot_key(file_uuid, idx);
let wrapped = cipher.encrypt(&wrap_key, &ZERO_NONCE, fek.as_key().as_bytes(), &[])?;
w.bytes(&wrapped);
}
let mut carried = 0usize;
match others {
OtherSlots::Carry => {
for i in keks.len()..SLOT_COUNT {
let start = i.saturating_mul(SLOT_LEN);
let end = start.saturating_add(SLOT_LEN);
match old_area.get(start..end) {
Some(slot) => {
w.bytes(slot);
carried = carried.saturating_add(1);
}
None => {
w.random(SLOT_LEN);
}
}
}
}
OtherSlots::Discard => {
let used = keks.len().saturating_mul(SLOT_LEN);
w.random(SLOT_AREA_LEN.saturating_sub(used));
}
}
let out = w.into_vec();
debug_assert_eq!(out.len(), SLOT_AREA_LEN, "slot 区必须恰好 384 字节");
Ok((out, carried))
}
#[derive(Debug)]
pub enum SlotPlan {
Keep,
Write(Kek),
Clear,
}
pub fn rewrite_slots_managed(
data: &[u8],
unlock: &[Kek],
plans: &[SlotPlan],
new_directory: &crate::slotdir::SlotDirectory,
) -> Result<RewriteOutcome> {
if plans.len() != SLOT_COUNT {
return Err(Error::TooManySlots { got: plans.len(), max: SLOT_COUNT });
}
if !plans.iter().any(|p| matches!(p, SlotPlan::Keep | SlotPlan::Write(_))) {
return Err(Error::MalformedHeader {
reason: "refusing to leave a file with zero key slots; it could never be opened again",
});
}
{
let surviving: Vec<crate::slotdir::SlotKind> = (0..SLOT_COUNT)
.filter(|i| {
plans
.get(*i)
.is_some_and(|p| matches!(p, SlotPlan::Keep | SlotPlan::Write(_)))
})
.filter_map(|i| new_directory.get(i).map(|e| e.kind))
.filter(|k| k.is_occupied())
.collect();
if !surviving.is_empty()
&& surviving
.iter()
.all(|k| *k == crate::slotdir::SlotKind::Device)
{
return Err(Error::MalformedHeader {
reason: "refusing to leave only device keys; they stop working when the machine, \
OS or biometrics change, and there would be no way back in",
});
}
}
let opened = crate::file::open(data, unlock)?;
let header = FixedHeader::parse(data)?;
if !header.has_flag(crate::header::flags::SLOT_DIRECTORY) {
return Err(Error::MissingTlv { tlv_type: crate::tlv::types::SLOT_DIRECTORY });
}
let old_area = data
.get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN))
.ok_or(Error::Truncated {
context: "key slot area",
need: SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN),
got: data.len(),
})?;
let mut w = Writer::with_capacity(SLOT_AREA_LEN);
let mut carried = 0usize;
let mut used = 0usize;
for (i, plan) in plans.iter().enumerate() {
match plan {
SlotPlan::Keep => {
let start = i.saturating_mul(SLOT_LEN);
let end = start.saturating_add(SLOT_LEN);
match old_area.get(start..end) {
Some(slot) => {
w.bytes(slot);
carried = carried.saturating_add(1);
used = used.saturating_add(1);
}
None => {
w.random(SLOT_LEN);
}
}
}
SlotPlan::Write(kek) => {
let idx = u16::try_from(i)
.map_err(|_| Error::TooManySlots { got: i, max: SLOT_COUNT })?;
let wrap_key = kek.derive_slot_key(&header.file_uuid, idx);
let wrapped = header.cipher_id.encrypt(
&wrap_key,
&ZERO_NONCE,
opened.fek().as_key().as_bytes(),
&[],
)?;
w.bytes(&wrapped);
used = used.saturating_add(1);
}
SlotPlan::Clear => {
w.random(SLOT_LEN);
}
}
}
let slot_area = w.into_vec();
debug_assert_eq!(slot_area.len(), SLOT_AREA_LEN, "slot 区必须恰好 384 字节");
let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
context: "tlv area",
need: tlv_end,
got: data.len(),
})?;
let new_value = crate::tlv::encrypt_entry(
crate::tlv::types::SLOT_DIRECTORY,
crate::tlv::tlv_flags::CRITICAL,
&new_directory.encode(),
opened.fek(),
header.cipher_id,
)?
.value;
let new_tlv = replace_tlv_value(tlv_blob, crate::tlv::types::SLOT_DIRECTORY, &new_value)?;
let fixed = data.get(..FIXED_HEADER_LEN).ok_or(Error::Truncated {
context: "fixed header",
need: FIXED_HEADER_LEN,
got: data.len(),
})?;
let payload_start = tlv_end.saturating_add(HEADER_MAC_LEN);
let payload = data.get(payload_start..).ok_or(Error::Truncated {
context: "payload",
need: payload_start,
got: data.len(),
})?;
let mut covered = Writer::with_capacity(header.header_len as usize);
covered.bytes(fixed).bytes(&slot_area).bytes(&new_tlv);
let mac_key = opened.fek().derive_header_mac_key(&header.file_uuid);
let mac = header_mac(&mac_key, covered.as_slice());
let mut out =
Writer::with_capacity((header.header_len as usize).saturating_add(payload.len()));
out.bytes(covered.as_slice()).bytes(&mac).bytes(payload);
let bytes = out.into_vec();
debug_assert_eq!(bytes.len(), data.len(), "slot 改写不得改变文件长度");
Ok(RewriteOutcome {
bytes,
opened_slot: opened.slot_index,
slot_used: used,
carried_opaque: carried,
may_have_evicted: false,
})
}
fn replace_tlv_value(blob: &[u8], want_type: u16, new_value: &[u8]) -> Result<Vec<u8>> {
let mut out = blob.to_vec();
let mut at = 0usize;
while at.saturating_add(crate::tlv::TLV_HEADER_LEN) <= blob.len() {
let t = u16::from_le_bytes([
*blob.get(at).unwrap_or(&0),
*blob.get(at.saturating_add(1)).unwrap_or(&0),
]);
let len = u32::from_le_bytes([
*blob.get(at.saturating_add(4)).unwrap_or(&0),
*blob.get(at.saturating_add(5)).unwrap_or(&0),
*blob.get(at.saturating_add(6)).unwrap_or(&0),
*blob.get(at.saturating_add(7)).unwrap_or(&0),
]) as usize;
let vstart = at.saturating_add(crate::tlv::TLV_HEADER_LEN);
let vend = vstart.saturating_add(len);
if vend > blob.len() {
break;
}
if t == want_type {
if len != new_value.len() {
return Err(Error::MalformedTlv {
tlv_type: want_type,
reason: "slot directory length changed; header_len would no longer match",
});
}
out.get_mut(vstart..vend)
.ok_or(Error::MalformedTlv {
tlv_type: want_type,
reason: "tlv value out of range",
})?
.copy_from_slice(new_value);
return Ok(out);
}
at = vend;
}
Err(Error::MissingTlv { tlv_type: want_type })
}
#[must_use]
pub const fn slot_area_range() -> (usize, usize) {
(SLOT_AREA_OFFSET, SLOT_AREA_OFFSET + SLOT_AREA_LEN)
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "测试断言需要")]
mod tests {
use super::*;
use crate::crypto::{Argon2Params, SecretKey};
use crate::file::{EncryptOptions, RandomMaterial, encrypt};
fn kek(seed: u8) -> Kek {
Kek::from_key(SecretKey::from_bytes(core::array::from_fn(|i| {
(i as u8).wrapping_mul(31) ^ seed
})))
}
fn sample(keks: &[Kek]) -> Vec<u8> {
let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let opts = EncryptOptions {
argon2: Argon2Params { m_kib: 8, t: 1, p: 1 },
..Default::default()
};
encrypt(&plain, keks, &[7u8; 16], &opts, &RandomMaterial::generate())
.unwrap()
.bytes
}
#[test]
fn add_keeps_both_passwords() {
let a = kek(0x11);
let b = kek(0x22);
let data = sample(&[a.duplicate()]);
let out = rewrite_slots(&data, &[a.duplicate()], &[a.duplicate(), b.duplicate()], OtherSlots::Carry).unwrap();
assert!(crate::file::open(&out.bytes, &[a]).is_ok(), "原密码必须仍可用");
assert!(crate::file::open(&out.bytes, &[b]).is_ok(), "新密码必须可用");
assert_eq!(out.slot_used, 2);
}
#[test]
fn removed_password_no_longer_opens() {
let a = kek(0x33);
let b = kek(0x44);
let data = sample(&[a.duplicate(), b.duplicate()]);
assert!(crate::file::open(&data, &[b.duplicate()]).is_ok());
let out =
rewrite_slots(&data, &[a.duplicate()], &[a.duplicate()], OtherSlots::Discard).unwrap();
assert!(crate::file::open(&out.bytes, &[a]).is_ok(), "保留的密码应可用");
let err = crate::file::open(&out.bytes, &[b]).unwrap_err();
assert!(
matches!(err, Error::NoMatchingSlot),
"被移除的密码必须打不开,实际 {err:?}"
);
}
#[test]
fn payload_and_uuid_untouched() {
let a = kek(0x55);
let b = kek(0x66);
let data = sample(&[a.duplicate()]);
let out = rewrite_slots(&data, &[a.duplicate()], &[b], OtherSlots::Carry).unwrap();
let h0 = FixedHeader::parse(&data).unwrap();
let h1 = FixedHeader::parse(&out.bytes).unwrap();
assert_eq!(h0.file_uuid, h1.file_uuid, "file_uuid 不得改变");
assert_eq!(h0.base_nonce, h1.base_nonce, "base_nonce 不得改变");
assert_eq!(data.len(), out.bytes.len(), "文件长度不得改变");
let (lo, hi) = slot_area_range();
assert_eq!(data[..lo], out.bytes[..lo], "固定头不得改变");
let mac_end = h0.header_len as usize;
let mac_start = mac_end - HEADER_MAC_LEN;
assert_eq!(data[hi..mac_start], out.bytes[hi..mac_start], "TLV 区不得改变");
assert_eq!(data[mac_end..], out.bytes[mac_end..], "载荷必须逐字节相同");
assert_ne!(data[lo..hi], out.bytes[lo..hi], "slot 区应当变了");
assert_ne!(
data[mac_start..mac_end],
out.bytes[mac_start..mac_end],
"头部 MAC 应当随 slot 区一起变"
);
}
#[test]
fn plaintext_still_decrypts() {
let a = kek(0x77);
let b = kek(0x88);
let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let data = sample(&[a.duplicate()]);
let out = rewrite_slots(&data, &[a], &[b.duplicate()], OtherSlots::Carry).unwrap();
let opened = crate::file::open(&out.bytes, &[b]).unwrap();
assert_eq!(opened.decrypt_all(&out.bytes).unwrap(), plain);
}
#[test]
fn empty_keep_is_rejected() {
let a = kek(0x99);
let data = sample(&[a.duplicate()]);
assert!(
rewrite_slots(&data, &[a], &[], OtherSlots::Carry).is_err(),
"keep 为空必须报错"
);
}
#[test]
fn too_many_slots_is_rejected() {
let a = kek(0xAA);
let data = sample(&[a.duplicate()]);
let many: Vec<Kek> = (0..9).map(|i| kek(0xB0 + i)).collect();
let err = rewrite_slots(&data, &[a], &many, OtherSlots::Carry).unwrap_err();
assert!(matches!(err, Error::TooManySlots { got: 9, max: 8 }), "实际 {err:?}");
}
#[test]
fn wrong_unlock_password_fails() {
let a = kek(0xCC);
let wrong = kek(0xDD);
let data = sample(&[a]);
let err =
rewrite_slots(&data, &[wrong.duplicate()], &[wrong], OtherSlots::Carry).unwrap_err();
assert!(matches!(err, Error::NoMatchingSlot), "实际 {err:?}");
}
#[test]
fn tampered_header_is_rejected() {
let a = kek(0xEE);
let mut data = sample(&[a.duplicate()]);
data[64] ^= 0x01;
let err =
rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Carry).unwrap_err();
assert!(
matches!(err, Error::HeaderMacMismatch | Error::MalformedHeader { .. }),
"篡改必须被拒绝,实际 {err:?}"
);
}
#[test]
fn unused_slots_are_random_not_zero() {
let a = kek(0x12);
let data = sample(&[a.duplicate()]);
let out =
rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Discard).unwrap();
let (lo, hi) = slot_area_range();
let pad = &out.bytes[lo + SLOT_LEN..hi];
assert_eq!(pad.len(), SLOT_LEN * 7);
assert!(pad.iter().any(|&b| b != 0), "空槽不得填零");
let out2 =
rewrite_slots(&data, &[kek(0x12)], &[kek(0x12)], OtherSlots::Discard).unwrap();
assert_ne!(
&out.bytes[lo + SLOT_LEN..hi],
&out2.bytes[lo + SLOT_LEN..hi],
"两次改写的随机填充不应相同"
);
}
#[test]
fn header_len_still_consistent() {
let a = kek(0x34);
let data = sample(&[a.duplicate()]);
let out =
rewrite_slots(&data, &[a.duplicate()], &[a, kek(0x56)], OtherSlots::Carry).unwrap();
let h = FixedHeader::parse(&out.bytes).unwrap();
h.validate().unwrap();
assert!(out.bytes.len() >= h.header_len as usize);
}
#[test]
fn carry_preserves_unknown_slot_across_password_change() {
let main = kek(0x41);
let reco = kek(0x42); let fresh = kek(0x43);
let data = sample(&[main.duplicate(), reco.duplicate()]);
assert!(crate::file::open(&data, &[reco.duplicate()]).is_ok(), "基线:恢复码应可用");
let out =
rewrite_slots(&data, &[main.duplicate()], &[fresh.duplicate()], OtherSlots::Carry)
.unwrap();
assert!(crate::file::open(&out.bytes, &[fresh]).is_ok(), "新密码必须可用");
assert!(
crate::file::open(&out.bytes, &[reco]).is_ok(),
"没参与重建的槽必须被原样保住——这是本次改动的全部意义"
);
assert!(
crate::file::open(&out.bytes, &[main]).is_err(),
"旧主密码必须失效,否则 change 是假的"
);
}
#[test]
fn carried_slot_decrypts_correct_plaintext() {
let main = kek(0x44);
let reco = kek(0x45);
let fresh = kek(0x46);
let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let data = sample(&[main.duplicate(), reco.duplicate()]);
let out = rewrite_slots(&data, &[main], &[fresh], OtherSlots::Carry).unwrap();
let opened = crate::file::open(&out.bytes, &[reco]).unwrap();
assert_eq!(opened.decrypt_all(&out.bytes).unwrap(), plain, "搬运后明文必须原样");
}
#[test]
fn carry_keeps_slot_indices_aligned() {
let main = kek(0x47);
let far = kek(0x48);
let fresh = kek(0x49);
let filler = kek(0x4A);
let data = sample(&[main.duplicate(), filler, far.duplicate()]);
assert!(crate::file::open(&data, &[far.duplicate()]).is_ok(), "基线");
let out = rewrite_slots(&data, &[main], &[fresh], OtherSlots::Carry).unwrap();
assert!(
crate::file::open(&out.bytes, &[far]).is_ok(),
"slot 2 的槽必须仍在 slot 2 上,压缩到前面会让它永远解不开"
);
let (lo, _) = slot_area_range();
let s2 = lo + SLOT_LEN * 2;
assert_eq!(
&data[s2..s2 + SLOT_LEN],
&out.bytes[s2..s2 + SLOT_LEN],
"slot 2 必须逐字节原样"
);
}
#[test]
fn discard_really_wipes_other_slots() {
let main = kek(0x4B);
let other = kek(0x4C);
let data = sample(&[main.duplicate(), other.duplicate()]);
let out =
rewrite_slots(&data, &[main.duplicate()], &[main], OtherSlots::Discard).unwrap();
let err = crate::file::open(&out.bytes, &[other]).unwrap_err();
assert!(matches!(err, Error::NoMatchingSlot), "Discard 必须让其它密码失效,实际 {err:?}");
}
#[test]
fn carry_and_discard_differ_observably() {
let a = kek(0x4D);
let b = kek(0x4E);
let data = sample(&[a.duplicate(), b]);
let carried =
rewrite_slots(&data, &[a.duplicate()], &[a.duplicate()], OtherSlots::Carry).unwrap();
let wiped =
rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Discard).unwrap();
assert_eq!(carried.carried_opaque, 7, "保留了 8-1=7 个来历不明的槽");
assert_eq!(wiped.carried_opaque, 0, "清场不搬运任何槽");
let (lo, hi) = slot_area_range();
assert_ne!(
&carried.bytes[lo..hi],
&wiped.bytes[lo..hi],
"两种策略必须产出不同的 slot 区"
);
assert_eq!(
&data[lo + SLOT_LEN..hi],
&carried.bytes[lo + SLOT_LEN..hi],
"Carry 下未涉及的槽必须逐字节不变"
);
}
#[test]
fn carry_preserves_length_and_slot_area_size() {
let a = kek(0x4F);
let data = sample(&[a.duplicate()]);
let out = rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Carry).unwrap();
assert_eq!(out.bytes.len(), data.len(), "文件长度不得改变");
let (lo, hi) = slot_area_range();
assert_eq!(hi - lo, SLOT_LEN * 8, "slot 区必须恒为 8 槽");
let h = FixedHeader::parse(&out.bytes).unwrap();
h.validate().unwrap();
}
#[test]
fn carry_reports_possible_eviction_when_keep_grows() {
let main = kek(0x51);
let reco = kek(0x52); let extra = kek(0x53);
let data = sample(&[main.duplicate(), reco.duplicate()]);
let grown = rewrite_slots(
&data,
&[main.duplicate()],
&[main.duplicate(), extra],
OtherSlots::Carry,
)
.unwrap();
assert!(grown.may_have_evicted, "keep 增长必须如实报告可能顶掉了槽");
assert!(
crate::file::open(&grown.bytes, &[reco.duplicate()]).is_err(),
"确认这个边界真实存在——若哪天能保住了,本断言会提醒来更新文档"
);
let same = rewrite_slots(&data, &[main.duplicate()], &[kek(0x54)], OtherSlots::Carry)
.unwrap();
assert!(!same.may_have_evicted, "只保留一个密码时不该报告顶掉");
assert!(
crate::file::open(&same.bytes, &[reco]).is_ok(),
"change 必须保住 slot 1 上的恢复码"
);
}
#[test]
fn discard_never_reports_eviction() {
let a = kek(0x55);
let b = kek(0x56);
let data = sample(&[a.duplicate(), b.duplicate()]);
let out =
rewrite_slots(&data, &[a.duplicate()], &[a, b], OtherSlots::Discard).unwrap();
assert!(
!out.may_have_evicted,
"Discard 的语义就是作废其它全部,不存在「意外顶掉」这回事"
);
}
}