feat(secret_store): Argon2id + AES-256-GCM 加密的会话密钥存储(M1.3a)

依赖新增:
  - aes-gcm 0.11
  - argon2 0.5.3
  - getrandom 0.3 (std feature)
  - base64 0.22.1
  - serde_json 1.0
  - zeroize 1.9
  - rand_core (尝试后移除:rand 0.10 / 0.9 的 OsRng API 都复杂,改为更底层的 getrandom)

文件格式(JSON 包装):
  {
    "version": 1,
    "kdf": "argon2id",
    "salt_b64": "...",     // 16 bytes
    "nonce_b64": "...",    // 12 bytes
    "ciphertext_b64": "..."  // AES-256-GCM(plaintext, AAD="matrix-music-bot secret v1")
  }

安全参数:
  - KDF: Argon2id (m=19456 KiB, t=2, p=1) — OWASP 推荐
  - 加密: AES-256-GCM(认证加密,任何篡改都会被检测)
  - AAD: 标识文件版本与 KDF,防止 ciphertext 重放到不同上下文
  - 每次加密随机 salt + nonce,确保同样明文产生不同 ciphertext
  - 写入采用 .bin.tmp → rename 原子操作,避免半写状态

API:
  save(path, &secrets, passphrase) -> AppResult<()>
  load(path, passphrase) -> AppResult<SessionSecrets>
  exists(path) -> bool

SessionSecrets 包含 access_token / device_id / user_id 三个字段,
实现 Zeroize on drop。

测试:
  cargo test → 4 passed
    - secret_store::roundtrip_via_disk: 加密 + 解密往返,错误口令被 AES-GCM 拒绝
    - secret_store::ciphertext_differs_each_time: 同样明文两次加密 ciphertext 不同

