omy_core/devicekey.rs
1//! 设备密钥:把一把随机 KEK 交给这台机器的 OS 密钥库保管。
2//!
3//! # 它是什么
4//!
5//! 一个普通的 slot,和密码槽、恢复码槽并列。区别只在**这把 KEK 从哪来**:
6//!
7//! | 槽位 | KEK 的来源 |
8//! |---|---|
9//! | 密码 | Argon2 从密码派生 |
10//! | 恢复码 | HKDF 从 26 个词的熵派生 |
11//! | 设备密钥 | **随机生成**,密文交给 OS 密钥库保管 |
12//!
13//! 前两者可以随时重新算出来(记得密码/拿着纸条就行),设备密钥不能——
14//! 它只存在于那台机器的 OS 密钥库里。这个差别决定了它的全部性质。
15//!
16//! # 为什么绑 `vault_salt`
17//!
18//! `vault_salt` 在同一个 vault 内是相同的(见 [`crate::header::FixedHeader`]),
19//! 所以拿它当保管 id 意味着「一个库共用一把设备密钥」:
20//!
21//! - 给库里任一文件挂上设备密钥,同库其它文件也能用同一把开
22//! - 换个库自动隔离,不会互相解开
23//!
24//! 换成绑文件路径就不成立了:文件改名、移动之后 id 就变了,密钥还在
25//! OS 密钥库里但再也对不上——表现为「明明开过一次,第二天打不开了」。
26//!
27//! # 它绝不能是唯一凭据
28//!
29//! 换机器、重装系统或重置系统生物识别——任一发生,这个槽就永久
30//! 解不开。所以挂设备密钥时必须已经有一个密码槽,删密码槽时也必须
31//! 拦住「只剩设备密钥」的状态。
32//!
33//! 这一条不是提醒,是需要代码强制的:用户很容易觉得「有指纹就够了」,
34//! 而代价要到换电脑那天才显现,那时数据已经拿不回来了。
35//!
36//! # 它不比密码更安全
37//!
38//! 设备密钥保管后端挡得住硬盘被偷和换机器解密,挡不住**正在这台机器上
39//! 以你的身份运行的恶意程序**——它可以在你按下指纹之后的窗口里发起解封。
40//! 所以设备密钥的定位是**省去每次输密码**,不是「更安全」。
41//!
42//! 界面上必须这么说。反过来说会让用户因为开了它而把密码设得更弱,
43//! 那是净损失。
44
45use crate::crypto::{Kek, SecretKey};
46use hkdf::Hkdf;
47use sha2::Sha256;
48
49/// HKDF info:把 OS 密钥库保管的那把密钥派生成 KEK。
50///
51/// 与恢复码、目录名等用不同的 info:同一份熵派生出的两把密钥不能相等,
52/// 否则一处的密文可以拿到另一处去试。
53const INFO_DEVICE: &[u8] = b"omy/v1/device-key";
54
55/// OS 密钥库保管的那把密钥的长度。
56pub const SECRET_LEN: usize = 32;
57
58/// 保管 id 用的前缀,避免与别的用途撞名。
59const ID_PREFIX: &str = "vault-";
60
61/// 一个 vault 的设备密钥在 OS 密钥库里的保管 id。
62///
63/// 用 `vault_salt` 而不是文件路径:路径会变(改名、移动),用它当 id
64/// 会让密钥一改名就对不上——密钥还在 OS 密钥库里,但再也找不到。
65#[must_use]
66pub fn slot_id(vault_salt: &[u8; 16]) -> String {
67 let mut s = String::with_capacity(ID_PREFIX.len().saturating_add(32));
68 s.push_str(ID_PREFIX);
69 for b in vault_salt {
70 s.push_str(&format!("{b:02x}"));
71 }
72 s
73}
74
75/// 把 OS 密钥库保管的密钥派生成 KEK。
76///
77/// 不直接把那 32 字节当 KEK 用:保管层(`omy-secret`)与加密层是两个
78/// 独立的信任域,中间过一次 HKDF 才能保证「保管层泄露一把密钥」不等于
79/// 「直接拿到 vault 的 KEK」——虽然实际影响有限,但这层隔离几乎免费。
80#[must_use]
81pub fn kek_from_secret(secret: &[u8; SECRET_LEN], vault_salt: &[u8; 16]) -> Kek {
82 let hk = Hkdf::<Sha256>::new(Some(vault_salt), secret);
83 let mut out = [0u8; 32];
84 // 32 字节远小于 SHA-256 的 255*32 上限,不可能失败
85 hk.expand(INFO_DEVICE, &mut out)
86 .unwrap_or_else(|_| unreachable!("HKDF expand 32 bytes cannot fail"));
87 Kek::from_key(SecretKey::from_bytes(out))
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn slot_id_is_stable_and_vault_scoped() {
96 // 同一个库要得到同一个 id,否则每次挂设备密钥都会在 OS 密钥库里
97 // 多留一把用不到的密钥,而旧的那把再也找不到
98 let a = [7u8; 16];
99 assert_eq!(slot_id(&a), slot_id(&a), "同一个 salt 必须得到同一个 id");
100
101 // 不同库必须隔离,否则 A 库的设备密钥能开 B 库
102 let mut b = a;
103 b[0] = 8;
104 assert_ne!(slot_id(&a), slot_id(&b), "不同 salt 必须得到不同 id");
105
106 // 十六进制编码:id 会当文件名用,必须只含安全字符
107 assert!(
108 slot_id(&a).strip_prefix(ID_PREFIX).is_some_and(|h| h
109 .chars()
110 .all(|c| c.is_ascii_hexdigit())),
111 "id 里出现了非十六进制字符"
112 );
113 }
114
115 #[test]
116 fn same_secret_different_vault_gives_different_kek() {
117 // 关键性质:同一台机器上的两个库即使拿到同一把保管后端的密钥,
118 // 派生出的 KEK 也必须不同——否则 A 库的设备密钥能开 B 库。
119 //
120 // 这条由 HKDF 的 salt 保证。不这样会怎样:所有库共用一把 KEK,
121 // 而用户以为它们是隔离的
122 let secret = [0x5Au8; SECRET_LEN];
123 let k1 = kek_from_secret(&secret, &[1u8; 16]);
124 let k2 = kek_from_secret(&secret, &[2u8; 16]);
125 assert_ne!(
126 k1.fingerprint(&[0u8; 16]),
127 k2.fingerprint(&[0u8; 16]),
128 "不同 vault 派生出了相同的 KEK"
129 );
130 }
131
132 #[test]
133 fn derivation_is_deterministic() {
134 // 同样的输入必须得到同样的 KEK,否则今天挂上的设备密钥
135 // 明天就开不了了
136 let secret = [0x33u8; SECRET_LEN];
137 let salt = [9u8; 16];
138 assert_eq!(
139 kek_from_secret(&secret, &salt).fingerprint(&salt),
140 kek_from_secret(&secret, &salt).fingerprint(&salt),
141 );
142 }
143
144 #[test]
145 fn different_secret_gives_different_kek() {
146 // 反证:换一把保管后端的密钥必须得到不同的 KEK。
147 // 否则「重新生成设备密钥」这个操作等于什么都没做
148 let salt = [9u8; 16];
149 let k1 = kek_from_secret(&[1u8; SECRET_LEN], &salt);
150 let k2 = kek_from_secret(&[2u8; SECRET_LEN], &salt);
151 assert_ne!(k1.fingerprint(&salt), k2.fingerprint(&salt));
152 }
153}