Skip to main content

omy_core/
slotdir.rs

1//! 槽位目录(`TLV_SLOT_DIRECTORY`,规范 §3.5)。
2//!
3//! # 它解决什么
4//!
5//! 可否认模式有一个必然代价:空槽填随机、与真实槽不可区分,于是**连文件
6//! 的主人自己也分不清哪个槽是谁的**。后果实测确认过——文件挂着「主密码 +
7//! 恢复码」,`key change` 只提供当前与新密码时,恢复码被当作不认识的槽
8//! 填成随机字节,用户改一次密码就永久失去了它。
9//!
10//! 上一轮的缓解是「原样搬运」([`crate::keyslot::OtherSlots::Carry`]):
11//! 不知道某个槽属于谁,照样能把它原样搬过去。但那只能**无差别**保留,
12//! 做不到「精确删除某个协作者而保留恢复码」,`add` 时也仍然会赌一个下标。
13//!
14//! 想要精确管理就必须放弃一部分可否认性。这个取舍交给用户,在加密时二选一。
15//!
16//! # 让渡的究竟是什么
17//!
18//! 比直觉小。目录本身是 ENCRYPTED 的,读它要先有 FEK:
19//!
20//! | 观察者 | 可否认模式 | 可管理模式 |
21//! |---|---|---|
22//! | 拿到文件、没有密码 | 槽位一无所知 | 槽位**仍然**一无所知 |
23//! | 有一个密码、能打开 | 仍看不出还有几个 | **能看出**共几个、哪些是恢复码 |
24//!
25//! 即:让渡的只是「对**已经能打开这个文件的人**,无法再隐瞒还有几个密码」。
26//!
27//! # 为什么定长 24 字节
28//!
29//! 同一模式内的文件之间长度必须一致,否则「这个文件的槽位目录比那个长」
30//! 会泄露密码数量——那正是要藏的东西。所以只存 `label_id`(2 字节),
31//! 真正的名字放在应用侧,不进文件。
32//!
33//! 注意这与「模式标志公开」不冲突:定长是为了**在同一模式内部**防止泄露
34//! 密码数量,不是为了让两种模式的 `header_len` 相等——后者已确认是无效
35//! 遮掩(`flags` 位本身就是明文可见的)。
36
37use crate::error::{Error, Result};
38use crate::header::SLOT_COUNT;
39use crate::session::CredentialKind;
40
41/// 单条目录项的字节数:kind(1) + label_id(2)。
42pub const ENTRY_LEN: usize = 3;
43
44/// 槽位目录的固定字节数。
45pub const DIRECTORY_LEN: usize = SLOT_COUNT * ENTRY_LEN;
46
47/// 一个槽位在目录里记录的类型。
48///
49/// 编码与规范 §3.5.4 一一对应。
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum SlotKind {
52    /// 空槽,对应 slot 区的随机填充。
53    Empty,
54    /// 日常浏览密码。
55    Vault,
56    /// 设备密钥(生物识别)。
57    Device,
58    /// 单文件独立密码。
59    Portable,
60    /// 恢复码。
61    Recovery,
62    /// 本实现不认识的类型。
63    ///
64    /// **不得当成空槽**:把未知当空,新版本写的槽会被旧版本覆盖掉。
65    /// 保留原始字节,改写时原样写回。
66    Unknown(u8),
67}
68
69impl SlotKind {
70    /// 编码成 1 字节。
71    #[must_use]
72    pub const fn to_byte(self) -> u8 {
73        match self {
74            Self::Empty => 0x00,
75            Self::Vault => 0x01,
76            Self::Device => 0x02,
77            Self::Portable => 0x03,
78            Self::Recovery => 0x04,
79            Self::Unknown(b) => b,
80        }
81    }
82
83    /// 从 1 字节解码。
84    #[must_use]
85    pub const fn from_byte(b: u8) -> Self {
86        match b {
87            0x00 => Self::Empty,
88            0x01 => Self::Vault,
89            0x02 => Self::Device,
90            0x03 => Self::Portable,
91            0x04 => Self::Recovery,
92            other => Self::Unknown(other),
93        }
94    }
95
96    /// 这个槽位上有没有挂东西。
97    ///
98    /// 未知类型算「占用」——它是别的版本写的,不能拿去放新密码。
99    #[must_use]
100    pub const fn is_occupied(self) -> bool {
101        !matches!(self, Self::Empty)
102    }
103
104    /// 给人看的类型名。
105    #[must_use]
106    pub const fn name(self) -> &'static str {
107        match self {
108            Self::Empty => "empty",
109            Self::Vault => "vault",
110            Self::Device => "device",
111            Self::Portable => "portable",
112            Self::Recovery => "recovery",
113            Self::Unknown(_) => "unknown",
114        }
115    }
116}
117
118impl From<CredentialKind> for SlotKind {
119    fn from(k: CredentialKind) -> Self {
120        match k {
121            CredentialKind::Vault => Self::Vault,
122            CredentialKind::Device => Self::Device,
123            CredentialKind::Portable => Self::Portable,
124            CredentialKind::Recovery => Self::Recovery,
125        }
126    }
127}
128
129/// 一个槽位的目录项。
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct SlotEntry {
132    /// 槽位类型。
133    pub kind: SlotKind,
134    /// 应用侧标签表的 id,0 表示无标签。
135    ///
136    /// 只存 id 不存字符串:名字是变长的,会让目录长度泄露密码数量。
137    pub label_id: u16,
138}
139
140impl SlotEntry {
141    /// 一个空槽。
142    #[must_use]
143    pub const fn empty() -> Self {
144        Self { kind: SlotKind::Empty, label_id: 0 }
145    }
146
147    /// 带类型、无标签的槽位。
148    #[must_use]
149    pub const fn of(kind: SlotKind) -> Self {
150        Self { kind, label_id: 0 }
151    }
152}
153
154impl Default for SlotEntry {
155    fn default() -> Self {
156        Self::empty()
157    }
158}
159
160/// 8 个槽位的目录。
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct SlotDirectory {
163    entries: [SlotEntry; SLOT_COUNT],
164}
165
166impl Default for SlotDirectory {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172impl SlotDirectory {
173    /// 全空的目录。
174    #[must_use]
175    pub const fn new() -> Self {
176        Self { entries: [SlotEntry::empty(); SLOT_COUNT] }
177    }
178
179    /// 借出全部条目。
180    #[must_use]
181    pub const fn entries(&self) -> &[SlotEntry; SLOT_COUNT] {
182        &self.entries
183    }
184
185    /// 取第 `i` 个槽位。越界返回 `None`。
186    #[must_use]
187    pub fn get(&self, i: usize) -> Option<SlotEntry> {
188        self.entries.get(i).copied()
189    }
190
191    /// 设置第 `i` 个槽位。
192    ///
193    /// # Errors
194    ///
195    /// 下标越界时返回 [`Error::TooManySlots`]。
196    pub fn set(&mut self, i: usize, e: SlotEntry) -> Result<()> {
197        let slot = self
198            .entries
199            .get_mut(i)
200            .ok_or(Error::TooManySlots { got: i, max: SLOT_COUNT })?;
201        *slot = e;
202        Ok(())
203    }
204
205    /// 找一个真正空闲的槽位。
206    ///
207    /// 这正是可管理模式的核心价值:可否认模式下没有这种操作可用,`add`
208    /// 只能把新密码写在 `keep.len()` 那个下标上,赌它原来是空的。
209    #[must_use]
210    pub fn first_free(&self) -> Option<usize> {
211        self.entries.iter().position(|e| !e.kind.is_occupied())
212    }
213
214    /// 占用了几个槽位。
215    #[must_use]
216    pub fn used(&self) -> usize {
217        self.entries.iter().filter(|e| e.kind.is_occupied()).count()
218    }
219
220    /// 列出某个类型占用的全部下标。
221    #[must_use]
222    pub fn indices_of(&self, kind: SlotKind) -> Vec<usize> {
223        self.entries
224            .iter()
225            .enumerate()
226            .filter(|(_, e)| e.kind == kind)
227            .map(|(i, _)| i)
228            .collect()
229    }
230
231    /// 编码成固定 24 字节。
232    #[must_use]
233    pub fn encode(&self) -> Vec<u8> {
234        let mut out = Vec::with_capacity(DIRECTORY_LEN);
235        for e in &self.entries {
236            out.push(e.kind.to_byte());
237            out.extend_from_slice(&e.label_id.to_le_bytes());
238        }
239        debug_assert_eq!(out.len(), DIRECTORY_LEN, "槽位目录必须恰好 {DIRECTORY_LEN} 字节");
240        out
241    }
242
243    /// 从 24 字节解码。
244    ///
245    /// # Errors
246    ///
247    /// 长度不是 [`DIRECTORY_LEN`] 时返回 [`Error::MalformedTlv`]。长度不对
248    /// 就拒绝而不是尽力解析:目录与 slot 区必须严格对应,一个解析出 5 项
249    /// 的「目录」比没有目录更危险——用户会拿它去删协作者。
250    pub fn decode(raw: &[u8]) -> Result<Self> {
251        if raw.len() != DIRECTORY_LEN {
252            return Err(Error::MalformedTlv {
253                tlv_type: crate::tlv::types::SLOT_DIRECTORY,
254                reason: "slot directory must be exactly 24 bytes",
255            });
256        }
257        let mut dir = Self::new();
258        for i in 0..SLOT_COUNT {
259            let at = i.saturating_mul(ENTRY_LEN);
260            let kind = SlotKind::from_byte(*raw.get(at).unwrap_or(&0));
261            let lo = *raw.get(at.saturating_add(1)).unwrap_or(&0);
262            let hi = *raw.get(at.saturating_add(2)).unwrap_or(&0);
263            // 用 get_mut 而不是索引:omy-core 禁止可能 panic 的切片索引。
264            // i 恒在 0..SLOT_COUNT 内,所以这个 if 永远成立,但让编译器
265            // 看得见这件事比让人来保证更可靠
266            if let Some(slot) = dir.entries.get_mut(i) {
267                *slot = SlotEntry { kind, label_id: u16::from_le_bytes([lo, hi]) };
268            }
269        }
270        Ok(dir)
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn roundtrip_preserves_every_slot() {
280        let mut dir = SlotDirectory::new();
281        dir.set(0, SlotEntry { kind: SlotKind::Vault, label_id: 7 }).unwrap();
282        dir.set(3, SlotEntry { kind: SlotKind::Recovery, label_id: 0 }).unwrap();
283        dir.set(7, SlotEntry { kind: SlotKind::Device, label_id: 65535 }).unwrap();
284
285        let raw = dir.encode();
286        assert_eq!(raw.len(), DIRECTORY_LEN);
287        let back = SlotDirectory::decode(&raw).unwrap();
288        // 不这样会怎样:label_id 用错字节序或写串位置,界面上会显示别人的
289        // 标签名——而目录本身「看起来是对的」,只有对照才发现
290        assert_eq!(back, dir);
291    }
292
293    #[test]
294    fn length_is_constant_regardless_of_how_many_slots_are_used() {
295        // 不这样会怎样:长度随密码数量变化,就能从 header_len 数出这个
296        // 文件配了几个密码——那正是这个模式要藏住的东西
297        for n in 0..=SLOT_COUNT {
298            let mut dir = SlotDirectory::new();
299            for i in 0..n {
300                dir.set(i, SlotEntry::of(SlotKind::Vault)).unwrap();
301            }
302            assert_eq!(dir.encode().len(), DIRECTORY_LEN, "{n} 个槽位时长度变了");
303        }
304    }
305
306    #[test]
307    fn unknown_kind_is_preserved_and_counts_as_occupied() {
308        // 不这样会怎样:把未知类型当空槽,新版本写的槽会被旧版本当空闲
309        // 拿去放新密码,那个槽就此丢失——而且是静默的
310        let mut raw = SlotDirectory::new().encode();
311        raw[0] = 0x7F; // 一个本实现不认识的类型
312        let dir = SlotDirectory::decode(&raw).unwrap();
313        assert_eq!(dir.get(0).unwrap().kind, SlotKind::Unknown(0x7F));
314        assert!(dir.get(0).unwrap().kind.is_occupied(), "未知类型必须算占用");
315        assert_eq!(dir.first_free(), Some(1), "不该把未知槽当成空闲");
316        // 原样写回,不被规整成别的值
317        assert_eq!(dir.encode()[0], 0x7F, "未知类型的字节被改写了");
318    }
319
320    #[test]
321    fn first_free_finds_a_real_hole_not_just_the_tail() {
322        // 可管理模式的核心价值:add 不再赌下标。
323        //
324        // 不这样会怎样:若 first_free 只会返回末尾,中间被 remove 腾出来的
325        // 槽位永远用不上,8 槽很快就「满」了
326        let mut dir = SlotDirectory::new();
327        for i in 0..SLOT_COUNT {
328            dir.set(i, SlotEntry::of(SlotKind::Vault)).unwrap();
329        }
330        assert_eq!(dir.first_free(), None, "全满时必须返回 None");
331
332        dir.set(2, SlotEntry::empty()).unwrap();
333        assert_eq!(dir.first_free(), Some(2), "中间的空洞要能被找到");
334    }
335
336    #[test]
337    fn rejects_wrong_length() {
338        // 不这样会怎样:一个解析出 5 项的「目录」比没有目录更危险——
339        // 用户会拿它去删协作者,而它与 slot 区根本对不上
340        assert!(SlotDirectory::decode(&[0u8; DIRECTORY_LEN - 1]).is_err());
341        assert!(SlotDirectory::decode(&[0u8; DIRECTORY_LEN + 1]).is_err());
342        assert!(SlotDirectory::decode(&[]).is_err());
343    }
344
345    #[test]
346    fn credential_kind_maps_onto_slot_kind() {
347        // 两处枚举必须对得上。不这样会怎样:会话里是恢复码、目录里记成
348        // 日常密码,用户按目录删「日常密码」时把恢复码删了
349        assert_eq!(SlotKind::from(CredentialKind::Vault), SlotKind::Vault);
350        assert_eq!(SlotKind::from(CredentialKind::Device), SlotKind::Device);
351        assert_eq!(SlotKind::from(CredentialKind::Portable), SlotKind::Portable);
352        assert_eq!(SlotKind::from(CredentialKind::Recovery), SlotKind::Recovery);
353    }
354
355    #[test]
356    fn byte_encoding_matches_the_spec() {
357        // 编码是格式的一部分,写错了就是另一种格式。规范 §3.5.4 的表
358        assert_eq!(SlotKind::Empty.to_byte(), 0x00);
359        assert_eq!(SlotKind::Vault.to_byte(), 0x01);
360        assert_eq!(SlotKind::Device.to_byte(), 0x02);
361        assert_eq!(SlotKind::Portable.to_byte(), 0x03);
362        assert_eq!(SlotKind::Recovery.to_byte(), 0x04);
363    }
364
365    #[test]
366    fn indices_and_used_count_agree() {
367        let mut dir = SlotDirectory::new();
368        dir.set(0, SlotEntry::of(SlotKind::Vault)).unwrap();
369        dir.set(1, SlotEntry::of(SlotKind::Recovery)).unwrap();
370        dir.set(5, SlotEntry::of(SlotKind::Vault)).unwrap();
371
372        assert_eq!(dir.used(), 3);
373        assert_eq!(dir.indices_of(SlotKind::Vault), vec![0, 5]);
374        assert_eq!(dir.indices_of(SlotKind::Recovery), vec![1]);
375        assert!(dir.indices_of(SlotKind::Device).is_empty());
376    }
377}