Skip to main content

omy_core/
dirsidecar.rs

1//! 目录名的钥匙包裹边车(`.omy-name`)。
2//!
3//! # 为什么目录名要两层结构
4//!
5//! 原先目录名直接用 `HKDF(KEK, info="omy/v1/dirname")` 派生的密钥加密。
6//! 这个做法有一个致命的结构性限制:**目录名只能被一把钥匙解开**。
7//!
8//! 实测过它的后果(`probe-tree-multikey`):一棵树用两个密码加密,两个
9//! 密码都能打开每一个密文文件,但只有第一个能解开目录名,第二个解密整棵
10//! 树时报 `content hash mismatch`——用户看到的是「文件损坏」。恢复码撞的
11//! 是同一堵墙。
12//!
13//! 所以改成与文件一致的两层结构:
14//!
15//! ```text
16//! 目录名密文 = AEAD(DK, 目录名)          DK 是随机的目录密钥
17//! 边车       = 每把 KEK 各包一份 DK
18//! ```
19//!
20//! 这样加一把钥匙只是多一份包裹,与目录名本身的长度无关。
21//!
22//! # 为什么包裹必须放边车文件,不能塞进目录名
23//!
24//! 算过了(`probe-dirname-length`),这不是取舍而是唯一可行解。
25//! 包裹塞进目录名时,以 32 字节的明文目录名为例:
26//!
27//! | 钥匙数 | 磁盘名长度 | 上限 255 |
28//! |---|---|---|
29//! | 1 | 196 | 勉强 |
30//! | 2 | 292 | 超 |
31//! | 4 | 484 | 超 |
32//! | 8 | 868 | 超 |
33//!
34//! 两把钥匙就破了 255(NTFS / ext4 / APFS 的共同下限)。更糟的是 8 把
35//! 钥匙时留给明文目录名的额度是 **0 字节**——连一个字都放不下。
36//!
37//! 包裹放边车则与钥匙数量完全无关:同样 32 字节的明文名恒定 100 字符,
38//! 明文目录名要超过 127 字节(约 42 个汉字)才触发截断。
39//!
40//! # 边车固定 8 槽、空槽填随机
41//!
42//! 与 [`crate::slot`] 同样的两条可否认性规则,理由也一样:边车长度固定,
43//! 看不出这个目录配了几把钥匙;空槽填随机而非填零,否则「哪些槽在用」
44//! 直接可见。
45//!
46//! 这里还多一层收益:边车现在**恒定存在**。原先它只在目录名超长时才出现,
47//! 那反而泄露了「这个目录的名字很长」。
48//!
49//! # 丢了边车会怎样
50//!
51//! 目录名再也解不开——这是必须在 UI 里说清楚的后果。它和目录是一体的,
52//! 不是缓存、不是可再生的索引。放在被加密的那个目录**内部**而不是父目录,
53//! 正是为了让它在目录被移动或复制时跟着走。
54
55use crate::crypto::{CipherId, Kek, NONCE_LEN, SecretKey, ZERO_NONCE};
56use crate::error::{Error, Result};
57use crate::util::Writer;
58
59/// 钥匙包裹边车的文件名。
60///
61/// 放在被加密的那个目录**内部**,而不是父目录里:这样目录被整体移动或
62/// 复制时它跟着走。放父目录的话,移动一个目录会让它的名字变成不可恢复。
63///
64/// # 与 `.omy-name` 的分工
65///
66/// 两个边车存的东西完全不同,不能混:
67///
68/// | 文件 | 存什么 | 何时存在 |
69/// |---|---|---|
70/// | `.omy-keys` | DK 的 8 份包裹 | **总是** |
71/// | `.omy-name` | 目录名完整密文 | 仅当磁盘名被截断 |
72///
73/// 前者是打开目录名的钥匙,后者是目录名本身。丢掉任何一个,这个目录的
74/// 名字都再也解不开。
75pub const KEYS_SIDECAR: &str = ".omy-keys";
76
77/// 边车魔数,用于快速排除「这不是我们的文件」。
78pub const SIDECAR_MAGIC: &[u8; 8] = b"OMYDNAME";
79
80/// 边车格式版本。
81pub const SIDECAR_VERSION: u8 = 1;
82
83/// 目录密钥长度。
84pub const DK_LEN: usize = 32;
85
86/// 包裹后的长度:32 字节 DK + 16 字节 AEAD tag。
87pub const WRAP_LEN: usize = 48;
88
89/// 固定槽位数,与文件的 Key Slot Area 保持一致。
90///
91/// 不取更大值:一棵树能用的钥匙数与单个文件能用的应当相同,两个数字
92/// 不一致会让「我给文件加了第 9 个密码,为什么树不行」变成一个需要
93/// 解释的问题。
94pub const SIDECAR_SLOTS: usize = 8;
95
96/// 槽位区总长。
97pub const SIDECAR_SLOT_AREA: usize = SIDECAR_SLOTS * WRAP_LEN;
98
99/// 边车文件总长:magic(8) + version(1) + flags(1) + reserved(2) + nonce(24)
100/// + 槽位区(384)。
101///
102/// reserved 那几个字节是为了让 nonce 从 12 字节偏移开始——对齐不是强需求,
103/// 但让十六进制 dump 读起来容易得多,排查问题时值这几个字节。
104pub const SIDECAR_LEN: usize = 8 + 1 + 1 + 2 + NONCE_LEN + SIDECAR_SLOT_AREA;
105
106/// 可管理模式下的边车总长:在基础长度之后追加加密的槽位目录。
107///
108/// 目录 24 字节 + AEAD tag 16 字节 = 40。与文件那边同理,定长是刻意的:
109/// 变长会让边车长度泄露这棵树配了几把钥匙。
110pub const SIDECAR_LEN_MANAGED: usize = SIDECAR_LEN + crate::slotdir::DIRECTORY_LEN + 16;
111
112/// 边车 flags 位:这棵树带槽位目录(可管理模式)。
113///
114/// 放在明文的第 9 字节。与文件头的 `flags::SLOT_DIRECTORY` 同一个判据:
115/// 「omy 支持两种模式」是产品事实,如实公开它不泄露属于这个用户的东西;
116/// 目录**内容**才是用户秘密,所以那部分用 DK 加密。
117pub const SIDECAR_FLAG_MANAGED: u8 = 1 << 0;
118
119/// HKDF info:把 DK 派生成槽位目录的加密密钥。
120///
121/// 不直接拿 DK 当密钥用:DK 已经在给目录名做 AEAD,同一个密钥用于两处
122/// 不同用途时,一处的 nonce 复用会波及另一处。
123const INFO_SIDECAR_DIR: &[u8] = b"omy/v1/sidecar-slotdir";
124
125/// HKDF info:把 KEK 派生成边车的包裹密钥。
126///
127/// 与目录名密钥的 info 不同域:同一个 KEK 派生出的两个密钥不能相等,
128/// 否则一处的密文可以拿到另一处去试。
129const INFO_SIDECAR_WRAP: &[u8] = b"omy/v1/dirname-wrap";
130
131/// 随机生成的目录密钥。
132///
133/// 独立类型而不是裸 [`SecretKey`]:类型系统会阻止把它和 KEK、FEK 混用。
134/// 三者都是 32 字节,混用不会编译报错,但会产出解不开的数据。
135#[derive(Debug)]
136pub struct DirKey(SecretKey);
137
138impl DirKey {
139    /// 随机生成一个新的目录密钥。
140    #[must_use]
141    pub fn generate() -> Self {
142        let mut raw = [0u8; DK_LEN];
143        crate::util::fill_random(&mut raw);
144        Self(SecretKey::from_bytes(raw))
145    }
146
147    /// 从原始字节还原(解包时用)。
148    #[must_use]
149    pub const fn from_bytes(raw: [u8; DK_LEN]) -> Self {
150        Self(SecretKey::from_bytes(raw))
151    }
152
153    /// 借出底层密钥。
154    #[must_use]
155    pub const fn as_key(&self) -> &SecretKey {
156        &self.0
157    }
158
159    /// 复制一份。目录密钥在一次操作里常要用于多处(解名、重新加密)。
160    #[must_use]
161    pub fn duplicate(&self) -> Self {
162        Self(SecretKey::from_bytes(*self.0.as_bytes()))
163    }
164
165    /// 派生槽位目录的加密密钥。
166    ///
167    /// 不直接拿 DK 本身当密钥:它已经在给目录名做 AEAD,同一个密钥用于
168    /// 两处不同用途时,一处的 nonce 复用会波及另一处。
169    #[must_use]
170    fn slotdir_key(&self, vault_salt: &[u8; 16]) -> SecretKey {
171        crate::crypto::derive_from_secret(&self.0, vault_salt, INFO_SIDECAR_DIR)
172    }
173}
174
175/// 这份边车是不是可管理模式。
176///
177/// 只看明文的 flags 位,不需要密钥——与文件头的 `SLOT_DIRECTORY` 同理,
178/// 「用了哪种模式」是产品事实而非用户秘密。
179#[must_use]
180pub fn is_managed(blob: &[u8]) -> bool {
181    blob.get(..8) == Some(SIDECAR_MAGIC.as_slice())
182        && blob.get(9).is_some_and(|f| f & SIDECAR_FLAG_MANAGED != 0)
183}
184
185/// 从可管理模式的边车里解出槽位目录。
186///
187/// # Errors
188///
189/// 边车不是可管理模式、长度不对、或目录密文解不开时返回错误。
190/// **不返回空目录**:那会让界面显示「一把钥匙都没有」,而它明明打得开。
191pub fn read_slot_directory(
192    blob: &[u8],
193    dk: &DirKey,
194    vault_salt: &[u8; 16],
195    cipher: CipherId,
196) -> Result<crate::slotdir::SlotDirectory> {
197    if !is_managed(blob) {
198        return Err(Error::MalformedTlv {
199            tlv_type: 0,
200            reason: "this sidecar has no slot directory (deniable mode)",
201        });
202    }
203    if blob.len() != SIDECAR_LEN_MANAGED {
204        return Err(Error::MalformedTlv {
205            tlv_type: 0,
206            reason: "managed sidecar has wrong length",
207        });
208    }
209    let ct = blob.get(SIDECAR_LEN..).ok_or(Error::MalformedTlv {
210        tlv_type: 0,
211        reason: "managed sidecar truncated before slot directory",
212    })?;
213    let key = dk.slotdir_key(vault_salt);
214    // 用 ZERO_NONCE:这个密钥只加密这一条消息,且 DK 每棵树各不相同
215    let plain = cipher
216        .decrypt(&key, &ZERO_NONCE, ct, &[])?
217        .ok_or(Error::MalformedTlv {
218            tlv_type: 0,
219            reason: "slot directory failed authentication",
220        })?;
221    crate::slotdir::SlotDirectory::decode(&plain)
222}
223
224/// 把槽位目录加密后追加到边车末尾。
225fn append_slot_directory(
226    base: Vec<u8>,
227    dir: &crate::slotdir::SlotDirectory,
228    dk: &DirKey,
229    vault_salt: &[u8; 16],
230    cipher: CipherId,
231) -> Result<Vec<u8>> {
232    let key = dk.slotdir_key(vault_salt);
233    let ct = cipher.encrypt(&key, &ZERO_NONCE, &dir.encode(), &[])?;
234    let mut out = base;
235    // flags 位要在明文里置上,否则读取侧不会去找目录
236    if let Some(f) = out.get_mut(9) {
237        *f |= SIDECAR_FLAG_MANAGED;
238    }
239    out.extend_from_slice(&ct);
240    debug_assert_eq!(out.len(), SIDECAR_LEN_MANAGED, "可管理边车必须恰好 {SIDECAR_LEN_MANAGED} 字节");
241    Ok(out)
242}
243
244/// 把一把 KEK 派生成边车的包裹密钥。
245///
246/// salt 用 `vault_salt` 而不是目录路径:路径会变(改名、移动),用它做
247/// salt 会让目录一改名边车就失效。
248fn wrap_key(kek: &Kek, vault_salt: &[u8; 16]) -> SecretKey {
249    kek.derive_dirname_key(vault_salt, INFO_SIDECAR_WRAP)
250}
251
252/// 构建边车内容。
253///
254/// `keks` 中第 `i` 把钥匙占第 `i` 个槽,其余槽填随机字节。
255///
256/// # Errors
257///
258/// - [`Error::TooManySlots`]:钥匙数超过 8
259/// - AEAD 加密失败
260pub fn build_sidecar(
261    keks: &[Kek],
262    dk: &DirKey,
263    vault_salt: &[u8; 16],
264    nonce: &[u8; NONCE_LEN],
265    cipher: CipherId,
266) -> Result<Vec<u8>> {
267    if keks.is_empty() {
268        return Err(Error::MalformedTlv {
269            tlv_type: 0,
270            reason: "refusing to build a sidecar with zero keys; the directory name could never be decrypted",
271        });
272    }
273    if keks.len() > SIDECAR_SLOTS {
274        return Err(Error::TooManySlots { got: keks.len(), max: SIDECAR_SLOTS });
275    }
276
277    let mut w = Writer::with_capacity(SIDECAR_LEN);
278    w.bytes(SIDECAR_MAGIC);
279    w.u8(SIDECAR_VERSION);
280    w.bytes(&[0u8; 3]);
281    w.bytes(nonce);
282
283    for kek in keks {
284        let wk = wrap_key(kek, vault_salt);
285        // 用 ZERO_NONCE:包裹密钥每把钥匙各不相同,且只用这一次,
286        // 与 slot 区同理——nonce 复用的风险来自同一密钥加密多条消息
287        let wrapped = cipher.encrypt(&wk, &ZERO_NONCE, dk.as_key().as_bytes(), &[])?;
288        debug_assert_eq!(wrapped.len(), WRAP_LEN, "包裹后必须是 48 字节");
289        w.bytes(&wrapped);
290    }
291
292    // 剩余槽填随机——可否认性的关键,绝不能改成填零
293    let used = keks.len().saturating_mul(WRAP_LEN);
294    w.random(SIDECAR_SLOT_AREA.saturating_sub(used));
295
296    let out = w.into_vec();
297    debug_assert_eq!(out.len(), SIDECAR_LEN, "边车必须恰好 {SIDECAR_LEN} 字节");
298    Ok(out)
299}
300
301/// 在保留原有包裹的前提下,用新的钥匙集合重建边车。
302///
303/// # 为什么需要这个,而不是直接 [`build_sidecar`]
304///
305/// 文件槽位那边有 [`crate::keyslot::OtherSlots::Carry`]:改密码时,
306/// 认不出来的槽位会被原样搬过去,所以挂在文件上的恢复码不会因为改密码
307/// 而失效。边车必须有**同样的语义**,否则会出现一种很难查的半残状态——
308/// 恢复码还能打开每一个文件,却打不开目录名,解密整棵树报
309/// 「content hash mismatch」。
310///
311/// 实测确认过:没有这个函数时,换一次密码恢复码就从边车里消失了。
312///
313/// `old` 是原边车内容;解不出来或格式不对时退化成 [`build_sidecar`],
314/// 因为那时也搬不了什么。
315///
316/// # Errors
317///
318/// 同 [`build_sidecar`]。
319pub fn rebuild_sidecar_carrying(
320    old: Option<&[u8]>,
321    keep: &[Kek],
322    dk: &DirKey,
323    vault_salt: &[u8; 16],
324    nonce: &[u8; NONCE_LEN],
325    cipher: CipherId,
326) -> Result<Vec<u8>> {
327    if keep.is_empty() {
328        return Err(Error::MalformedTlv {
329            tlv_type: 0,
330            reason: "refusing to build a sidecar with zero keys; the directory name could never be decrypted",
331        });
332    }
333    if keep.len() > SIDECAR_SLOTS {
334        return Err(Error::TooManySlots { got: keep.len(), max: SIDECAR_SLOTS });
335    }
336
337    // 先照常放 keep。它们占前面的槽,与 build_sidecar 一致
338    let mut w = Writer::with_capacity(SIDECAR_LEN);
339    w.bytes(SIDECAR_MAGIC);
340    w.u8(SIDECAR_VERSION);
341    w.bytes(&[0u8; 3]);
342    w.bytes(nonce);
343
344    for kek in keep {
345        let wk = wrap_key(kek, vault_salt);
346        let wrapped = cipher.encrypt(&wk, &ZERO_NONCE, dk.as_key().as_bytes(), &[])?;
347        w.bytes(&wrapped);
348    }
349
350    // 再把认不出来的旧槽原样搬过来。
351    //
352    // 为什么可以原样搬:包裹密钥只依赖 (KEK, vault_salt),与槽位下标无关,
353    // 所以同一份 48 字节挪到任何位置都仍然解得开。这与文件槽位不同——
354    // 那边的包裹密钥绑定 slot_index,搬运时下标必须对得上
355    let mut written = keep.len();
356    if let Some(prev) = old {
357        // 两种长度都接受:可管理模式的边车在基础长度之后还追加了
358        // 加密目录,只认 SIDECAR_LEN 的话它的旧包裹一个都搬不过来——
359        // 表现为「换密码后恢复码打不开目录名了」
360        let known_len = prev.len() == SIDECAR_LEN || prev.len() == SIDECAR_LEN_MANAGED;
361        if known_len && prev.get(..8) == Some(SIDECAR_MAGIC.as_slice()) {
362            let slots_start = 12 + NONCE_LEN;
363            for i in 0..SIDECAR_SLOTS {
364                if written >= SIDECAR_SLOTS {
365                    break;
366                }
367                let from = slots_start.saturating_add(i.saturating_mul(WRAP_LEN));
368                let to = from.saturating_add(WRAP_LEN);
369                let Some(slot) = prev.get(from..to) else { continue };
370                // 能被 keep 里某把钥匙解开的槽已经重新写过了,再搬一份
371                // 只是浪费槽位
372                let already = keep.iter().any(|k| {
373                    let wk = wrap_key(k, vault_salt);
374                    matches!(cipher.decrypt(&wk, &ZERO_NONCE, slot, &[]), Ok(Some(_)))
375                });
376                if already {
377                    continue;
378                }
379                w.bytes(slot);
380                written = written.saturating_add(1);
381            }
382        }
383    }
384
385    let used = written.saturating_mul(WRAP_LEN);
386    w.random(SIDECAR_SLOT_AREA.saturating_sub(used));
387
388    let out = w.into_vec();
389    debug_assert_eq!(out.len(), SIDECAR_LEN, "边车必须恰好 {SIDECAR_LEN} 字节");
390    Ok(out)
391}
392
393/// 构建带槽位目录的边车(可管理模式)。
394///
395/// `keks[i]` 占第 `i` 个槽,`dir` 必须与之对应——目录说 slot 2 是恢复码,
396/// 那么 `keks[2]` 就得真是那把恢复码派生出来的 KEK。对不上的话用户会
397///照着目录去删错东西。
398///
399/// # Errors
400///
401/// 同 [`build_sidecar`]。
402pub fn build_sidecar_managed(
403    keks: &[Kek],
404    dir: &crate::slotdir::SlotDirectory,
405    dk: &DirKey,
406    vault_salt: &[u8; 16],
407    nonce: &[u8; NONCE_LEN],
408    cipher: CipherId,
409) -> Result<Vec<u8>> {
410    let base = build_sidecar(keks, dk, vault_salt, nonce, cipher)?;
411    append_slot_directory(base, dir, dk, vault_salt, cipher)
412}
413
414/// 按计划精确改写边车的槽位(可管理模式)。
415///
416/// `plans[i]` 严格对应第 `i` 个槽。与文件那边的
417/// [`crate::keyslot::rewrite_slots_managed`] 语义一致,但有一处关键差异:
418/// 边车的包裹密钥只依赖 `(KEK, vault_salt)`、**与槽位下标无关**,所以
419/// `Keep` 在这里是真正的「原样搬那 48 字节」,不像文件那边还要求下标对齐。
420///
421/// # Errors
422///
423/// - 旧边车格式不对、或不是可管理模式
424/// - 计划会让一个槽都不剩(写出去就再也解不开目录名了)
425pub fn rewrite_sidecar_managed(
426    old: &[u8],
427    plans: &[SidecarPlan],
428    dir: &crate::slotdir::SlotDirectory,
429    dk: &DirKey,
430    vault_salt: &[u8; 16],
431    nonce: &[u8; NONCE_LEN],
432    cipher: CipherId,
433) -> Result<Vec<u8>> {
434    if !is_managed(old) {
435        return Err(Error::MalformedTlv {
436            tlv_type: 0,
437            reason: "refusing precise rewrite on a deniable-mode sidecar",
438        });
439    }
440    if old.len() != SIDECAR_LEN_MANAGED {
441        return Err(Error::MalformedTlv {
442            tlv_type: 0,
443            reason: "managed sidecar has wrong length",
444        });
445    }
446    // 一个都不留会写出谁也解不开目录名的边车。必须在写盘前拦住:
447    // 写完才发现解不开,用户同时失去目录名和访问它的办法
448    if !plans.iter().any(|p| matches!(p, SidecarPlan::Keep | SidecarPlan::Write(_))) {
449        return Err(Error::MalformedTlv {
450            tlv_type: 0,
451            reason: "refusing to leave the sidecar with zero usable keys",
452        });
453    }
454
455    let mut w = Writer::with_capacity(SIDECAR_LEN);
456    w.bytes(SIDECAR_MAGIC);
457    w.u8(SIDECAR_VERSION);
458    w.u8(SIDECAR_FLAG_MANAGED);
459    w.bytes(&[0u8; 2]);
460    w.bytes(nonce);
461
462    let slots_start = 12 + NONCE_LEN;
463    for (i, plan) in plans.iter().enumerate().take(SIDECAR_SLOTS) {
464        match plan {
465            SidecarPlan::Keep => {
466                let from = slots_start.saturating_add(i.saturating_mul(WRAP_LEN));
467                let to = from.saturating_add(WRAP_LEN);
468                match old.get(from..to) {
469                    Some(slot) => {
470                        w.bytes(slot);
471                    }
472                    None => {
473                        w.random(WRAP_LEN);
474                    }
475                }
476            }
477            SidecarPlan::Write(kek) => {
478                let wk = wrap_key(kek, vault_salt);
479                let wrapped = cipher.encrypt(&wk, &ZERO_NONCE, dk.as_key().as_bytes(), &[])?;
480                w.bytes(&wrapped);
481            }
482            // 空槽填随机而非填零:填零的话「哪些槽在用」直接可见,
483            // 而边车的可否认性正建立在这条上
484            SidecarPlan::Clear => {
485                w.random(WRAP_LEN);
486            }
487        }
488    }
489    // 计划不足 8 条时补满
490    let written = plans.len().min(SIDECAR_SLOTS);
491    w.random(SIDECAR_SLOT_AREA.saturating_sub(written.saturating_mul(WRAP_LEN)));
492
493    append_slot_directory(w.into_vec(), dir, dk, vault_salt, cipher)
494}
495
496/// 可管理模式下,一个边车槽位要怎么处理。
497///
498/// 不 derive Clone:`Kek` 刻意没有 Clone(密钥不该被随手复制)。
499#[derive(Debug)]
500pub enum SidecarPlan {
501    /// 原样搬运这一槽。
502    Keep,
503    /// 用这把钥匙重新包裹 DK。
504    Write(Kek),
505    /// 清成随机字节。
506    Clear,
507}
508/// 从边车里解出目录密钥。
509///
510/// 对每把候选钥匙尝试每个槽,返回 `(DirKey, nonce)`。
511///
512/// # Errors
513///
514/// - [`Error::MalformedTlv`]:长度不对、magic 不匹配、版本不认识
515/// - [`Error::NoMatchingSlot`]:所有组合都失败
516///
517/// 最后一种**不代表边车损坏**,也可能只是手头的钥匙不属于这棵树,
518/// 面向用户的文案必须涵盖两种可能。
519pub fn open_sidecar(
520    blob: &[u8],
521    keks: &[Kek],
522    vault_salt: &[u8; 16],
523    cipher: CipherId,
524) -> Result<(DirKey, [u8; NONCE_LEN])> {
525    open_sidecar_at(blob, keks, vault_salt, cipher).map(|(dk, nonce, _)| (dk, nonce))
526}
527
528/// 同 [`open_sidecar`],另外返回命中的**槽位下标**。
529///
530/// 精确管理需要这个下标:remove 要避免删掉当前正在用的那把钥匙,
531/// change 要就地替换它。`open_sidecar` 不返回它是因为绝大多数调用方
532/// 只关心能不能解开。
533///
534/// # Errors
535///
536/// 同 [`open_sidecar`]。
537pub fn open_sidecar_at(
538    blob: &[u8],
539    keks: &[Kek],
540    vault_salt: &[u8; 16],
541    cipher: CipherId,
542) -> Result<(DirKey, [u8; NONCE_LEN], usize)> {
543    // 可管理模式的边车更长,两种都要接受
544    if blob.len() != SIDECAR_LEN && blob.len() != SIDECAR_LEN_MANAGED {
545        return Err(Error::MalformedTlv {
546            tlv_type: 0,
547            reason: "dirname sidecar has wrong length",
548        });
549    }
550    if blob.get(..8) != Some(SIDECAR_MAGIC.as_slice()) {
551        return Err(Error::MalformedTlv {
552            tlv_type: 0,
553            reason: "dirname sidecar magic mismatch",
554        });
555    }
556    if blob.get(8) != Some(&SIDECAR_VERSION) {
557        return Err(Error::MalformedTlv {
558            tlv_type: 0,
559            reason: "unsupported dirname sidecar version",
560        });
561    }
562
563    let nonce_start = 12;
564    let slots_start = nonce_start + NONCE_LEN;
565    let mut nonce = [0u8; NONCE_LEN];
566    nonce.copy_from_slice(
567        blob.get(nonce_start..slots_start).ok_or(Error::MalformedTlv {
568            tlv_type: 0,
569            reason: "dirname sidecar truncated before nonce",
570        })?,
571    );
572
573    for kek in keks {
574        let wk = wrap_key(kek, vault_salt);
575        for i in 0..SIDECAR_SLOTS {
576            let from = slots_start.saturating_add(i.saturating_mul(WRAP_LEN));
577            let to = from.saturating_add(WRAP_LEN);
578            let Some(slot) = blob.get(from..to) else { continue };
579            // decrypt 返回 Result<Option<..>>:Err 是用法错误(长度之类),
580            // Ok(None) 才是「认证失败」。这里两者都只意味着「这把钥匙不对,
581            // 换下一个」,不该中断整个尝试
582            let Ok(Some(plain)) = cipher.decrypt(&wk, &ZERO_NONCE, slot, &[]) else {
583                continue;
584            };
585            if plain.len() != DK_LEN {
586                continue;
587            }
588            let mut raw = [0u8; DK_LEN];
589            raw.copy_from_slice(&plain);
590            return Ok((DirKey::from_bytes(raw), nonce, i));
591        }
592    }
593    Err(Error::NoMatchingSlot)
594}
595
596#[cfg(test)]
597#[expect(clippy::panic, reason = "测试里 panic 就是断言失败的表达方式")]
598mod tests {
599    use super::*;
600    use crate::crypto::Argon2Params;
601
602    fn kek(pw: &str, salt: &[u8; 16]) -> Kek {
603        // TEST_WEAK 而不是生产档位:这些测试只关心包裹与解包的逻辑,
604        // 用 64 MiB 的 Argon2 会让整个测试文件慢上两个数量级
605        Kek::from_password(pw.as_bytes(), salt, Argon2Params::TEST_WEAK).expect("派生 KEK")
606    }
607
608    fn nonce_of(n: u8) -> [u8; NONCE_LEN] {
609        core::array::from_fn(|i| (i as u8).wrapping_mul(3) ^ n)
610    }
611
612    #[test]
613    fn every_key_opens_the_same_dir_key() {
614        let salt = [9u8; 16];
615        let keks = vec![kek("one", &salt), kek("two", &salt), kek("three", &salt)];
616        let dk = DirKey::generate();
617        let n = nonce_of(1);
618        let blob = build_sidecar(&keks, &dk, &salt, &n, CipherId::ChaCha20Poly1305).expect("构建");
619
620        // 不这样会怎样:这正是整个改造要解决的问题。若只有第一把钥匙
621        // 能解开,多密码和恢复码在树上依旧只能看第一把的脸色
622        for (i, k) in keks.iter().enumerate() {
623            let (got, got_n) =
624                open_sidecar(&blob, std::slice::from_ref(k), &salt, CipherId::ChaCha20Poly1305)
625                    .unwrap_or_else(|e| panic!("第 {i} 把钥匙应能解开边车:{e}"));
626            assert_eq!(got.as_key().as_bytes(), dk.as_key().as_bytes(), "第 {i} 把解出的 DK 不对");
627            assert_eq!(got_n, n, "nonce 应原样取回");
628        }
629    }
630
631    #[test]
632    fn length_is_constant_regardless_of_key_count() {
633        let salt = [3u8; 16];
634        let dk = DirKey::generate();
635        let n = nonce_of(2);
636        let all = vec![kek("a", &salt), kek("b", &salt), kek("c", &salt), kek("d", &salt)];
637
638        // 不这样会怎样:长度随钥匙数变化,就能一眼数出这棵树配了几把钥匙
639        for count in 1..=4 {
640            let blob = build_sidecar(
641                all.get(..count).unwrap_or(&all),
642                &dk,
643                &salt,
644                &n,
645                CipherId::ChaCha20Poly1305,
646            )
647            .expect("构建");
648            assert_eq!(blob.len(), SIDECAR_LEN, "{count} 把钥匙时长度变了");
649        }
650    }
651
652    #[test]
653    fn unused_slots_are_not_zero_filled() {
654        let salt = [4u8; 16];
655        let dk = DirKey::generate();
656        let blob = build_sidecar(
657            &[kek("only", &salt)],
658            &dk,
659            &salt,
660            &nonce_of(3),
661            CipherId::ChaCha20Poly1305,
662        )
663        .expect("构建");
664
665        // 不这样会怎样:空槽填零的话,「这个目录只配了 1 把钥匙」
666        // 直接可见,可否认性当场失效
667        let slots_start = 12 + NONCE_LEN;
668        let unused = blob.get(slots_start + WRAP_LEN..).expect("应有剩余槽");
669        assert!(
670            unused.iter().any(|&b| b != 0),
671            "未使用的槽被填成了零,可否认性失效"
672        );
673    }
674
675    #[test]
676    fn carrying_keeps_unknown_wraps_but_discarding_drops_them() {
677        // 重建边车时,Carry 要保住认不出来的旧包裹(典型是恢复码),
678        // 而调用方选择丢弃时必须真的丢掉。
679        //
680        // 不这样会怎样:Carry 那半边若失效,改一次密码就把恢复码从
681        // 边车里抹了——恢复码还能打开每个文件,却解不开目录名,
682        // 用户拿它解密整棵树得到「文件损坏」。
683        //
684        // 而丢弃那半边若失效(等于永远 Carry),一个本该被作废的钥匙
685        // 仍能从边车解出 DK。今天文件槽位挡着所以没后果,但两层一旦
686        // 不同步它就是真实的泄露。这条正是为了不让那半边成为死代码。
687        let salt = [11u8; 16];
688        let main_key = kek("main", &salt);
689        let reco = kek("recovery-code", &salt);
690        let dk = DirKey::generate();
691        let cipher = CipherId::ChaCha20Poly1305;
692
693        let original = build_sidecar(
694            &[main_key.duplicate(), reco.duplicate()],
695            &dk,
696            &salt,
697            &nonce_of(20),
698            cipher,
699        )
700        .expect("构建");
701
702        // 只拿 main 重建:reco 对 rebuild 来说是「认不出来的槽」
703        let carried = rebuild_sidecar_carrying(
704            Some(&original),
705            &[main_key.duplicate()],
706            &dk,
707            &salt,
708            &nonce_of(21),
709            cipher,
710        )
711        .expect("Carry 重建");
712        assert!(
713            open_sidecar(&carried, &[reco.duplicate()], &salt, cipher).is_ok(),
714            "Carry 应当保住恢复码的包裹"
715        );
716        assert!(
717            open_sidecar(&carried, &[main_key.duplicate()], &salt, cipher).is_ok(),
718            "Carry 之后 main 自己也得还能用"
719        );
720
721        // 不传旧边车 = 丢弃:恢复码必须再也解不开
722        let dropped = rebuild_sidecar_carrying(
723            None,
724            &[main_key.duplicate()],
725            &dk,
726            &salt,
727            &nonce_of(22),
728            cipher,
729        )
730        .expect("Discard 重建");
731        assert!(
732            open_sidecar(&dropped, &[reco], &salt, cipher).is_err(),
733            "丢弃时旧包裹必须真的没了——留着等于那把钥匙没被作废"
734        );
735        assert!(
736            open_sidecar(&dropped, &[main_key], &salt, cipher).is_ok(),
737            "丢弃不该连保留的钥匙一起丢"
738        );
739    }
740
741    #[test]
742    fn wrong_key_is_rejected() {
743        let salt = [5u8; 16];
744        let dk = DirKey::generate();
745        let blob = build_sidecar(
746            &[kek("right", &salt)],
747            &dk,
748            &salt,
749            &nonce_of(4),
750            CipherId::ChaCha20Poly1305,
751        )
752        .expect("构建");
753        let wrong = kek("wrong", &salt);
754        assert!(
755            matches!(
756                open_sidecar(&blob, &[wrong], &salt, CipherId::ChaCha20Poly1305),
757                Err(Error::NoMatchingSlot)
758            ),
759            "不属于这棵树的钥匙必须被拒绝"
760        );
761    }
762
763    #[test]
764    fn sidecar_is_bound_to_vault_salt() {
765        let salt_a = [1u8; 16];
766        let salt_b = [2u8; 16];
767        let k = kek("same-password", &salt_a);
768        let dk = DirKey::generate();
769        let blob =
770            build_sidecar(&[k], &dk, &salt_a, &nonce_of(5), CipherId::ChaCha20Poly1305)
771                .expect("构建");
772
773        // 不这样会怎样:换个 vault 用同一把 KEK 就能解开别人的目录名。
774        // 这里换的是解包时用的 salt,KEK 本身不变——正是要锁住这一点
775        let k2 = kek("same-password", &salt_a);
776        assert!(
777            open_sidecar(&blob, &[k2], &salt_b, CipherId::ChaCha20Poly1305).is_err(),
778            "边车必须绑定 vault_salt"
779        );
780    }
781
782    #[test]
783    fn refuses_to_build_with_no_keys() {
784        // 不这样会怎样:产出一个谁都解不开的边车,目录名永久丢失,
785        // 而调用方拿到的是 Ok
786        let salt = [6u8; 16];
787        assert!(
788            build_sidecar(
789                &[],
790                &DirKey::generate(),
791                &salt,
792                &nonce_of(6),
793                CipherId::ChaCha20Poly1305
794            )
795            .is_err(),
796            "零把钥匙必须拒绝"
797        );
798    }
799
800    #[test]
801    fn rejects_corrupted_header() {
802        let salt = [8u8; 16];
803        let dk = DirKey::generate();
804        let good = build_sidecar(
805            &[kek("k", &salt)],
806            &dk,
807            &salt,
808            &nonce_of(7),
809            CipherId::ChaCha20Poly1305,
810        )
811        .expect("构建");
812
813        // 不这样会怎样:把任意文件当边车读,会拿一堆垃圾去试解密,
814        // 报出来的错误是「钥匙不对」而不是「这不是边车」,排查方向全错
815        let mut bad_magic = good.clone();
816        if let Some(b) = bad_magic.get_mut(0) {
817            *b ^= 0xFF;
818        }
819        assert!(open_sidecar(&bad_magic, &[kek("k", &salt)], &salt, CipherId::ChaCha20Poly1305)
820            .is_err());
821
822        let mut bad_ver = good.clone();
823        if let Some(b) = bad_ver.get_mut(8) {
824            *b = 99;
825        }
826        assert!(open_sidecar(&bad_ver, &[kek("k", &salt)], &salt, CipherId::ChaCha20Poly1305)
827            .is_err());
828
829        let short = good.get(..SIDECAR_LEN - 1).unwrap_or(&good).to_vec();
830        assert!(open_sidecar(&short, &[kek("k", &salt)], &salt, CipherId::ChaCha20Poly1305)
831            .is_err());
832    }
833    // ---------- 可管理模式(树形槽位目录) ----------
834
835    #[test]
836    fn managed_sidecar_roundtrips_the_directory() {
837        // 最基础的一条:写进去的目录要能原样读出来,flags 位也要置上。
838        let salt = [9u8; 16];
839        let owner = kek("owner", &salt);
840        let dk = DirKey::generate();
841        let mut dir = crate::slotdir::SlotDirectory::new();
842        dir.set(0, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
843            .expect("set 0");
844
845        let blob = build_sidecar_managed(
846            &[owner.duplicate()],
847            &dir,
848            &dk,
849            &salt,
850            &nonce_of(1),
851            CipherId::ChaCha20Poly1305,
852        )
853        .expect("构建");
854
855        assert!(is_managed(&blob), "flags 位没置上");
856        assert_eq!(blob.len(), SIDECAR_LEN_MANAGED);
857
858        let (got_dk, _, idx) =
859            open_sidecar_at(&blob, &[owner], &salt, CipherId::ChaCha20Poly1305).expect("解开");
860        assert_eq!(idx, 0, "命中的应当是 slot 0");
861        let got = read_slot_directory(&blob, &got_dk, &salt, CipherId::ChaCha20Poly1305)
862            .expect("读目录");
863        assert_eq!(got, dir, "目录内容对不上");
864    }
865
866    #[test]
867    fn deniable_sidecar_refuses_to_give_a_directory() {
868        // 反证:可否认模式必须报错,而不是返回一个空目录。
869        //
870        // 不这样会怎样:返回空目录会让界面显示「这棵树一把钥匙都没有」,
871        // 而它明明打得开——用户会以为目录坏了。
872        let salt = [9u8; 16];
873        let owner = kek("owner", &salt);
874        let dk = DirKey::generate();
875        let blob = build_sidecar(
876            &[owner.duplicate()],
877            &dk,
878            &salt,
879            &nonce_of(2),
880            CipherId::ChaCha20Poly1305,
881        )
882        .expect("构建");
883
884        assert!(!is_managed(&blob));
885        assert!(
886            read_slot_directory(&blob, &dk, &salt, CipherId::ChaCha20Poly1305).is_err(),
887            "可否认模式不该给出目录"
888        );
889    }
890
891    #[test]
892    fn managed_length_is_constant_regardless_of_key_count() {
893        // 长度恒定是可否认性的基础:变长会让边车大小泄露这棵树配了
894        // 几把钥匙,而那正是这个格式想保住的东西。
895        let salt = [9u8; 16];
896        let dk = DirKey::generate();
897        let dir = crate::slotdir::SlotDirectory::new();
898        let mut lens = Vec::new();
899        for n in 1..=4usize {
900            let keks: Vec<Kek> = (0..n).map(|i| kek(&format!("pw{i}"), &salt)).collect();
901            let blob = build_sidecar_managed(
902                &keks,
903                &dir,
904                &dk,
905                &salt,
906                &nonce_of(3),
907                CipherId::ChaCha20Poly1305,
908            )
909            .expect("构建");
910            lens.push(blob.len());
911        }
912        assert!(
913            lens.iter().all(|&l| l == SIDECAR_LEN_MANAGED),
914            "长度随钥匙数变化了:{lens:?}"
915        );
916    }
917
918    #[test]
919    fn precise_rewrite_removes_one_key_and_keeps_the_rest() {
920        // 这是树形可管理模式存在的理由:精确踢掉一把钥匙而保住其它的。
921        let salt = [9u8; 16];
922        let owner = kek("owner", &salt);
923        let mate = kek("mate", &salt);
924        let reco = kek("reco", &salt);
925        let dk = DirKey::generate();
926
927        let mut dir = crate::slotdir::SlotDirectory::new();
928        dir.set(0, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
929            .expect("0");
930        dir.set(1, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
931            .expect("1");
932        dir.set(2, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Recovery))
933            .expect("2");
934
935        let blob = build_sidecar_managed(
936            &[owner.duplicate(), mate.duplicate(), reco.duplicate()],
937            &dir,
938            &dk,
939            &salt,
940            &nonce_of(4),
941            CipherId::ChaCha20Poly1305,
942        )
943        .expect("构建");
944
945        // 前提自证:三把钥匙一开始都能解开
946        for (name, k) in [("owner", &owner), ("mate", &mate), ("reco", &reco)] {
947            assert!(
948                open_sidecar(&blob, &[k.duplicate()], &salt, CipherId::ChaCha20Poly1305).is_ok(),
949                "{name} 一开始就该能解开"
950            );
951        }
952
953        // 只踢掉 slot 1 的协作者
954        let mut after = dir;
955        after.set(1, crate::slotdir::SlotEntry::empty()).expect("清空");
956        let mut plans = vec![SidecarPlan::Keep, SidecarPlan::Clear, SidecarPlan::Keep];
957        plans.resize_with(SIDECAR_SLOTS, || SidecarPlan::Clear);
958
959        let out = rewrite_sidecar_managed(
960            &blob,
961            &plans,
962            &after,
963            &dk,
964            &salt,
965            &nonce_of(4),
966            CipherId::ChaCha20Poly1305,
967        )
968        .expect("改写");
969
970        assert!(
971            open_sidecar(&out, &[owner], &salt, CipherId::ChaCha20Poly1305).is_ok(),
972            "主密码不该受影响"
973        );
974        assert!(
975            open_sidecar(&out, &[reco], &salt, CipherId::ChaCha20Poly1305).is_ok(),
976            "恢复码必须保住——这正是可管理模式的全部意义"
977        );
978        assert!(
979            open_sidecar(&out, &[mate], &salt, CipherId::ChaCha20Poly1305).is_err(),
980            "被踢掉的协作者必须再也解不开"
981        );
982    }
983
984    #[test]
985    fn precise_rewrite_refuses_to_orphan_the_directory_name() {
986        // 全 Clear 会写出一份谁也解不开的边车,目录名就此永远丢失。
987        // 必须在写盘前拦住。
988        let salt = [9u8; 16];
989        let owner = kek("owner", &salt);
990        let dk = DirKey::generate();
991        let dir = crate::slotdir::SlotDirectory::new();
992        let blob = build_sidecar_managed(
993            &[owner],
994            &dir,
995            &dk,
996            &salt,
997            &nonce_of(5),
998            CipherId::ChaCha20Poly1305,
999        )
1000        .expect("构建");
1001
1002        let plans: Vec<SidecarPlan> = (0..SIDECAR_SLOTS).map(|_| SidecarPlan::Clear).collect();
1003        assert!(
1004            rewrite_sidecar_managed(
1005                &blob,
1006                &plans,
1007                &dir,
1008                &dk,
1009                &salt,
1010                &nonce_of(5),
1011                CipherId::ChaCha20Poly1305,
1012            )
1013            .is_err(),
1014            "一个槽都不留必须报错"
1015        );
1016    }
1017
1018    #[test]
1019    fn precise_rewrite_rejects_a_deniable_sidecar() {
1020        // 走错路径要明确报错,而不是写出一份 flags 与实际不一致的边车——
1021        // 那种损坏不会立刻显现。
1022        let salt = [9u8; 16];
1023        let owner = kek("owner", &salt);
1024        let dk = DirKey::generate();
1025        let blob = build_sidecar(
1026            &[owner.duplicate()],
1027            &dk,
1028            &salt,
1029            &nonce_of(6),
1030            CipherId::ChaCha20Poly1305,
1031        )
1032        .expect("构建");
1033
1034        let mut plans = vec![SidecarPlan::Keep];
1035        plans.resize_with(SIDECAR_SLOTS, || SidecarPlan::Clear);
1036        assert!(
1037            rewrite_sidecar_managed(
1038                &blob,
1039                &plans,
1040                &crate::slotdir::SlotDirectory::new(),
1041                &dk,
1042                &salt,
1043                &nonce_of(6),
1044                CipherId::ChaCha20Poly1305,
1045            )
1046            .is_err(),
1047            "对可否认模式的边车应当拒绝"
1048        );
1049    }
1050
1051    #[test]
1052    fn precise_rewrite_fills_cleared_slots_with_random() {
1053        // 已有的 unused_slots_are_not_zero_filled 只覆盖 build_sidecar,
1054        // 不覆盖精确改写的 Clear 分支——变异测试把这个缺口抓了出来。
1055        //
1056        // 不这样会怎样:被删掉的那个槽填零,「这里原来有人、现在删了」
1057        // 直接可见,而边车的可否认性正建立在「看不出哪些槽在用」上。
1058        let salt = [9u8; 16];
1059        let owner = kek("owner", &salt);
1060        let mate = kek("mate", &salt);
1061        let dk = DirKey::generate();
1062        let mut dir = crate::slotdir::SlotDirectory::new();
1063        dir.set(0, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
1064            .expect("0");
1065        dir.set(1, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
1066            .expect("1");
1067
1068        let blob = build_sidecar_managed(
1069            &[owner, mate],
1070            &dir,
1071            &dk,
1072            &salt,
1073            &nonce_of(8),
1074            CipherId::ChaCha20Poly1305,
1075        )
1076        .expect("构建");
1077
1078        let mut after = dir;
1079        after.set(1, crate::slotdir::SlotEntry::empty()).expect("清空");
1080        let mut plans = vec![SidecarPlan::Keep, SidecarPlan::Clear];
1081        plans.resize_with(SIDECAR_SLOTS, || SidecarPlan::Clear);
1082        let out = rewrite_sidecar_managed(
1083            &blob,
1084            &plans,
1085            &after,
1086            &dk,
1087            &salt,
1088            &nonce_of(8),
1089            CipherId::ChaCha20Poly1305,
1090        )
1091        .expect("改写");
1092
1093        // 整个槽位区(含被清掉的那些)都不该出现整段零
1094        let slots_start = 12 + NONCE_LEN;
1095        let area = out
1096            .get(slots_start..slots_start + SIDECAR_SLOT_AREA)
1097            .expect("槽位区");
1098        let zeros = vec![0u8; WRAP_LEN];
1099        for i in 0..SIDECAR_SLOTS {
1100            let from = i * WRAP_LEN;
1101            let slot = area.get(from..from + WRAP_LEN).expect("槽");
1102            assert_ne!(slot, zeros.as_slice(), "slot {i} 被填成了零");
1103        }
1104    }
1105
1106    #[test]
1107    fn wrong_key_cannot_read_the_directory() {
1108        // 目录是加密的:拿不到 DK 就读不到它。这条守的是
1109        // 「对解不开这棵树的人,两种模式一样什么都看不出来」。
1110        let salt = [9u8; 16];
1111        let owner = kek("owner", &salt);
1112        let outsider = DirKey::generate();
1113        let dk = DirKey::generate();
1114        let mut dir = crate::slotdir::SlotDirectory::new();
1115        dir.set(0, crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault))
1116            .expect("set");
1117
1118        let blob = build_sidecar_managed(
1119            &[owner],
1120            &dir,
1121            &dk,
1122            &salt,
1123            &nonce_of(7),
1124            CipherId::ChaCha20Poly1305,
1125        )
1126        .expect("构建");
1127
1128        assert!(
1129            read_slot_directory(&blob, &outsider, &salt, CipherId::ChaCha20Poly1305).is_err(),
1130            "用别的 DK 不该读出目录"
1131        );
1132    }
1133}