Skip to main content

omy_core/
crypto.rs

1//! 密码学层:两级 KDF、AEAD 抽象、密钥材料的安全擦除。
2//!
3//! # 两级 KDF 是可行性前提,不是优化
4//!
5//! 规范要求「给定 N 个密码,快速判断某文件是否匹配」。实测(见验证报告 §3.2):
6//!
7//! | 操作 | 耗时 |
8//! |---|---|
9//! | Argon2id (m=64MiB, t=3) | ~180 ms |
10//! | HKDF-SHA256 | ~13 µs |
11//! | 比值 | ~13,700× |
12//!
13//! 若每个文件都跑一次 Argon2,扫描 10,000 个文件需要约 2.5 小时;两级 KDF 下约 3.4 秒。
14//! 所以 `Kek` 必须在**会话内缓存**,绝不能每文件重新派生。
15//!
16//! # 域分隔
17//!
18//! 四种用途的子密钥使用互不相同的 HKDF `info`,确保任一密钥泄露不影响其它用途:
19//!
20//! - slot 包裹密钥:`"omy/v1/slot" || u16le(slot_index)`,salt = `file_uuid`
21//! - 载荷密钥:`"omy/v1/payload"`,salt = `file_uuid`
22//! - 头部 MAC 密钥:`"omy/v1/header-mac"`,salt = `file_uuid`
23//! - TLV 密钥:`"omy/v1/tlv" || u16le(tlv_type)`,salt = 空
24
25use crate::error::{Error, Result};
26use aes_gcm::Aes256Gcm;
27use chacha20poly1305::ChaCha20Poly1305;
28use chacha20poly1305::aead::{Aead, KeyInit, Payload};
29use hkdf::Hkdf;
30use hmac::{Hmac, Mac};
31use sha2::Sha256;
32use zeroize::{Zeroize, ZeroizeOnDrop};
33
34/// HKDF info 前缀:slot 包裹密钥。
35const INFO_SLOT: &[u8] = b"omy/v1/slot";
36/// HKDF info:载荷密钥。
37const INFO_PAYLOAD: &[u8] = b"omy/v1/payload";
38/// HKDF info:头部 MAC 密钥。
39const INFO_HEADER_MAC: &[u8] = b"omy/v1/header-mac";
40/// HKDF info 前缀:TLV 密钥。
41const INFO_TLV: &[u8] = b"omy/v1/tlv";
42/// HKDF info:会话内区分不同 KEK 的指纹。
43///
44/// 与上面四个的关键区别:**它派生出的不是密钥,而是一个允许比较的标识**。
45/// 单独一个 info 串是为了让它与任何真实子密钥落在不同的域里——万一指纹
46/// 泄露(它本就是用来在内存里传递比较的),也推不出任何解密用的密钥。
47const INFO_FINGERPRINT: &[u8] = b"omy/v1/kek-fingerprint";
48
49/// slot info 缓冲区长度:前缀 + u16 序号。
50const SLOT_INFO_LEN: usize = INFO_SLOT.len() + 2;
51/// TLV info 缓冲区长度:前缀 + u16 类型号。
52const TLV_INFO_LEN: usize = INFO_TLV.len() + 2;
53
54// 这些长度由 info 串推导而来。若 info 串变更,缓冲区自动跟随,
55// 不会出现硬编码长度与实际前缀不符导致的静默错误。
56const _: () = assert!(SLOT_INFO_LEN == 13, "omy/v1/slot 长度应为 11");
57const _: () = assert!(TLV_INFO_LEN == 12, "omy/v1/tlv 长度应为 10");
58
59/// 对称密钥长度(字节)。
60pub const KEY_LEN: usize = 32;
61/// AEAD 认证标签长度(字节)。
62pub const TAG_LEN: usize = 16;
63/// 参考实现使用的 nonce 长度(ChaCha20-Poly1305 / AES-GCM 均为 12)。
64pub const NONCE_LEN: usize = 12;
65
66/// 32 字节密钥材料,析构时自动擦除。
67///
68/// 刻意不实现 `Copy`/`Clone`,避免密钥在栈上留下无法追踪的副本;确实需要复制时用
69/// [`SecretKey::duplicate`],让复制行为在代码中显式可见。
70#[derive(Zeroize, ZeroizeOnDrop)]
71pub struct SecretKey([u8; KEY_LEN]);
72
73impl SecretKey {
74    /// 由原始字节构造。
75    #[must_use]
76    pub const fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
77        Self(bytes)
78    }
79
80    /// 借出底层字节。
81    #[must_use]
82    pub const fn as_bytes(&self) -> &[u8; KEY_LEN] {
83        &self.0
84    }
85
86    /// 显式复制。命名刻意不叫 `clone`,以便审计时能搜出所有密钥复制点。
87    #[must_use]
88    pub fn duplicate(&self) -> Self {
89        Self(self.0)
90    }
91
92    /// 生成随机密钥。
93    #[must_use]
94    pub fn random() -> Self {
95        let mut k = [0u8; KEY_LEN];
96        crate::util::fill_random(&mut k);
97        Self(k)
98    }
99}
100
101impl core::fmt::Debug for SecretKey {
102    /// 刻意不打印密钥内容,避免日志泄露。
103    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104        f.write_str("SecretKey(<redacted>)")
105    }
106}
107
108/// 库主密钥(Key Encryption Key)。
109///
110/// 由 `Argon2id(密码, vault_salt)` 派生,**每密码每会话只应派生一次**,
111/// 之后用 [`Kek::derive_slot_key`] 等方法为每个文件微秒级派生子密钥。
112#[derive(Debug)]
113pub struct Kek(SecretKey);
114
115impl Kek {
116    /// 由已有的 32 字节密钥材料构造(如从 OS 密钥库取出的设备密钥、或恢复码)。
117    #[must_use]
118    pub const fn from_key(key: SecretKey) -> Self {
119        Self(key)
120    }
121
122    /// 借出底层密钥。
123    #[must_use]
124    pub const fn as_key(&self) -> &SecretKey {
125        &self.0
126    }
127
128    /// 显式复制。
129    ///
130    /// 命名与 [`SecretKey::duplicate`] 一致,刻意不实现 `Clone`——这样审计时
131    /// 能搜出所有 KEK 复制点。扫描器需要它把会话缓存中的 KEK 传给解包函数。
132    #[must_use]
133    pub fn duplicate(&self) -> Self {
134        Self(self.0.duplicate())
135    }
136
137    /// 用 Argon2id 从密码派生 KEK。
138    ///
139    /// # 开销
140    ///
141    /// 这是**故意慢**的操作(默认参数约 180 ms)。调用方必须缓存结果,
142    /// 不得在循环中对每个文件重复调用。
143    ///
144    /// # Errors
145    ///
146    /// Argon2 参数非法(如内存成本过小)时返回 [`Error::KeyDerivation`]。
147    pub fn from_password(password: &[u8], vault_salt: &[u8], params: Argon2Params) -> Result<Self> {
148        use argon2::{Algorithm, Argon2, Version};
149
150        let p = argon2::Params::new(params.m_kib, params.t, params.p, Some(KEY_LEN))
151            .map_err(|_| Error::KeyDerivation { reason: "invalid Argon2 parameters" })?;
152        let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
153
154        let mut out = [0u8; KEY_LEN];
155        a2.hash_password_into(password, vault_salt, &mut out)
156            .map_err(|_| Error::KeyDerivation { reason: "Argon2 hashing failed" })?;
157        Ok(Self(SecretKey::from_bytes(out)))
158    }
159
160    /// 派生 vault 级的目录名加密密钥(树形模式,文档 05 §3.3)。
161    ///
162    /// `salt = vault_salt`,`info` 由调用方给出(见
163    /// [`crate::dirname::DirnameKey::derive`])。
164    ///
165    /// # 为什么 salt 是 vault_salt 而不是 file_uuid
166    ///
167    /// 目录名不属于任何单个文件,没有 file_uuid 可用。用 vault_salt 的
168    /// 副作用是**目录结构的可见性绑定整个 vault**:能解开 vault 的任一
169    /// 密码都能看到完整目录树,无法按 slot 区分权限。这是树形模式的固有
170    /// 约束,UI 必须告知用户。
171    #[must_use]
172    pub fn derive_dirname_key(&self, vault_salt: &[u8; 16], info: &[u8]) -> SecretKey {
173        hkdf_expand(self.0.as_bytes(), vault_salt, info)
174    }
175
176    /// 派生第 `slot_index` 个 key slot 的包裹密钥。
177    ///
178    /// `info = "omy/v1/slot" || u16le(slot_index)`,`salt = file_uuid`
179    #[must_use]
180    pub fn derive_slot_key(&self, file_uuid: &[u8; 16], slot_index: u16) -> SecretKey {
181        let mut info = [0u8; SLOT_INFO_LEN];
182        let (prefix, suffix) = info.split_at_mut(INFO_SLOT.len());
183        prefix.copy_from_slice(INFO_SLOT);
184        suffix.copy_from_slice(&slot_index.to_le_bytes());
185        hkdf_expand(self.0.as_bytes(), file_uuid, &info)
186    }
187
188    /// 派生该 KEK 的会话内指纹,用于判断两个 KEK 是否相同。
189    ///
190    /// # 为什么需要它
191    ///
192    /// 会话要同时装多个密码,就必须回答「刚输的这个,是不是已经装过的那个」。
193    /// 直接比较 KEK 字节是可以的,但那要求把密钥本身搬来搬去做相等比较;
194    /// 而按名字比较又不成立——同一个密码可以被起两个名字,不同密码也可以
195    /// 重名(上一轮「`label` 让同一个密码被算成两条凭据」正是后者)。
196    ///
197    /// # 为什么不能落盘
198    ///
199    /// 指纹是**密码的稳定标识**:同一密码在同一 vault 下恒为同一值。写进
200    /// 磁盘就等于给攻击者一个离线可比对的口令哈希——他能据此判断两个库是不是
201    /// 同一个密码、能拿字典逐个比对候选密码,而 Argon2 那几百毫秒的代价
202    /// 在这里完全不起作用(KEK 已经是派生结果,指纹只是一次 HKDF)。
203    ///
204    /// 所以它**只允许在内存里存在**,不得写入任何文件、日志或 IPC 响应。
205    ///
206    /// # 为什么 salt 是 vault_salt
207    ///
208    /// 让指纹天然绑定 vault。同一密码在不同 vault 下 KEK 本就不同,指纹也就
209    /// 不同——这与缓存键必须含 `vault_salt` 是同一个道理,避免跨库误判成
210    /// 「已经装过了」。
211    #[must_use]
212    pub fn fingerprint(&self, vault_salt: &[u8; 16]) -> [u8; KEY_LEN] {
213        let k = hkdf_expand(self.0.as_bytes(), vault_salt, INFO_FINGERPRINT);
214        *k.as_bytes()
215    }
216}
217
218/// 文件加密密钥(File Encryption Key)。
219///
220/// 每个文件独立随机生成,被各个 key slot 包裹。所有文件级子密钥都由它派生。
221#[derive(Debug)]
222pub struct Fek(SecretKey);
223
224impl Fek {
225    /// 生成随机 FEK。
226    #[must_use]
227    pub fn random() -> Self {
228        Self(SecretKey::random())
229    }
230
231    /// 由原始字节构造。
232    #[must_use]
233    pub const fn from_key(key: SecretKey) -> Self {
234        Self(key)
235    }
236
237    /// 借出底层密钥。
238    #[must_use]
239    pub const fn as_key(&self) -> &SecretKey {
240        &self.0
241    }
242
243    /// 派生载荷加密密钥。
244    #[must_use]
245    pub fn derive_payload_key(&self, file_uuid: &[u8; 16]) -> SecretKey {
246        hkdf_expand(self.0.as_bytes(), file_uuid, INFO_PAYLOAD)
247    }
248
249    /// 派生头部 MAC 密钥。
250    #[must_use]
251    pub fn derive_header_mac_key(&self, file_uuid: &[u8; 16]) -> SecretKey {
252        hkdf_expand(self.0.as_bytes(), file_uuid, INFO_HEADER_MAC)
253    }
254
255    /// 派生某个 TLV 类型的加密密钥。
256    ///
257    /// 注意 `salt` 为**空**(规范 §4.3),与其它派生不同。
258    #[must_use]
259    pub fn derive_tlv_key(&self, tlv_type: u16) -> SecretKey {
260        let mut info = [0u8; TLV_INFO_LEN];
261        let (prefix, suffix) = info.split_at_mut(INFO_TLV.len());
262        prefix.copy_from_slice(INFO_TLV);
263        suffix.copy_from_slice(&tlv_type.to_le_bytes());
264        hkdf_expand(self.0.as_bytes(), &[], &info)
265    }
266}
267
268/// 从任意一把已有密钥再派生一把,用于给同一份秘密划分用途域。
269///
270/// 存在的理由:一个密钥只该服务一个用途。`DirKey` 既要给目录名做 AEAD,
271/// 又要加密槽位目录,直接两处复用时一处的 nonce 复用会波及另一处。
272///
273/// `info` 必须在全局唯一,否则两个用途会派生出同一把密钥,等于没分。
274#[must_use]
275pub fn derive_from_secret(key: &SecretKey, salt: &[u8; 16], info: &[u8]) -> SecretKey {
276    hkdf_expand(key.as_bytes(), salt, info)
277}
278
279/// HKDF-SHA256 提取加扩展,输出 32 字节。
280fn hkdf_expand(ikm: &[u8], salt: &[u8], info: &[u8]) -> SecretKey {
281    let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
282    let mut out = [0u8; KEY_LEN];
283    // 32 字节远小于 SHA-256 的 255*32 上限,不可能失败
284    hk.expand(info, &mut out)
285        .unwrap_or_else(|_| unreachable!("HKDF output length 32 is always valid"));
286    SecretKey::from_bytes(out)
287}
288
289/// Argon2id 参数。
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub struct Argon2Params {
292    /// 内存成本(KiB)。
293    pub m_kib: u32,
294    /// 迭代次数。
295    pub t: u32,
296    /// 并行度。
297    pub p: u32,
298}
299
300impl Argon2Params {
301    /// 移动端档位:m=32 MiB, t=4。
302    pub const MOBILE: Self = Self { m_kib: 32 * 1024, t: 4, p: 1 };
303    /// 交互档位(默认):m=64 MiB, t=3。约 180 ms。
304    pub const INTERACTIVE: Self = Self { m_kib: 64 * 1024, t: 3, p: 1 };
305    /// 中等档位:m=256 MiB, t=4。
306    pub const MODERATE: Self = Self { m_kib: 256 * 1024, t: 4, p: 1 };
307    /// 高强度档位:m=1 GiB, t=4。移动端可能无法完成。
308    pub const SENSITIVE: Self = Self { m_kib: 1024 * 1024, t: 4, p: 1 };
309
310    /// 测试向量使用的弱参数。
311    ///
312    /// # 警告
313    ///
314    /// 仅用于复现测试向量,**生产环境禁用**。
315    pub const TEST_WEAK: Self = Self { m_kib: 64, t: 1, p: 1 };
316}
317
318impl Default for Argon2Params {
319    fn default() -> Self {
320        Self::INTERACTIVE
321    }
322}
323
324/// AEAD 算法选择。
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326#[repr(u8)]
327pub enum CipherId {
328    /// ChaCha20-Poly1305(RFC 8439),12 字节 nonce。
329    ///
330    /// 参考实现与测试向量使用此算法。生产默认为 XChaCha20-Poly1305(24 字节 nonce),
331    /// 目前尚未实现,见 `docs/research/02-file-format-spec.md` §5.2 的说明。
332    ChaCha20Poly1305 = 1,
333    /// AES-256-GCM。有 AES-NI 硬件加速时更快。
334    Aes256Gcm = 2,
335}
336
337impl CipherId {
338    /// 由 header 中的数值解析。
339    ///
340    /// # Errors
341    ///
342    /// 未知取值时返回 [`Error::MalformedHeader`]。
343    pub const fn from_u8(v: u8) -> Result<Self> {
344        match v {
345            1 => Ok(Self::ChaCha20Poly1305),
346            2 => Ok(Self::Aes256Gcm),
347            _ => Err(Error::MalformedHeader { reason: "unknown cipher_id" }),
348        }
349    }
350
351    /// AEAD 加密。输出为 `密文 || tag`。
352    ///
353    /// # Errors
354    ///
355    /// 底层 AEAD 失败时返回错误(正常输入下不会发生)。
356    pub fn encrypt(
357        self,
358        key: &SecretKey,
359        nonce: &[u8; NONCE_LEN],
360        plaintext: &[u8],
361        aad: &[u8],
362    ) -> Result<Vec<u8>> {
363        let payload = Payload { msg: plaintext, aad };
364        match self {
365            Self::ChaCha20Poly1305 => {
366                let c = ChaCha20Poly1305::new(key.as_bytes().into());
367                c.encrypt(nonce.into(), payload)
368            }
369            Self::Aes256Gcm => {
370                let c = Aes256Gcm::new(key.as_bytes().into());
371                c.encrypt(nonce.into(), payload)
372            }
373        }
374        .map_err(|_| Error::KeyDerivation { reason: "AEAD encryption failed" })
375    }
376
377    /// AEAD 解密。输入为 `密文 || tag`。
378    ///
379    /// 认证失败时返回 `Ok(None)`,让调用方区分「密码不对」和「真的坏了」——
380    /// 这在扫描场景下很关键:试错密码是正常流程,不该当作错误处理。
381    ///
382    /// # Errors
383    ///
384    /// 目前不返回 `Err`,签名保留以便未来扩展。
385    pub fn decrypt(
386        self,
387        key: &SecretKey,
388        nonce: &[u8; NONCE_LEN],
389        ciphertext: &[u8],
390        aad: &[u8],
391    ) -> Result<Option<Vec<u8>>> {
392        let payload = Payload { msg: ciphertext, aad };
393        let r = match self {
394            Self::ChaCha20Poly1305 => {
395                let c = ChaCha20Poly1305::new(key.as_bytes().into());
396                c.decrypt(nonce.into(), payload)
397            }
398            Self::Aes256Gcm => {
399                let c = Aes256Gcm::new(key.as_bytes().into());
400                c.decrypt(nonce.into(), payload)
401            }
402        };
403        Ok(r.ok())
404    }
405}
406
407/// 构造载荷块的 nonce。
408///
409/// ```text
410/// nonce(i) = base_nonce(7B) || u32be(i) || final_flag(1B)
411/// ```
412///
413/// `u32be(i)` 是**全格式唯一使用大端序**的地方,遵循 STREAM 构造惯例。
414/// `final_flag` 用于防截断攻击:删掉尾部块后,新的「最后一块」flag 不匹配,解密必然失败。
415#[must_use]
416pub fn chunk_nonce(base_nonce: &[u8; 7], index: u32, is_final: bool) -> [u8; NONCE_LEN] {
417    let mut n = [0u8; NONCE_LEN];
418    n[..7].copy_from_slice(base_nonce);
419    n[7..11].copy_from_slice(&index.to_be_bytes());
420    n[11] = u8::from(is_final);
421    n
422}
423
424/// 构造载荷块的 AAD。
425///
426/// ```text
427/// aad(i) = file_uuid(16B) || u32be(i)
428/// ```
429///
430/// 绑定文件身份防跨文件块移植,绑定块序号防块重排。
431#[must_use]
432pub fn chunk_aad(file_uuid: &[u8; 16], index: u32) -> [u8; 20] {
433    let mut a = [0u8; 20];
434    a[..16].copy_from_slice(file_uuid);
435    a[16..].copy_from_slice(&index.to_be_bytes());
436    a
437}
438
439/// key slot 与 TLV 使用的全零 nonce。
440///
441/// 安全性依据:每个 slot 的包裹密钥由 `(KEK, file_uuid, slot_index)` 唯一确定,
442/// 每个 TLV 密钥由 `(FEK, tlv_type)` 唯一确定,因此同一密钥永不重复使用,
443/// 零 nonce 不会导致 nonce 复用。
444pub const ZERO_NONCE: [u8; NONCE_LEN] = [0u8; NONCE_LEN];
445
446/// 计算头部 HMAC-SHA256。
447///
448/// 覆盖 `fixed_header || slot_area || tlv_blob`,即除 MAC 自身外的全部头部内容。
449#[must_use]
450pub fn header_mac(mac_key: &SecretKey, covered: &[u8]) -> [u8; 32] {
451    let mut m = <Hmac<Sha256> as Mac>::new_from_slice(mac_key.as_bytes())
452        .unwrap_or_else(|_| unreachable!("HMAC accepts any key length"));
453    m.update(covered);
454    m.finalize().into_bytes().into()
455}
456
457/// 以常量时间验证头部 MAC。
458///
459/// 使用常量时间比较避免时序侧信道泄露 MAC 的正确前缀长度。
460#[must_use]
461pub fn verify_header_mac(mac_key: &SecretKey, covered: &[u8], expected: &[u8; 32]) -> bool {
462    use subtle::ConstantTimeEq;
463    header_mac(mac_key, covered).ct_eq(expected).into()
464}
465
466/// 计算 BLAKE2b-256 内容哈希。
467#[must_use]
468pub fn content_hash(data: &[u8]) -> [u8; 32] {
469    use blake2::digest::consts::U32;
470    use blake2::{Blake2b, Digest};
471    let mut h = Blake2b::<U32>::new();
472    h.update(data);
473    h.finalize().into()
474}