feat: 客户端加密 encrypted 字段,数据库只存密文 (v0.5.0)
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m27s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m14s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 11m1s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- 新增 src/crypto.rs:AES-256-GCM 加解密 + Argon2id 密钥派生 + OS Keychain 读写
- 新增 `secrets init` 命令:输入 Master Password,派生 Master Key 存入 Keychain
- 新增 `secrets migrate-encrypt` 命令:将旧明文 JSONB 数据批量加密
- 修改 db.rs:encrypted 列 JSONB → BYTEA,新增 kv_config 表(存 Argon2id salt)
- 修改 models.rs:encrypted 字段类型 Value → Vec<u8>
- 修改 add/update:写入前 encrypt_json,update 读取后 decrypt → 合并 → 重新加密
- 修改 search:按需解密,未解密时显示 _encrypted:true/_key_count:N
- 通过 6 个 crypto 单元测试(加解密、JSON roundtrip、Argon2id 确定性)

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 20:10:13 +08:00
parent 1f7984d798
commit 8fdb6db87b
12 changed files with 828 additions and 66 deletions

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use serde_json::{Value, json};
use sqlx::PgPool;
use crate::crypto;
use crate::models::Secret;
use crate::output::OutputMode;
@@ -20,7 +21,7 @@ pub struct SearchArgs<'a> {
pub output: OutputMode,
}
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
let mut conditions: Vec<String> = Vec::new();
let mut idx: i32 = 1;
@@ -92,14 +93,14 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
// -f/--field: extract specific field values directly
if !args.fields.is_empty() {
return print_fields(&rows, args.fields);
return print_fields(&rows, args.fields, master_key);
}
match args.output {
OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows
.iter()
.map(|r| to_json(r, args.show_secrets, args.summary))
.map(|r| to_json(r, args.show_secrets, args.summary, master_key))
.collect();
let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)?
@@ -116,7 +117,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
);
}
if let Some(row) = rows.first() {
print_env(row, args.show_secrets)?;
print_env(row, args.show_secrets, master_key)?;
} else {
eprintln!("No records found.");
}
@@ -127,7 +128,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
return Ok(());
}
for row in &rows {
print_text(row, args.show_secrets, args.summary)?;
print_text(row, args.show_secrets, args.summary, master_key)?;
}
println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize {
@@ -143,7 +144,24 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
Ok(())
}
fn to_json(row: &Secret, show_secrets: bool, summary: bool) -> Value {
/// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs.
/// Returns an error value on decrypt failure (so callers can decide how to handle).
fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> {
if row.encrypted.is_empty() {
return Ok(Value::Object(Default::default()));
}
let key = master_key.ok_or_else(|| {
anyhow::anyhow!("master key required to decrypt secrets (run `secrets init`)")
})?;
crypto::decrypt_json(key, &row.encrypted)
}
fn to_json(
row: &Secret,
show_secrets: bool,
summary: bool,
master_key: Option<&[u8; 32]>,
) -> Value {
if summary {
let desc = row
.metadata
@@ -163,14 +181,12 @@ fn to_json(row: &Secret, show_secrets: bool, summary: bool) -> Value {
}
let secrets_val = if show_secrets {
row.encrypted.clone()
match try_decrypt(row, master_key) {
Ok(v) => v,
Err(e) => json!({"_error": e.to_string()}),
}
} else {
let keys: Vec<&str> = row
.encrypted
.as_object()
.map(|m| m.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
json!({"_hidden_keys": keys})
json!({"_encrypted": true, "_key_count": encrypted_key_count(row, master_key)})
};
json!({
@@ -186,7 +202,24 @@ fn to_json(row: &Secret, show_secrets: bool, summary: bool) -> Value {
})
}
fn print_text(row: &Secret, show_secrets: bool, summary: bool) -> Result<()> {
/// Return the number of keys in the encrypted JSON (decrypts to count; 0 on failure).
fn encrypted_key_count(row: &Secret, master_key: Option<&[u8; 32]>) -> usize {
if row.encrypted.is_empty() {
return 0;
}
let Some(key) = master_key else { return 0 };
match crypto::decrypt_json(key, &row.encrypted) {
Ok(Value::Object(m)) => m.len(),
_ => 0,
}
}
fn print_text(
row: &Secret,
show_secrets: bool,
summary: bool,
master_key: Option<&[u8; 32]>,
) -> Result<()> {
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
if summary {
let desc = row
@@ -214,22 +247,14 @@ fn print_text(row: &Secret, show_secrets: bool, summary: bool) -> Result<()> {
serde_json::to_string_pretty(&row.metadata)?
);
}
if show_secrets {
println!(
" secrets: {}",
serde_json::to_string_pretty(&row.encrypted)?
);
} else {
let keys: Vec<String> = row
.encrypted
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
if !keys.is_empty() {
println!(
" secrets: [{}] (--show-secrets to reveal)",
keys.join(", ")
);
if !row.encrypted.is_empty() {
if show_secrets {
match try_decrypt(row, master_key) {
Ok(v) => println!(" secrets: {}", serde_json::to_string_pretty(&v)?),
Err(e) => println!(" secrets: [decrypt error: {}]", e),
}
} else {
println!(" secrets: [encrypted] (--show-secrets to reveal)");
}
}
println!(
@@ -241,7 +266,7 @@ fn print_text(row: &Secret, show_secrets: bool, summary: bool) -> Result<()> {
Ok(())
}
fn print_env(row: &Secret, show_secrets: bool) -> Result<()> {
fn print_env(row: &Secret, show_secrets: bool, master_key: Option<&[u8; 32]>) -> Result<()> {
let prefix = row.name.to_uppercase().replace(['-', '.'], "_");
if let Some(meta) = row.metadata.as_object() {
for (k, v) in meta {
@@ -249,27 +274,40 @@ fn print_env(row: &Secret, show_secrets: bool) -> Result<()> {
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
if show_secrets && let Some(enc) = row.encrypted.as_object() {
for (k, v) in enc {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
if show_secrets {
let decrypted = try_decrypt(row, master_key)?;
if let Some(enc) = decrypted.as_object() {
for (k, v) in enc {
let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_"));
println!("{}={}", key, v.as_str().unwrap_or(&v.to_string()));
}
}
}
Ok(())
}
/// Extract one or more field paths like `metadata.url` or `secret.token`.
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> {
for row in rows {
// Decrypt once per row if any field requires it
let decrypted: Option<Value> = if fields
.iter()
.any(|f| f.starts_with("secret") || f.starts_with("encrypted"))
{
Some(try_decrypt(row, master_key)?)
} else {
None
};
for field in fields {
let val = extract_field(row, field)?;
let val = extract_field(row, field, decrypted.as_ref())?;
println!("{}", val);
}
}
Ok(())
}
fn extract_field(row: &Secret, field: &str) -> Result<String> {
fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result<String> {
let (section, key) = field.split_once('.').ok_or_else(|| {
anyhow::anyhow!(
"Invalid field path '{}'. Use metadata.<key> or secret.<key>",
@@ -279,7 +317,9 @@ fn extract_field(row: &Secret, field: &str) -> Result<String> {
let obj = match section {
"metadata" | "meta" => &row.metadata,
"secret" | "secrets" | "encrypted" => &row.encrypted,
"secret" | "secrets" | "encrypted" => {
decrypted.ok_or_else(|| anyhow::anyhow!("secret field requires master key"))?
}
other => anyhow::bail!(
"Unknown field section '{}'. Use 'metadata' or 'secret'",
other