diff --git a/Cargo.lock b/Cargo.lock index 9a5a195..ac4cb79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -82,6 +117,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "atoi" version = "2.0.0" @@ -118,6 +165,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -161,6 +217,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -175,6 +242,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -236,6 +313,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -251,6 +348,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -288,9 +394,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.10" @@ -532,10 +648,21 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.0", "wasip2", "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -745,6 +872,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -767,6 +903,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -893,7 +1041,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -940,6 +1088,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -975,6 +1129,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1029,6 +1194,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1089,7 +1266,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", ] [[package]] @@ -1099,7 +1287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1111,6 +1299,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1171,6 +1365,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1184,13 +1389,23 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1245,12 +1460,17 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.4.0" +version = "0.5.0" dependencies = [ + "aes-gcm", "anyhow", + "argon2", "chrono", "clap", "dirs", + "keyring", + "rand 0.10.0", + "rpassword", "serde", "serde_json", "sqlx", @@ -1261,6 +1481,42 @@ dependencies = [ "uuid", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1338,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1349,7 +1605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1385,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1551,7 +1807,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -1591,7 +1847,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -1914,6 +2170,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2188,6 +2454,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 7be41d1..ef12ba9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,18 @@ [package] name = "secrets" -version = "0.4.0" +version = "0.5.0" edition = "2024" [dependencies] +aes-gcm = "0.10.3" anyhow = "1.0.102" +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"] } +rand = "0.10.0" +rpassword = "7.4.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] } diff --git a/src/commands/add.rs b/src/commands/add.rs index e42dd4d..9801a1b 100644 --- a/src/commands/add.rs +++ b/src/commands/add.rs @@ -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 { +pub(crate) fn build_json(entries: &[String]) -> Result { 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?; diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..3deceaf --- /dev/null +++ b/src/commands/init.rs @@ -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(()) +} diff --git a/src/commands/migrate_encrypt.rs b/src/commands/migrate_encrypt.rs new file mode 100644 index 0000000..7aaa04a --- /dev/null +++ b/src/commands/migrate_encrypt.rs @@ -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, +} + +/// 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 9f6f0e8..62f1034 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -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; diff --git a/src/commands/search.rs b/src/commands/search.rs index e83b880..ef3c32e 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -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 = 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 = 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 { + 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 = 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 = 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 { +fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result { let (section, key) = field.split_once('.').ok_or_else(|| { anyhow::anyhow!( "Invalid field path '{}'. Use metadata. or secret.", @@ -279,7 +317,9 @@ fn extract_field(row: &Secret, field: &str) -> Result { 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 diff --git a/src/commands/update.rs b/src/commands/update.rs index fe216e3..accc4de 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -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, metadata: Value, - encrypted: Value, + encrypted: Vec, } 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 = 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 = 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 = 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?; diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..5d41d8d --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,192 @@ +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 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(65536, 3, 4, Some(32)).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> { + let key = Key::::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> { + 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::::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> { + 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 { + 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(()) +} + +/// Delete the Master Key from the OS Keychain (used by tests / reset). +#[cfg(test)] +pub fn delete_master_key() -> Result<()> { + let entry = + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?; + let _ = entry.delete_credential(); + 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> { + 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); + } +} diff --git a/src/db.rs b/src/db.rs index 8f96570..97b05bb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -23,7 +23,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { name VARCHAR(256) NOT NULL, tags TEXT[] NOT NULL DEFAULT '{}', metadata JSONB NOT NULL DEFAULT '{}', - encrypted JSONB NOT NULL DEFAULT '{}', + encrypted BYTEA NOT NULL DEFAULT '\x', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(namespace, kind, name) @@ -35,11 +35,37 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { EXCEPTION WHEN OTHERS THEN NULL; END $$; + -- 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. + DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'secrets' + AND column_name = 'encrypted' + AND data_type = 'jsonb' + ) THEN + ALTER TABLE secrets RENAME COLUMN encrypted TO encrypted_jsonb_old; + ALTER TABLE secrets ADD COLUMN encrypted BYTEA NOT NULL DEFAULT '\x'; + -- Copy existing JSONB data as raw UTF-8 bytes so nothing is lost + UPDATE secrets SET encrypted = convert_to(encrypted_jsonb_old::text, 'UTF8'); + ALTER TABLE secrets DROP COLUMN encrypted_jsonb_old; + END IF; + EXCEPTION WHEN OTHERS THEN NULL; + END $$; + CREATE INDEX IF NOT EXISTS idx_secrets_namespace ON secrets(namespace); CREATE INDEX IF NOT EXISTS idx_secrets_kind ON secrets(kind); CREATE INDEX IF NOT EXISTS idx_secrets_tags ON secrets USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_secrets_metadata ON secrets USING GIN(metadata jsonb_path_ops); + -- Key-value config table: stores Argon2id salt (shared across devices) + CREATE TABLE IF NOT EXISTS kv_config ( + key TEXT PRIMARY KEY, + value BYTEA NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_log ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, action VARCHAR(32) NOT NULL, @@ -60,3 +86,25 @@ pub async fn migrate(pool: &PgPool) -> Result<()> { tracing::debug!("migrations complete"); Ok(()) } + +/// Load the Argon2id salt from the database. +/// Returns None if not yet initialized. +pub async fn load_argon2_salt(pool: &PgPool) -> Result>> { + let row: Option<(Vec,)> = + sqlx::query_as("SELECT value FROM kv_config WHERE key = 'argon2_salt'") + .fetch_optional(pool) + .await?; + Ok(row.map(|(v,)| v)) +} + +/// Store the Argon2id salt in the database (only called once on first device init). +pub async fn store_argon2_salt(pool: &PgPool, salt: &[u8]) -> Result<()> { + sqlx::query( + "INSERT INTO kv_config (key, value) VALUES ('argon2_salt', $1) \ + ON CONFLICT (key) DO NOTHING", + ) + .bind(salt) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index f5da04b..0dda661 100644 --- a/src/main.rs +++ b/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(()) diff --git a/src/models.rs b/src/models.rs index aa1d6c8..fb7e35b 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,7 +11,9 @@ pub struct Secret { pub name: String, pub tags: Vec, pub metadata: Value, - pub encrypted: Value, + /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag + /// Decrypt with crypto::decrypt_json() before use. + pub encrypted: Vec, pub created_at: DateTime, pub updated_at: DateTime, }