use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
use rand::RngCore;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::paths::{app_dir, write_atomic};
const MAGIC: &[u8; 5] = b"DRSB1";
const ACCOUNT: &str = "master_key";
pub const KEYS: [&str; 3] = ["session", "pending_logout", "db_keys"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fallback {
File,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum KeySource {
Keychain,
File,
Memory,
}
pub trait KeyStore: Send + Sync {
fn get(&self) -> std::result::Result<Option<String>, String>;
fn set(&self, value: &str) -> std::result::Result<(), String>;
}
pub struct SystemKeyStore {
service: String,
}
impl SystemKeyStore {
pub fn new(identifier: &str) -> Self {
Self { service: format!("{identifier}.deeprespond_im") }
}
fn entry(&self) -> std::result::Result<keyring::Entry, String> {
keyring::Entry::new(&self.service, ACCOUNT).map_err(|e| e.to_string())
}
}
impl KeyStore for SystemKeyStore {
fn get(&self) -> std::result::Result<Option<String>, String> {
match self.entry()?.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
fn set(&self, value: &str) -> std::result::Result<(), String> {
self.entry()?.set_password(value).map_err(|e| e.to_string())
}
}
struct Master {
key: [u8; 32],
source: KeySource,
}
pub struct Secure {
data_dir: PathBuf,
fallback: Fallback,
keystore: Box<dyn KeyStore>,
master: Mutex<Option<Master>>,
cache: Mutex<BTreeMap<String, BTreeMap<String, String>>>,
}
impl Secure {
pub fn new(data_dir: PathBuf, fallback: Fallback, keystore: Box<dyn KeyStore>) -> Self {
Self { data_dir, fallback, keystore, master: Mutex::new(None), cache: Mutex::new(BTreeMap::new()) }
}
fn master_file(&self) -> PathBuf {
self.data_dir.join("master.key")
}
fn master(&self) -> Result<([u8; 32], KeySource)> {
let mut guard = self.master.lock().unwrap();
if let Some(m) = guard.as_ref() {
return Ok((m.key, m.source));
}
let file = self.master_file();
let from_file = read_key_file(&file);
let master = match self.keystore.get() {
Ok(Some(text)) => match decode_key(&text) {
Some(key) => {
if from_file.is_some() {
let _ = fs::remove_file(&file);
}
Master { key, source: KeySource::Keychain }
}
None => {
log::warn!("钥匙串中的主密钥格式不对,重新生成(已保存的会话随之失效)");
self.new_key_in_keychain(from_file)?
}
},
Ok(None) => self.new_key_in_keychain(from_file)?,
Err(reason) => {
log::warn!("系统钥匙串不可用:{reason}");
match self.fallback {
Fallback::File => {
let key = match from_file {
Some(key) => key,
None => {
let key = random_key();
write_key_file(&file, &key)?;
key
}
};
log::warn!("主密钥保存在数据目录的 master.key 中(secure_storage_fallback = File),安全性低于系统钥匙串");
Master { key, source: KeySource::File }
}
Fallback::None => {
log::warn!("主密钥只保存在内存中(secure_storage_fallback = None),下次启动需要重新登录");
Master { key: random_key(), source: KeySource::Memory }
}
}
}
};
let out = (master.key, master.source);
*guard = Some(master);
Ok(out)
}
fn new_key_in_keychain(&self, from_file: Option<[u8; 32]>) -> Result<Master> {
let key = from_file.unwrap_or_else(random_key);
match self.keystore.set(&hex::encode(key)) {
Ok(()) => {
if from_file.is_some() {
log::info!("钥匙串已恢复可用:主密钥移入钥匙串,删除 master.key");
let _ = fs::remove_file(self.master_file());
}
Ok(Master { key, source: KeySource::Keychain })
}
Err(reason) => {
log::warn!("写入系统钥匙串失败:{reason}");
match self.fallback {
Fallback::File => {
if from_file.is_none() {
write_key_file(&self.master_file(), &key)?;
}
Ok(Master { key, source: KeySource::File })
}
Fallback::None => Ok(Master { key, source: KeySource::Memory }),
}
}
}
}
pub fn key_source(&self) -> Option<KeySource> {
self.master.lock().unwrap().as_ref().map(|m| m.source)
}
fn file(&self, app_key: &str) -> PathBuf {
app_dir(&self.data_dir, app_key).join("secure.bin")
}
pub fn load(&self, app_key: &str) -> Result<BTreeMap<String, String>> {
if let Some(values) = self.cache.lock().unwrap().get(app_key) {
return Ok(values.clone());
}
let path = self.file(app_key);
let values = match fs::read(&path) {
Ok(bytes) => {
let (key, _) = self.master()?;
match decrypt(&key, app_key, &bytes) {
Some(values) => values,
None => {
log::warn!("secure.bin 无法解密(主密钥丢失或文件损坏),按未登录处理并删除");
let _ = fs::remove_file(&path);
BTreeMap::new()
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
Err(e) => return Err(Error::from_io(&e)),
};
self.cache.lock().unwrap().insert(app_key.to_string(), values.clone());
Ok(values)
}
pub fn set(&self, app_key: &str, key: &str, value: Option<String>) -> Result<()> {
if !KEYS.contains(&key) {
return Err(Error::validation("invalid_key", format!("不认识的键 {key}")));
}
let mut values = self.load(app_key)?;
match value {
Some(v) => values.insert(key.to_string(), v),
None => values.remove(key),
};
self.write_all(app_key, values)
}
fn write_all(&self, app_key: &str, values: BTreeMap<String, String>) -> Result<()> {
let path = self.file(app_key);
if values.is_empty() {
match fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(Error::from_io(&e)),
}
} else {
let (key, source) = self.master()?;
if source == KeySource::Memory && values.contains_key("session") {
log::warn!("钥匙串不可用且 secure_storage_fallback 为 None:会话只在本次运行中有效");
}
write_atomic(&path, &encrypt(&key, app_key, &values)?)?;
}
self.cache.lock().unwrap().insert(app_key.to_string(), values);
Ok(())
}
pub fn check_session_device(&self, app_key: &str, device_id: &str, now_ms: u64) -> Result<bool> {
let values = self.load(app_key)?;
let Some(text) = values.get("session") else { return Ok(false) };
let session: Value = serde_json::from_str(text).unwrap_or(Value::Null);
let stored = session.get("device_id").and_then(Value::as_str);
if stored.is_none_or(|d| d == device_id) {
return Ok(false);
}
log::warn!("会话的设备标识与当前的不同,丢弃会话(refresh token 补发退出)");
let mut values = values;
values.remove("session");
if let Some(token) = session.get("refresh_token").and_then(Value::as_str) {
let mut pending: Value = values.get("pending_logout").and_then(|t| serde_json::from_str(t).ok()).unwrap_or_else(|| json!({ "v": 1, "items": [] }));
if let Some(items) = pending.get_mut("items").and_then(Value::as_array_mut) {
if !items.iter().any(|i| i.get("refresh_token").and_then(Value::as_str) == Some(token)) {
items.push(json!({ "refresh_token": token, "added_at": now_ms }));
}
}
values.insert("pending_logout".into(), pending.to_string());
}
self.write_all(app_key, values)?;
Ok(true)
}
}
fn random_key() -> [u8; 32] {
let mut key = [0u8; 32];
rand::rng().fill_bytes(&mut key);
key
}
fn decode_key(text: &str) -> Option<[u8; 32]> {
let bytes = hex::decode(text.trim()).ok()?;
bytes.try_into().ok()
}
fn read_key_file(path: &Path) -> Option<[u8; 32]> {
fs::read_to_string(path).ok().and_then(|t| decode_key(&t))
}
fn write_key_file(path: &Path, key: &[u8; 32]) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(hex::encode(key).as_bytes())?;
file.sync_all()?;
Ok(())
}
fn aad(app_key: &str) -> Vec<u8> {
let mut out = b"deeprespond_im:secure:".to_vec();
out.extend_from_slice(app_key.as_bytes());
out
}
fn encrypt(key: &[u8; 32], app_key: &str, values: &BTreeMap<String, String>) -> Result<Vec<u8>> {
let plain = serde_json::to_vec(&json!({ "v": 1, "values": values }))?;
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| Error::internal(e.to_string()))?;
let mut nonce = [0u8; 12];
rand::rng().fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(Nonce::from_slice(&nonce), Payload { msg: &plain, aad: &aad(app_key) })
.map_err(|_| Error::internal("加密失败"))?;
let mut out = Vec::with_capacity(MAGIC.len() + nonce.len() + sealed.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
fn decrypt(key: &[u8; 32], app_key: &str, bytes: &[u8]) -> Option<BTreeMap<String, String>> {
if bytes.len() < MAGIC.len() + 12 + 16 || &bytes[..MAGIC.len()] != MAGIC {
return None;
}
let (nonce, sealed) = bytes[MAGIC.len()..].split_at(12);
let cipher = Aes256Gcm::new_from_slice(key).ok()?;
let plain = cipher.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad: &aad(app_key) }).ok()?;
let value: Value = serde_json::from_slice(&plain).ok()?;
if value.get("v").and_then(Value::as_u64) != Some(1) {
return None;
}
serde_json::from_value(value.get("values")?.clone()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[derive(Default)]
struct FakeKeychain {
value: Mutex<Option<String>>,
broken: Mutex<bool>,
}
struct Shared(Arc<FakeKeychain>);
impl KeyStore for Shared {
fn get(&self) -> std::result::Result<Option<String>, String> {
if *self.0.broken.lock().unwrap() {
return Err("no secret service".into());
}
Ok(self.0.value.lock().unwrap().clone())
}
fn set(&self, value: &str) -> std::result::Result<(), String> {
if *self.0.broken.lock().unwrap() {
return Err("no secret service".into());
}
*self.0.value.lock().unwrap() = Some(value.into());
Ok(())
}
}
fn secure(dir: &Path, kc: &Arc<FakeKeychain>, fallback: Fallback) -> Secure {
Secure::new(dir.to_path_buf(), fallback, Box::new(Shared(kc.clone())))
}
#[test]
fn roundtrip_and_only_master_key_in_keychain() {
let dir = tempfile::tempdir().unwrap();
let kc = Arc::new(FakeKeychain::default());
let s = secure(dir.path(), &kc, Fallback::File);
let items: Vec<Value> = (0..20).map(|i| json!({ "refresh_token": format!("rt_{i}_{}", "x".repeat(200)), "added_at": i })).collect();
s.set("org#app", "pending_logout", Some(json!({ "v": 1, "items": items }).to_string())).unwrap();
s.set("org#app", "session", Some(r#"{"v":1,"username":"alice"}"#.into())).unwrap();
assert_eq!(kc.value.lock().unwrap().as_ref().unwrap().len(), 64);
let again = secure(dir.path(), &kc, Fallback::File);
let values = again.load("org#app").unwrap();
assert!(values["session"].contains("alice"));
assert!(values["pending_logout"].len() > 2560);
let raw = fs::read(app_dir(dir.path(), "org#app").join("secure.bin")).unwrap();
assert!(!String::from_utf8_lossy(&raw).contains("alice"));
}
#[test]
fn corrupted_or_lost_key_means_signed_out() {
let dir = tempfile::tempdir().unwrap();
let kc = Arc::new(FakeKeychain::default());
secure(dir.path(), &kc, Fallback::File).set("a#b", "session", Some("{}".into())).unwrap();
*kc.value.lock().unwrap() = None;
let s = secure(dir.path(), &kc, Fallback::File);
assert!(s.load("a#b").unwrap().is_empty());
assert!(!app_dir(dir.path(), "a#b").join("secure.bin").exists());
s.set("a#b", "session", Some("{}".into())).unwrap();
let path = app_dir(dir.path(), "a#b").join("secure.bin");
let mut bytes = fs::read(&path).unwrap();
let n = bytes.len();
bytes[n - 1] ^= 0xff;
fs::write(&path, bytes).unwrap();
assert!(secure(dir.path(), &kc, Fallback::File).load("a#b").unwrap().is_empty());
}
#[test]
fn fallback_file_then_keychain_recovers() {
let dir = tempfile::tempdir().unwrap();
let kc = Arc::new(FakeKeychain::default());
*kc.broken.lock().unwrap() = true;
let s = secure(dir.path(), &kc, Fallback::File);
s.set("a#b", "session", Some("{\"u\":1}".into())).unwrap();
assert_eq!(s.key_source(), Some(KeySource::File));
let master = dir.path().join("master.key");
assert!(master.exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(fs::metadata(&master).unwrap().permissions().mode() & 0o777, 0o600);
}
assert!(secure(dir.path(), &kc, Fallback::File).load("a#b").unwrap().contains_key("session"));
*kc.broken.lock().unwrap() = false;
let s = secure(dir.path(), &kc, Fallback::File);
assert!(s.load("a#b").unwrap().contains_key("session"));
assert_eq!(s.key_source(), Some(KeySource::Keychain));
assert!(!master.exists());
}
#[test]
fn fallback_none_forgets_after_restart() {
let dir = tempfile::tempdir().unwrap();
let kc = Arc::new(FakeKeychain::default());
*kc.broken.lock().unwrap() = true;
let s = secure(dir.path(), &kc, Fallback::None);
s.set("a#b", "session", Some("{}".into())).unwrap();
assert!(s.load("a#b").unwrap().contains_key("session"));
assert!(!dir.path().join("master.key").exists());
assert!(secure(dir.path(), &kc, Fallback::None).load("a#b").unwrap().is_empty());
}
#[test]
fn session_of_other_device_is_dropped() {
let dir = tempfile::tempdir().unwrap();
let kc = Arc::new(FakeKeychain::default());
let s = secure(dir.path(), &kc, Fallback::File);
s.set("a#b", "session", Some(json!({ "v": 1, "username": "alice", "refresh_token": "rt1", "device_id": "tauri_old" }).to_string())).unwrap();
assert!(!s.check_session_device("a#b", "tauri_old", 1).unwrap());
assert!(s.check_session_device("a#b", "tauri_new", 5).unwrap());
let values = s.load("a#b").unwrap();
assert!(!values.contains_key("session"));
assert!(values["pending_logout"].contains("rt1"));
}
}