From dc0534cbc98700bf007525447e7c3f31a271043e Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 19 Mar 2026 09:17:04 +0800 Subject: [PATCH] refactor(secrets): remove migrate_encrypt command Made-with: Cursor --- AGENTS.md | 47 ++++++++++++++--- Cargo.lock | 89 ++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- README.md | 26 ++++++++-- src/commands/migrate_encrypt.rs | 85 ------------------------------- src/commands/mod.rs | 1 - src/db.rs | 3 +- src/main.rs | 10 ---- 8 files changed, 152 insertions(+), 111 deletions(-) delete mode 100644 src/commands/migrate_encrypt.rs diff --git a/AGENTS.md b/AGENTS.md index 2a90f99..a91d81a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Secrets CLI — AGENTS.md -跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18,供 AI 工具读取上下文。 +跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18,供 AI 工具读取上下文。敏感数据(encrypted 字段)使用 AES-256-GCM 加密,主密钥由 Argon2id 从主密码派生并存入平台安全存储(macOS Keychain / Windows Credential Manager / Linux keyutils)。 ## 项目结构 @@ -10,10 +10,12 @@ secrets/ main.rs # CLI 入口,clap 命令定义,auto-migrate,--verbose 全局参数 output.rs # OutputMode 枚举 + TTY 检测(TTY→text,非 TTY→json-compact) config.rs # 配置读写:~/.config/secrets/config.toml(database_url) - db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log) + db.rs # PgPool 创建 + 建表/索引(幂等,含 audit_log + kv_config) + crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串 models.rs # Secret 结构体(sqlx::FromRow + serde) audit.rs # 审计写入:向 audit_log 表记录所有写操作 commands/ + init.rs # init 命令:主密钥初始化(每台设备一次) add.rs # add 命令:upsert,支持 --meta key=value / --secret key=@file / -o json config.rs # config 命令:set-db / show / path(持久化 database_url) search.rs # search 命令:多条件查询,-f/-o/--summary/--limit/--offset/--sort @@ -31,7 +33,7 @@ secrets/ - **Host**: `:` - **Database**: `secrets` - **连接串**: `postgres://postgres:@:/secrets` -- **表**: `secrets`(主表)+ `audit_log`(审计表),首次连接自动建表(auto-migrate) +- **表**: `secrets`(主表)+ `audit_log`(审计表)+ `kv_config`(Argon2 salt 等),首次连接自动建表(auto-migrate) ### 表结构 @@ -43,13 +45,20 @@ secrets ( name VARCHAR(256) NOT NULL, -- 人类可读标识 tags TEXT[] NOT NULL DEFAULT '{}', -- 灵活标签: ["aliyun","hongkong"] metadata JSONB NOT NULL DEFAULT '{}', -- 明文描述: ip, desc, domains, location... - encrypted JSONB NOT NULL DEFAULT '{}', -- 敏感数据: ssh_key, password, token... + encrypted BYTEA NOT NULL DEFAULT '\x', -- AES-256-GCM 密文: nonce(12B)||ciphertext+tag created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(namespace, kind, name) ) ``` +```sql +kv_config ( + key TEXT PRIMARY KEY, -- 如 'argon2_salt' + value BYTEA NOT NULL -- Argon2id salt,首台设备 init 时生成 +) +``` + ### audit_log 表结构 ```sql @@ -74,7 +83,7 @@ audit_log ( | `name` | 唯一标识名 | `i-uf63f2uookgs5uxmrdyc`, `gitea` | | `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` | | `metadata` | 明文非敏感信息 | `{"ip":"47.243.154.187","desc":"Grafana","domains":["..."]}` | -| `encrypted` | 敏感凭据(MVP 阶段明文存储,后续对 value 加密) | `{"ssh_key":"-----BEGIN...","password":"..."}` | +| `encrypted` | 敏感凭据,AES-256-GCM 加密存储 | 二进制密文,解密后为 `{"ssh_key":"...","password":"..."}` | ## 数据库配置 @@ -88,6 +97,21 @@ secrets config path # 打印配置文件路径 配置文件:`~/.config/secrets/config.toml`,权限 0600。`--db-url` 参数可一次性覆盖。 +## 主密钥与加密 + +首次使用(每台设备各执行一次): + +```bash +secrets config set-db "postgres://postgres:@:/secrets" +secrets init # 提示输入主密码,Argon2id 派生主密钥后存入 OS 钥匙串 +``` + +主密码不存储;salt 存于 `kv_config`,首台设备生成后共享,确保同一主密码在所有设备派生出相同主密钥。 + +主密钥存储后端:macOS Keychain、Windows Credential Manager、Linux keyutils(会话级,重启后需再次 `secrets init`)。 + +**从旧版(明文 JSONB)升级**:升级后执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。 + ## CLI 命令 ### AI 使用主路径 @@ -102,6 +126,16 @@ secrets config path # 打印配置文件路径 --- +### init — 主密钥初始化(每台设备一次) + +```bash +# 首次设备:生成 Argon2id salt 并存库,派生主密钥后存 OS 钥匙串 +secrets init + +# 后续设备:复用已有 salt,派生主密钥后存钥匙串(主密码需与首台相同) +secrets init +``` + ### search — 发现与读取 ```bash @@ -250,7 +284,7 @@ secrets delete -n ricnsmart --kind server --name i-old-server-id --- -### config — 配置管理 +### config — 配置管理(无需主密钥) ```bash # 设置数据库连接(每台设备执行一次,之后永久生效) @@ -289,6 +323,7 @@ secrets --db-url "postgres://..." search -n refining - 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断 +- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载) - 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json` ## 提交前检查(必须全部通过) diff --git a/Cargo.lock b/Cargo.lock index ac4cb79..5a090cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -909,9 +909,12 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "linux-keyutils", "log", "security-framework 2.11.1", "security-framework 3.7.0", + "windows-sys 0.60.2", "zeroize", ] @@ -964,6 +967,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "litemap" version = "0.8.1" @@ -2463,6 +2476,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2496,13 +2518,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2515,6 +2554,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2527,6 +2572,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2539,12 +2590,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2557,6 +2620,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2569,6 +2638,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2581,6 +2656,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2593,6 +2674,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index ef12ba9..a226d5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ argon2 = { version = "0.5.3", features = ["std"] } chrono = { version = "0.4.44", features = ["serde"] } clap = { version = "4.6.0", features = ["derive", "env"] } dirs = "6.0.0" -keyring = { version = "3.6.3", features = ["apple-native"] } +keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "linux-native"] } rand = "0.10.0" rpassword = "7.4.0" serde = { version = "1.0.228", features = ["derive"] } diff --git a/README.md b/README.md index 46e6866..10d427b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 跨设备密钥与配置管理 CLI,基于 Rust + PostgreSQL 18。 -将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。 +将服务器信息、服务凭据统一存入数据库,供本地工具和 AI 读取上下文。敏感数据(`encrypted` 字段)使用 AES-256-GCM 加密存储,主密钥由 Argon2id 从主密码派生并存入系统钥匙串。 ## 安装 @@ -11,12 +11,22 @@ cargo build --release # 或从 Release 页面下载预编译二进制 ``` -配置数据库连接(首次使用需执行一次,之后在该设备上持久生效): +## 首次使用(每台设备各执行一次) ```bash +# 1. 配置数据库连接 secrets config set-db "postgres://postgres:@:/secrets" + +# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串) +secrets init ``` +主密码不会存储,仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。 + +**主密钥存储**:macOS → Keychain;Windows → Credential Manager;Linux → keyutils(会话级,重启后需再次 `secrets init`)。 + +**从旧版(明文存储)升级**:升级后首次运行需执行 `secrets init` 即可(明文记录需手动重新 add 或通过 update 更新)。 + ## AI Agent 快速指南 这个 CLI 以 AI 使用优先设计。核心路径只有一条:**读取用 `search`,写入用 `add` / `update`**。 @@ -80,6 +90,7 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \ ```bash # 查看帮助(包含各子命令 EXAMPLES) secrets --help +secrets init --help # 主密钥初始化 secrets search --help secrets add --help secrets update --help @@ -116,6 +127,9 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r # ── delete ─────────────────────────────────────────────────────────────────── secrets delete -n refining --kind service --name legacy-mqtt +# ── init ───────────────────────────────────────────────────────────────────── +secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串) + # ── config ─────────────────────────────────────────────────────────────────── secrets config set-db "postgres://postgres:@:/secrets" secrets config show # 密码脱敏展示 @@ -137,9 +151,9 @@ RUST_LOG=secrets=trace secrets search | `name` | 人类可读唯一标识 | | `tags` | 多维标签,如 `["aliyun","hongkong"]` | | `metadata` | 明文描述信息(ip、desc、domains 等) | -| `encrypted` | 敏感凭据(ssh_key、password、token 等),MVP 阶段明文存储,预留加密字段 | +| `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 | -`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。 +`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。加解密使用主密钥(由 `secrets init` 设置)。 ## 审计日志 @@ -160,10 +174,12 @@ src/ main.rs # CLI 入口(clap),含各子命令 after_help 示例 output.rs # OutputMode 枚举 + TTY 检测 config.rs # 配置读写(~/.config/secrets/config.toml) - db.rs # 连接池 + auto-migrate(secrets + audit_log) + db.rs # 连接池 + auto-migrate(secrets + audit_log + kv_config) + crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串 models.rs # Secret 结构体 audit.rs # 审计日志写入(audit_log 表) commands/ + init.rs # 主密钥初始化(首次/新设备) add.rs # upsert,支持 -o json config.rs # config set-db/show/path search.rs # 多条件查询,支持 -f/-o/--summary/--limit/--offset/--sort diff --git a/src/commands/migrate_encrypt.rs b/src/commands/migrate_encrypt.rs deleted file mode 100644 index 7aaa04a..0000000 --- a/src/commands/migrate_encrypt.rs +++ /dev/null @@ -1,85 +0,0 @@ -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, -} - -/// 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 = - 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(()) -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 62f1034..3a98606 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,5 @@ pub mod add; pub mod config; pub mod delete; pub mod init; -pub mod migrate_encrypt; pub mod search; pub mod update; diff --git a/src/db.rs b/src/db.rs index 97b05bb..ac191fe 100644 --- a/src/db.rs +++ b/src/db.rs @@ -37,8 +37,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { -- Migrate encrypted column from JSONB to BYTEA if still JSONB type. -- After migration, old plaintext rows will have their JSONB data - -- stored as raw bytes (not yet re-encrypted); run `secrets migrate-encrypt` - -- to encrypt them with the master key. + -- stored as raw bytes (UTF-8 encoded). DO $$ BEGIN IF EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/src/main.rs b/src/main.rs index 0dda661..67b177d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,12 +60,6 @@ enum Commands { 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 @@ -330,10 +324,6 @@ async fn main() -> Result<()> { match &cli.command { Commands::Init | Commands::Config { .. } => unreachable!(), - Commands::MigrateEncrypt => { - commands::migrate_encrypt::run(&pool, &master_key).await?; - } - Commands::Add { namespace, kind,