use std::collections::HashMap;
use std::time::{Duration, Instant};
use zeroize::Zeroize as _;
use crate::crypto::{Argon2Params, Kek, SecretKey};
use crate::error::Result;
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CredentialKind {
Vault,
Device,
Portable,
Recovery,
}
impl CredentialKind {
#[must_use]
pub const fn scannable(self) -> bool {
matches!(self, Self::Vault | Self::Device)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Vault => "vault",
Self::Device => "device",
Self::Portable => "portable",
Self::Recovery => "recovery",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
vault_salt: [u8; 16],
kind: CredentialKind,
label: String,
}
#[derive(Debug)]
pub struct UnlockedCredential {
pub label: String,
pub kind: CredentialKind,
pub kek: Kek,
}
#[derive(Debug)]
pub struct SessionKeys {
cache: HashMap<CacheKey, Kek>,
last_activity: Instant,
idle_timeout: Duration,
kdf_runs: u64,
}
impl Default for SessionKeys {
fn default() -> Self {
Self::new()
}
}
impl SessionKeys {
#[must_use]
pub fn new() -> Self {
Self {
cache: HashMap::new(),
last_activity: Instant::now(),
idle_timeout: DEFAULT_IDLE_TIMEOUT,
kdf_runs: 0,
}
}
#[must_use]
pub const fn with_idle_timeout(mut self, timeout: Duration) -> Self {
self.idle_timeout = timeout;
self
}
pub fn unlock_password(
&mut self,
label: &str,
vault_salt: &[u8; 16],
password: &str,
params: Argon2Params,
) -> Result<()> {
self.touch();
let key = CacheKey {
vault_salt: *vault_salt,
kind: CredentialKind::Vault,
label: label.to_owned(),
};
let kek = Kek::from_password(password.as_bytes(), vault_salt, params)?;
self.kdf_runs = self.kdf_runs.saturating_add(1);
self.cache.insert(key, kek);
Ok(())
}
pub fn insert_kek(
&mut self,
label: &str,
kind: CredentialKind,
vault_salt: &[u8; 16],
kek: Kek,
) {
self.touch();
let key = CacheKey { vault_salt: *vault_salt, kind, label: label.to_owned() };
self.cache.insert(key, kek);
}
pub fn add_password(
&mut self,
label: &str,
vault_salt: &[u8; 16],
password: &str,
params: Argon2Params,
) -> Result<bool> {
self.touch();
let kek = Kek::from_password(password.as_bytes(), vault_salt, params)?;
self.kdf_runs = self.kdf_runs.saturating_add(1);
let fp = kek.fingerprint(vault_salt);
let dup = self.cache.iter().any(|(k, v)| {
&k.vault_salt == vault_salt
&& k.kind == CredentialKind::Vault
&& v.fingerprint(vault_salt) == fp
});
if dup {
return Ok(false);
}
let label = self.unique_label(vault_salt, CredentialKind::Vault, label);
let key = CacheKey { vault_salt: *vault_salt, kind: CredentialKind::Vault, label };
self.cache.insert(key, kek);
Ok(true)
}
pub fn add_kek(
&mut self,
label: &str,
kind: CredentialKind,
vault_salt: &[u8; 16],
kek: Kek,
) -> bool {
self.touch();
let fp = kek.fingerprint(vault_salt);
let dup = self.cache.iter().any(|(k, v)| {
&k.vault_salt == vault_salt && k.kind == kind && v.fingerprint(vault_salt) == fp
});
if dup {
return false;
}
let label = self.unique_label(vault_salt, kind, label);
let key = CacheKey { vault_salt: *vault_salt, kind, label };
self.cache.insert(key, kek);
true
}
fn unique_label(&self, vault_salt: &[u8; 16], kind: CredentialKind, want: &str) -> String {
let taken = |name: &str| {
self.cache
.keys()
.any(|k| &k.vault_salt == vault_salt && k.kind == kind && k.label == name)
};
if !taken(want) {
return want.to_owned();
}
for n in 2..=64u32 {
let candidate = format!("{want}#{n}");
if !taken(&candidate) {
return candidate;
}
}
want.to_owned()
}
pub fn forget(&mut self, label: &str, kind: CredentialKind, vault_salt: &[u8; 16]) -> bool {
let key = CacheKey { vault_salt: *vault_salt, kind, label: label.to_owned() };
self.cache.remove(&key).is_some()
}
#[must_use]
pub fn scannable_for(&self, vault_salt: &[u8; 16]) -> Vec<UnlockedCredential> {
self.cache
.iter()
.filter(|(k, _)| &k.vault_salt == vault_salt && k.kind.scannable())
.map(|(k, v)| UnlockedCredential {
label: k.label.clone(),
kind: k.kind,
kek: v.duplicate(),
})
.collect()
}
#[must_use]
pub fn all_for(&self, vault_salt: &[u8; 16]) -> Vec<UnlockedCredential> {
self.cache
.iter()
.filter(|(k, _)| &k.vault_salt == vault_salt)
.map(|(k, v)| UnlockedCredential {
label: k.label.clone(),
kind: k.kind,
kek: v.duplicate(),
})
.collect()
}
#[must_use]
pub fn all_keks(&self) -> Vec<Kek> {
self.cache.values().map(Kek::duplicate).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.cache.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
#[must_use]
pub const fn kdf_runs(&self) -> u64 {
self.kdf_runs
}
pub fn lock(&mut self) {
self.cache.clear();
self.cache.shrink_to_fit();
}
pub fn touch(&mut self) {
self.last_activity = Instant::now();
}
#[must_use]
pub fn idle_for(&self) -> Duration {
self.last_activity.elapsed()
}
#[must_use]
pub fn should_auto_lock(&self) -> bool {
self.should_auto_lock_for_idle(self.idle_for())
}
fn should_auto_lock_for_idle(&self, idle: Duration) -> bool {
!self.cache.is_empty() && idle >= self.idle_timeout
}
pub fn auto_lock_if_idle(&mut self) -> bool {
if self.should_auto_lock() {
self.lock();
true
} else {
false
}
}
}
impl Drop for SessionKeys {
fn drop(&mut self) {
self.cache.clear();
}
}
#[deprecated(
since = "0.0.1",
note = "改用 recovery::RecoveryCode::from_phrase():它会校验词表与校验和,并能指出第几个词可疑"
)]
pub fn kek_from_recovery_words(words: &[&str]) -> Result<Kek> {
use sha2::{Digest as _, Sha256};
let mut h = Sha256::new();
h.update(b"omy/v1/recovery");
for (i, w) in words.iter().enumerate() {
if i > 0 {
h.update(b" ");
}
h.update(w.trim().to_lowercase().as_bytes());
}
let mut out = h.finalize();
let mut key = [0u8; 32];
key.copy_from_slice(&out);
out.zeroize();
Ok(Kek::from_key(SecretKey::from_bytes(key)))
}
#[cfg(test)]
mod idle_tests {
use super::*;
fn keyed_session() -> SessionKeys {
let mut session = SessionKeys::new();
let salt = core::array::from_fn(|i| (i as u8).wrapping_mul(7) ^ 0x66);
let key = core::array::from_fn(|i| (i as u8).wrapping_mul(11) ^ 0x3D);
session.insert_kek("main", CredentialKind::Vault, &salt,
Kek::from_key(SecretKey::from_bytes(key)));
session
}
#[test]
fn auto_lock_triggers_after_idle_timeout() {
let mut session = keyed_session().with_idle_timeout(Duration::MAX);
assert!(!session.auto_lock_if_idle(), "未超时不能清掉仍在使用的密钥");
let timeout = Duration::from_millis(50);
session = session.with_idle_timeout(timeout);
assert!(!session.should_auto_lock_for_idle(Duration::from_millis(49)),
"阈值前一毫秒仍不应锁定,不能提前清除密钥");
assert!(session.should_auto_lock_for_idle(timeout),
"恰好达到阈值就应锁定,不能把 >= 写成 >");
session.last_activity = Instant::now().checked_sub(timeout).expect("过去的活动时间");
assert!(session.should_auto_lock(), "真实时钟也必须使用空闲判定,不能忽略活动时间");
assert!(session.auto_lock_if_idle(), "超时后必须实际执行锁定");
assert!(session.is_empty(), "锁定必须清除缓存,不能只返回成功");
}
#[test]
fn touch_postpones_auto_lock() {
let mut session = keyed_session().with_idle_timeout(Duration::from_millis(100));
let old = Instant::now().checked_sub(Duration::from_secs(1)).expect("过去的活动时间");
session.last_activity = old;
assert!(session.should_auto_lock(), "过期的活动时间应触发锁定,否则测试没有覆盖延期");
session.touch();
assert!(session.last_activity > old, "touch 必须更新活动时间,不能只是保留缓存");
assert!(!session.should_auto_lock_for_idle(Duration::from_millis(99)),
"刷新后的阈值前仍可继续使用密钥");
assert_eq!(session.len(), 1, "活动只推迟锁定,不应丢弃已有凭据");
}
#[test]
fn empty_session_never_auto_locks() {
let mut session = SessionKeys::new().with_idle_timeout(Duration::ZERO);
assert!(!session.should_auto_lock_for_idle(Duration::MAX),
"即使已超时,空缓存也不应报告需要锁定");
assert!(!session.auto_lock_if_idle(), "空会话不能报告执行了锁定");
}
}