Skip to main content

omy_core/
keyslot.rs

1//! slot 区的原地改写:增删改密码,不触碰载荷。
2//!
3//! # 为什么必须是原地改写
4//!
5//! 密码变更的正确成本是**毫秒级**(文档 03 §4.3)。用「解密后重新加密」来
6//! 代替是错的,且错得不止是慢:
7//!
8//! - 会换掉 `file_uuid` 与 `base_nonce`,全部密文字节随之改变。对外部观察者
9//!   而言这是一个全新文件——增量备份要重传整份,去重失效;
10//! - 大文件是数小时的重写,中途失败就可能同时失去新旧两份;
11//! - 破坏了「改密码只动头部」这一可验证的性质。
12//!
13//! 所以本模块只重建 384 字节的 slot 区并重算 32 字节头部 MAC,
14//! 载荷密文与 `file_uuid` **逐字节不变**。
15//!
16//! # 为什么不能只覆写「那一个 slot」
17//!
18//! 每个 slot 的包裹密钥由 `(KEK, file_uuid, slot_index)` 唯一确定,所以
19//! 一个 slot 的密文只在它所在的下标上有效。但**我们无法知道其余 7 个 slot
20//! 里哪些是真实的**——这正是可否认性的设计目标(空槽填随机,字节层面不可
21//! 区分)。因此:
22//!
23//! - `add` 无法「找一个空槽填进去」,因为分不清空槽和别人的槽。若猜错就会
24//!   静默覆盖掉另一个密码,用户直到下次用那个密码才会发现,那时已无法恢复。
25//! - 唯一诚实的做法是:**要求调用方提供本次操作后应当保留的全部密码**,
26//!   据此整体重建 slot 区。
27//!
28//! 这个约束不是实现偷懒,而是格式的直接后果,必须如实反映在 UI 文案里:
29//! 「加一个密码」实际语义是「重新声明这个文件的密码集合」。
30//!
31//! # 但「保住一个槽」不需要先认出它([`OtherSlots::Carry`])
32//!
33//! 上面那段长期被理解成「改密码必然抹掉不认识的槽」,于是出现了一个实测
34//! 确认的真缺陷:文件挂着「主密码 + 恢复码」,`key change` 只提供当前与新
35//! 密码,恢复码就被当作不认识的槽填成了随机字节。用户改一次密码即永久失去
36//! 恢复码,且只会在真正忘记密码那天才发现。
37//!
38//! 实际上**不知道某个槽属于谁,照样能把它原样搬过去**:
39//!
40//! ```text
41//! 常规重建:        新密码可开 = true,恢复码可开 = false
42//! 原样搬运+重算 MAC:新密码可开 = true,恢复码可开 = true,明文正确
43//! ```
44//!
45//! 成立靠三个性质叠加:slot 密文只依赖 `(KEK, file_uuid, slot_index)`、与
46//! 相邻槽无关;本模块保留原 FEK,旧槽里包的还是同一个 FEK;头部 MAC 的密钥
47//! 同样由 FEK 派生,搬完重算一次即自洽。
48//!
49//! **「保住某个槽」与「知道某个槽是什么」是两件事,前者不需要后者。**
50//!
51//! 所以上面那条约束要精确化:调用方仍须提供「本次操作后应当能打开此文件的
52//! 密码」,但**不必**穷举文件上原有的全部密码——不提供的那些会被原样保留
53//! (`Carry`)或明确作废(`Discard`),由调用方选择。
54//!
55//! 代价是保留只能**无差别**:想清掉某个特定密码,本模块做不到,只能整体
56//! `Discard` 或换 FEK 重新加密。UI 必须讲清这点,否则用户会以为「改了密码
57//! 别人就进不来了」。
58//!
59//! # 与「轮换 FEK」的区别
60//!
61//! 本模块**不换 FEK**。所以移除一个密码后,如果攻击者留有旧文件副本,他仍
62//!能用旧密码打开那个副本——移除只影响这一份文件的未来。真正的密钥轮换必须
63//! 重新加密载荷,属于另一个操作。这一点必须对用户讲清楚,否则会给人错误的
64//! 安全感。
65
66use crate::crypto::{CipherId, Fek, Kek, ZERO_NONCE, header_mac};
67use crate::error::{Error, Result};
68use crate::header::{
69    FIXED_HEADER_LEN, FixedHeader, HEADER_MAC_LEN, SLOT_AREA_LEN, SLOT_AREA_OFFSET, SLOT_COUNT,
70    SLOT_LEN, TLV_AREA_OFFSET,
71};
72use crate::util::Writer;
73
74/// slot 区改写的结果。
75#[derive(Debug)]
76pub struct RewriteOutcome {
77    /// 改写后的完整文件字节。
78    pub bytes: Vec<u8>,
79    /// 解开原文件时命中的 slot 下标,供调用方展示。
80    pub opened_slot: u16,
81    /// 改写后占用的 slot 数量(即传入的 KEK 个数)。
82    pub slot_used: usize,
83    /// 原样搬运过来的、来历不明的槽数量。
84    ///
85    /// 这个数字**不代表「还有几个密码」**:它把真实的其它密码和随机填充
86    /// 算在了一起,因为两者在字节层面本就不可区分(可否认性)。
87    /// 所以 UI 不能拿它说「保留了 N 个密码」——那是它并不知道的事。
88    ///
89    /// 它唯一的正当用途是端到端验证:断言「搬运策略下这个数是 7,
90    /// 清场策略下是 0」,从而证明两条路径真的走了不同实现。
91    pub carried_opaque: usize,
92    /// 本次操作是否可能顶掉了原先占着这些下标的槽。
93    ///
94    /// `keep` 里的 KEK 占据 slot 0..n,搬运只能从 n 开始。所以当 `keep`
95    /// 比文件原有的密码数更多时(典型是 `add`),原本在 slot n 上的槽会被
96    /// 新密码盖掉——**而我们无法知道那里原来是真密码还是随机填充**。
97    ///
98    /// 为 true 时调用方应提醒用户:这次新增可能作废了此文件上的某个
99    /// 其它密码(如恢复码)。不能断言「一定作废了」,那同样是我们不知道
100    /// 的事;只能如实说「可能」。
101    ///
102    /// 判据是 `keep.len() > 1`:只保留一个密码时(`change` / `remove`)
103    /// 不存在增长,slot 0 本来就会被重写。
104    pub may_have_evicted: bool,
105}
106
107/// 未被 `keep` 覆盖的那些槽怎么处理。
108///
109/// # 为什么要做成显式选择,而不是默认某一种
110///
111/// 两种处理方式的后果**都不可逆**,且方向相反:一个会永久丢失用户可能
112/// 依赖的恢复码,另一个会让用户以为「删掉的密码」其实还留着。调用方必须
113/// 明确表态,不能靠一个容易漏看的布尔默认值。
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum OtherSlots {
116    /// 原样搬运:把不属于 `keep` 的槽逐字节复制过去。
117    ///
118    /// 用于 `add` / `change`——用户只想动自己这一个密码,不该殃及
119    /// 这个文件上的恢复码或别人的密码。
120    ///
121    /// # 为什么不需要先认出那些槽
122    ///
123    /// 每个 slot 的密文由 `(KEK, file_uuid, slot_index)` 唯一确定,
124    /// 与相邻槽无关;而本函数保留原 FEK,所以旧槽里包的还是同一个 FEK。
125    /// 头部 MAC 的密钥同样由 FEK 派生,搬完重算一次即自洽。
126    ///
127    /// **「保住某个槽」与「知道某个槽是什么」是两件事,前者不需要后者。**
128    ///
129    /// 代价是保留只能**无差别**:当初给同事配的临时密码也会一起留下。
130    /// 想清掉特定密码,本模式做不到(格式上分不清谁是谁),只能整体
131    /// [`OtherSlots::Discard`] 或换 FEK 重新加密。
132    ///
133    /// # 边界:`keep` 增长时仍会顶掉靠前的槽
134    ///
135    /// `keep` 占据 slot 0..n,搬运只能从 n 开始。所以 `add` 一个新密码时,
136    /// 原本在 slot n 上的槽会被新密码盖掉。实测:slot0=主密码、
137    /// slot1=恢复码的文件,`add` 一个新密码后恢复码没了。
138    ///
139    /// 这个边界**绕不开**:包裹密钥绑定 `slot_index`,新密码必须写在它
140    /// 自己的下标上;而我们又分不清哪个下标是空的,没有「找个真空位」
141    /// 这种操作可用。调用方应根据 [`RewriteOutcome::may_have_evicted`]
142    /// 提醒用户。
143    Carry,
144    /// 全部丢弃,填随机字节——即「只留下 `keep` 里这些」。
145    ///
146    /// 用于 `remove`。UI 文案必须点明这会作废**包括恢复码在内**的其它
147    /// 全部密码,否则用户以为只是删掉了某个协作者。
148    Discard,
149}
150
151/// 用一组新的 KEK 重建 slot 区,保留原 FEK 与全部载荷。
152///
153/// `unlock` 用于解开现有文件(只需其中一个能命中);`keep` 是改写后应当能打开
154/// 该文件的 KEK,按顺序占用 slot 0..n。其余槽按 `others` 处理。
155///
156/// # 为什么 `keep` 为空要报错而不是「清空所有密码」
157///
158/// 没有任何 slot 的文件永远无法再打开,等同于销毁数据。这种操作如果真的需要,
159/// 应该由调用方明确地删除文件,而不是通过一次「移除密码」意外达成。
160///
161/// # Errors
162///
163/// - [`Error::TooManySlots`]:`keep` 超过 8 个
164/// - [`Error::NoMatchingSlot`]:`unlock` 里没有能解开该文件的 KEK
165/// - [`Error::HeaderMacMismatch`]:原文件头部已被篡改
166/// - [`Error::MalformedHeader`]:`keep` 为空,或文件头部字段非法
167pub fn rewrite_slots(
168    data: &[u8],
169    unlock: &[Kek],
170    keep: &[Kek],
171    others: OtherSlots,
172) -> Result<RewriteOutcome> {
173    if keep.is_empty() {
174        return Err(Error::MalformedHeader {
175            reason: "refusing to leave a file with zero key slots; it could never be opened again",
176        });
177    }
178    if keep.len() > SLOT_COUNT {
179        return Err(Error::TooManySlots { got: keep.len(), max: SLOT_COUNT });
180    }
181
182    // 完整走一遍 open:它会验证头部 MAC。绝不能跳过——若头部已被篡改,
183    // 我们会把篡改后的字段连同新 MAC 一起签进去,等于替攻击者背书。
184    let opened = crate::file::open(data, unlock)?;
185    let header = FixedHeader::parse(data)?;
186
187    let old_area = data.get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN))
188        .ok_or(Error::Truncated {
189            context: "key slot area",
190            need: SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN),
191            got: data.len(),
192        })?;
193    let (slot_area, carried_opaque) = build_area(
194        keep,
195        opened.fek(),
196        &header.file_uuid,
197        header.cipher_id,
198        old_area,
199        others,
200    )?;
201
202    // TLV 区与载荷原样搬运。这里刻意不做任何「顺手整理」:
203    // 本操作的可验证性质就是「除 slot 区和 MAC 外一字节不变」
204    let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
205    let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
206        context: "tlv area",
207        need: tlv_end,
208        got: data.len(),
209    })?;
210    let fixed = data.get(..FIXED_HEADER_LEN).ok_or(Error::Truncated {
211        context: "fixed header",
212        need: FIXED_HEADER_LEN,
213        got: data.len(),
214    })?;
215    let payload_start = tlv_end.saturating_add(HEADER_MAC_LEN);
216    let payload = data.get(payload_start..).ok_or(Error::Truncated {
217        context: "payload",
218        need: payload_start,
219        got: data.len(),
220    })?;
221
222    let mut covered = Writer::with_capacity(header.header_len as usize);
223    covered.bytes(fixed).bytes(&slot_area).bytes(tlv_blob);
224    let mac_key = opened.fek().derive_header_mac_key(&header.file_uuid);
225    let mac = header_mac(&mac_key, covered.as_slice());
226
227    let mut out =
228        Writer::with_capacity((header.header_len as usize).saturating_add(payload.len()));
229    out.bytes(covered.as_slice()).bytes(&mac).bytes(payload);
230    let bytes = out.into_vec();
231
232    debug_assert_eq!(bytes.len(), data.len(), "slot 改写不得改变文件长度");
233    Ok(RewriteOutcome {
234        bytes,
235        opened_slot: opened.slot_index,
236        slot_used: keep.len(),
237        carried_opaque,
238        // keep 占 slot 0..n,搬运从 n 起。n > 1 就意味着 slot 1..n 这几个
239        // 下标被新写入覆盖了,而那里原来是什么我们不知道
240        may_have_evicted: others == OtherSlots::Carry && keep.len() > 1,
241    })
242}
243
244/// 构建 slot 区:前 n 个槽包裹同一个 FEK,其余按 `others` 处理。
245///
246/// 返回 `(slot 区字节, 原样搬运的槽数)`。
247///
248/// 与 [`crate::slot::build_slot_area`] 的区别:那个用于**新建**文件,没有
249/// 「旧槽」可搬,所以只有填随机一种行为。逻辑重叠的部分(前 n 槽的包裹方式、
250/// 空槽必须随机)两处都要改。
251fn build_area(
252    keks: &[Kek],
253    fek: &Fek,
254    file_uuid: &[u8; 16],
255    cipher: CipherId,
256    old_area: &[u8],
257    others: OtherSlots,
258) -> Result<(Vec<u8>, usize)> {
259    let mut w = Writer::with_capacity(SLOT_AREA_LEN);
260    for (i, kek) in keks.iter().enumerate() {
261        let idx = u16::try_from(i)
262            .map_err(|_| Error::TooManySlots { got: keks.len(), max: SLOT_COUNT })?;
263        let wrap_key = kek.derive_slot_key(file_uuid, idx);
264        let wrapped = cipher.encrypt(&wrap_key, &ZERO_NONCE, fek.as_key().as_bytes(), &[])?;
265        w.bytes(&wrapped);
266    }
267
268    let mut carried = 0usize;
269    match others {
270        // 原样搬运:逐槽从旧区复制。
271        //
272        // **下标必须对齐**:slot 的包裹密钥含 slot_index,搬到别的下标上
273        // 就再也解不开了。所以这里按 i 对 i 复制,绝不能「压缩到前面」——
274        // 那会让被搬运的密码静默失效,而文件看起来完全正常。
275        //
276        // # 一个实测暴露的边界:keep 增长时会踩掉靠前的旧槽
277        //
278        // keep 里的 KEK 占据 slot 0..n,搬运只能从 n 开始。所以当 keep 比
279        // 上次多一个时,原本在 slot n 上的那个「不认识的槽」会被新密码盖掉。
280        //
281        // 实测:文件是 slot0=主密码、slot1=恢复码,再 add 一个新密码
282        // (keep=[主, 新])→ 新密码写进 slot1,恢复码没了。
283        //
284        // 这不是可以靠「换个下标写」绕开的:包裹密钥绑定 slot_index,
285        // 新密码必须写在它自己那个下标上;而我们又分不清 slot1 上原来是
286        // 真密码还是随机填充(可否认性),没有「找个真空位」这种操作。
287        //
288        // 所以这里如实统计**实际保住了几个**,由调用方决定要不要提醒用户。
289        // 掩盖它才是错的:用户以为 add 是纯增量,而它可能悄悄顶掉一个槽。
290        OtherSlots::Carry => {
291            for i in keks.len()..SLOT_COUNT {
292                let start = i.saturating_mul(SLOT_LEN);
293                let end = start.saturating_add(SLOT_LEN);
294                match old_area.get(start..end) {
295                    Some(slot) => {
296                        w.bytes(slot);
297                        carried = carried.saturating_add(1);
298                    }
299                    // 旧区短于预期(理论上不可能,调用方已按 SLOT_AREA_LEN
300                    // 取过切片)。退回随机而不是报错:宁可多丢一个来历不明
301                    // 的槽,也不要让整个改密码操作失败
302                    None => {
303                        w.random(SLOT_LEN);
304                    }
305                }
306            }
307        }
308        // 空槽必须是随机而非零:填零会让「用了几个密码」直接可见
309        OtherSlots::Discard => {
310            let used = keks.len().saturating_mul(SLOT_LEN);
311            w.random(SLOT_AREA_LEN.saturating_sub(used));
312        }
313    }
314
315    let out = w.into_vec();
316    debug_assert_eq!(out.len(), SLOT_AREA_LEN, "slot 区必须恰好 384 字节");
317    Ok((out, carried))
318}
319
320/// 可管理模式下,一个槽位要怎么处理。
321///
322/// 不 derive Clone:`Kek` 刻意没有 Clone(密钥不该被随手复制),调用方
323/// 按引用传这个切片即可。
324#[derive(Debug)]
325pub enum SlotPlan {
326    /// 保留这个槽位的原有字节。
327    ///
328    /// 原样搬运,不需要知道它属于谁——「保住某个槽」与「知道某个槽是
329    /// 什么」是两件事,前者不需要后者。
330    Keep,
331    /// 把这个槽位换成给定的 KEK。
332    Write(Kek),
333    /// 清空这个槽位(填随机,目录里应相应记成 Empty)。
334    Clear,
335}
336
337/// 按槽位目录精确改写 slot 区(可管理模式,规范 §3.5)。
338///
339/// # 与 [`rewrite_slots`] 的区别
340///
341/// 那个是可否认模式的路径:`keep` 里的钥匙依次占 slot 0..n,其余槽位
342/// 只能**无差别**地整体搬运或整体丢弃。它做不到「删掉协作者 B 而保住
343/// 恢复码」,因为它根本分不清哪个槽是谁的。
344///
345/// 这里要求调用方给出**每个下标**的处置方案,所以能精确管理。前提是
346/// 文件处于可管理模式、有一份槽位目录告诉调用方谁在哪个下标上。
347///
348/// # 为什么下标不能重排
349///
350/// 包裹密钥含 `slot_index`,一个槽搬到别的下标上就再也解不开了。所以
351/// `plans[i]` 严格对应 slot i,`Keep` 是原地保留而不是「挪到前面去」。
352/// 这也意味着删除之后会留下空洞——那不是缺陷,`SlotDirectory::first_free`
353/// 正是为了把空洞重新用起来,这也是可管理模式下 `add` 不必再赌下标的原因。
354///
355/// # Errors
356///
357/// - `plans` 长度不是 [`SLOT_COUNT`]
358/// - 改写后一个槽位都不剩(会留下永远打不开的文件)
359/// - `unlock` 打不开这个文件,或头部 MAC 校验失败
360/// - 文件不是可管理模式(没有槽位目录可替换)
361pub fn rewrite_slots_managed(
362    data: &[u8],
363    unlock: &[Kek],
364    plans: &[SlotPlan],
365    new_directory: &crate::slotdir::SlotDirectory,
366) -> Result<RewriteOutcome> {
367    if plans.len() != SLOT_COUNT {
368        return Err(Error::TooManySlots { got: plans.len(), max: SLOT_COUNT });
369    }
370    // 一个都不留等于销毁数据。写出去之后才发现打不开,用户同时失去了
371    // 文件和访问权,所以在这里就拦住
372    if !plans.iter().any(|p| matches!(p, SlotPlan::Keep | SlotPlan::Write(_))) {
373        return Err(Error::MalformedHeader {
374            reason: "refusing to leave a file with zero key slots; it could never be opened again",
375        });
376    }
377
378    // 只剩设备密钥也等于销毁数据,只是代价延迟到换机器那天。
379    //
380    // 设备密钥绑在这台机器的 OS 密钥库上:换机器、重装系统或重置系统
381    // 生物识别——任一发生它就永久解不开,而且**无法恢复**。那时若
382    // 没有密码或恢复码,数据彻底拿不回来。
383    //
384    // 用户很容易走到这一步:按指纹进来、看着清单觉得「密码反正记不住,
385    // 删了吧」。删的那一刻什么都正常,代价几个月后才显现。
386    //
387    // 这一层拦得住,是因为可管理模式的目录记着每个槽是什么类型。可否认
388    // 模式拦不住(那里分不清谁是谁),所以那条路径上只能靠 UI 警示。
389    {
390        let surviving: Vec<crate::slotdir::SlotKind> = (0..SLOT_COUNT)
391            .filter(|i| {
392                plans
393                    .get(*i)
394                    .is_some_and(|p| matches!(p, SlotPlan::Keep | SlotPlan::Write(_)))
395            })
396            .filter_map(|i| new_directory.get(i).map(|e| e.kind))
397            .filter(|k| k.is_occupied())
398            .collect();
399        // 全是设备密钥才拦。留着一个密码、一个恢复码或任何其它类型都放行——
400        // 那些都能在换机器之后重新用上
401        if !surviving.is_empty()
402            && surviving
403                .iter()
404                .all(|k| *k == crate::slotdir::SlotKind::Device)
405        {
406            return Err(Error::MalformedHeader {
407                reason: "refusing to leave only device keys; they stop working when the machine, \
408                         OS or biometrics change, and there would be no way back in",
409            });
410        }
411    }
412
413    // 完整走一遍 open:它会验证头部 MAC。绝不能跳过——若头部已被篡改,
414    // 我们会把篡改后的字段连同新 MAC 一起签进去,等于替攻击者背书
415    let opened = crate::file::open(data, unlock)?;
416    let header = FixedHeader::parse(data)?;
417    if !header.has_flag(crate::header::flags::SLOT_DIRECTORY) {
418        return Err(Error::MissingTlv { tlv_type: crate::tlv::types::SLOT_DIRECTORY });
419    }
420
421    let old_area = data
422        .get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN))
423        .ok_or(Error::Truncated {
424            context: "key slot area",
425            need: SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN),
426            got: data.len(),
427        })?;
428
429    let mut w = Writer::with_capacity(SLOT_AREA_LEN);
430    let mut carried = 0usize;
431    let mut used = 0usize;
432    for (i, plan) in plans.iter().enumerate() {
433        match plan {
434            SlotPlan::Keep => {
435                let start = i.saturating_mul(SLOT_LEN);
436                let end = start.saturating_add(SLOT_LEN);
437                match old_area.get(start..end) {
438                    Some(slot) => {
439                        w.bytes(slot);
440                        carried = carried.saturating_add(1);
441                        used = used.saturating_add(1);
442                    }
443                    // 旧区短于预期(理论上不可能)。退回随机而不是报错,
444                    // 理由同可否认路径:宁可多丢一个槽也不要整个操作失败
445                    None => {
446                        w.random(SLOT_LEN);
447                    }
448                }
449            }
450            SlotPlan::Write(kek) => {
451                let idx = u16::try_from(i)
452                    .map_err(|_| Error::TooManySlots { got: i, max: SLOT_COUNT })?;
453                let wrap_key = kek.derive_slot_key(&header.file_uuid, idx);
454                let wrapped = header.cipher_id.encrypt(
455                    &wrap_key,
456                    &ZERO_NONCE,
457                    opened.fek().as_key().as_bytes(),
458                    &[],
459                )?;
460                w.bytes(&wrapped);
461                used = used.saturating_add(1);
462            }
463            // 空槽必须是随机而非零。这一条在可管理模式下同样成立:目录是
464            // 加密的,但 slot 区不是——填零照样能被数出用了几个槽
465            SlotPlan::Clear => {
466                w.random(SLOT_LEN);
467            }
468        }
469    }
470    let slot_area = w.into_vec();
471    debug_assert_eq!(slot_area.len(), SLOT_AREA_LEN, "slot 区必须恰好 384 字节");
472
473    // TLV 区:换掉槽位目录那一条的密文,其余字节原样。
474    //
475    // 直接替换密文段而不是重建整个 TLV 区:后者会重排条目顺序、改变
476    // 长度,而本操作的可验证性质是「文件长度不变」。槽位目录定长 24
477    // 字节,同一 AEAD 下密文长度也固定,所以就地替换是安全的
478    let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
479    let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
480        context: "tlv area",
481        need: tlv_end,
482        got: data.len(),
483    })?;
484    let new_value = crate::tlv::encrypt_entry(
485        crate::tlv::types::SLOT_DIRECTORY,
486        crate::tlv::tlv_flags::CRITICAL,
487        &new_directory.encode(),
488        opened.fek(),
489        header.cipher_id,
490    )?
491    .value;
492    let new_tlv = replace_tlv_value(tlv_blob, crate::tlv::types::SLOT_DIRECTORY, &new_value)?;
493
494    let fixed = data.get(..FIXED_HEADER_LEN).ok_or(Error::Truncated {
495        context: "fixed header",
496        need: FIXED_HEADER_LEN,
497        got: data.len(),
498    })?;
499    let payload_start = tlv_end.saturating_add(HEADER_MAC_LEN);
500    let payload = data.get(payload_start..).ok_or(Error::Truncated {
501        context: "payload",
502        need: payload_start,
503        got: data.len(),
504    })?;
505
506    let mut covered = Writer::with_capacity(header.header_len as usize);
507    covered.bytes(fixed).bytes(&slot_area).bytes(&new_tlv);
508    let mac_key = opened.fek().derive_header_mac_key(&header.file_uuid);
509    let mac = header_mac(&mac_key, covered.as_slice());
510
511    let mut out =
512        Writer::with_capacity((header.header_len as usize).saturating_add(payload.len()));
513    out.bytes(covered.as_slice()).bytes(&mac).bytes(payload);
514    let bytes = out.into_vec();
515
516    debug_assert_eq!(bytes.len(), data.len(), "slot 改写不得改变文件长度");
517    Ok(RewriteOutcome {
518        bytes,
519        opened_slot: opened.slot_index,
520        slot_used: used,
521        carried_opaque: carried,
522        // 可管理模式不会「可能顶掉」:调用方明确指定了每个下标怎么处理。
523        // 这是这个模式最实在的收益——add 不再需要赌
524        may_have_evicted: false,
525    })
526}
527
528/// 就地替换某个 TLV 条目的 value,其余字节原样保留。
529///
530/// 要求新旧 value 等长——调用方负责保证(槽位目录定长)。不等长时报错
531/// 而不是尽力而为:长度一变,`header_len` 与 `tlv_len` 就都对不上了,
532/// 而那两个字段在固定头里,改它们等于重写整个文件。
533fn replace_tlv_value(blob: &[u8], want_type: u16, new_value: &[u8]) -> Result<Vec<u8>> {
534    let mut out = blob.to_vec();
535    let mut at = 0usize;
536    while at.saturating_add(crate::tlv::TLV_HEADER_LEN) <= blob.len() {
537        let t = u16::from_le_bytes([
538            *blob.get(at).unwrap_or(&0),
539            *blob.get(at.saturating_add(1)).unwrap_or(&0),
540        ]);
541        let len = u32::from_le_bytes([
542            *blob.get(at.saturating_add(4)).unwrap_or(&0),
543            *blob.get(at.saturating_add(5)).unwrap_or(&0),
544            *blob.get(at.saturating_add(6)).unwrap_or(&0),
545            *blob.get(at.saturating_add(7)).unwrap_or(&0),
546        ]) as usize;
547        let vstart = at.saturating_add(crate::tlv::TLV_HEADER_LEN);
548        let vend = vstart.saturating_add(len);
549        if vend > blob.len() {
550            break;
551        }
552        if t == want_type {
553            if len != new_value.len() {
554                return Err(Error::MalformedTlv {
555                    tlv_type: want_type,
556                    reason: "slot directory length changed; header_len would no longer match",
557                });
558            }
559            out.get_mut(vstart..vend)
560                .ok_or(Error::MalformedTlv {
561                    tlv_type: want_type,
562                    reason: "tlv value out of range",
563                })?
564                .copy_from_slice(new_value);
565            return Ok(out);
566        }
567        at = vend;
568    }
569    Err(Error::MissingTlv { tlv_type: want_type })
570}
571/// slot 区在文件中的字节范围,供调用方做「只有这一段变了」的校验。
572#[must_use]
573pub const fn slot_area_range() -> (usize, usize) {
574    (SLOT_AREA_OFFSET, SLOT_AREA_OFFSET + SLOT_AREA_LEN)
575}
576
577#[cfg(test)]
578#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "测试断言需要")]
579mod tests {
580    use super::*;
581    use crate::crypto::{Argon2Params, SecretKey};
582    use crate::file::{EncryptOptions, RandomMaterial, encrypt};
583
584    fn kek(seed: u8) -> Kek {
585        // 直接构造 KEK,避开 Argon2 的开销:本模块的逻辑与 KEK 怎么来的无关
586        Kek::from_key(SecretKey::from_bytes(core::array::from_fn(|i| {
587            (i as u8).wrapping_mul(31) ^ seed
588        })))
589    }
590
591    fn sample(keks: &[Kek]) -> Vec<u8> {
592        let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
593        let opts = EncryptOptions {
594            argon2: Argon2Params { m_kib: 8, t: 1, p: 1 },
595            ..Default::default()
596        };
597        encrypt(&plain, keks, &[7u8; 16], &opts, &RandomMaterial::generate())
598            .unwrap()
599            .bytes
600    }
601
602    /// 加了新密码后,新旧密码都要能打开。
603    ///
604    /// 不这样会怎样:只测新密码可用,就无法发现「重建 slot 区时把原密码
605    /// 漏掉了」——而这正是 add 最容易出的错。
606    #[test]
607    fn add_keeps_both_passwords() {
608        let a = kek(0x11);
609        let b = kek(0x22);
610        let data = sample(&[a.duplicate()]);
611
612        let out = rewrite_slots(&data, &[a.duplicate()], &[a.duplicate(), b.duplicate()], OtherSlots::Carry).unwrap();
613
614        assert!(crate::file::open(&out.bytes, &[a]).is_ok(), "原密码必须仍可用");
615        assert!(crate::file::open(&out.bytes, &[b]).is_ok(), "新密码必须可用");
616        assert_eq!(out.slot_used, 2);
617    }
618
619    /// 移除后被移除的密码必须真的打不开。
620    ///
621    /// 不这样会怎样:如果实现只是「把新集合写进前几个槽」而没有覆盖掉原来
622    /// 更靠后的槽,被移除的密码依然能命中,remove 就是假的。
623    #[test]
624    fn removed_password_no_longer_opens() {
625        let a = kek(0x33);
626        let b = kek(0x44);
627        let data = sample(&[a.duplicate(), b.duplicate()]);
628        // 先确认两个都能开,否则后面的断言证明不了任何东西
629        assert!(crate::file::open(&data, &[b.duplicate()]).is_ok());
630
631        let out =
632            rewrite_slots(&data, &[a.duplicate()], &[a.duplicate()], OtherSlots::Discard).unwrap();
633
634        assert!(crate::file::open(&out.bytes, &[a]).is_ok(), "保留的密码应可用");
635        let err = crate::file::open(&out.bytes, &[b]).unwrap_err();
636        assert!(
637            matches!(err, Error::NoMatchingSlot),
638            "被移除的密码必须打不开,实际 {err:?}"
639        );
640    }
641
642    /// 载荷与 file_uuid 必须逐字节不变——这是「不重写载荷」的可验证形式。
643    ///
644    /// 不这样会怎样:一旦有人图省事改成「解密再加密」,这条会立刻失败。
645    #[test]
646    fn payload_and_uuid_untouched() {
647        let a = kek(0x55);
648        let b = kek(0x66);
649        let data = sample(&[a.duplicate()]);
650        let out = rewrite_slots(&data, &[a.duplicate()], &[b], OtherSlots::Carry).unwrap();
651
652        let h0 = FixedHeader::parse(&data).unwrap();
653        let h1 = FixedHeader::parse(&out.bytes).unwrap();
654        assert_eq!(h0.file_uuid, h1.file_uuid, "file_uuid 不得改变");
655        assert_eq!(h0.base_nonce, h1.base_nonce, "base_nonce 不得改变");
656        assert_eq!(data.len(), out.bytes.len(), "文件长度不得改变");
657
658        let (lo, hi) = slot_area_range();
659        assert_eq!(data[..lo], out.bytes[..lo], "固定头不得改变");
660        let mac_end = h0.header_len as usize;
661        let mac_start = mac_end - HEADER_MAC_LEN;
662        assert_eq!(data[hi..mac_start], out.bytes[hi..mac_start], "TLV 区不得改变");
663        assert_eq!(data[mac_end..], out.bytes[mac_end..], "载荷必须逐字节相同");
664        assert_ne!(data[lo..hi], out.bytes[lo..hi], "slot 区应当变了");
665        assert_ne!(
666            data[mac_start..mac_end],
667            out.bytes[mac_start..mac_end],
668            "头部 MAC 应当随 slot 区一起变"
669        );
670    }
671
672    /// 改写后明文仍能正确还原。
673    ///
674    /// 不这样会怎样:前面的断言只看结构,万一 FEK 包裹错了(例如包裹了
675    /// 派生密钥而不是 FEK 本身),open 也可能通过,但解出来是垃圾。
676    #[test]
677    fn plaintext_still_decrypts() {
678        let a = kek(0x77);
679        let b = kek(0x88);
680        let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
681        let data = sample(&[a.duplicate()]);
682
683        let out = rewrite_slots(&data, &[a], &[b.duplicate()], OtherSlots::Carry).unwrap();
684        let opened = crate::file::open(&out.bytes, &[b]).unwrap();
685        assert_eq!(opened.decrypt_all(&out.bytes).unwrap(), plain);
686    }
687
688    /// 空集合必须被拒绝:留下 0 个 slot 等于销毁文件。
689    #[test]
690    fn empty_keep_is_rejected() {
691        let a = kek(0x99);
692        let data = sample(&[a.duplicate()]);
693        assert!(
694            rewrite_slots(&data, &[a], &[], OtherSlots::Carry).is_err(),
695            "keep 为空必须报错"
696        );
697    }
698
699    /// 超过 8 个必须被拒绝,而不是静默丢掉多余的。
700    #[test]
701    fn too_many_slots_is_rejected() {
702        let a = kek(0xAA);
703        let data = sample(&[a.duplicate()]);
704        let many: Vec<Kek> = (0..9).map(|i| kek(0xB0 + i)).collect();
705        let err = rewrite_slots(&data, &[a], &many, OtherSlots::Carry).unwrap_err();
706        assert!(matches!(err, Error::TooManySlots { got: 9, max: 8 }), "实际 {err:?}");
707    }
708
709    /// 拿不到能解开的密码时必须失败,不能产出一个新 slot 区。
710    #[test]
711    fn wrong_unlock_password_fails() {
712        let a = kek(0xCC);
713        let wrong = kek(0xDD);
714        let data = sample(&[a]);
715        let err =
716            rewrite_slots(&data, &[wrong.duplicate()], &[wrong], OtherSlots::Carry).unwrap_err();
717        assert!(matches!(err, Error::NoMatchingSlot), "实际 {err:?}");
718    }
719
720    /// 头部被篡改的文件必须拒绝改写。
721    ///
722    /// 不这样会怎样:若跳过 MAC 校验直接重建,我们会给篡改后的头部算一个
723    /// 新的有效 MAC,把攻击者的修改「洗白」成合法文件。
724    #[test]
725    fn tampered_header_is_rejected() {
726        let a = kek(0xEE);
727        let mut data = sample(&[a.duplicate()]);
728        // 改动固定头里的 plaintext_size(偏移 64,见 header 布局),
729        // 它被 MAC 覆盖,因此必须被检出
730        data[64] ^= 0x01;
731        let err =
732            rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Carry).unwrap_err();
733        assert!(
734            matches!(err, Error::HeaderMacMismatch | Error::MalformedHeader { .. }),
735            "篡改必须被拒绝,实际 {err:?}"
736        );
737    }
738
739    /// 空槽必须是随机填充,不能是零。
740    ///
741    /// 不这样会怎样:填零会让「这个文件只配了 1 个密码」从字节上直接可见,
742    /// 可否认性当场失效。而这种回归不会让任何功能测试变红。
743    #[test]
744    fn unused_slots_are_random_not_zero() {
745        let a = kek(0x12);
746        let data = sample(&[a.duplicate()]);
747        let out =
748            rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Discard).unwrap();
749        let (lo, hi) = slot_area_range();
750        let pad = &out.bytes[lo + SLOT_LEN..hi];
751        assert_eq!(pad.len(), SLOT_LEN * 7);
752        assert!(pad.iter().any(|&b| b != 0), "空槽不得填零");
753
754        // 两次改写的填充应当不同,否则说明填充不是每次新取的随机
755        let out2 =
756            rewrite_slots(&data, &[kek(0x12)], &[kek(0x12)], OtherSlots::Discard).unwrap();
757        assert_ne!(
758            &out.bytes[lo + SLOT_LEN..hi],
759            &out2.bytes[lo + SLOT_LEN..hi],
760            "两次改写的随机填充不应相同"
761        );
762    }
763
764    /// 改写后的文件长度与 header_len 必须仍然自洽。
765    #[test]
766    fn header_len_still_consistent() {
767        let a = kek(0x34);
768        let data = sample(&[a.duplicate()]);
769        let out =
770            rewrite_slots(&data, &[a.duplicate()], &[a, kek(0x56)], OtherSlots::Carry).unwrap();
771        let h = FixedHeader::parse(&out.bytes).unwrap();
772        h.validate().unwrap();
773        assert!(out.bytes.len() >= h.header_len as usize);
774    }
775
776    // ========================================================
777    // OtherSlots::Carry —— 改密码不再抹掉认不出来的槽
778    // ========================================================
779
780    /// 改密码后,那个没参与重建的槽(如恢复码)必须仍然能打开文件。
781    ///
782    /// 不这样会怎样:这正是实测到的真缺陷——文件挂着「主密码 + 恢复码」,
783    /// `key change` 只提供当前与新密码,恢复码被当作不认识的槽填成随机,
784    /// 用户改一次密码就永久失去它,且只在真忘密码那天才发现。
785    #[test]
786    fn carry_preserves_unknown_slot_across_password_change() {
787        let main = kek(0x41);
788        let reco = kek(0x42); // 模拟恢复码:占 slot 1,改密码时不提供它
789        let fresh = kek(0x43);
790        let data = sample(&[main.duplicate(), reco.duplicate()]);
791        // 基线:不先确认两个都能开,后面的断言证明不了任何东西
792        assert!(crate::file::open(&data, &[reco.duplicate()]).is_ok(), "基线:恢复码应可用");
793
794        let out =
795            rewrite_slots(&data, &[main.duplicate()], &[fresh.duplicate()], OtherSlots::Carry)
796                .unwrap();
797
798        assert!(crate::file::open(&out.bytes, &[fresh]).is_ok(), "新密码必须可用");
799        assert!(
800            crate::file::open(&out.bytes, &[reco]).is_ok(),
801            "没参与重建的槽必须被原样保住——这是本次改动的全部意义"
802        );
803        assert!(
804            crate::file::open(&out.bytes, &[main]).is_err(),
805            "旧主密码必须失效,否则 change 是假的"
806        );
807    }
808
809    /// 被搬运的槽解出来的明文必须正确,不能只是「能打开」。
810    ///
811    /// 不这样会怎样:搬字节最怕「open 通过但解出垃圾」。若搬运时下标错位,
812    /// AEAD 可能碰巧不报错(概率极低但不为零),而内容已经不对了。
813    #[test]
814    fn carried_slot_decrypts_correct_plaintext() {
815        let main = kek(0x44);
816        let reco = kek(0x45);
817        let fresh = kek(0x46);
818        let plain: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
819        let data = sample(&[main.duplicate(), reco.duplicate()]);
820
821        let out = rewrite_slots(&data, &[main], &[fresh], OtherSlots::Carry).unwrap();
822        let opened = crate::file::open(&out.bytes, &[reco]).unwrap();
823        assert_eq!(opened.decrypt_all(&out.bytes).unwrap(), plain, "搬运后明文必须原样");
824    }
825
826    /// 搬运必须**下标对齐**,不能把后面的槽压缩到前面。
827    ///
828    /// 不这样会怎样:slot 的包裹密钥含 slot_index,搬到别的下标上就再也
829    /// 解不开了。而文件看起来完全正常——长度对、MAC 对、新密码能开,
830    /// 只有那个被搬错位的密码静默失效。这是最难查的一类缺陷。
831    #[test]
832    fn carry_keeps_slot_indices_aligned() {
833        let main = kek(0x47);
834        let far = kek(0x48);
835        let fresh = kek(0x49);
836        // 让 far 落在 slot 2(中间隔一个空槽),这样「压缩到前面」的错误
837        // 实现会把它挪到 slot 1,从而解不开
838        let filler = kek(0x4A);
839        let data = sample(&[main.duplicate(), filler, far.duplicate()]);
840        assert!(crate::file::open(&data, &[far.duplicate()]).is_ok(), "基线");
841
842        let out = rewrite_slots(&data, &[main], &[fresh], OtherSlots::Carry).unwrap();
843
844        assert!(
845            crate::file::open(&out.bytes, &[far]).is_ok(),
846            "slot 2 的槽必须仍在 slot 2 上,压缩到前面会让它永远解不开"
847        );
848
849        // 直接比字节:搬运意味着那几个槽逐字节不变
850        let (lo, _) = slot_area_range();
851        let s2 = lo + SLOT_LEN * 2;
852        assert_eq!(
853            &data[s2..s2 + SLOT_LEN],
854            &out.bytes[s2..s2 + SLOT_LEN],
855            "slot 2 必须逐字节原样"
856        );
857    }
858
859    /// `Discard` 必须真的清场——这是 remove 赖以成立的性质。
860    ///
861    /// 不这样会怎样:两种策略若行为相同,`remove` 就成了假操作,用户以为
862    /// 作废了别人的密码,实际对方照样能打开。
863    #[test]
864    fn discard_really_wipes_other_slots() {
865        let main = kek(0x4B);
866        let other = kek(0x4C);
867        let data = sample(&[main.duplicate(), other.duplicate()]);
868
869        let out =
870            rewrite_slots(&data, &[main.duplicate()], &[main], OtherSlots::Discard).unwrap();
871
872        let err = crate::file::open(&out.bytes, &[other]).unwrap_err();
873        assert!(matches!(err, Error::NoMatchingSlot), "Discard 必须让其它密码失效,实际 {err:?}");
874    }
875
876    /// 两种策略必须产出不同的 slot 区,且 `carried_opaque` 如实反映。
877    ///
878    /// 不这样会怎样:若调用方传了策略却没生效(例如实现里写死一种),
879    /// 上面两条测试各自都能过,只有并排比较才能发现它们其实走了同一条路。
880    #[test]
881    fn carry_and_discard_differ_observably() {
882        let a = kek(0x4D);
883        let b = kek(0x4E);
884        let data = sample(&[a.duplicate(), b]);
885
886        let carried =
887            rewrite_slots(&data, &[a.duplicate()], &[a.duplicate()], OtherSlots::Carry).unwrap();
888        let wiped =
889            rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Discard).unwrap();
890
891        assert_eq!(carried.carried_opaque, 7, "保留了 8-1=7 个来历不明的槽");
892        assert_eq!(wiped.carried_opaque, 0, "清场不搬运任何槽");
893
894        let (lo, hi) = slot_area_range();
895        assert_ne!(
896            &carried.bytes[lo..hi],
897            &wiped.bytes[lo..hi],
898            "两种策略必须产出不同的 slot 区"
899        );
900        // 搬运版的第 2..8 槽应与原文件逐字节相同
901        assert_eq!(
902            &data[lo + SLOT_LEN..hi],
903            &carried.bytes[lo + SLOT_LEN..hi],
904            "Carry 下未涉及的槽必须逐字节不变"
905        );
906    }
907
908    /// `Carry` 下文件长度与可否认性仍然成立。
909    ///
910    /// 不这样会怎样:搬运若多写或少写字节,slot 区就不再是 384,
911    /// 而 header_len 是明文可见的——长度一变,「用了几个密码」就泄露了。
912    #[test]
913    fn carry_preserves_length_and_slot_area_size() {
914        let a = kek(0x4F);
915        let data = sample(&[a.duplicate()]);
916        let out = rewrite_slots(&data, &[a.duplicate()], &[a], OtherSlots::Carry).unwrap();
917
918        assert_eq!(out.bytes.len(), data.len(), "文件长度不得改变");
919        let (lo, hi) = slot_area_range();
920        assert_eq!(hi - lo, SLOT_LEN * 8, "slot 区必须恒为 8 槽");
921        let h = FixedHeader::parse(&out.bytes).unwrap();
922        h.validate().unwrap();
923    }
924
925    /// `keep` 增长时会顶掉靠前的槽,这个边界必须被如实报告。
926    ///
927    /// 这是端到端验证抓出来的:文件是 slot0=主密码、slot1=恢复码,
928    /// 再 `add` 一个新密码(keep=[主, 新])→ 新密码写进 slot1,恢复码没了。
929    ///
930    /// 不这样会怎样:`add` 看起来是纯增量操作,用户完全想不到它会顶掉
931    /// 一个自己看不见的槽。若不报告,这就是又一个「静默毁掉恢复码」——
932    /// 与本次改动要修的缺陷同类,只是触发路径换成了 add。
933    ///
934    /// 绕不开的原因见 [`OtherSlots::Carry`] 的文档:包裹密钥绑定下标,
935    /// 而我们分不清哪个下标是空的。
936    #[test]
937    fn carry_reports_possible_eviction_when_keep_grows() {
938        let main = kek(0x51);
939        let reco = kek(0x52); // 占 slot 1
940        let extra = kek(0x53);
941        let data = sample(&[main.duplicate(), reco.duplicate()]);
942
943        // add:keep 有两个,会占 slot 0/1,slot1 上的恢复码被顶掉
944        let grown = rewrite_slots(
945            &data,
946            &[main.duplicate()],
947            &[main.duplicate(), extra],
948            OtherSlots::Carry,
949        )
950        .unwrap();
951        assert!(grown.may_have_evicted, "keep 增长必须如实报告可能顶掉了槽");
952        assert!(
953            crate::file::open(&grown.bytes, &[reco.duplicate()]).is_err(),
954            "确认这个边界真实存在——若哪天能保住了,本断言会提醒来更新文档"
955        );
956
957        // change:keep 只有一个,不存在增长,slot 0 本来就会被重写
958        let same = rewrite_slots(&data, &[main.duplicate()], &[kek(0x54)], OtherSlots::Carry)
959            .unwrap();
960        assert!(!same.may_have_evicted, "只保留一个密码时不该报告顶掉");
961        assert!(
962            crate::file::open(&same.bytes, &[reco]).is_ok(),
963            "change 必须保住 slot 1 上的恢复码"
964        );
965    }
966
967    /// `Discard` 永远不报告顶掉——它本来就是清场,语义上没有「意外」。
968    #[test]
969    fn discard_never_reports_eviction() {
970        let a = kek(0x55);
971        let b = kek(0x56);
972        let data = sample(&[a.duplicate(), b.duplicate()]);
973        let out =
974            rewrite_slots(&data, &[a.duplicate()], &[a, b], OtherSlots::Discard).unwrap();
975        assert!(
976            !out.may_have_evicted,
977            "Discard 的语义就是作废其它全部,不存在「意外顶掉」这回事"
978        );
979    }
980}