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:
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
}