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:
48
src/main.rs
48
src/main.rs
@@ -1,6 +1,7 @@
|
||||
mod audit;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod db;
|
||||
mod models;
|
||||
mod output;
|
||||
@@ -16,7 +17,10 @@ use output::resolve_output_mode;
|
||||
name = "secrets",
|
||||
version,
|
||||
about = "Secrets & config manager backed by PostgreSQL — optimised for AI agents",
|
||||
after_help = "QUICK START (AI agents):
|
||||
after_help = "QUICK START:
|
||||
# First time setup (run once per device)
|
||||
secrets init
|
||||
|
||||
# Discover what namespaces / kinds exist
|
||||
secrets search --summary --limit 20
|
||||
|
||||
@@ -44,6 +48,24 @@ struct Cli {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize master key on this device (run once per device).
|
||||
///
|
||||
/// Prompts for a master password, derives a key with Argon2id, and stores
|
||||
/// it in the OS Keychain. Use the same password on every device.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# First device: generates a new Argon2id salt and stores master key
|
||||
secrets init
|
||||
|
||||
# Subsequent devices: reuses existing salt from the database
|
||||
secrets init")]
|
||||
Init,
|
||||
|
||||
/// Encrypt any pre-existing plaintext records in the database.
|
||||
///
|
||||
/// Run this once after upgrading from a version that stored secrets as
|
||||
/// plaintext JSONB. Requires `secrets init` to have been run first.
|
||||
MigrateEncrypt,
|
||||
|
||||
/// Add or update a record (upsert). Use -m for plaintext metadata, -s for secrets.
|
||||
#[command(after_help = "EXAMPLES:
|
||||
# Add a server
|
||||
@@ -281,7 +303,7 @@ async fn main() -> Result<()> {
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
// config 子命令不需要数据库连接,提前处理
|
||||
// config subcommand needs no database or master key
|
||||
if let Commands::Config { action } = &cli.command {
|
||||
let cmd_action = match action {
|
||||
ConfigAction::SetDb { url } => {
|
||||
@@ -297,7 +319,21 @@ async fn main() -> Result<()> {
|
||||
let pool = db::create_pool(&db_url).await?;
|
||||
db::migrate(&pool).await?;
|
||||
|
||||
// init needs a pool but sets up the master key — handle before loading it
|
||||
if let Commands::Init = &cli.command {
|
||||
return commands::init::run(&pool).await;
|
||||
}
|
||||
|
||||
// All remaining commands require the master key from the OS Keychain
|
||||
let master_key = crypto::load_master_key()?;
|
||||
|
||||
match &cli.command {
|
||||
Commands::Init | Commands::Config { .. } => unreachable!(),
|
||||
|
||||
Commands::MigrateEncrypt => {
|
||||
commands::migrate_encrypt::run(&pool, &master_key).await?;
|
||||
}
|
||||
|
||||
Commands::Add {
|
||||
namespace,
|
||||
kind,
|
||||
@@ -321,9 +357,11 @@ async fn main() -> Result<()> {
|
||||
secret_entries: secrets,
|
||||
output: out,
|
||||
},
|
||||
&master_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::Search {
|
||||
namespace,
|
||||
kind,
|
||||
@@ -339,7 +377,6 @@ async fn main() -> Result<()> {
|
||||
output,
|
||||
} => {
|
||||
let _span = tracing::info_span!("cmd", command = "search").entered();
|
||||
// -f implies --show-secrets when any field path starts with "secret"
|
||||
let show = *show_secrets || fields.iter().any(|f| f.starts_with("secret"));
|
||||
let out = resolve_output_mode(output.as_deref())?;
|
||||
commands::search::run(
|
||||
@@ -358,9 +395,11 @@ async fn main() -> Result<()> {
|
||||
sort,
|
||||
output: out,
|
||||
},
|
||||
Some(&master_key),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Commands::Delete {
|
||||
namespace,
|
||||
kind,
|
||||
@@ -370,6 +409,7 @@ async fn main() -> Result<()> {
|
||||
tracing::info_span!("cmd", command = "delete", %namespace, %kind, %name).entered();
|
||||
commands::delete::run(&pool, namespace, kind, name).await?;
|
||||
}
|
||||
|
||||
Commands::Update {
|
||||
namespace,
|
||||
kind,
|
||||
@@ -396,10 +436,10 @@ async fn main() -> Result<()> {
|
||||
secret_entries: secrets,
|
||||
remove_secrets,
|
||||
},
|
||||
&master_key,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Commands::Config { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user