myc

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

commit ba89cdabd338b76ff9da0f1550df2a71b107ac2e
parent 7f4d506df78500f38b130eb60dab8274c729ab64
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 01:56:14 +0000

cli: surface runtime audit and proof lane

- extend audit list to return signer request audit and runtime operation audit
- add runtime audit coverage for publish rejection and auth replay restoration
- assert live listener and connect accept write operator-visible audit records
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --check, cargo check --locked, and cargo test --locked

Diffstat:
Msrc/cli.rs | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtests/nip46_e2e.rs | 155++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 289 insertions(+), 9 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,13 +1,15 @@ use std::path::{Path, PathBuf}; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::{ RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerRequestAuditRecord, }; use serde::Serialize; use crate::app::MycRuntime; +use crate::audit::MycOperationAuditRecord; use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; use crate::error::MycError; @@ -57,9 +59,18 @@ pub enum MycAuditCommand { List { #[arg(long)] connection_id: Option<String>, + #[arg(long, value_enum, default_value_t = MycAuditScope::All)] + scope: MycAuditScope, }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum MycAuditScope { + All, + Request, + Operation, +} + #[derive(Debug, Subcommand)] pub enum MycAuthCommand { Require { @@ -98,6 +109,12 @@ pub struct MycConnectionReasonArgs { reason: Option<String>, } +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct MycAuditListOutput { + pub signer_request_audit: Vec<RadrootsNostrSignerRequestAuditRecord>, + pub runtime_operation_audit: Vec<MycOperationAuditRecord>, +} + pub async fn run_from_env() -> Result<(), MycError> { let cli = MycCli::parse(); let config = load_config(cli.config.as_deref())?; @@ -144,13 +161,13 @@ pub async fn run_from_env() -> Result<(), MycError> { 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()?) - } + MycAuditCommand::List { + connection_id, + scope, + } => { + let output = + load_audit_output(&runtime, &manager, connection_id.as_deref(), scope)?; + print_json(&output) } } } @@ -211,6 +228,32 @@ fn granted_permissions_for_approval( Ok(connection.requested_permissions.clone()) } +fn load_audit_output( + runtime: &MycRuntime, + manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager, + connection_id: Option<&str>, + scope: MycAuditScope, +) -> Result<MycAuditListOutput, MycError> { + let connection_id = connection_id.map(parse_connection_id).transpose()?; + let signer_request_audit = match (scope, connection_id.as_ref()) { + (MycAuditScope::Operation, _) => Vec::new(), + (_, Some(connection_id)) => manager.audit_records_for_connection(connection_id)?, + (_, None) => manager.list_audit_records()?, + }; + let runtime_operation_audit = match (scope, connection_id.as_ref()) { + (MycAuditScope::Request, _) => Vec::new(), + (_, Some(connection_id)) => runtime + .operation_audit_store() + .list_for_connection(connection_id)?, + (_, None) => runtime.operation_audit_store().list()?, + }; + + Ok(MycAuditListOutput { + signer_request_audit, + runtime_operation_audit, + }) +} + fn print_json<T>(value: &T) -> Result<(), MycError> where T: Serialize, @@ -218,3 +261,87 @@ where println!("{}", serde_json::to_string_pretty(value)?); Ok(()) } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use radroots_identity::RadrootsIdentity; + use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest; + use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; + + use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; + use crate::config::MycConfig; + + use super::{MycAuditScope, load_audit_output}; + use crate::app::MycRuntime; + + fn write_identity(path: &std::path::Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn runtime() -> MycRuntime { + let temp = tempfile::tempdir().expect("tempdir").keep(); + let mut config = MycConfig::default(); + config.paths.state_dir = PathBuf::from(&temp).join("state"); + config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); + config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + MycRuntime::bootstrap(config).expect("runtime") + } + + #[test] + fn audit_output_surfaces_both_request_and_operation_records() { + let runtime = runtime(); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + let request_evaluation = manager + .evaluate_request( + &connection.connection_id, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( + "request-1", + RadrootsNostrConnectRequest::Ping, + ), + ) + .expect("record audit"); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayRestore, + MycOperationAuditOutcome::Restored, + Some(&connection.connection_id), + Some(request_evaluation.audit.request_id.as_str()), + 1, + 0, + "restored pending auth challenge after replay failure", + )); + + let output = load_audit_output( + &runtime, + &manager, + Some(connection.connection_id.as_str()), + MycAuditScope::All, + ) + .expect("load audit output"); + + assert_eq!(output.signer_request_audit, vec![request_evaluation.audit]); + assert_eq!(output.runtime_operation_audit.len(), 1); + assert_eq!( + output.runtime_operation_audit[0].operation, + MycOperationAuditKind::AuthReplayRestore + ); + } +} diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -4,7 +4,10 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use myc::control; -use myc::{MycConfig, MycConnectionApproval, MycRuntime}; +use myc::{ + MycConfig, MycConnectionApproval, MycOperationAuditKind, MycOperationAuditOutcome, + MycOperationAuditRecord, MycRuntime, +}; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; use nostr::nips::nip44::Version; @@ -472,6 +475,26 @@ async fn wait_for_connect_secret_consumed(runtime: &MycRuntime) -> TestResult<() Ok(()) } +async fn wait_for_operation_audit_count( + runtime: &MycRuntime, + expected: usize, +) -> TestResult<Vec<MycOperationAuditRecord>> { + timeout(Duration::from_secs(5), async { + loop { + let records = runtime + .operation_audit_store() + .list() + .expect("operation audit"); + if records.len() >= expected { + return records; + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .map_err(Into::into) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn live_listener_consumes_connect_secret_only_after_successful_publish() -> TestResult<()> { let relay = TestRelay::spawn().await?; @@ -521,6 +544,28 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() - .next() .expect("stored connection"); assert!(!initial_connection.connect_secret_is_consumed()); + let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; + assert_eq!(operation_audit.len(), 1); + assert_eq!( + operation_audit[0].operation, + MycOperationAuditKind::ListenerResponsePublish + ); + assert_eq!( + operation_audit[0].outcome, + MycOperationAuditOutcome::Rejected + ); + assert_eq!( + operation_audit[0].connection_id.as_deref(), + Some(initial_connection.connection_id.as_str()) + ); + assert_eq!(operation_audit[0].request_id.as_deref(), Some("connect-1")); + assert_eq!(operation_audit[0].relay_count, 1); + assert_eq!(operation_audit[0].acknowledged_relay_count, 0); + assert!( + operation_audit[0] + .relay_outcome_summary + .contains("blocked by test relay") + ); let request_two = build_request_event( &client_identity, @@ -565,6 +610,7 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() - .len(), 1 ); + assert_eq!(runtime.operation_audit_store().list()?.len(), 1); let _ = shutdown_tx.send(()); listener_task.await??; @@ -605,6 +651,27 @@ async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds( .next() .expect("stored connection"); assert!(!stored_after_failure.connect_secret_is_consumed()); + let operation_audit = wait_for_operation_audit_count(&runtime, 1).await?; + assert_eq!( + operation_audit[0].operation, + MycOperationAuditKind::ConnectAcceptPublish + ); + assert_eq!( + operation_audit[0].outcome, + MycOperationAuditOutcome::Rejected + ); + assert_eq!( + operation_audit[0].connection_id.as_deref(), + Some(stored_after_failure.connection_id.as_str()) + ); + assert!(operation_audit[0].request_id.is_some()); + assert_eq!(operation_audit[0].relay_count, 1); + assert_eq!(operation_audit[0].acknowledged_relay_count, 0); + assert!( + operation_audit[0] + .relay_outcome_summary + .contains("blocked by test relay") + ); let accepted = control::accept_client_uri(&runtime, &client_uri).await?; assert_eq!(accepted.response_request_id.len(), 36); @@ -626,6 +693,30 @@ async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds( .next() .expect("stored connection"); assert!(stored_after_success.connect_secret_is_consumed()); + let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?; + assert_eq!( + operation_audit[1].operation, + MycOperationAuditKind::ConnectAcceptPublish + ); + assert_eq!( + operation_audit[1].outcome, + MycOperationAuditOutcome::Succeeded + ); + assert_eq!( + operation_audit[1].connection_id.as_deref(), + Some(stored_after_success.connection_id.as_str()) + ); + assert_eq!( + operation_audit[1].request_id.as_deref(), + Some(accepted.response_request_id.as_str()) + ); + assert_eq!(operation_audit[1].relay_count, 1); + assert_eq!(operation_audit[1].acknowledged_relay_count, 1); + assert!( + operation_audit[1] + .relay_outcome_summary + .contains("1/1 relays acknowledged publish") + ); let consumed = control::accept_client_uri(&runtime, &client_uri) .await @@ -690,6 +781,47 @@ async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestRe .authorized_at_unix, None ); + let operation_audit = wait_for_operation_audit_count(&runtime, 2).await?; + assert_eq!( + operation_audit[0].operation, + MycOperationAuditKind::AuthReplayPublish + ); + assert_eq!( + operation_audit[0].outcome, + MycOperationAuditOutcome::Rejected + ); + assert_eq!( + operation_audit[0].connection_id.as_deref(), + Some(connection.connection_id.as_str()) + ); + assert_eq!(operation_audit[0].request_id.as_deref(), Some("auth-ping")); + assert_eq!(operation_audit[0].relay_count, 1); + assert_eq!(operation_audit[0].acknowledged_relay_count, 0); + assert!( + operation_audit[0] + .relay_outcome_summary + .contains("blocked by test relay") + ); + assert_eq!( + operation_audit[1].operation, + MycOperationAuditKind::AuthReplayRestore + ); + assert_eq!( + operation_audit[1].outcome, + MycOperationAuditOutcome::Restored + ); + assert_eq!( + operation_audit[1].connection_id.as_deref(), + Some(connection.connection_id.as_str()) + ); + assert_eq!(operation_audit[1].request_id.as_deref(), Some("auth-ping")); + assert_eq!(operation_audit[1].relay_count, 1); + assert_eq!(operation_audit[1].acknowledged_relay_count, 0); + assert!( + operation_audit[1] + .relay_outcome_summary + .contains("restored pending auth challenge") + ); let replayed = control::authorize_auth_challenge(&runtime, &connection.connection_id).await?; assert_eq!(replayed.replayed_request_id.as_deref(), Some("auth-ping")); @@ -716,6 +848,27 @@ async fn auth_replay_restores_pending_request_until_publish_succeeds() -> TestRe ); assert!(authorized.pending_request.is_none()); assert!(authorized.last_authenticated_at_unix.is_some()); + let operation_audit = wait_for_operation_audit_count(&runtime, 3).await?; + assert_eq!( + operation_audit[2].operation, + MycOperationAuditKind::AuthReplayPublish + ); + assert_eq!( + operation_audit[2].outcome, + MycOperationAuditOutcome::Succeeded + ); + assert_eq!( + operation_audit[2].connection_id.as_deref(), + Some(connection.connection_id.as_str()) + ); + assert_eq!(operation_audit[2].request_id.as_deref(), Some("auth-ping")); + assert_eq!(operation_audit[2].relay_count, 1); + assert_eq!(operation_audit[2].acknowledged_relay_count, 1); + assert!( + operation_audit[2] + .relay_outcome_summary + .contains("1/1 relays acknowledged publish") + ); Ok(()) }