后续 M1.3b:在 src/bootstrap.rs 中调用 matrix-sdk 的 Client::builder() + login_username,
拿到 access_token 后调 secret_store::save() 写入 ./data/secret.bin。
This commit is contained in:
Matrix Music Bot Dev
2026-07-02 19:50:56 +08:00
parent 19e724444b
commit ecc40beed8
4 changed files with 479 additions and 21 deletions
Generated
+203 -21
View File
@@ -26,10 +26,20 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aead"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.2",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -37,10 +47,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
[[package]]
name = "aes-gcm"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
dependencies = [
"aead 0.6.1",
"aes 0.9.1",
"cipher 0.5.2",
"ctr 0.10.1",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -141,6 +176,18 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -323,6 +370,15 @@ version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "blake3"
version = "1.8.5"
@@ -346,6 +402,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -394,7 +459,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
"cipher 0.4.4",
]
[[package]]
@@ -428,7 +493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cipher 0.4.4",
"cpufeatures 0.2.17",
]
@@ -449,9 +514,9 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"aead 0.5.2",
"chacha20 0.9.1",
"cipher",
"cipher 0.4.4",
"poly1305",
"zeroize",
]
@@ -476,11 +541,22 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"crypto-common 0.1.7",
"inout 0.1.4",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"inout 0.2.2",
]
[[package]]
name = "clap"
version = "4.6.1"
@@ -530,6 +606,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -609,6 +691,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -653,13 +741,42 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"getrandom 0.4.3",
"hybrid-array",
"rand_core 0.10.1",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
"cipher 0.4.4",
]
[[package]]
name = "ctr"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
@@ -820,8 +937,8 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
@@ -1186,6 +1303,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
@@ -1345,6 +1471,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -1607,6 +1742,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1893,18 +2037,24 @@ dependencies = [
name = "matrix-music-bot"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"argon2",
"async-trait",
"base64",
"clap",
"getrandom 0.3.4",
"matrix-sdk",
"matrix-sdk-crypto",
"matrix-sdk-ui",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"zeroize",
]
[[package]]
@@ -2048,14 +2198,14 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
dependencies = [
"aes",
"aes 0.8.4",
"aquamarine",
"as_variant",
"async-trait",
"bs58",
"byteorder",
"cfg-if",
"ctr",
"ctr 0.9.2",
"eyeball",
"futures-core",
"futures-util",
@@ -2065,7 +2215,7 @@ dependencies = [
"js_option",
"matrix-sdk-common",
"pbkdf2",
"rand 0.10.1",
"rand 0.10.2",
"rmp-serde",
"ruma",
"serde",
@@ -2156,7 +2306,7 @@ dependencies = [
"getrandom 0.4.3",
"hmac",
"pbkdf2",
"rand 0.10.1",
"rand 0.10.2",
"rmp-serde",
"serde",
"serde_json",
@@ -2404,6 +2554,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@@ -2488,7 +2649,18 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede"
dependencies = [
"cpubits",
"cpufeatures 0.3.0",
"universal-hash 0.6.1",
]
[[package]]
@@ -2683,9 +2855,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20 0.10.1",
"getrandom 0.4.3",
@@ -2926,7 +3098,7 @@ dependencies = [
"js_int",
"konst",
"percent-encoding",
"rand 0.10.1",
"rand 0.10.2",
"regex",
"ruma-identifiers-validation",
"ruma-macros",
@@ -3899,10 +4071,20 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "universal-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
dependencies = [
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3982,7 +4164,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"
dependencies = [
"aes",
"aes 0.8.4",
"arrayvec",
"base64",
"base64ct",
+6
View File
@@ -4,18 +4,24 @@ version = "0.1.0"
edition = "2024"
[dependencies]
aes-gcm = "0.11.0"
anyhow = "1.0.103"
argon2 = "0.5.3"
async-trait = "0.1.89"
base64 = "0.22.1"
clap = { version = "4.6.1", features = ["derive", "env"] }
getrandom = { version = "0.3", features = ["std"] }
matrix-sdk = "0.18.0"
matrix-sdk-crypto = "0.18.0"
matrix-sdk-ui = "0.18.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
zeroize = "1.9.0"
# 推迟到对应 milestone 添加(避免 libsqlite3-sys 与 matrix-sdk-sqlite 冲突):
# - rusqlite → M5 (SqliteQueue)
+1
View File
@@ -10,6 +10,7 @@
mod config;
mod error;
mod secret_store;
use std::path::PathBuf;
use std::process::ExitCode;
+269
View File
@@ -0,0 +1,269 @@
//! 加密的密钥存储:保存 access token + device id + user id。
//!
//! 文件格式(JSON 包装):
//! ```json
//! {
//! "version": 1,
//! "kdf": "argon2id",
//! "salt_b64": "...",
//! "nonce_b64": "...",
//! "ciphertext_b64": "..."
//! }
//! ```
//!
//! 加密:AES-256-GCM
//! KDFArgon2id(默认参数 m=19456 KiB, t=2, p=1OWASP 推荐)
//!
//! 威胁模型:阻止通过文件系统读取 data/secret.bin 直接拿到 access token。
//! 真正的安全要求操作员提供强口令(从环境变量 `MATRIX_BOT_SECRET_PASSPHRASE` 读取)。
use std::fs;
use std::path::Path;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine;
use getrandom::fill as random_fill;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
use crate::error::{AppError, AppResult};
/// 加密文件 magic 版本号;未来升级加密方案时递增
const FILE_VERSION: u32 = 1;
/// Argon2id salt 长度(字节)
const SALT_LEN: usize = 16;
/// AES-GCM nonce 长度(字节)
const NONCE_LEN: usize = 12;
/// 加密文件中实际保存的明文负载
#[derive(Debug, Serialize, Deserialize, Zeroize)]
#[zeroize(drop)]
pub struct SessionSecrets {
pub access_token: String,
pub device_id: String,
pub user_id: String,
}
/// 加密文件顶层 JSON
#[derive(Debug, Serialize, Deserialize)]
struct EncryptedFile {
version: u32,
kdf: String,
salt_b64: String,
nonce_b64: String,
ciphertext_b64: String,
}
impl EncryptedFile {
fn from_secrets(secrets: &SessionSecrets, passphrase: &str) -> AppResult<Self> {
// 1. 生成随机 salt + nonce
let mut salt = [0u8; SALT_LEN];
let mut nonce_bytes = [0u8; NONCE_LEN];
random_fill(&mut salt).map_err(|e| AppError::SecretStore(format!("getrandom 失败: {e}")))?;
random_fill(&mut nonce_bytes)
.map_err(|e| AppError::SecretStore(format!("getrandom 失败: {e}")))?;
// 2. Argon2id 派生 32 字节 key
let mut key_bytes = derive_key(passphrase, &salt)?;
// 3. 序列化明文负载
let plaintext = serde_json::to_vec(secrets).map_err(|e| AppError::Json(e.to_string()))?;
let plaintext_len = plaintext.len();
// 4. AES-256-GCM 加密(带 AAD 标识文件版本与 KDF)
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = b"matrix-music-bot secret v1";
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: &plaintext,
aad,
},
)
.map_err(|e| AppError::SecretStore(format!("AES-GCM 加密失败: {e}")))?;
// 5. 清零 key_bytes 内存
key_bytes.zeroize();
Ok(EncryptedFile {
version: FILE_VERSION,
kdf: "argon2id".to_string(),
salt_b64: base64::engine::general_purpose::STANDARD.encode(salt),
nonce_b64: base64::engine::general_purpose::STANDARD.encode(nonce_bytes),
ciphertext_b64: base64::engine::general_purpose::STANDARD.encode(&ciphertext),
})
// ciphertext 与 plaintext 在 drop 时由系统释放;这里不额外 zeroize
// 避免影响性能;secret 仅在内存中短暂存在
.map(|f| {
let _ = plaintext_len; // 抑制未使用变量警告
f
})
}
fn decrypt_with_passphrase(&self, passphrase: &str) -> AppResult<SessionSecrets> {
if self.version != FILE_VERSION {
return Err(AppError::SecretStore(format!(
"secret.bin 版本不支持: {}(期望 {}",
self.version, FILE_VERSION
)));
}
if self.kdf != "argon2id" {
return Err(AppError::SecretStore(format!(
"不支持的 KDF: {}(仅支持 argon2id",
self.kdf
)));
}
let salt = base64::engine::general_purpose::STANDARD
.decode(&self.salt_b64)
.map_err(|e| AppError::SecretStore(format!("salt base64 解码失败: {e}")))?;
if salt.len() != SALT_LEN {
return Err(AppError::SecretStore(format!(
"salt 长度异常: {}(期望 {}",
salt.len(),
SALT_LEN
)));
}
let nonce_bytes = base64::engine::general_purpose::STANDARD
.decode(&self.nonce_b64)
.map_err(|e| AppError::SecretStore(format!("nonce base64 解码失败: {e}")))?;
if nonce_bytes.len() != NONCE_LEN {
return Err(AppError::SecretStore(format!(
"nonce 长度异常: {}(期望 {}",
nonce_bytes.len(),
NONCE_LEN
)));
}
let ciphertext = base64::engine::general_purpose::STANDARD
.decode(&self.ciphertext_b64)
.map_err(|e| AppError::SecretStore(format!("ciphertext base64 解码失败: {e}")))?;
let mut key_bytes = derive_key(passphrase, &salt)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(&nonce_bytes);
let aad = b"matrix-music-bot secret v1";
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: &ciphertext,
aad,
},
)
.map_err(|_| {
AppError::SecretStore("解密失败(口令错误或文件被篡改)".to_string())
})?;
key_bytes.zeroize();
let secrets: SessionSecrets = serde_json::from_slice(&plaintext)
.map_err(|e| AppError::Json(format!("解密后 JSON 解析失败: {e}")))?;
Ok(secrets)
}
}
/// 使用 Argon2id 从口令派生 32 字节 AES 密钥
fn derive_key(passphrase: &str, salt: &[u8]) -> AppResult<[u8; 32]> {
// OWASP 推荐参数:m=19456 KiB, t=2, p=1
let params = Params::new(19456, 2, 1, Some(32))
.map_err(|e| AppError::SecretStore(format!("Argon2 参数错误: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output = [0u8; 32];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut output)
.map_err(|e| AppError::SecretStore(format!("Argon2 派生失败: {e}")))?;
Ok(output)
}
/// 保存 SessionSecrets 到加密文件
pub fn save(path: &Path, secrets: &SessionSecrets, passphrase: &str) -> AppResult<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = EncryptedFile::from_secrets(secrets, passphrase)?;
let json = serde_json::to_string_pretty(&file)
.map_err(|e| AppError::Json(e.to_string()))?;
// 写入临时文件后原子 rename,避免半写状态
let tmp = path.with_extension("bin.tmp");
fs::write(&tmp, json)?;
fs::rename(&tmp, path)?;
Ok(())
}
/// 从加密文件恢复 SessionSecrets
pub fn load(path: &Path, passphrase: &str) -> AppResult<SessionSecrets> {
let json = fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
AppError::SecretStore(format!("secret 文件不存在: {}", path.display()))
} else {
AppError::Io(e)
}
})?;
let file: EncryptedFile = serde_json::from_str(&json)
.map_err(|e| AppError::Json(format!("secret 文件 JSON 解析失败: {e}")))?;
file.decrypt_with_passphrase(passphrase)
}
/// 检查 secret 文件是否存在
pub fn exists(path: &Path) -> bool {
path.exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_via_disk() {
let dir = std::env::temp_dir().join(format!(
"matrix-bot-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("secret.bin");
let secrets = SessionSecrets {
access_token: "syt_dGhpc19pc19hX2Zha2VfdG9rZW5fMTIzNDU2Nzg5MA".to_string(),
device_id: "ABCDEFGHIJ".to_string(),
user_id: "@bot:example.cn".to_string(),
};
let passphrase = "super-secret-passphrase-correct-horse";
save(&path, &secrets, passphrase).expect("save");
let loaded = load(&path, passphrase).expect("load");
assert_eq!(loaded.access_token, secrets.access_token);
assert_eq!(loaded.device_id, secrets.device_id);
assert_eq!(loaded.user_id, secrets.user_id);
// 错误口令应被拒绝(AES-GCM 认证失败)
let bad = load(&path, "wrong-passphrase");
assert!(bad.is_err(), "错误口令应被拒绝");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ciphertext_differs_each_time() {
// 同样的 plaintext + passphrase 应产生不同的 ciphertext(因 salt + nonce 随机)
let s = SessionSecrets {
access_token: "tok".to_string(),
device_id: "dev".to_string(),
user_id: "@u:s".to_string(),
};
let p = "pass";
let dir = std::env::temp_dir().join(format!("matrix-bot-test-{}-2", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let p1 = dir.join("a.bin");
let p2 = dir.join("b.bin");
save(&p1, &s, p).unwrap();
save(&p2, &s, p).unwrap();
let b1 = fs::read(&p1).unwrap();
let b2 = fs::read(&p2).unwrap();
assert_ne!(b1, b2, "两次加密的 ciphertext 应该不同");
let _ = fs::remove_dir_all(&dir);
}
}