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:
Generated
+203
-21
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod secret_store;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user