cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 58d5634db5f0a98fee58c5cceb5d1697691fdf2f
parent 060130e8975a1d676870ab949433cc7c27fb04c4
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 18:26:13 +0000

cli: adopt shared secret-vault contract

- route local account secrets through shared secret-vault selection and protected-store envelopes
- replace plaintext local secret files with encrypted fallback and host-vault probing
- expose configured and active secret backends in config show and signer status
- cover fallback selection and fail-closed downgrade behavior across cli tests

Diffstat:
MCargo.lock | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 5+++++
Msrc/commands/mod.rs | 2+-
Msrc/commands/runtime.rs | 25+++++++++++++++++++------
Msrc/domain/runtime.rs | 16++++++++++++++++
Msrc/render/mod.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Msrc/runtime/accounts.rs | 446++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/runtime/config.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/myc.rs | 2++
Msrc/runtime/signer.rs | 34++++++++++++++++++++++++++++++++++
Mtests/doctor.rs | 4++++
Mtests/find.rs | 4++++
Mtests/identity_commands.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/job_rpc.rs | 4++++
Mtests/listing.rs | 4++++
Mtests/local.rs | 4++++
Mtests/myc_status.rs | 4++++
Mtests/order.rs | 4++++
Mtests/relay_net.rs | 4++++
Mtests/runtime_show.rs | 22++++++++++++++++++++++
Mtests/signer_status.rs | 34++++++++++++++++++++++++++++++++++
Mtests/sync.rs | 4++++
22 files changed, 999 insertions(+), 122 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -251,6 +251,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -430,6 +436,26 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -477,6 +503,28 @@ dependencies = [ ] [[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + +[[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -584,6 +632,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1076,6 +1139,23 @@ dependencies = [ ] [[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1094,6 +1174,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] name = "libsqlite3-sys" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1105,6 +1195,16 @@ dependencies = [ ] [[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1237,6 +1337,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] name = "ordered-multimap" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1496,8 +1644,10 @@ name = "radroots-cli" version = "0.1.0" dependencies = [ "assert_cmd", + "chacha20poly1305", "chrono", "clap", + "getrandom 0.2.17", "radroots-core", "radroots-events", "radroots-events-codec", @@ -1505,8 +1655,10 @@ dependencies = [ "radroots-log", "radroots-nostr-accounts", "radroots-nostr-signer", + "radroots-protected-store", "radroots-replica-db", "radroots-replica-sync", + "radroots-secret-vault", "radroots-sql-core", "radroots-trade", "reqwest", @@ -1516,6 +1668,7 @@ dependencies = [ "thiserror 2.0.18", "toml", "url", + "zeroize", ] [[package]] @@ -1579,6 +1732,7 @@ dependencies = [ "radroots-identity", "radroots-nostr-signer", "radroots-runtime", + "radroots-secret-vault", "serde", "serde_json", "thiserror 1.0.69", @@ -1614,6 +1768,18 @@ dependencies = [ ] [[package]] +name = "radroots-protected-store" +version = "0.1.0-alpha.1" +dependencies = [ + "chacha20poly1305", + "getrandom 0.2.17", + "radroots-secret-vault", + "serde", + "serde_json", + "zeroize", +] + +[[package]] name = "radroots-replica-db" version = "0.1.0-alpha.1" dependencies = [ @@ -1670,6 +1836,13 @@ dependencies = [ ] [[package]] +name = "radroots-secret-vault" +version = "0.1.0-alpha.1" +dependencies = [ + "keyring", +] + +[[package]] name = "radroots-sql-core" version = "0.1.0-alpha.1" dependencies = [ @@ -2007,6 +2180,42 @@ dependencies = [ ] [[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.28" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2920,6 +3129,15 @@ dependencies = [ [[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.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" @@ -3248,6 +3466,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -17,8 +17,10 @@ path = "src/main.rs" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] +chacha20poly1305 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } +getrandom = "0.2" radroots-core = { path = "../lib/crates/core", features = ["std", "serde"] } radroots-events = { path = "../lib/crates/events" } radroots-events-codec = { path = "../lib/crates/events-codec", features = ["serde_json"] } @@ -26,8 +28,10 @@ radroots-identity = { path = "../lib/crates/identity" } radroots-log = { path = "../lib/crates/log" } radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts" } radroots-nostr-signer = { path = "../lib/crates/nostr-signer" } +radroots-protected-store = { path = "../lib/crates/protected-store", features = ["std"] } radroots-replica-db = { path = "../lib/crates/replica-db" } radroots-replica-sync = { path = "../lib/crates/replica-sync" } +radroots-secret-vault = { path = "../lib/crates/secret-vault", features = ["std", "os-keyring"] } radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] } radroots-trade = { path = "../lib/crates/trade" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } @@ -36,6 +40,7 @@ serde_json = "1.0" thiserror = "2.0" toml = "0.8" url = "2.5" +zeroize = "1.8" [dev-dependencies] assert_cmd = "2.0" diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -43,7 +43,7 @@ pub fn dispatch( }, Command::Config(config_command) => match &config_command.command { ConfigCommand::Show => Ok(CommandOutput::success(CommandView::ConfigShow( - runtime::show(config, logging), + runtime::show(config, logging)?, ))), }, Command::Signer(signer) => match &signer.command { diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -1,13 +1,18 @@ use crate::domain::runtime::{ - AccountRuntimeView, ConfigFilesRuntimeView, ConfigShowView, LocalRuntimeView, - LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, - RpcRuntimeView, SignerRuntimeView, + AccountRuntimeView, AccountSecretRuntimeView, ConfigFilesRuntimeView, ConfigShowView, + LocalRuntimeView, LoggingRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, + RelayRuntimeView, RpcRuntimeView, SignerRuntimeView, }; +use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; -pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { - ConfigShowView { +pub fn show( + config: &RuntimeConfig, + logging: &LoggingState, +) -> Result<ConfigShowView, RuntimeError> { + let secret_backend = crate::runtime::accounts::secret_backend_status(config); + Ok(ConfigShowView { source: "local runtime state".to_owned(), output: OutputRuntimeView { format: config.output.format.as_str().to_owned(), @@ -43,6 +48,14 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { store_path: config.account.store_path.display().to_string(), secrets_dir: config.account.secrets_dir.display().to_string(), legacy_identity_path: config.identity.path.display().to_string(), + secret_backend: AccountSecretRuntimeView { + configured_primary: secret_backend.configured_primary, + configured_fallback: secret_backend.configured_fallback, + state: secret_backend.state, + active_backend: secret_backend.active_backend, + used_fallback: secret_backend.used_fallback, + reason: secret_backend.reason, + }, }, signer: SignerRuntimeView { mode: config.signer.backend.as_str().to_owned(), @@ -66,5 +79,5 @@ pub fn show(config: &RuntimeConfig, logging: &LoggingState) -> ConfigShowView { url: config.rpc.url.clone(), bridge_auth_configured: config.rpc.bridge_bearer_token.is_some(), }, - } + }) } diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -157,6 +157,20 @@ pub struct AccountRuntimeView { pub store_path: String, pub secrets_dir: String, pub legacy_identity_path: String, + pub secret_backend: AccountSecretRuntimeView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AccountSecretRuntimeView { + pub configured_primary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub configured_fallback: Option<String>, + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_backend: Option<String>, + pub used_fallback: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, } #[derive(Debug, Clone, Serialize)] @@ -1251,6 +1265,8 @@ pub struct LocalSignerStatusView { pub public_identity: IdentityPublicView, pub availability: String, pub secret_backed: bool, + pub backend: String, + pub used_fallback: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -522,14 +522,32 @@ fn render_config_show( ("store path", view.account.store_path.as_str()), ("secrets dir", view.account.secrets_dir.as_str()), ( + "secret backend", + view.account.secret_backend.configured_primary.as_str(), + ), + ( "legacy import path", view.account.legacy_identity_path.as_str(), ), ]; + if let Some(fallback) = &view.account.secret_backend.configured_fallback { + account_rows.push(("secret fallback", fallback.as_str())); + } + account_rows.push(("secret status", view.account.secret_backend.state.as_str())); + if let Some(active_backend) = &view.account.secret_backend.active_backend { + account_rows.push(("active secret backend", active_backend.as_str())); + } + account_rows.push(( + "used secret fallback", + yes_no(view.account.secret_backend.used_fallback), + )); if let Some(selector) = &view.account.selector { account_rows.insert(0, ("selector", selector.as_str())); } render_pairs(stdout, "account", account_rows.as_slice())?; + if let Some(reason) = &view.account.secret_backend.reason { + writeln!(stdout, "account secret backend reason: {reason}")?; + } render_pairs(stdout, "signer", &[("mode", view.signer.mode.as_str())])?; let relay_count = view.relay.count.to_string(); render_pairs( @@ -1843,6 +1861,8 @@ fn render_local_signer( )?; writeln!(stdout, " availability: {}", local.availability)?; writeln!(stdout, " secret backed: {}", yes_no(local.secret_backed))?; + writeln!(stdout, " backend: {}", local.backend)?; + writeln!(stdout, " used fallback: {}", yes_no(local.used_fallback))?; Ok(()) } @@ -2033,6 +2053,7 @@ mod tests { RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; + use radroots_secret_vault::RadrootsSecretBackend; #[test] fn human_render_contains_config_sections() { @@ -2058,6 +2079,8 @@ mod tests { selector: Some("acct_demo".into()), store_path: "/home/tester/.local/share/radroots/accounts/store.json".into(), secrets_dir: "/home/tester/.local/share/radroots/accounts/secrets".into(), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, }, identity: IdentityConfig { path: "identity.json".into(), @@ -2089,7 +2112,8 @@ mod tests { initialized: true, current_file: None, }, - ); + ) + .expect("runtime show"); assert_eq!(view.output.format, "human"); assert_eq!( view.paths.workspace_config_path, @@ -2133,60 +2157,65 @@ mod tests { #[test] fn ndjson_rejects_singular_views() { - let output = CommandOutput::success(CommandView::ConfigShow(runtime::show( - &RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Ndjson, - verbosity: Verbosity::Trace, - color: false, - dry_run: true, + let output = CommandOutput::success(CommandView::ConfigShow( + runtime::show( + &RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Ndjson, + verbosity: Verbosity::Trace, + color: false, + dry_run: true, + }, + paths: PathsConfig { + user_config_path: "/home/tester/.config/radroots/config.toml".into(), + workspace_config_path: "/workspace/.radroots/config.toml".into(), + user_state_root: "/home/tester/.local/share/radroots".into(), + }, + logging: LoggingConfig { + filter: "info".to_owned(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: "/home/tester/.local/share/radroots/accounts/store.json".into(), + secrets_dir: "/home/tester/.local/share/radroots/accounts/secrets".into(), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + identity: IdentityConfig { + path: "identity.json".into(), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: "/home/tester/.local/share/radroots/replica".into(), + replica_db_path: + "/home/tester/.local/share/radroots/replica/replica.sqlite".into(), + backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), + exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), + }, + myc: MycConfig { + executable: "myc".into(), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".to_owned(), + bridge_bearer_token: None, + }, }, - paths: PathsConfig { - user_config_path: "/home/tester/.config/radroots/config.toml".into(), - workspace_config_path: "/workspace/.radroots/config.toml".into(), - user_state_root: "/home/tester/.local/share/radroots".into(), + &LoggingState { + initialized: true, + current_file: None, }, - logging: LoggingConfig { - filter: "info".to_owned(), - directory: None, - stdout: false, - }, - account: AccountConfig { - selector: None, - store_path: "/home/tester/.local/share/radroots/accounts/store.json".into(), - secrets_dir: "/home/tester/.local/share/radroots/accounts/secrets".into(), - }, - identity: IdentityConfig { - path: "identity.json".into(), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - relay: RelayConfig { - urls: Vec::new(), - publish_policy: RelayPublishPolicy::Any, - source: RelayConfigSource::Defaults, - }, - local: LocalConfig { - root: "/home/tester/.local/share/radroots/replica".into(), - replica_db_path: "/home/tester/.local/share/radroots/replica/replica.sqlite" - .into(), - backups_dir: "/home/tester/.local/share/radroots/replica/backups".into(), - exports_dir: "/home/tester/.local/share/radroots/replica/exports".into(), - }, - myc: MycConfig { - executable: "myc".into(), - }, - rpc: RpcConfig { - url: "http://127.0.0.1:7070".to_owned(), - bridge_bearer_token: None, - }, - }, - &LoggingState { - initialized: true, - current_file: None, - }, - ))); + ) + .expect("runtime show"), + )); let mut buffer = Vec::new(); let error = render_ndjson_to(&mut buffer, &output).expect_err("unsupported ndjson"); assert!( diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -2,15 +2,35 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use radroots_identity::RadrootsIdentityId; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use getrandom::getrandom; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, - RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, + RadrootsNostrSecretVaultMemory, RadrootsNostrSelectedAccountStatus, }; +use radroots_protected_store::{ + RADROOTS_PROTECTED_STORE_KEY_LENGTH, RADROOTS_PROTECTED_STORE_NONCE_LENGTH, + RadrootsProtectedStoreEnvelope, +}; +use radroots_secret_vault::{ + RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend, + RadrootsSecretBackendSelection, RadrootsSecretKeyWrapping, RadrootsSecretVault, + RadrootsSecretVaultAccessError, RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, +}; +use zeroize::Zeroize; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; +const HOST_VAULT_AVAILABILITY_OVERRIDE_ENV: &str = "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE"; +const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.cli.local-account"; +const HOST_VAULT_PROBE_SLOT: &str = "__radroots_cli_host_vault_probe__"; +const ENCRYPTED_FILE_MASTER_KEY_FILE: &str = ".vault.key"; +const ENCRYPTED_FILE_SECRET_SUFFIX: &str = ".secret.json"; +const PLAINTEXT_FILE_SECRET_SUFFIX: &str = ".secret"; +const WRAPPED_KEY_VERSION: u8 = 1; + #[derive(Debug, Clone)] pub struct AccountSnapshot { pub accounts: Vec<AccountRecordView>, @@ -23,6 +43,16 @@ pub struct AccountRecordView { pub signer: &'static str, } +#[derive(Debug, Clone)] +pub struct AccountSecretBackendStatus { + pub configured_primary: String, + pub configured_fallback: Option<String>, + pub state: String, + pub active_backend: Option<String>, + pub used_fallback: bool, + pub reason: Option<String>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountCreateMode { Created, @@ -126,12 +156,11 @@ pub fn selected_account_status( }; return Ok( - match secret_file_path(&config.account.secrets_dir, &account.record.account_id).exists() - { - true => RadrootsNostrSelectedAccountStatus::Ready { + match manager.get_signing_identity(&account.record.account_id)? { + Some(_) => RadrootsNostrSelectedAccountStatus::Ready { account: account.record.clone(), }, - false => RadrootsNostrSelectedAccountStatus::PublicOnly { + None => RadrootsNostrSelectedAccountStatus::PublicOnly { account: account.record.clone(), }, }, @@ -141,6 +170,41 @@ pub fn selected_account_status( Ok(manager.selected_account_status()?) } +pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus { + let configured_primary = config.account.secret_backend.kind().to_string(); + let configured_fallback = config + .account + .secret_fallback + .map(|backend| backend.kind().to_string()); + + match resolve_secret_backend(config) { + Ok(resolved) => AccountSecretBackendStatus { + configured_primary, + configured_fallback, + state: "ready".to_owned(), + active_backend: Some(resolved.backend.kind().to_string()), + used_fallback: resolved.used_fallback, + reason: None, + }, + Err(SecretBackendResolutionError::Unavailable(reason)) => AccountSecretBackendStatus { + configured_primary, + configured_fallback, + state: "unavailable".to_owned(), + active_backend: None, + used_fallback: false, + reason: Some(reason), + }, + Err(SecretBackendResolutionError::Invalid(reason)) => AccountSecretBackendStatus { + configured_primary, + configured_fallback, + state: "error".to_owned(), + active_backend: None, + used_fallback: false, + reason: Some(reason), + }, + } +} + fn snapshot_from_manager( manager: &RadrootsNostrAccountsManager, ) -> Result<AccountSnapshot, RuntimeError> { @@ -180,99 +244,357 @@ fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManage let store = Arc::new(RadrootsNostrFileAccountStore::new( config.account.store_path.as_path(), )); - let vault = Arc::new(CliFileSecretVault::new( - config.account.secrets_dir.as_path(), - )); + let vault = secret_vault(config)?; Ok(RadrootsNostrAccountsManager::new(store, vault)?) } +fn secret_vault(config: &RuntimeConfig) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { + let resolved = resolve_secret_backend(config).map_err(secret_backend_runtime_error)?; + match resolved.backend { + RadrootsSecretBackend::HostVault(_) => Ok(Arc::new(RadrootsSecretVaultOsKeyring::new( + HOST_VAULT_SERVICE_NAME, + ))), + RadrootsSecretBackend::EncryptedFile => Ok(Arc::new(CliEncryptedFileSecretVault::new( + config.account.secrets_dir.as_path(), + ))), + RadrootsSecretBackend::Memory => Ok(Arc::new(RadrootsNostrSecretVaultMemory::new())), + RadrootsSecretBackend::PlaintextFile => Ok(Arc::new(CliPlaintextFileSecretVault::new( + config.account.secrets_dir.as_path(), + ))), + RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config( + "external_command secret backend is not supported for local cli accounts".to_owned(), + )), + } +} + +fn resolve_secret_backend( + config: &RuntimeConfig, +) -> Result<RadrootsResolvedSecretBackend, SecretBackendResolutionError> { + let availability = secret_backend_availability().map_err(|error| { + SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) + })?; + let selection = RadrootsSecretBackendSelection { + primary: config.account.secret_backend, + fallback: config.account.secret_fallback, + }; + + selection + .resolve(availability) + .map_err(|error| match error { + RadrootsSecretVaultError::BackendUnavailable { .. } + | RadrootsSecretVaultError::FallbackUnavailable { .. } => { + SecretBackendResolutionError::Unavailable(format!( + "account secret backend: {error}" + )) + } + RadrootsSecretVaultError::FallbackDisallowed { .. } + | RadrootsSecretVaultError::HostVaultPolicyUnsupported { .. } => { + SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) + } + }) +} + +fn secret_backend_availability() +-> Result<radroots_secret_vault::RadrootsSecretBackendAvailability, RuntimeError> { + Ok(radroots_secret_vault::RadrootsSecretBackendAvailability { + host_vault: host_vault_capabilities()?, + encrypted_file: true, + external_command: false, + memory: true, + plaintext_file: true, + }) +} + +fn host_vault_capabilities() -> Result<RadrootsHostVaultCapabilities, RuntimeError> { + if let Some(available) = host_vault_availability_override()? { + return Ok(match available { + true => RadrootsHostVaultCapabilities::desktop_keyring(), + false => RadrootsHostVaultCapabilities::unavailable(), + }); + } + + let keyring = RadrootsSecretVaultOsKeyring::new(HOST_VAULT_SERVICE_NAME); + match keyring.load_secret(HOST_VAULT_PROBE_SLOT) { + Ok(_) => Ok(RadrootsHostVaultCapabilities::desktop_keyring()), + Err(_) => Ok(RadrootsHostVaultCapabilities::unavailable()), + } +} + +fn host_vault_availability_override() -> Result<Option<bool>, RuntimeError> { + let Ok(value) = std::env::var(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV) else { + return Ok(None); + }; + + parse_bool_value(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV, value.trim()).map(Some) +} + +fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeError> { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(RuntimeError::Config(format!( + "{key} must be a boolean value, got `{other}`" + ))), + } +} + +fn secret_backend_runtime_error(error: SecretBackendResolutionError) -> RuntimeError { + match error { + SecretBackendResolutionError::Unavailable(message) + | SecretBackendResolutionError::Invalid(message) => RuntimeError::Config(message), + } +} + #[derive(Debug, Clone)] -struct CliFileSecretVault { +enum SecretBackendResolutionError { + Unavailable(String), + Invalid(String), +} + +#[derive(Debug, Clone)] +struct CliEncryptedFileSecretVault { secrets_dir: PathBuf, } -impl CliFileSecretVault { +impl CliEncryptedFileSecretVault { fn new(path: impl AsRef<Path>) -> Self { Self { secrets_dir: path.as_ref().to_path_buf(), } } -} -impl RadrootsNostrSecretVault for CliFileSecretVault { - fn store_secret_hex( + fn secret_file_path(&self, slot: &str) -> PathBuf { + self.secrets_dir + .join(format!("{slot}{ENCRYPTED_FILE_SECRET_SUFFIX}")) + } + + fn wrapping_key_path(&self) -> PathBuf { + self.secrets_dir.join(ENCRYPTED_FILE_MASTER_KEY_FILE) + } + + fn load_or_create_wrapping_key( + &self, + ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { + if self.wrapping_key_path().exists() { + return self.load_wrapping_key(); + } + + fs::create_dir_all(&self.secrets_dir).map_err(io_backend_error)?; + let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; + getrandom(&mut key) + .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; + fs::write(self.wrapping_key_path(), key.as_slice()).map_err(io_backend_error)?; + set_secret_permissions(self.wrapping_key_path().as_path())?; + Ok(key) + } + + fn load_wrapping_key( &self, - account_id: &RadrootsIdentityId, - secret_key_hex: &str, - ) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { - fs::create_dir_all(&self.secrets_dir).map_err(|source| { - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) - })?; - let path = secret_file_path(&self.secrets_dir, account_id); - fs::write(&path, secret_key_hex.as_bytes()).map_err(|source| { - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) - })?; + ) -> Result<[u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH], RadrootsSecretVaultAccessError> { + let raw = fs::read(self.wrapping_key_path()).map_err(io_backend_error)?; + if raw.len() != RADROOTS_PROTECTED_STORE_KEY_LENGTH { + return Err(RadrootsSecretVaultAccessError::Backend(format!( + "encrypted file wrapping key {} has invalid length {}", + self.wrapping_key_path().display(), + raw.len() + ))); + } + + let mut key = [0_u8; RADROOTS_PROTECTED_STORE_KEY_LENGTH]; + key.copy_from_slice(&raw); + Ok(key) + } +} + +impl RadrootsSecretVault for CliEncryptedFileSecretVault { + fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { + fs::create_dir_all(&self.secrets_dir).map_err(io_backend_error)?; + let envelope = + RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(self, slot, secret.as_bytes()) + .map_err(|error| RadrootsSecretVaultAccessError::Backend(error.to_string()))?; + let encoded = envelope + .encode_json() + .map_err(|error| RadrootsSecretVaultAccessError::Backend(error.to_string()))?; + let path = self.secret_file_path(slot); + fs::write(&path, encoded).map_err(io_backend_error)?; set_secret_permissions(&path)?; Ok(()) } - fn load_secret_hex( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<Option<String>, radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { - let path = secret_file_path(&self.secrets_dir, account_id); - match fs::read_to_string(path) { + fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + let path = self.secret_file_path(slot); + let encoded = match fs::read(&path) { + Ok(bytes) => bytes, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(io_backend_error(source)), + }; + let envelope = RadrootsProtectedStoreEnvelope::decode_json(encoded.as_slice()) + .map_err(|error| RadrootsSecretVaultAccessError::Backend(error.to_string()))?; + let plaintext = envelope + .open_with_wrapped_key(self) + .map_err(|error| RadrootsSecretVaultAccessError::Backend(error.to_string()))?; + String::from_utf8(plaintext) + .map(Some) + .map_err(|error| RadrootsSecretVaultAccessError::Backend(error.to_string())) + } + + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + match fs::remove_file(self.secret_file_path(slot)) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(io_backend_error(source)), + } + } +} + +impl RadrootsSecretKeyWrapping for CliEncryptedFileSecretVault { + type Error = RadrootsSecretVaultAccessError; + + fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + let mut master_key = self.load_or_create_wrapping_key()?; + let mut nonce = [0_u8; RADROOTS_PROTECTED_STORE_NONCE_LENGTH]; + getrandom(&mut nonce) + .map_err(|_| RadrootsSecretVaultAccessError::Backend("entropy unavailable".into()))?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext_key, + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSecretVaultAccessError::Backend("failed to wrap data key".into()) + })?; + master_key.zeroize(); + + let mut encoded = Vec::with_capacity(1 + nonce.len() + ciphertext.len()); + encoded.push(WRAPPED_KEY_VERSION); + encoded.extend_from_slice(&nonce); + encoded.extend_from_slice(ciphertext.as_slice()); + Ok(encoded) + } + + fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + if wrapped_key.len() <= 1 + RADROOTS_PROTECTED_STORE_NONCE_LENGTH { + return Err(RadrootsSecretVaultAccessError::Backend( + "wrapped data key is truncated".into(), + )); + } + if wrapped_key[0] != WRAPPED_KEY_VERSION { + return Err(RadrootsSecretVaultAccessError::Backend(format!( + "unsupported wrapped data key version {}", + wrapped_key[0] + ))); + } + + let mut master_key = self.load_wrapping_key()?; + let nonce_offset = 1; + let ciphertext_offset = nonce_offset + RADROOTS_PROTECTED_STORE_NONCE_LENGTH; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&master_key)); + let plaintext = cipher + .decrypt( + XNonce::from_slice(&wrapped_key[nonce_offset..ciphertext_offset]), + Payload { + msg: &wrapped_key[ciphertext_offset..], + aad: key_slot.as_bytes(), + }, + ) + .map_err(|_| { + RadrootsSecretVaultAccessError::Backend("failed to unwrap data key".into()) + })?; + master_key.zeroize(); + Ok(plaintext) + } +} + +#[derive(Debug, Clone)] +struct CliPlaintextFileSecretVault { + secrets_dir: PathBuf, +} + +impl CliPlaintextFileSecretVault { + fn new(path: impl AsRef<Path>) -> Self { + Self { + secrets_dir: path.as_ref().to_path_buf(), + } + } + + fn secret_file_path(&self, slot: &str) -> PathBuf { + self.secrets_dir + .join(format!("{slot}{PLAINTEXT_FILE_SECRET_SUFFIX}")) + } +} + +impl RadrootsSecretVault for CliPlaintextFileSecretVault { + fn store_secret(&self, slot: &str, secret: &str) -> Result<(), RadrootsSecretVaultAccessError> { + fs::create_dir_all(&self.secrets_dir).map_err(io_backend_error)?; + let path = self.secret_file_path(slot); + fs::write(&path, secret.as_bytes()).map_err(io_backend_error)?; + set_secret_permissions(&path)?; + Ok(()) + } + + fn load_secret(&self, slot: &str) -> Result<Option<String>, RadrootsSecretVaultAccessError> { + match fs::read_to_string(self.secret_file_path(slot)) { Ok(contents) => Ok(Some(contents.trim().to_owned())), Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(source) => Err( - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault( - source.to_string(), - ), - ), + Err(source) => Err(io_backend_error(source)), } } - fn remove_secret( - &self, - account_id: &RadrootsIdentityId, - ) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { - let path = secret_file_path(&self.secrets_dir, account_id); - match fs::remove_file(path) { + fn remove_secret(&self, slot: &str) -> Result<(), RadrootsSecretVaultAccessError> { + match fs::remove_file(self.secret_file_path(slot)) { Ok(()) => Ok(()), Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(source) => Err( - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault( - source.to_string(), - ), - ), + Err(source) => Err(io_backend_error(source)), } } } -fn secret_file_path(secrets_dir: &Path, account_id: &RadrootsIdentityId) -> PathBuf { - secrets_dir.join(format!("{}.secret", account_id)) +fn io_backend_error(source: std::io::Error) -> RadrootsSecretVaultAccessError { + RadrootsSecretVaultAccessError::Backend(source.to_string()) } #[cfg(unix)] -fn set_secret_permissions( - path: &Path, -) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { +fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(path) - .map_err(|source| { - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) - })? - .permissions(); + let mut permissions = fs::metadata(path).map_err(io_backend_error)?.permissions(); permissions.set_mode(0o600); - fs::set_permissions(path, permissions).map_err(|source| { - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::Vault(source.to_string()) - }) + fs::set_permissions(path, permissions).map_err(io_backend_error) } #[cfg(not(unix))] -fn set_secret_permissions( - _path: &Path, -) -> Result<(), radroots_nostr_accounts::prelude::RadrootsNostrAccountsError> { +fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn encrypted_file_vault_round_trips_secret() { + let temp = tempdir().expect("tempdir"); + let vault = CliEncryptedFileSecretVault::new(temp.path()); + + vault.store_secret("acct_demo", "deadbeef").expect("store"); + let loaded = vault.load_secret("acct_demo").expect("load"); + assert_eq!(loaded.as_deref(), Some("deadbeef")); + let raw = fs::read_to_string(temp.path().join("acct_demo.secret.json")).expect("raw file"); + assert!(!raw.contains("deadbeef")); + } + + #[test] + fn encrypted_file_vault_removes_secret() { + let temp = tempdir().expect("tempdir"); + let vault = CliEncryptedFileSecretVault::new(temp.path()); + + vault.store_secret("acct_demo", "deadbeef").expect("store"); + vault.remove_secret("acct_demo").expect("remove"); + assert!(vault.load_secret("acct_demo").expect("load").is_none()); + } +} diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; +use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use serde::Deserialize; use url::Url; @@ -28,6 +29,8 @@ const ENV_LOG_FILTER: &str = "RADROOTS_LOG_FILTER"; const ENV_LOG_DIR: &str = "RADROOTS_LOG_DIR"; const ENV_LOG_STDOUT: &str = "RADROOTS_LOG_STDOUT"; const ENV_ACCOUNT: &str = "RADROOTS_ACCOUNT"; +const ENV_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_ACCOUNT_SECRET_BACKEND"; +const ENV_ACCOUNT_SECRET_FALLBACK: &str = "RADROOTS_ACCOUNT_SECRET_FALLBACK"; const ENV_IDENTITY_PATH: &str = "RADROOTS_IDENTITY_PATH"; const ENV_SIGNER: &str = "RADROOTS_SIGNER"; const ENV_RELAYS: &str = "RADROOTS_RELAYS"; @@ -43,6 +46,8 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_LOG_DIR, ENV_LOG_STDOUT, ENV_ACCOUNT, + ENV_ACCOUNT_SECRET_BACKEND, + ENV_ACCOUNT_SECRET_FALLBACK, ENV_IDENTITY_PATH, ENV_SIGNER, ENV_RELAYS, @@ -112,6 +117,8 @@ pub struct AccountConfig { pub selector: Option<String>, pub store_path: PathBuf, pub secrets_dir: PathBuf, + pub secret_backend: RadrootsSecretBackend, + pub secret_fallback: Option<RadrootsSecretBackend>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -275,6 +282,15 @@ impl RuntimeConfig { let paths = resolve_paths(env)?; let workspace_config = load_cli_config_file(paths.workspace_config_path.as_path())?; let user_config = load_cli_config_file(paths.user_config_path.as_path())?; + let account_secret_backend = resolve_account_secret_backend(args, env, env_file)? + .unwrap_or(RadrootsSecretBackend::HostVault( + RadrootsHostVaultPolicy::desktop(), + )); + let account_secret_fallback = resolve_account_secret_fallback(args, env, env_file)? + .unwrap_or(match account_secret_backend { + RadrootsSecretBackend::HostVault(_) => Some(RadrootsSecretBackend::EncryptedFile), + _ => None, + }); Ok(Self { output: OutputConfig { format: resolve_output_format(args, env, env_file)?, @@ -310,6 +326,8 @@ impl RuntimeConfig { .or_else(|| env_value(env, env_file, &[ENV_ACCOUNT])), store_path: paths.user_state_root.join("accounts/store.json"), secrets_dir: paths.user_state_root.join("accounts/secrets"), + secret_backend: account_secret_backend, + secret_fallback: account_secret_fallback, }, identity: IdentityConfig { path: args @@ -757,6 +775,54 @@ fn parse_signer_mode(value: String) -> Result<SignerBackend, RuntimeError> { } } +fn resolve_account_secret_backend( + _args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, +) -> Result<Option<RadrootsSecretBackend>, RuntimeError> { + env_value_entry(env, env_file, &[ENV_ACCOUNT_SECRET_BACKEND]) + .map(|(key, value)| parse_account_secret_backend(key.as_str(), value.as_str())) + .transpose() +} + +fn resolve_account_secret_fallback( + _args: &CliArgs, + env: &dyn Environment, + env_file: &EnvFileValues, +) -> Result<Option<Option<RadrootsSecretBackend>>, RuntimeError> { + env_value_entry(env, env_file, &[ENV_ACCOUNT_SECRET_FALLBACK]) + .map(|(key, value)| parse_account_secret_fallback(key.as_str(), value.as_str())) + .transpose() +} + +fn parse_account_secret_fallback( + key: &str, + value: &str, +) -> Result<Option<RadrootsSecretBackend>, RuntimeError> { + if value.trim().eq_ignore_ascii_case("none") { + return Ok(None); + } + + parse_account_secret_backend(key, value).map(Some) +} + +fn parse_account_secret_backend( + key: &str, + value: &str, +) -> Result<RadrootsSecretBackend, RuntimeError> { + match value.trim().to_ascii_lowercase().as_str() { + "host_vault" => Ok(RadrootsSecretBackend::HostVault( + RadrootsHostVaultPolicy::desktop(), + )), + "encrypted_file" => Ok(RadrootsSecretBackend::EncryptedFile), + "memory" => Ok(RadrootsSecretBackend::Memory), + "plaintext_file" => Ok(RadrootsSecretBackend::PlaintextFile), + other => Err(RuntimeError::Config(format!( + "{key} must be `host_vault`, `encrypted_file`, `memory`, `plaintext_file`, or `none` for fallback, got `{other}`" + ))), + } +} + fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), @@ -776,6 +842,7 @@ mod tests { }; use crate::cli::CliArgs; use clap::Parser; + use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -881,6 +948,10 @@ mod tests { selector: None, store_path: PathBuf::from("/home/tester/.local/share/radroots/accounts/store.json"), secrets_dir: PathBuf::from("/home/tester/.local/share/radroots/accounts/secrets"), + secret_backend: RadrootsSecretBackend::HostVault( + RadrootsHostVaultPolicy::desktop(), + ), + secret_fallback: Some(RadrootsSecretBackend::EncryptedFile), } ); assert_eq!(resolved.signer.backend, SignerBackend::Local); @@ -935,6 +1006,14 @@ mod tests { ); assert!(resolved.logging.stdout); assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); + assert_eq!( + resolved.account.secret_backend, + RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) + ); + assert_eq!( + resolved.account.secret_fallback, + Some(RadrootsSecretBackend::EncryptedFile) + ); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); assert_eq!( diff --git a/src/runtime/myc.rs b/src/runtime/myc.rs @@ -203,6 +203,8 @@ fn local_signer_status_view( } }, secret_backed: capability.is_secret_backed(), + backend: "myc".to_owned(), + used_fallback: false, } } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -14,6 +14,36 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { } fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { + let secret_backend = crate::runtime::accounts::secret_backend_status(config); + if secret_backend.state == "unavailable" { + return SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "unavailable".to_owned(), + source: "local account store · local first".to_owned(), + account_id: None, + reason: secret_backend.reason, + local: None, + myc: None, + }; + } + + if secret_backend.state == "error" { + return SignerStatusView { + mode: config.signer.backend.as_str().to_owned(), + state: "error".to_owned(), + source: "local account store · local first".to_owned(), + account_id: None, + reason: secret_backend.reason, + local: None, + myc: None, + }; + } + + let backend = secret_backend + .active_backend + .unwrap_or_else(|| "unknown".to_owned()); + let used_fallback = secret_backend.used_fallback; + match crate::runtime::accounts::selected_account_status(config) { Ok(RadrootsNostrSelectedAccountStatus::Ready { account }) => { let capability = RadrootsNostrSignerCapability::LocalAccount( @@ -40,6 +70,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { ), availability: local_availability(local.availability).to_owned(), secret_backed: local.is_secret_backed(), + backend: backend.clone(), + used_fallback, }), myc: None, } @@ -59,6 +91,8 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { availability: local_availability(RadrootsNostrLocalSignerAvailability::PublicOnly) .to_owned(), secret_backed: false, + backend: backend.clone(), + used_fallback, }), myc: None, }, diff --git a/tests/doctor.rs b/tests/doctor.rs @@ -20,6 +20,9 @@ fn doctor_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn doctor_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/find.rs b/tests/find.rs @@ -20,6 +20,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs @@ -1,3 +1,4 @@ +use std::fs; use std::path::Path; use std::process::Command; @@ -19,6 +20,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -28,6 +32,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } @@ -59,6 +64,38 @@ fn account_new_json_creates_local_account_store_entry() { } #[test] +fn account_new_encrypts_file_backed_secret_fallback_by_default() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json output"); + let account_id = json["account"]["id"].as_str().expect("account id"); + let secrets_dir = dir + .path() + .join("home/.local/share/radroots/accounts/secrets"); + let envelope_path = secrets_dir.join(format!("{account_id}.secret.json")); + + assert!(secrets_dir.join(".vault.key").exists()); + assert!(envelope_path.exists()); + assert!(!secrets_dir.join(format!("{account_id}.secret")).exists()); + + let envelope: Value = serde_json::from_slice( + fs::read(envelope_path) + .expect("read encrypted envelope") + .as_slice(), + ) + .expect("envelope json"); + assert_eq!(envelope["header"]["cipher"], "x_cha_cha20_poly1305"); + assert_eq!(envelope["header"]["key_source"], "secret_vault_wrapped"); + assert!(envelope["ciphertext"].is_array()); +} + +#[test] fn account_new_rejects_dry_run_without_creating_store_state() { let dir = tempdir().expect("tempdir"); let store_path = dir @@ -78,6 +115,22 @@ fn account_new_rejects_dry_run_without_creating_store_state() { } #[test] +fn account_new_rejects_plaintext_fallback_downgrade() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") + .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "plaintext_file") + .args(["account", "new"]) + .output() + .expect("run account new"); + + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("may not silently downgrade to plaintext_file")); +} + +#[test] fn account_whoami_json_reads_selected_account() { let dir = tempdir().expect("tempdir"); diff --git a/tests/job_rpc.rs b/tests/job_rpc.rs @@ -25,6 +25,9 @@ fn job_rpc_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -34,6 +37,7 @@ fn job_rpc_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/listing.rs b/tests/listing.rs @@ -27,6 +27,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -36,6 +39,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/local.rs b/tests/local.rs @@ -20,6 +20,9 @@ fn local_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn local_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/myc_status.rs b/tests/myc_status.rs @@ -23,6 +23,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -32,6 +35,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/order.rs b/tests/order.rs @@ -26,6 +26,9 @@ fn order_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -35,6 +38,7 @@ fn order_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/relay_net.rs b/tests/relay_net.rs @@ -20,6 +20,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -20,6 +20,9 @@ fn runtime_show_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn runtime_show_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } @@ -95,6 +99,20 @@ fn config_show_json_reports_default_bootstrap_state() { .to_string() ); assert_eq!(json["account"]["legacy_identity_path"], "identity.json"); + assert_eq!( + json["account"]["secret_backend"]["configured_primary"], + "host_vault" + ); + assert_eq!( + json["account"]["secret_backend"]["configured_fallback"], + "encrypted_file" + ); + assert_eq!(json["account"]["secret_backend"]["state"], "ready"); + assert_eq!( + json["account"]["secret_backend"]["active_backend"], + "encrypted_file" + ); + assert_eq!(json["account"]["secret_backend"]["used_fallback"], true); assert_eq!(json["signer"]["mode"], "local"); assert_eq!(json["relay"]["count"], 0); assert_eq!(json["relay"]["publish_policy"], "any"); @@ -150,6 +168,10 @@ fn config_show_json_reflects_environment_configuration() { json["account"]["legacy_identity_path"], "state/identity.json" ); + assert_eq!( + json["account"]["secret_backend"]["active_backend"], + "encrypted_file" + ); assert_eq!(json["signer"]["mode"], "myc"); assert_eq!(json["relay"]["count"], 2); assert_eq!(json["relay"]["urls"][0], "wss://relay.one"); diff --git a/tests/signer_status.rs b/tests/signer_status.rs @@ -20,6 +20,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command } @@ -57,6 +61,8 @@ fn signer_status_reports_local_ready_when_account_exists() { assert_eq!(json["reason"], Value::Null); assert_eq!(json["local"]["availability"], "secret_backed"); assert_eq!(json["local"]["secret_backed"], true); + assert_eq!(json["local"]["backend"], "encrypted_file"); + assert_eq!(json["local"]["used_fallback"], true); } #[test] @@ -142,4 +148,32 @@ fn signer_status_honors_explicit_account_selector_over_default_account() { assert_eq!(json["state"], "ready"); assert_eq!(json["account_id"], first_id); assert_eq!(json["local"]["account_id"], first_id); + assert_eq!(json["local"]["backend"], "encrypted_file"); + assert_eq!(json["local"]["used_fallback"], true); +} + +#[test] +fn signer_status_reports_explicit_host_vault_fallback_selection_truthfully() { + let dir = tempdir().expect("tempdir"); + + let init = cli_command_in(dir.path()) + .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") + .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "encrypted_file") + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(init.status.success()); + + let output = cli_command_in(dir.path()) + .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") + .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "encrypted_file") + .args(["--json", "--signer", "local", "signer", "status"]) + .output() + .expect("run signer status"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("signer json"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["local"]["backend"], "encrypted_file"); + assert_eq!(json["local"]["used_fallback"], true); } diff --git a/tests/sync.rs b/tests/sync.rs @@ -20,6 +20,9 @@ fn cli_command_in(workdir: &Path) -> Command { "RADROOTS_LOG_DIR", "RADROOTS_LOG_STDOUT", "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "RADROOTS_IDENTITY_PATH", "RADROOTS_SIGNER", "RADROOTS_RELAYS", @@ -29,6 +32,7 @@ fn cli_command_in(workdir: &Path) -> Command { ] { command.env_remove(key); } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); command }