Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m46s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m27s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m0s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 提取 EntryRow/SecretFieldRow 到 models.rs - 提取 current_actor()、print_json() 公共函数 - ExportFormat::from_extension 复用 from_str - fetch_entries 默认 limit 100k(export/inject/run 不再截断) - history 独立为 history.rs 模块 - delete 改用 DeleteArgs 结构体 - config_dir 改为 Result,Argon2id 参数提取常量 - Cargo 依赖 ^ 前缀、tokio 精简 features - 更新 AGENTS.md 项目结构 Made-with: Cursor
196 lines
7.1 KiB
Rust
196 lines
7.1 KiB
Rust
use aes_gcm::{
|
|
Aes256Gcm, Key, Nonce,
|
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
|
};
|
|
use anyhow::{Context, Result, bail};
|
|
use argon2::{Argon2, Params, Version};
|
|
use serde_json::Value;
|
|
|
|
const KEYRING_SERVICE: &str = "secrets-cli";
|
|
const KEYRING_USER: &str = "master-key";
|
|
const NONCE_LEN: usize = 12;
|
|
|
|
// Argon2id parameters — OWASP recommended (m=64 MiB, t=3 iterations, p=4 threads, key=32 B)
|
|
const ARGON2_M_COST: u32 = 65_536;
|
|
const ARGON2_T_COST: u32 = 3;
|
|
const ARGON2_P_COST: u32 = 4;
|
|
const ARGON2_KEY_LEN: usize = 32;
|
|
|
|
// ─── Argon2id key derivation ─────────────────────────────────────────────────
|
|
|
|
/// Derive a 32-byte Master Key from a password and salt using Argon2id.
|
|
/// Parameters: m=65536 KiB (64 MB), t=3, p=4 — OWASP recommended.
|
|
pub fn derive_master_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
|
let params = Params::new(
|
|
ARGON2_M_COST,
|
|
ARGON2_T_COST,
|
|
ARGON2_P_COST,
|
|
Some(ARGON2_KEY_LEN),
|
|
)
|
|
.context("invalid Argon2id params")?;
|
|
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, params);
|
|
let mut key = [0u8; 32];
|
|
argon2
|
|
.hash_password_into(password.as_bytes(), salt, &mut key)
|
|
.map_err(|e| anyhow::anyhow!("Argon2id derivation failed: {}", e))?;
|
|
Ok(key)
|
|
}
|
|
|
|
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
|
|
|
|
/// Encrypt plaintext bytes with AES-256-GCM.
|
|
/// Returns `nonce (12 B) || ciphertext+tag`.
|
|
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
|
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
|
let ciphertext = cipher
|
|
.encrypt(&nonce, plaintext)
|
|
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
|
|
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
|
|
out.extend_from_slice(&nonce);
|
|
out.extend_from_slice(&ciphertext);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
|
|
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
|
if data.len() < NONCE_LEN {
|
|
bail!(
|
|
"encrypted data too short ({}B); possibly corrupted",
|
|
data.len()
|
|
);
|
|
}
|
|
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
|
|
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
let cipher = Aes256Gcm::new(key);
|
|
let nonce = Nonce::from_slice(nonce_bytes);
|
|
cipher
|
|
.decrypt(nonce, ciphertext)
|
|
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
|
|
}
|
|
|
|
// ─── JSON helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
|
|
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
|
|
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
|
|
encrypt(master_key, &bytes)
|
|
}
|
|
|
|
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
|
|
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
|
|
let bytes = decrypt(master_key, data)?;
|
|
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
|
|
}
|
|
|
|
// ─── OS Keychain ──────────────────────────────────────────────────────────────
|
|
|
|
/// Load the Master Key from the OS Keychain.
|
|
/// Returns an error with a helpful message if it hasn't been initialized.
|
|
pub fn load_master_key() -> Result<[u8; 32]> {
|
|
let entry =
|
|
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
|
|
let hex = entry.get_password().map_err(|_| {
|
|
anyhow::anyhow!("Master key not found in keychain. Run `secrets init` first.")
|
|
})?;
|
|
let bytes = hex::decode_hex(&hex)?;
|
|
if bytes.len() != 32 {
|
|
bail!(
|
|
"stored master key has unexpected length {}; re-run `secrets init`",
|
|
bytes.len()
|
|
);
|
|
}
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&bytes);
|
|
Ok(key)
|
|
}
|
|
|
|
/// Store the Master Key in the OS Keychain (overwrites any existing value).
|
|
pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
|
|
let entry =
|
|
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
|
|
let hex = hex::encode_hex(key);
|
|
entry
|
|
.set_password(&hex)
|
|
.map_err(|e| anyhow::anyhow!("keychain write failed: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
|
|
|
|
mod hex {
|
|
use anyhow::{Result, bail};
|
|
|
|
pub fn encode_hex(bytes: &[u8]) -> String {
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
|
if !s.len().is_multiple_of(2) {
|
|
bail!("hex string has odd length");
|
|
}
|
|
(0..s.len())
|
|
.step_by(2)
|
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn roundtrip_encrypt_decrypt() {
|
|
let key = [0x42u8; 32];
|
|
let plaintext = b"hello world";
|
|
let enc = encrypt(&key, plaintext).unwrap();
|
|
let dec = decrypt(&key, &enc).unwrap();
|
|
assert_eq!(dec, plaintext);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_produces_different_ciphertexts() {
|
|
let key = [0x42u8; 32];
|
|
let plaintext = b"hello world";
|
|
let enc1 = encrypt(&key, plaintext).unwrap();
|
|
let enc2 = encrypt(&key, plaintext).unwrap();
|
|
// Different nonces → different ciphertexts
|
|
assert_ne!(enc1, enc2);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_fails_decryption() {
|
|
let key1 = [0x42u8; 32];
|
|
let key2 = [0x43u8; 32];
|
|
let enc = encrypt(&key1, b"secret").unwrap();
|
|
assert!(decrypt(&key2, &enc).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn json_roundtrip() {
|
|
let key = [0x42u8; 32];
|
|
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
|
|
let enc = encrypt_json(&key, &value).unwrap();
|
|
let dec = decrypt_json(&key, &enc).unwrap();
|
|
assert_eq!(dec, value);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_master_key_deterministic() {
|
|
let salt = b"fixed_test_salt_";
|
|
let k1 = derive_master_key("password", salt).unwrap();
|
|
let k2 = derive_master_key("password", salt).unwrap();
|
|
assert_eq!(k1, k2);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_master_key_different_passwords() {
|
|
let salt = b"fixed_test_salt_";
|
|
let k1 = derive_master_key("password1", salt).unwrap();
|
|
let k2 = derive_master_key("password2", salt).unwrap();
|
|
assert_ne!(k1, k2);
|
|
}
|
|
}
|