myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 4b72b37d429104cffa4e32aa196ceb97f8a38ff5
parent 15933f42a2649bbd1a034dc6d88992806b06675d
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 00:06:25 +0000

app: add operator control commands

- add explicit connection approval policy and reload signer state from disk so operator changes reach the live listener
- add repo-local CLI commands for connection lifecycle, audit inspection, and auth challenge management
- support client-initiated nostrconnect acceptance and authorized request replay over the configured transport
- validate with cargo fmt --check, cargo check --locked, cargo test --locked, cargo run -- --help, and cargo run -- connect accept --help

Diffstat:
MCargo.lock | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Msrc/app/runtime.rs | 51+++++++++++++++++++++++++++++++++++++--------------
Asrc/cli.rs | 395+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/config.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Msrc/error.rs | 4++++
Msrc/lib.rs | 9+++++++--
Msrc/main.rs | 2+-
Msrc/transport.rs | 28+++++++++++++++++++++++++++-
Msrc/transport/nip46.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
10 files changed, 727 insertions(+), 48 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -51,6 +51,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -274,6 +324,52 @@ dependencies = [ ] [[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] name = "config" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -813,6 +909,12 @@ dependencies = [ ] [[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -917,6 +1019,7 @@ dependencies = [ name = "myc" version = "0.1.0" dependencies = [ + "clap", "nostr", "radroots-identity", "radroots-nostr", @@ -1057,6 +1160,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1685,6 +1794,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2118,6 +2233,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] name = "uuid" version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -14,6 +14,7 @@ resolver = "2" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] +clap = { version = "4.5", features = ["derive"] } nostr = { version = "0.44.2", features = ["nip04", "nip44"] } radroots-identity = { path = "../lib/crates/identity" } radroots-nostr = { path = "../lib/crates/nostr", features = ["client"] } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1,12 +1,15 @@ use std::fs; -use std::path::PathBuf; -use std::sync::Arc; +use std::path::{Path, PathBuf}; use crate::config::MycConfig; use crate::error::MycError; use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; -use radroots_nostr_signer::prelude::{RadrootsNostrFileSignerStore, RadrootsNostrSignerManager}; +use radroots_nostr_signer::prelude::{ + RadrootsNostrFileSignerStore, RadrootsNostrSignerApprovalRequirement, + RadrootsNostrSignerManager, +}; +use std::sync::Arc; #[derive(Debug, Clone, PartialEq, Eq)] pub struct MycRuntimePaths { @@ -37,7 +40,8 @@ pub struct MycStartupSnapshot { pub struct MycSignerContext { signer_identity: RadrootsIdentity, user_identity: RadrootsIdentity, - manager: RadrootsNostrSignerManager, + signer_state_path: PathBuf, + connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, } #[derive(Clone)] @@ -54,7 +58,13 @@ impl MycRuntime { let paths = MycRuntimePaths::from_config(&config); Self::prepare_filesystem_for(&paths)?; - let signer = MycSignerContext::bootstrap(&paths)?; + let signer = MycSignerContext::bootstrap( + &paths, + config + .policy + .connection_approval + .into_signer_approval_requirement(), + )?; let transport = MycNostrTransport::bootstrap(&config.transport, &signer.signer_identity)?; let runtime = Self { paths, @@ -89,8 +99,8 @@ impl MycRuntime { self.signer.user_public_identity() } - pub fn signer_manager(&self) -> &RadrootsNostrSignerManager { - self.signer.signer_manager() + pub fn signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> { + self.signer.load_signer_manager() } pub fn transport(&self) -> Option<&MycNostrTransport> { @@ -192,16 +202,21 @@ impl MycSignerContext { self.user_identity.to_public() } - pub fn signer_manager(&self) -> &RadrootsNostrSignerManager { - &self.manager + pub fn load_signer_manager(&self) -> Result<RadrootsNostrSignerManager, MycError> { + Self::load_signer_manager_from_path(&self.signer_state_path) + } + + pub fn connection_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { + self.connection_approval_requirement } - fn bootstrap(paths: &MycRuntimePaths) -> Result<Self, MycError> { + fn bootstrap( + paths: &MycRuntimePaths, + connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, + ) -> Result<Self, MycError> { let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?; - let manager = RadrootsNostrSignerManager::new(Arc::new( - RadrootsNostrFileSignerStore::new(&paths.signer_state_path), - ))?; + let manager = Self::load_signer_manager_from_path(&paths.signer_state_path)?; let configured_public = signer_identity.to_public(); match manager.signer_identity()? { @@ -220,9 +235,16 @@ impl MycSignerContext { Ok(Self { signer_identity, user_identity, - manager, + signer_state_path: paths.signer_state_path.clone(), + connection_approval_requirement, }) } + + fn load_signer_manager_from_path(path: &Path) -> Result<RadrootsNostrSignerManager, MycError> { + Ok(RadrootsNostrSignerManager::new(Arc::new( + RadrootsNostrFileSignerStore::new(path), + ))?) + } } #[cfg(test)] @@ -284,6 +306,7 @@ mod tests { assert_eq!( runtime .signer_manager() + .expect("manager") .signer_identity() .expect("signer identity") .expect("configured signer") diff --git a/src/cli.rs b/src/cli.rs @@ -0,0 +1,395 @@ +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use clap::{Args, Parser, Subcommand}; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectResponse, RadrootsNostrConnectUri, +}; +use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthorizationOutcome, + RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerRequestId, +}; +use serde::Serialize; + +use crate::app::MycRuntime; +use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; +use crate::error::MycError; +use crate::logging; +use crate::transport::{MycNip46Handler, MycNostrTransport}; + +#[derive(Debug, Parser)] +#[command(name = "myc")] +#[command(about = "Mycorrhiza NIP-46 signer service")] +pub struct MycCli { + #[arg(long, global = true)] + config: Option<PathBuf>, + #[command(subcommand)] + command: Option<MycCommand>, +} + +#[derive(Debug, Subcommand)] +pub enum MycCommand { + Run, + Connections { + #[command(subcommand)] + command: MycConnectionsCommand, + }, + Audit { + #[command(subcommand)] + command: MycAuditCommand, + }, + Auth { + #[command(subcommand)] + command: MycAuthCommand, + }, + Connect { + #[command(subcommand)] + command: MycConnectCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum MycConnectionsCommand { + List, + Approve(MycConnectionApprovalArgs), + Reject(MycConnectionReasonArgs), + Revoke(MycConnectionReasonArgs), +} + +#[derive(Debug, Subcommand)] +pub enum MycAuditCommand { + List { + #[arg(long)] + connection_id: Option<String>, + }, +} + +#[derive(Debug, Subcommand)] +pub enum MycAuthCommand { + Require { + #[arg(long)] + connection_id: String, + #[arg(long)] + url: String, + }, + Authorize { + #[arg(long)] + connection_id: String, + }, +} + +#[derive(Debug, Subcommand)] +pub enum MycConnectCommand { + Accept { + #[arg(long)] + uri: String, + }, +} + +#[derive(Debug, Args)] +pub struct MycConnectionApprovalArgs { + #[arg(long)] + connection_id: String, + #[arg(long = "grant")] + grants: Vec<String>, +} + +#[derive(Debug, Args)] +pub struct MycConnectionReasonArgs { + #[arg(long)] + connection_id: String, + #[arg(long)] + reason: Option<String>, +} + +#[derive(Debug, Serialize)] +struct MycAuthorizedReplayOutput { + connection: RadrootsNostrSignerConnectionRecord, + replayed_request_id: Option<String>, +} + +#[derive(Debug, Serialize)] +struct MycAcceptedConnectionOutput { + connection: RadrootsNostrSignerConnectionRecord, + response_request_id: String, + response_relays: Vec<String>, +} + +pub async fn run_from_env() -> Result<(), MycError> { + let cli = MycCli::parse(); + let config = load_config(cli.config.as_deref())?; + + match cli.command.unwrap_or(MycCommand::Run) { + MycCommand::Run => { + logging::init_logging(&config.logging)?; + MycRuntime::bootstrap(config)?.run().await + } + MycCommand::Connections { command } => { + let runtime = MycRuntime::bootstrap(config)?; + match command { + MycConnectionsCommand::List => { + let manager = runtime.signer_manager()?; + print_json(&manager.list_connections()?) + } + MycConnectionsCommand::Approve(args) => { + let connection_id = parse_connection_id(&args.connection_id)?; + let manager = runtime.signer_manager()?; + let granted_permissions = granted_permissions_for_approval( + &manager.list_connections()?, + &connection_id, + &args.grants, + )?; + let connection = + manager.approve_connection(&connection_id, granted_permissions)?; + print_json(&connection) + } + MycConnectionsCommand::Reject(args) => { + let connection_id = parse_connection_id(&args.connection_id)?; + let manager = runtime.signer_manager()?; + let connection = manager.reject_connection(&connection_id, args.reason)?; + print_json(&connection) + } + MycConnectionsCommand::Revoke(args) => { + let connection_id = parse_connection_id(&args.connection_id)?; + let manager = runtime.signer_manager()?; + let connection = manager.revoke_connection(&connection_id, args.reason)?; + print_json(&connection) + } + } + } + MycCommand::Audit { command } => { + let runtime = MycRuntime::bootstrap(config)?; + let manager = runtime.signer_manager()?; + match command { + MycAuditCommand::List { connection_id } => { + if let Some(connection_id) = connection_id { + let connection_id = parse_connection_id(&connection_id)?; + print_json(&manager.audit_records_for_connection(&connection_id)?) + } else { + print_json(&manager.list_audit_records()?) + } + } + } + } + MycCommand::Auth { command } => { + let runtime = MycRuntime::bootstrap(config)?; + match command { + MycAuthCommand::Require { connection_id, url } => { + let connection_id = parse_connection_id(&connection_id)?; + let manager = runtime.signer_manager()?; + let connection = manager.require_auth_challenge(&connection_id, url)?; + print_json(&connection) + } + MycAuthCommand::Authorize { connection_id } => { + let connection_id = parse_connection_id(&connection_id)?; + let outcome = runtime + .signer_manager()? + .authorize_auth_challenge(&connection_id)?; + let replayed_request_id = replay_authorized_request(&runtime, &outcome).await?; + print_json(&MycAuthorizedReplayOutput { + connection: outcome.connection, + replayed_request_id, + }) + } + } + } + MycCommand::Connect { command } => { + let runtime = MycRuntime::bootstrap(config)?; + match command { + MycConnectCommand::Accept { uri } => { + let accepted = accept_client_uri(&runtime, &uri).await?; + print_json(&accepted) + } + } + } + } +} + +fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> { + match path { + Some(path) => MycConfig::load_from_path_if_exists(path), + None => MycConfig::load_from_path_if_exists(DEFAULT_CONFIG_PATH), + } +} + +fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> { + Ok(RadrootsNostrSignerConnectionId::parse(value)?) +} + +fn parse_permission_values(values: &[String]) -> Result<RadrootsNostrConnectPermissions, MycError> { + let mut permissions = Vec::new(); + for value in values { + for fragment in value.split(',') { + let trimmed = fragment.trim(); + if trimmed.is_empty() { + continue; + } + permissions.push(RadrootsNostrConnectPermission::from_str(trimmed)?); + } + } + permissions.sort(); + permissions.dedup(); + Ok(permissions.into()) +} + +fn granted_permissions_for_approval( + connections: &[RadrootsNostrSignerConnectionRecord], + connection_id: &RadrootsNostrSignerConnectionId, + grants: &[String], +) -> Result<RadrootsNostrConnectPermissions, MycError> { + if !grants.is_empty() { + return parse_permission_values(grants); + } + + let connection = connections + .iter() + .find(|connection| &connection.connection_id == connection_id) + .ok_or_else(|| { + MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) + })?; + Ok(connection.requested_permissions.clone()) +} + +async fn replay_authorized_request( + runtime: &MycRuntime, + outcome: &RadrootsNostrSignerAuthorizationOutcome, +) -> Result<Option<String>, MycError> { + let Some(pending_request) = &outcome.pending_request else { + return Ok(None); + }; + let transport = runtime.transport().ok_or_else(|| { + MycError::InvalidOperation( + "transport.enabled must be true to replay authorized requests".to_owned(), + ) + })?; + let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec()); + let response = handler.handle_request( + outcome.connection.client_public_key, + pending_request.request_message.clone(), + )?; + let event = handler.build_response_event( + outcome.connection.client_public_key, + pending_request.request_message.id.clone(), + response, + )?; + let publish_relays = if outcome.connection.relays.is_empty() { + transport.relays().to_vec() + } else { + outcome.connection.relays.clone() + }; + MycNostrTransport::publish_once( + runtime.signer_identity(), + &publish_relays, + transport.connect_timeout_secs(), + event, + ) + .await?; + Ok(Some(pending_request.request_message.id.clone())) +} + +async fn accept_client_uri( + runtime: &MycRuntime, + uri: &str, +) -> Result<MycAcceptedConnectionOutput, MycError> { + let Some(transport) = runtime.transport() else { + return Err(MycError::InvalidOperation( + "transport.enabled must be true to accept client nostrconnect URIs".to_owned(), + )); + }; + let preferred_relays = transport.relays().to_vec(); + if preferred_relays.is_empty() { + return Err(MycError::InvalidOperation( + "transport.relays must not be empty to accept client nostrconnect URIs".to_owned(), + )); + } + + let client_uri = match RadrootsNostrConnectUri::parse(uri)? { + RadrootsNostrConnectUri::Client(client_uri) => client_uri, + RadrootsNostrConnectUri::Bunker(_) => { + return Err(MycError::InvalidOperation( + "connect accept requires a nostrconnect:// client URI".to_owned(), + )); + } + }; + + let request = RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some(client_uri.secret.clone()), + requested_permissions: client_uri.metadata.requested_permissions.clone(), + }; + let manager = runtime.signer_manager()?; + let proposal = match manager.evaluate_connect_request(client_uri.client_public_key, request)? { + radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => { + return Err(MycError::InvalidOperation( + "connect secret is already bound to an existing connection".to_owned(), + )); + } + radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired( + proposal, + ) => proposal, + }; + + let draft = proposal + .into_connection_draft(runtime.user_public_identity()) + .with_relays(preferred_relays.clone()) + .with_approval_requirement(runtime.signer_context().connection_approval_requirement()); + let connection = manager.register_connection(draft)?; + if runtime.signer_context().connection_approval_requirement() + == RadrootsNostrSignerApprovalRequirement::NotRequired + { + let _ = manager.set_granted_permissions( + &connection.connection_id, + connection.requested_permissions.clone(), + )?; + } + + let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone()); + let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string(); + let event = handler.build_response_event( + client_uri.client_public_key, + response_request_id.clone(), + RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret), + )?; + let response_relays = merge_relays(&client_uri.relays, &preferred_relays); + MycNostrTransport::publish_once( + runtime.signer_identity(), + &response_relays, + transport.connect_timeout_secs(), + event, + ) + .await?; + + Ok(MycAcceptedConnectionOutput { + connection: runtime + .signer_manager()? + .list_connections()? + .into_iter() + .find(|record| record.connection_id == connection.connection_id) + .ok_or_else(|| { + MycError::InvalidOperation("accepted connection was not persisted".to_owned()) + })?, + response_request_id, + response_relays: response_relays.iter().map(ToString::to_string).collect(), + }) +} + +fn merge_relays( + primary: &[nostr::RelayUrl], + secondary: &[nostr::RelayUrl], +) -> Vec<nostr::RelayUrl> { + let mut relays = primary.to_vec(); + relays.extend_from_slice(secondary); + relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); + relays.dedup_by(|left, right| left.as_str() == right.as_str()); + relays +} + +fn print_json<T>(value: &T) -> Result<(), MycError> +where + T: Serialize, +{ + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use radroots_identity::DEFAULT_IDENTITY_PATH; use radroots_nostr::prelude::RadrootsNostrRelayUrl; +use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement; use serde::{Deserialize, Serialize}; use tracing_subscriber::EnvFilter; @@ -16,6 +17,7 @@ pub struct MycConfig { pub service: MycServiceConfig, pub logging: MycLoggingConfig, pub paths: MycPathsConfig, + pub policy: MycPolicyConfig, pub transport: MycTransportConfig, } @@ -47,12 +49,26 @@ pub struct MycTransportConfig { pub relays: Vec<String>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycConnectionApproval { + NotRequired, + ExplicitUser, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct MycPolicyConfig { + pub connection_approval: MycConnectionApproval, +} + impl Default for MycConfig { fn default() -> Self { Self { service: MycServiceConfig::default(), logging: MycLoggingConfig::default(), paths: MycPathsConfig::default(), + policy: MycPolicyConfig::default(), transport: MycTransportConfig::default(), } } @@ -94,6 +110,23 @@ impl Default for MycTransportConfig { } } +impl Default for MycPolicyConfig { + fn default() -> Self { + Self { + connection_approval: MycConnectionApproval::ExplicitUser, + } + } +} + +impl MycConnectionApproval { + pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement { + match self { + Self::NotRequired => RadrootsNostrSignerApprovalRequirement::NotRequired, + Self::ExplicitUser => RadrootsNostrSignerApprovalRequirement::ExplicitUser, + } + } +} + impl MycConfig { pub fn load_from_default_path_if_exists() -> Result<Self, MycError> { Self::load_from_path_if_exists(DEFAULT_CONFIG_PATH) @@ -220,6 +253,10 @@ mod tests { config.paths.user_identity_path, PathBuf::from(DEFAULT_IDENTITY_PATH) ); + assert_eq!( + config.policy.connection_approval, + MycConnectionApproval::ExplicitUser + ); assert!(!config.transport.enabled); assert_eq!(config.transport.connect_timeout_secs, 10); assert!(config.transport.relays.is_empty()); @@ -240,6 +277,9 @@ mod tests { signer_identity_path = "/tmp/myc-identity.json" user_identity_path = "/tmp/myc-user.json" + [policy] + connection_approval = "not_required" + [transport] enabled = true connect_timeout_secs = 15 @@ -259,6 +299,10 @@ mod tests { config.paths.user_identity_path, PathBuf::from("/tmp/myc-user.json") ); + assert_eq!( + config.policy.connection_approval, + MycConnectionApproval::NotRequired + ); assert!(config.transport.enabled); assert_eq!(config.transport.connect_timeout_secs, 15); assert_eq!( diff --git a/src/error.rs b/src/error.rs @@ -22,6 +22,8 @@ pub enum MycError { }, #[error("invalid config: {0}")] InvalidConfig(String), + #[error("invalid operation: {0}")] + InvalidOperation(String), #[error("invalid log filter `{filter}`: {source}")] InvalidLogFilter { filter: String, @@ -44,6 +46,8 @@ pub enum MycError { NostrConnect(#[from] RadrootsNostrConnectError), #[error(transparent)] SignerState(#[from] RadrootsNostrSignerError), + #[error(transparent)] + Json(#[from] serde_json::Error), #[error("NIP-46 decrypt failed: {0}")] Nip46Decrypt(String), #[error("NIP-46 encrypt failed: {0}")] diff --git a/src/lib.rs b/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] pub mod app; +pub mod cli; pub mod config; pub mod error; pub mod logging; @@ -8,8 +9,8 @@ pub mod transport; pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; pub use config::{ - DEFAULT_CONFIG_PATH, MycConfig, MycLoggingConfig, MycPathsConfig, MycServiceConfig, - MycTransportConfig, + DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig, + MycPolicyConfig, MycServiceConfig, MycTransportConfig, }; pub use error::MycError; pub use transport::{MycNostrTransport, MycTransportSnapshot}; @@ -19,3 +20,7 @@ pub async fn run() -> Result<(), MycError> { logging::init_logging(&config.logging)?; MycApp::bootstrap(config)?.run().await } + +pub async fn run_cli() -> Result<(), MycError> { + cli::run_from_env().await +} diff --git a/src/main.rs b/src/main.rs @@ -2,7 +2,7 @@ #[tokio::main] async fn main() { - if let Err(err) = myc::run().await { + if let Err(err) = myc::run_cli().await { eprintln!("myc: {err}"); std::process::exit(1); } diff --git a/src/transport.rs b/src/transport.rs @@ -3,7 +3,9 @@ pub mod nip46; use std::time::Duration; use radroots_identity::RadrootsIdentity; -use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrRelayUrl}; +use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEventBuilder, RadrootsNostrRelayUrl, +}; use crate::config::MycTransportConfig; use crate::error::MycError; @@ -63,6 +65,30 @@ impl MycNostrTransport { Ok(()) } + pub async fn publish_once( + signer_identity: &RadrootsIdentity, + relays: &[RadrootsNostrRelayUrl], + connect_timeout_secs: u64, + event: RadrootsNostrEventBuilder, + ) -> Result<(), MycError> { + if relays.is_empty() { + return Err(MycError::InvalidOperation( + "cannot publish without at least one relay".to_owned(), + )); + } + + let client = RadrootsNostrClient::from_identity(signer_identity); + for relay in relays { + let _ = client.add_relay(relay.as_str()).await?; + } + client.connect().await; + client + .wait_for_connection(Duration::from_secs(connect_timeout_secs)) + .await; + let _ = client.send_event_builder(event).await?; + Ok(()) + } + pub fn snapshot(&self) -> MycTransportSnapshot { MycTransportSnapshot { enabled: true, diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -127,10 +127,8 @@ impl MycNip46Handler { request: RadrootsNostrConnectRequest, secret: Option<String>, ) -> Result<RadrootsNostrConnectResponse, MycError> { - let evaluation = self - .signer - .signer_manager() - .evaluate_connect_request(client_public_key, request)?; + let manager = self.signer.load_signer_manager()?; + let evaluation = manager.evaluate_connect_request(client_public_key, request)?; match evaluation { RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => { @@ -139,14 +137,19 @@ impl MycNip46Handler { RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => { let draft = proposal .into_connection_draft(self.signer.user_public_identity()) - .with_relays(self.relays.clone()); - let connection = self.signer.signer_manager().register_connection(draft)?; - let granted_permissions = - grant_permissions_for_new_connection(connection.requested_permissions.clone()); - let _ = self - .signer - .signer_manager() - .set_granted_permissions(&connection.connection_id, granted_permissions)?; + .with_relays(self.relays.clone()) + .with_approval_requirement(self.signer.connection_approval_requirement()); + let connection = manager.register_connection(draft)?; + if self.signer.connection_approval_requirement() + == radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement::NotRequired + { + let granted_permissions = + grant_permissions_for_new_connection(connection.requested_permissions.clone()); + let _ = manager.set_granted_permissions( + &connection.connection_id, + granted_permissions, + )?; + } Ok(connect_response(secret)) } } @@ -162,10 +165,8 @@ impl MycNip46Handler { Err(response) => return Ok(response), }; - let evaluation = self - .signer - .signer_manager() - .evaluate_request(&connection.connection_id, request_message)?; + let manager = self.signer.load_signer_manager()?; + let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { RadrootsNostrSignerRequestAction::Denied { reason } => { @@ -194,10 +195,8 @@ impl MycNip46Handler { Err(response) => return Ok(response), }; - let evaluation = self - .signer - .signer_manager() - .evaluate_request(&connection.connection_id, request_message)?; + let manager = self.signer.load_signer_manager()?; + let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { RadrootsNostrSignerRequestAction::Denied { reason } => { @@ -226,10 +225,8 @@ impl MycNip46Handler { Err(response) => return Ok(response), }; - let evaluation = self - .signer - .signer_manager() - .evaluate_request(&connection.connection_id, request_message)?; + let manager = self.signer.load_signer_manager()?; + let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { RadrootsNostrSignerRequestAction::Denied { reason } => { @@ -253,7 +250,7 @@ impl MycNip46Handler { Ok( match self .signer - .signer_manager() + .load_signer_manager()? .lookup_session(&client_public_key, None)? { RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(connection), @@ -472,7 +469,7 @@ mod tests { use serde_json::json; use crate::app::MycRuntime; - use crate::config::MycConfig; + use crate::config::{MycConfig, MycConnectionApproval}; use super::MycNip46Handler; @@ -484,11 +481,33 @@ mod tests { } fn runtime() -> MycRuntime { - let temp = tempfile::tempdir().expect("tempdir"); + let temp = tempfile::tempdir().expect("tempdir").keep(); + let mut config = MycConfig::default(); + config.paths.state_dir = temp.join("state"); + config.paths.signer_identity_path = temp.join("signer.json"); + config.paths.user_identity_path = temp.join("user.json"); + config.policy.connection_approval = MycConnectionApproval::NotRequired; + config.transport.enabled = true; + config.transport.connect_timeout_secs = 15; + config.transport.relays = vec!["wss://relay.example.com".to_owned()]; + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + MycRuntime::bootstrap(config).expect("runtime") + } + + fn runtime_with_explicit_approval() -> MycRuntime { + let temp = tempfile::tempdir().expect("tempdir").keep(); let mut config = MycConfig::default(); - config.paths.state_dir = temp.path().join("state"); - config.paths.signer_identity_path = temp.path().join("signer.json"); - config.paths.user_identity_path = temp.path().join("user.json"); + config.paths.state_dir = temp.join("state"); + config.paths.signer_identity_path = temp.join("signer.json"); + config.paths.user_identity_path = temp.join("user.json"); + config.policy.connection_approval = MycConnectionApproval::ExplicitUser; config.transport.enabled = true; config.transport.connect_timeout_secs = 15; config.transport.relays = vec!["wss://relay.example.com".to_owned()]; @@ -643,6 +662,7 @@ mod tests { ); let connections = runtime .signer_manager() + .expect("manager") .list_connections() .expect("connections"); assert_eq!(connections.len(), 1); @@ -654,6 +674,45 @@ mod tests { } #[test] + fn connect_preserves_pending_status_when_explicit_approval_is_required() { + let runtime = runtime_with_explicit_approval(); + let handler = handler(&runtime); + + let response = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: vec![sign_event_permission(1)].into(), + }, + ), + ) + .expect("connect response"); + + assert_eq!(response, RadrootsNostrConnectResponse::ConnectAcknowledged); + let connection = runtime + .signer_manager() + .expect("manager") + .list_connections() + .expect("connections") + .into_iter() + .next() + .expect("connection"); + assert_eq!( + connection.status, + radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionStatus::Pending + ); + assert_eq!( + connection.approval_state, + radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalState::Pending + ); + assert!(connection.granted_permissions().as_slice().is_empty()); + } + + #[test] fn base_methods_return_spec_results_after_connect() { let runtime = runtime(); let handler = handler(&runtime); @@ -736,6 +795,7 @@ mod tests { let connection = runtime .signer_manager() + .expect("manager") .list_connections() .expect("connections") .into_iter()