Skip to main content

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}