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
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:
@@ -3,6 +3,7 @@ use serde_json::{Map, Value, json};
|
||||
use sqlx::PgPool;
|
||||
use std::fs;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
/// Parse "key=value" entries. Value starting with '@' reads from file.
|
||||
@@ -24,7 +25,7 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
|
||||
Ok((key.to_string(), value))
|
||||
}
|
||||
|
||||
fn build_json(entries: &[String]) -> Result<Value> {
|
||||
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
||||
let mut map = Map::new();
|
||||
for entry in entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
@@ -43,9 +44,12 @@ pub struct AddArgs<'a> {
|
||||
pub output: OutputMode,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
|
||||
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let metadata = build_json(args.meta_entries)?;
|
||||
let encrypted = build_json(args.secret_entries)?;
|
||||
let secret_json = build_json(args.secret_entries)?;
|
||||
|
||||
// Encrypt the secret JSON before storing
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||
|
||||
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
|
||||
|
||||
@@ -66,7 +70,7 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>) -> Result<()> {
|
||||
.bind(args.name)
|
||||
.bind(args.tags)
|
||||
.bind(&metadata)
|
||||
.bind(&encrypted)
|
||||
.bind(&encrypted_bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
62
src/commands/init.rs
Normal file
62
src/commands/init.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngExt;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{crypto, db};
|
||||
|
||||
pub async fn run(pool: &PgPool) -> Result<()> {
|
||||
println!("Initializing secrets master key...");
|
||||
println!();
|
||||
|
||||
// Read password (no echo)
|
||||
let password =
|
||||
rpassword::prompt_password("Enter master password: ").context("failed to read password")?;
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Master password must not be empty.");
|
||||
}
|
||||
let confirm = rpassword::prompt_password("Confirm master password: ")
|
||||
.context("failed to read password confirmation")?;
|
||||
if password != confirm {
|
||||
anyhow::bail!("Passwords do not match.");
|
||||
}
|
||||
|
||||
// Get or create Argon2id salt
|
||||
let salt = match db::load_argon2_salt(pool).await? {
|
||||
Some(existing) => {
|
||||
println!("Found existing salt in database (not the first device).");
|
||||
existing
|
||||
}
|
||||
None => {
|
||||
println!("Generating new Argon2id salt and storing in database...");
|
||||
let mut salt = vec![0u8; 16];
|
||||
rand::rng().fill(&mut salt[..]);
|
||||
db::store_argon2_salt(pool, &salt).await?;
|
||||
salt
|
||||
}
|
||||
};
|
||||
|
||||
// Derive master key
|
||||
print!("Deriving master key (Argon2id, this takes a moment)... ");
|
||||
let master_key = crypto::derive_master_key(&password, &salt)?;
|
||||
println!("done.");
|
||||
|
||||
// Store in OS Keychain
|
||||
crypto::store_master_key(&master_key)?;
|
||||
|
||||
// Self-test: encrypt and decrypt a canary value
|
||||
let canary = b"secrets-cli-canary";
|
||||
let enc = crypto::encrypt(&master_key, canary)?;
|
||||
let dec = crypto::decrypt(&master_key, &enc)?;
|
||||
if dec != canary {
|
||||
anyhow::bail!("Self-test failed: encryption roundtrip mismatch");
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("Master key stored in OS Keychain.");
|
||||
println!("You can now use `secrets add` / `secrets search` commands.");
|
||||
println!();
|
||||
println!("IMPORTANT: Remember your master password — it is not stored anywhere.");
|
||||
println!(" On a new device, run `secrets init` with the same password.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
85
src/commands/migrate_encrypt.rs
Normal file
85
src/commands/migrate_encrypt.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
|
||||
/// Row fetched for migration
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MigrateRow {
|
||||
id: Uuid,
|
||||
namespace: String,
|
||||
kind: String,
|
||||
name: String,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Encrypt any records whose `encrypted` column contains raw (unencrypted) bytes.
|
||||
///
|
||||
/// After the schema migration, old JSONB rows were stored as raw UTF-8 bytes.
|
||||
/// A valid AES-256-GCM blob is always at least 28 bytes (12 nonce + 16 tag).
|
||||
/// We attempt to decrypt each row; if decryption fails, we assume it's plaintext
|
||||
/// JSON and re-encrypt it.
|
||||
pub async fn run(pool: &PgPool, master_key: &[u8; 32]) -> Result<()> {
|
||||
println!("Scanning for unencrypted secret rows...");
|
||||
|
||||
let rows: Vec<MigrateRow> =
|
||||
sqlx::query_as("SELECT id, namespace, kind, name, encrypted FROM secrets")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let total = rows.len();
|
||||
let mut migrated = 0usize;
|
||||
let mut already_encrypted = 0usize;
|
||||
let mut skipped_empty = 0usize;
|
||||
|
||||
for row in rows {
|
||||
if row.encrypted.is_empty() {
|
||||
skipped_empty += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to decrypt; success → already encrypted, skip
|
||||
if crypto::decrypt_json(master_key, &row.encrypted).is_ok() {
|
||||
already_encrypted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Treat as plaintext JSON bytes (from schema migration copy)
|
||||
let json_str = String::from_utf8(row.encrypted.clone()).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Row [{}/{}/{}]: encrypted column contains non-UTF-8 bytes that are also not valid ciphertext. Manual inspection required.",
|
||||
row.namespace, row.kind, row.name
|
||||
)
|
||||
})?;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Row [{}/{}/{}]: failed to parse as JSON: {}",
|
||||
row.namespace,
|
||||
row.kind,
|
||||
row.name,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &value)?;
|
||||
|
||||
sqlx::query("UPDATE secrets SET encrypted = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(&encrypted_bytes)
|
||||
.bind(row.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
println!(" Encrypted: [{}/{}] {}", row.namespace, row.kind, row.name);
|
||||
migrated += 1;
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"Done. Total: {total}, encrypted this run: {migrated}, \
|
||||
already encrypted: {already_encrypted}, empty (skipped): {skipped_empty}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod add;
|
||||
pub mod config;
|
||||
pub mod delete;
|
||||
pub mod init;
|
||||
pub mod migrate_encrypt;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,13 +4,14 @@ use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::add::parse_kv;
|
||||
use crate::crypto;
|
||||
|
||||
#[derive(FromRow)]
|
||||
struct UpdateRow {
|
||||
id: Uuid,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
encrypted: Value,
|
||||
encrypted: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct UpdateArgs<'a> {
|
||||
@@ -25,7 +26,7 @@ pub struct UpdateArgs<'a> {
|
||||
pub remove_secrets: &'a [String],
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
|
||||
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
||||
let row: Option<UpdateRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, tags, metadata, encrypted
|
||||
@@ -71,8 +72,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
|
||||
}
|
||||
let metadata = Value::Object(meta_map);
|
||||
|
||||
// Merge encrypted
|
||||
let mut enc_map: Map<String, Value> = match row.encrypted {
|
||||
// Decrypt existing encrypted blob, merge changes, re-encrypt
|
||||
let existing_json = if row.encrypted.is_empty() {
|
||||
Value::Object(Map::new())
|
||||
} else {
|
||||
crypto::decrypt_json(master_key, &row.encrypted)?
|
||||
};
|
||||
let mut enc_map: Map<String, Value> = match existing_json {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
@@ -83,7 +89,8 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
|
||||
for key in args.remove_secrets {
|
||||
enc_map.remove(key);
|
||||
}
|
||||
let encrypted = Value::Object(enc_map);
|
||||
let secret_json = Value::Object(enc_map);
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||
|
||||
tracing::debug!(
|
||||
namespace = args.namespace,
|
||||
@@ -101,7 +108,7 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
|
||||
)
|
||||
.bind(&tags)
|
||||
.bind(metadata)
|
||||
.bind(encrypted)
|
||||
.bind(encrypted_bytes)
|
||||
.bind(row.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user