diff --git a/Cargo.lock b/Cargo.lock index 965198a..3ad2c1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index c1f7da8..cc776a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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) diff --git a/src/main.rs b/src/main.rs index 449c376..3c17e9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod config; mod error; +mod secret_store; use std::path::PathBuf; use std::process::ExitCode; diff --git a/src/secret_store.rs b/src/secret_store.rs new file mode 100644 index 0000000..88fdac4 --- /dev/null +++ b/src/secret_store.rs @@ -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 +//! KDF:Argon2id(默认参数 m=19456 KiB, t=2, p=1,OWASP 推荐) +//! +//! 威胁模型:阻止通过文件系统读取 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 { + // 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::::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 { + 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::::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 { + 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); + } +} \ No newline at end of file