omy_core/session.rs
1//! 会话密钥缓存与自动锁定。
2//!
3//! # 这个模块解决的核心问题
4//!
5//! 需求 F-12 要求「扫描目录、自动匹配多个浏览密码」。若每个文件都跑一次
6//! Argon2id,代价是灾难性的:
7//!
8//! ```text
9//! 10000 文件 × 5 密码 × 180 ms ≈ 2.5 小时
10//! ```
11//!
12//! 两级密钥体系让慢速 KDF 只在「密码 → KEK」这一步执行**一次**,此后每个文件
13//! 只做 HKDF(13.2 µs,快 13,700×)。前提是 KEK 必须被缓存复用——这正是本模块
14//! 的职责。实测:5 密码派生 765 ms,随后扫描 500 文件仅 171 ms。
15//!
16//! # 缓存键的设计
17//!
18//! 缓存键是 `(vault_salt, 凭据标识)`(规范 §11.1)。
19//!
20//! **为什么必须含 `vault_salt`**:同一个密码在不同 vault(不同 salt)下派生出的
21//! KEK 完全不同。若只用密码做键,切换 vault 后会复用错误的 KEK,导致本该解开的
22//! 文件解不开——而且这种 bug 表现为「密码错误」,极难排查。
23//!
24//! # 内存卫生
25//!
26//! [`SessionKeys`] 持有的 KEK 在移除或整体析构时清零。缓存**只存在于内存**,
27//! 绝不落盘(规范 §10 内存卫生)。
28
29use std::collections::HashMap;
30use std::time::{Duration, Instant};
31
32use zeroize::Zeroize as _;
33
34use crate::crypto::{Argon2Params, Kek, SecretKey};
35use crate::error::Result;
36
37/// 默认空闲锁定时长:15 分钟(规范 §11.2)。
38pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
39
40/// 凭据来源。
41///
42/// 决定该凭据是否参与自动扫描——这是性能与安全的权衡点。
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum CredentialKind {
45 /// 用户输入的浏览密码,`Argon2id(密码, vault_salt)`。参与扫描。
46 Vault,
47 /// 由 OS 密钥库经生物识别解出的设备密钥。参与扫描。
48 Device,
49 /// 单文件独立密码,salt 不是 `vault_salt`。**不参与扫描**。
50 Portable,
51 /// 恢复码,熵已足够,直接作 KEK。**不参与扫描**。
52 Recovery,
53}
54
55impl CredentialKind {
56 /// 该类型是否参与目录自动扫描。
57 ///
58 /// `Portable` 与 `Recovery` 不参与:前者的 salt 因文件而异,无法复用 KEK;
59 /// 后者需用户手动输入。两者都只在「手动打开单个文件」时尝试。
60 #[must_use]
61 pub const fn scannable(self) -> bool {
62 matches!(self, Self::Vault | Self::Device)
63 }
64
65 /// 稳定标识串,用于日志与错误信息(不含任何秘密)。
66 #[must_use]
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::Vault => "vault",
70 Self::Device => "device",
71 Self::Portable => "portable",
72 Self::Recovery => "recovery",
73 }
74 }
75}
76
77/// 缓存键:`(vault_salt, 凭据标识)`。
78///
79/// `label` 是调用方给凭据起的名字(如 "主密码"、"诱饵"),**不是密码本身**。
80/// 这样既能区分多个凭据,又不必在缓存键里保留秘密。
81///
82/// 代价是「键命中」只说明这个名字用过,**不说明密码相同**。所以
83/// [`SessionKeys::unlock_password`] 不能拿命中当「已经解锁过」,
84/// 必须重新派生并覆盖。
85///
86/// # 需要「多个密码并存」时不要用它
87///
88/// 用 label 区分密码有两个方向都会错:同一个密码起两个名字会被算成两条
89/// 凭据(状态栏显示的数量虚高),不同密码用同一个名字则会互相覆盖。
90/// 想累加多个密码的调用方应当用 [`SessionKeys::add_password`],它按
91/// KEK 指纹判断异同,label 只作显示用。
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93struct CacheKey {
94 vault_salt: [u8; 16],
95 kind: CredentialKind,
96 label: String,
97}
98
99/// 一个已解锁的凭据条目。
100#[derive(Debug)]
101pub struct UnlockedCredential {
102 /// 凭据名称,供 UI 显示(不含秘密)。
103 pub label: String,
104 /// 凭据类型。
105 pub kind: CredentialKind,
106 /// 派生出的 KEK。
107 pub kek: Kek,
108}
109
110/// 会话密钥缓存。
111///
112/// # 生命周期
113///
114/// 会话内有效。任一锁定条件触发(空闲超时、系统睡眠、切后台、手动锁定)
115/// 即调用 [`SessionKeys::lock`] 清零全部 KEK。
116///
117/// # 示例
118///
119/// ```
120/// use omy_core::session::{SessionKeys, CredentialKind};
121/// use omy_core::crypto::Argon2Params;
122///
123/// let mut s = SessionKeys::new();
124/// let salt = [0x11u8; 16];
125/// s.unlock_password("主密码", &salt, "hunter2", Argon2Params::TEST_WEAK)?;
126/// // 同名同盐再解锁一次会覆盖,不会多出一条凭据
127/// s.unlock_password("主密码", &salt, "hunter2", Argon2Params::TEST_WEAK)?;
128/// assert_eq!(s.len(), 1);
129/// # Ok::<(), omy_core::Error>(())
130/// ```
131#[derive(Debug)]
132pub struct SessionKeys {
133 cache: HashMap<CacheKey, Kek>,
134 /// 最近一次活动时间,用于空闲判定。
135 last_activity: Instant,
136 /// 空闲锁定阈值。
137 idle_timeout: Duration,
138 /// 累计的 Argon2 执行次数,用于验证缓存确实生效。
139 kdf_runs: u64,
140}
141
142impl Default for SessionKeys {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl SessionKeys {
149 /// 新建空会话,使用默认 15 分钟空闲锁定。
150 #[must_use]
151 pub fn new() -> Self {
152 Self {
153 cache: HashMap::new(),
154 last_activity: Instant::now(),
155 idle_timeout: DEFAULT_IDLE_TIMEOUT,
156 kdf_runs: 0,
157 }
158 }
159
160 /// 设置空闲锁定时长。
161 #[must_use]
162 pub const fn with_idle_timeout(mut self, timeout: Duration) -> Self {
163 self.idle_timeout = timeout;
164 self
165 }
166
167 /// 用密码解锁并缓存 KEK。
168 ///
169 /// 同一个 `(vault_salt, kind, label)` 再次解锁时**会重新派生并覆盖**旧条目,
170 /// 旧 KEK 在被挤出时清零。
171 ///
172 /// # 为什么不能「命中缓存就跳过 KDF」
173 ///
174 /// 缓存键里没有密码(有意如此,见 [`CacheKey`]),所以「命中」只说明
175 /// 这个**名字**用过,不说明密码相同。早先的实现在命中时直接
176 /// `return Ok(())`,于是同一 label 下的第二个密码从未被派生,会话里留着
177 /// 的还是上一个 KEK——而返回值是 `Ok`,调用方以为解锁成功了。
178 ///
179 /// 上层大量使用固定 label(GUI 恒为 `"main"`,见 `commands::unlock`),
180 /// 这条路径因此是常态而非边角:
181 ///
182 /// - 先打错一次密码、再输对的 → 第二次被忽略,一直提示打不开;
183 /// - 改完密码后自动装入新密码 → 装不进去,文件被显示成「需要密码」。
184 ///
185 /// 扫描性能不依赖这个早退:慢速 KDF 每个密码只跑一次是靠调用方在
186 /// **文件循环外**解锁(见 `scan` 命令),而每个文件复用 KEK 是靠
187 /// [`SessionKeys::scannable_for`] 从缓存读。想显式避免重复派生的调用方
188 /// 可以自己留住 KEK 走 [`SessionKeys::insert_kek`]。
189 ///
190 /// # 为什么不是把密码指纹加进缓存键
191 ///
192 /// 那需要在 Argon2 之前先算一个**快速**的密码哈希并驻留内存。能读到
193 /// 进程内存的攻击者本来只能对着 Argon2 逐个猜(每次几百毫秒),有了
194 /// 快速指纹就能离线秒破——而密码常被复用到别处。为省一次 KDF 削弱
195 /// 密码强度不值得。
196 ///
197 /// # Errors
198 ///
199 /// Argon2 派生失败时返回 [`crate::Error::KeyDerivation`]。
200 pub fn unlock_password(
201 &mut self,
202 label: &str,
203 vault_salt: &[u8; 16],
204 password: &str,
205 params: Argon2Params,
206 ) -> Result<()> {
207 self.touch();
208 let key = CacheKey {
209 vault_salt: *vault_salt,
210 kind: CredentialKind::Vault,
211 label: label.to_owned(),
212 };
213 let kek = Kek::from_password(password.as_bytes(), vault_salt, params)?;
214 self.kdf_runs = self.kdf_runs.saturating_add(1);
215 // insert 会把同键的旧 Kek 返回,就地 drop 即清零(ZeroizeOnDrop)
216 self.cache.insert(key, kek);
217 Ok(())
218 }
219
220 /// 直接插入一个已有的 KEK。
221 ///
222 /// 用于生物识别(OS 密钥库解出)和恢复码(熵已足够,无需 KDF)两种来源。
223 pub fn insert_kek(
224 &mut self,
225 label: &str,
226 kind: CredentialKind,
227 vault_salt: &[u8; 16],
228 kek: Kek,
229 ) {
230 self.touch();
231 let key = CacheKey { vault_salt: *vault_salt, kind, label: label.to_owned() };
232 self.cache.insert(key, kek);
233 }
234
235 /// 追加一个密码,**不挤掉已装入的其它密码**。
236 ///
237 /// 这是「同时用多个密码浏览」的入口:真实密码与诱饵密码可以并存,
238 /// 扫描时一遍遍历就能各自命中自己的文件。
239 ///
240 /// # 与 [`SessionKeys::unlock_password`] 的分工
241 ///
242 /// 那个是**替换**语义:同一个 `(salt, kind, label)` 再解锁一次就覆盖,
243 /// 适合「这个位置只该有一个密码」的场景(如改完密码后装入新密码)。
244 /// 本方法是**累加**语义,同一个 vault 下可以有任意多个。
245 ///
246 /// # 为什么去重判据是指纹,不是 label
247 ///
248 /// 两个方向都必须挡住,而 label 一个都挡不住:
249 ///
250 /// - 同一个密码输两次(用户忘了已经输过、或加密后又手动解锁一次)
251 /// 必须**只算一条**。按 label 判断做不到:调用方给的名字可能不同,
252 /// 于是同一个密码被算成两条凭据,状态栏显示「2 个密码已解锁」。
253 /// 这正是上一轮踩过的缺陷,当时的对策是把 label 统一写死成 `"main"`,
254 /// 而那又导致第二个**不同**的密码被静默挤掉——两个缺陷是同一个根因
255 /// 的两面:拿名字当身份。
256 /// - 两个不同的密码恰好重名必须**各算一条**。写死 label 时这条必然失败。
257 ///
258 /// KEK 指纹是密码在本 vault 下的稳定标识,两个方向都正确。
259 ///
260 /// # 返回值
261 ///
262 /// `true` 表示这是一个新密码,`false` 表示它与已装入的某个密码相同、
263 /// 本次没有新增条目。调用方**不应**把 `false` 当作失败:用户重复输入
264 /// 同一个密码是正常操作,只是不该让计数虚高。
265 ///
266 /// # 开销
267 ///
268 /// 无论是否重复都会跑一次 Argon2——判断重复需要先有 KEK,而拿到 KEK
269 /// 就得跑 KDF。想省掉这次 KDF 只能在派生前先算一个**快速**的密码哈希
270 /// 并驻留内存,那等于给能读进程内存的攻击者一个可离线秒破的口令摘要
271 /// (同 [`SessionKeys::unlock_password`] 中的论述)。为省几百毫秒削弱
272 /// 密码强度不值得。
273 ///
274 /// # Errors
275 ///
276 /// Argon2 派生失败时返回 [`crate::Error::KeyDerivation`]。
277 pub fn add_password(
278 &mut self,
279 label: &str,
280 vault_salt: &[u8; 16],
281 password: &str,
282 params: Argon2Params,
283 ) -> Result<bool> {
284 self.touch();
285 let kek = Kek::from_password(password.as_bytes(), vault_salt, params)?;
286 self.kdf_runs = self.kdf_runs.saturating_add(1);
287
288 let fp = kek.fingerprint(vault_salt);
289 // 只在同一个 vault 内比对:不同 salt 下同一密码的 KEK 本就不同,
290 // 让它们各占一条是对的(那是两个库,各自要解各自的文件)
291 let dup = self.cache.iter().any(|(k, v)| {
292 &k.vault_salt == vault_salt
293 && k.kind == CredentialKind::Vault
294 && v.fingerprint(vault_salt) == fp
295 });
296 if dup {
297 return Ok(false);
298 }
299
300 // 名字只用于显示,不参与身份判断,所以重名要让开而不是覆盖——
301 // 覆盖会把一个**不同**的密码悄悄挤掉,正是本方法要避免的事
302 let label = self.unique_label(vault_salt, CredentialKind::Vault, label);
303 let key = CacheKey { vault_salt: *vault_salt, kind: CredentialKind::Vault, label };
304 self.cache.insert(key, kek);
305 Ok(true)
306 }
307
308 /// 追加一把已有的 KEK,**不挤掉已装入的其它凭据**。
309 ///
310 /// # 与 [`SessionKeys::insert_kek`] 的分工
311 ///
312 /// 那个是**替换**语义:同一个 `(salt, kind, label)` 再插一次就覆盖。
313 /// 本方法是**累加**语义,并按 KEK 指纹去重——与
314 /// [`SessionKeys::add_password`] 一致。
315 ///
316 /// 设备密钥必须走这条:用户按了指纹之后,之前手动输入的密码不该消失。
317 /// 走 `insert_kek` 的话只要 label 撞上就会静默挤掉一条,而那条可能
318 /// 正是他刚输的另一个库的密码。
319 ///
320 /// # 返回值
321 ///
322 /// `true` 表示这是一把新凭据,`false` 表示它与已装入的某条相同。
323 /// 调用方**不应**把 `false` 当失败:重复装入同一把钥匙是正常操作
324 /// (比如又按了一次指纹),只是没有新增条目。
325 pub fn add_kek(
326 &mut self,
327 label: &str,
328 kind: CredentialKind,
329 vault_salt: &[u8; 16],
330 kek: Kek,
331 ) -> bool {
332 self.touch();
333 let fp = kek.fingerprint(vault_salt);
334 // 只在同一个 vault、同一种类型内比对。跨 vault 的同一把保管后端密钥
335 // 派生出的 KEK 本就不同(HKDF 的 salt 是 vault_salt),让它们各占
336 // 一条是对的——那是两个库
337 let dup = self.cache.iter().any(|(k, v)| {
338 &k.vault_salt == vault_salt && k.kind == kind && v.fingerprint(vault_salt) == fp
339 });
340 if dup {
341 return false;
342 }
343 let label = self.unique_label(vault_salt, kind, label);
344 let key = CacheKey { vault_salt: *vault_salt, kind, label };
345 self.cache.insert(key, kek);
346 true
347 }
348
349 /// 造一个在该 `(vault_salt, kind)` 下尚未被占用的显示名。
350 ///
351 /// 重名时加 `#2` / `#3` 后缀。这纯粹是为了让缓存键唯一,用户看到的
352 /// 名字略有出入不影响任何解密行为——但若不这样做,两个不同密码会
353 /// 因为重名而互相覆盖。
354 fn unique_label(&self, vault_salt: &[u8; 16], kind: CredentialKind, want: &str) -> String {
355 let taken = |name: &str| {
356 self.cache
357 .keys()
358 .any(|k| &k.vault_salt == vault_salt && k.kind == kind && k.label == name)
359 };
360 if !taken(want) {
361 return want.to_owned();
362 }
363 // 上界取 SLOT_COUNT 之外还留了余量:会话里的凭据数不受 8 槽限制
364 // (不同文件可以有不同的密码集合),但也不该无限尝试
365 for n in 2..=64u32 {
366 let candidate = format!("{want}#{n}");
367 if !taken(&candidate) {
368 return candidate;
369 }
370 }
371 // 极端情况下退回原名(会覆盖)。走到这里说明同名凭据已有 64 个,
372 // 属于误用而非正常路径,不值得为它引入一个错误分支
373 want.to_owned()
374 }
375
376 /// 移除某个凭据(其 KEK 会被清零)。
377 ///
378 /// 返回是否确实移除了条目。
379 pub fn forget(&mut self, label: &str, kind: CredentialKind, vault_salt: &[u8; 16]) -> bool {
380 let key = CacheKey { vault_salt: *vault_salt, kind, label: label.to_owned() };
381 // Kek 实现了 ZeroizeOnDrop,移除即清零
382 self.cache.remove(&key).is_some()
383 }
384
385 /// 列出适用于指定 `vault_salt` 且**参与扫描**的凭据。
386 ///
387 /// 扫描器用这个列表逐一尝试解包 slot。返回的 KEK 是副本,
388 /// 调用方用完即析构清零。
389 #[must_use]
390 pub fn scannable_for(&self, vault_salt: &[u8; 16]) -> Vec<UnlockedCredential> {
391 self.cache
392 .iter()
393 .filter(|(k, _)| &k.vault_salt == vault_salt && k.kind.scannable())
394 .map(|(k, v)| UnlockedCredential {
395 label: k.label.clone(),
396 kind: k.kind,
397 kek: v.duplicate(),
398 })
399 .collect()
400 }
401
402 /// 列出适用于指定 `vault_salt` 的**全部**凭据(含不参与扫描的)。
403 ///
404 /// 用于「手动打开单个文件」——此时可以接受 portable/recovery 的额外成本。
405 #[must_use]
406 pub fn all_for(&self, vault_salt: &[u8; 16]) -> Vec<UnlockedCredential> {
407 self.cache
408 .iter()
409 .filter(|(k, _)| &k.vault_salt == vault_salt)
410 .map(|(k, v)| UnlockedCredential {
411 label: k.label.clone(),
412 kind: k.kind,
413 kek: v.duplicate(),
414 })
415 .collect()
416 }
417
418 /// 列出会话里**全部**已解锁凭据的 KEK 副本,不按 vault_salt 过滤。
419 ///
420 /// # 为什么需要一个「不分库」的入口
421 ///
422 /// [`Self::all_for`] 按 `vault_salt` 过滤,服务的是「打开某个 vault 里的
423 /// 文件」——那里 KEK 必须与文件所属 vault 的盐匹配。但远程位置的密码槽
424 /// 不属于任何 vault:它按位置自己的 uuid 派生槽密钥(见
425 /// `omy-remote` 的 `place_secret`),解锁时要拿**所有**已输入过的密码去
426 /// 试,无论它当初是为哪个库输的。这正是「输过任一密码就能开设了该密码的
427 /// 位置」的实现基础。
428 ///
429 /// 返回的是 KEK 副本,调用方用完即析构清零。
430 #[must_use]
431 pub fn all_keks(&self) -> Vec<Kek> {
432 self.cache.values().map(Kek::duplicate).collect()
433 }
434
435 /// 缓存中的凭据数量。
436 #[must_use]
437 pub fn len(&self) -> usize {
438 self.cache.len()
439 }
440
441 /// 缓存是否为空(即处于锁定状态)。
442 #[must_use]
443 pub fn is_empty(&self) -> bool {
444 self.cache.is_empty()
445 }
446
447 /// 至今执行过的 Argon2 次数。
448 ///
449 /// 用于验证缓存生效:解锁 N 个不同凭据后该值应为 N,
450 /// 无论调用了多少次 [`SessionKeys::unlock_password`]。
451 #[must_use]
452 pub const fn kdf_runs(&self) -> u64 {
453 self.kdf_runs
454 }
455
456 /// 立即锁定:清零所有 KEK。
457 ///
458 /// 调用方还应同步关闭已打开的媒体流、清理 WebView 缓存(规范 §11.2)。
459 pub fn lock(&mut self) {
460 // HashMap::clear 会 drop 所有 Kek,触发 ZeroizeOnDrop
461 self.cache.clear();
462 // label 里虽无秘密,但可能含用户起的名字,一并清理
463 self.cache.shrink_to_fit();
464 }
465
466 /// 刷新活动时间戳,推迟空闲锁定。
467 pub fn touch(&mut self) {
468 self.last_activity = Instant::now();
469 }
470
471 /// 距上次活动的时长。
472 #[must_use]
473 pub fn idle_for(&self) -> Duration {
474 self.last_activity.elapsed()
475 }
476
477 /// 是否已达到空闲锁定阈值。
478 #[must_use]
479 pub fn should_auto_lock(&self) -> bool {
480 self.should_auto_lock_for_idle(self.idle_for())
481 }
482
483 // 把阈值判定与真实时钟分开,测试无需假定共享 runner 会在几十毫秒内调度。
484 fn should_auto_lock_for_idle(&self, idle: Duration) -> bool {
485 !self.cache.is_empty() && idle >= self.idle_timeout
486 }
487
488 /// 若已空闲超时则锁定,返回是否执行了锁定。
489 ///
490 /// 宿主应用应定期(如每 30 秒)调用此方法。
491 pub fn auto_lock_if_idle(&mut self) -> bool {
492 if self.should_auto_lock() {
493 self.lock();
494 true
495 } else {
496 false
497 }
498 }
499}
500
501impl Drop for SessionKeys {
502 /// 会话析构时确保清零。
503 fn drop(&mut self) {
504 self.cache.clear();
505 }
506}
507
508/// 从恢复码助记词还原 KEK。
509///
510/// # 已废弃:请改用 [`crate::recovery::RecoveryCode`]
511///
512/// 本函数把词序列拼起来做一次 SHA-256,**丢掉了熵的结构**——它接受任意词、
513/// 任意个数,因而无从校验。后果是「抄错一个词」与「这不是本库的恢复码」
514/// 表现完全一样(都是解不开),而这两种处境的处置方式相反。
515///
516/// 正确做法是先把词解码回 256 bit 原始熵、校验通过后再派生,见
517/// [`crate::recovery`]。保留本函数只为不破坏既有调用方;新代码不要用。
518#[deprecated(
519 since = "0.0.1",
520 note = "改用 recovery::RecoveryCode::from_phrase():它会校验词表与校验和,并能指出第几个词可疑"
521)]
522pub fn kek_from_recovery_words(words: &[&str]) -> Result<Kek> {
523 use sha2::{Digest as _, Sha256};
524 let mut h = Sha256::new();
525 h.update(b"omy/v1/recovery");
526 for (i, w) in words.iter().enumerate() {
527 if i > 0 {
528 h.update(b" ");
529 }
530 // 归一化:去空白 + 转小写,容忍用户手抄时的大小写差异
531 h.update(w.trim().to_lowercase().as_bytes());
532 }
533 let mut out = h.finalize();
534 let mut key = [0u8; 32];
535 key.copy_from_slice(&out);
536 out.zeroize();
537 Ok(Kek::from_key(SecretKey::from_bytes(key)))
538}
539
540#[cfg(test)]
541mod idle_tests {
542 use super::*;
543
544 fn keyed_session() -> SessionKeys {
545 let mut session = SessionKeys::new();
546 let salt = core::array::from_fn(|i| (i as u8).wrapping_mul(7) ^ 0x66);
547 let key = core::array::from_fn(|i| (i as u8).wrapping_mul(11) ^ 0x3D);
548 session.insert_kek("main", CredentialKind::Vault, &salt,
549 Kek::from_key(SecretKey::from_bytes(key)));
550 session
551 }
552
553 #[test]
554 fn auto_lock_triggers_after_idle_timeout() {
555 let mut session = keyed_session().with_idle_timeout(Duration::MAX);
556 assert!(!session.auto_lock_if_idle(), "未超时不能清掉仍在使用的密钥");
557 let timeout = Duration::from_millis(50);
558 session = session.with_idle_timeout(timeout);
559 assert!(!session.should_auto_lock_for_idle(Duration::from_millis(49)),
560 "阈值前一毫秒仍不应锁定,不能提前清除密钥");
561 assert!(session.should_auto_lock_for_idle(timeout),
562 "恰好达到阈值就应锁定,不能把 >= 写成 >");
563 session.last_activity = Instant::now().checked_sub(timeout).expect("过去的活动时间");
564 assert!(session.should_auto_lock(), "真实时钟也必须使用空闲判定,不能忽略活动时间");
565 assert!(session.auto_lock_if_idle(), "超时后必须实际执行锁定");
566 assert!(session.is_empty(), "锁定必须清除缓存,不能只返回成功");
567 }
568
569 #[test]
570 fn touch_postpones_auto_lock() {
571 let mut session = keyed_session().with_idle_timeout(Duration::from_millis(100));
572 let old = Instant::now().checked_sub(Duration::from_secs(1)).expect("过去的活动时间");
573 session.last_activity = old;
574 assert!(session.should_auto_lock(), "过期的活动时间应触发锁定,否则测试没有覆盖延期");
575 session.touch();
576 assert!(session.last_activity > old, "touch 必须更新活动时间,不能只是保留缓存");
577 assert!(!session.should_auto_lock_for_idle(Duration::from_millis(99)),
578 "刷新后的阈值前仍可继续使用密钥");
579 assert_eq!(session.len(), 1, "活动只推迟锁定,不应丢弃已有凭据");
580 }
581
582 #[test]
583 fn empty_session_never_auto_locks() {
584 let mut session = SessionKeys::new().with_idle_timeout(Duration::ZERO);
585 assert!(!session.should_auto_lock_for_idle(Duration::MAX),
586 "即使已超时,空缓存也不应报告需要锁定");
587 assert!(!session.auto_lock_if_idle(), "空会话不能报告执行了锁定");
588 }
589}