myc

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

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

app: record publish and replay operation audit

- record listener publish rejections with connection and request context
- record connect accept and auth replay publish outcomes with relay summaries
- restore pending auth challenges with a durable operation audit trail
- add structured tracing fields for operation, request, connection, and relay results

Diffstat:
Msrc/control.rs | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/transport.rs | 62++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/transport/nip46.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
3 files changed, 259 insertions(+), 64 deletions(-)

diff --git a/src/control.rs b/src/control.rs @@ -12,8 +12,9 @@ use radroots_nostr_signer::prelude::{ use serde::Serialize; use crate::app::MycRuntime; +use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::error::MycError; -use crate::transport::{MycNip46Handler, MycNostrTransport}; +use crate::transport::{MycNip46Handler, MycNostrTransport, MycPublishOutcome}; #[derive(Debug, Serialize)] pub struct MycAuthorizedReplayOutput { @@ -113,13 +114,39 @@ pub async fn accept_client_uri( RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret), )?; let response_relays = merge_relays(&client_uri.relays, &preferred_relays); - MycNostrTransport::publish_once( + let publish_outcome = match MycNostrTransport::publish_once( runtime.signer_identity(), &response_relays, transport.connect_timeout_secs(), event, ) - .await?; + .await + { + Ok(outcome) => outcome, + Err(error) => { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ConnectAcceptPublish, + MycOperationAuditOutcome::Rejected, + Some(&connection.connection_id), + Some(response_request_id.as_str()), + response_relays.len(), + error + .publish_rejection_counts() + .map(|(_, acknowledged)| acknowledged) + .unwrap_or_default(), + publish_failure_summary(&error), + )); + return Err(error); + } + }; + record_publish_audit( + runtime, + MycOperationAuditKind::ConnectAcceptPublish, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some(response_request_id.as_str()), + &publish_outcome, + ); let _ = manager.mark_connect_secret_consumed(&connection.connection_id)?; Ok(MycAcceptedConnectionOutput { @@ -190,7 +217,8 @@ async fn replay_authorized_request( )); } }; - let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts() else { + let Some((response, _, consume_connect_secret_for)) = handled_request.into_publish_parts() + else { let error = MycError::InvalidOperation( "authorized auth replay did not produce a response".to_owned(), ); @@ -221,7 +249,7 @@ async fn replay_authorized_request( } else { outcome.connection.relays.clone() }; - if let Err(error) = MycNostrTransport::publish_once( + let publish_outcome = match MycNostrTransport::publish_once( runtime.signer_identity(), &publish_relays, transport.connect_timeout_secs(), @@ -229,13 +257,36 @@ async fn replay_authorized_request( ) .await { - return Err(restore_pending_auth_challenge_on_error( - runtime, - &outcome.connection.connection_id, - pending_request, - error, - )); - } + Ok(publish_outcome) => publish_outcome, + Err(error) => { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayPublish, + MycOperationAuditOutcome::Rejected, + Some(&outcome.connection.connection_id), + Some(pending_request.request_message.id.as_str()), + publish_relays.len(), + error + .publish_rejection_counts() + .map(|(_, acknowledged)| acknowledged) + .unwrap_or_default(), + publish_failure_summary(&error), + )); + return Err(restore_pending_auth_challenge_on_error( + runtime, + &outcome.connection.connection_id, + pending_request, + error, + )); + } + }; + record_publish_audit( + runtime, + MycOperationAuditKind::AuthReplayPublish, + MycOperationAuditOutcome::Succeeded, + Some(&outcome.connection.connection_id), + Some(pending_request.request_message.id.as_str()), + &publish_outcome, + ); if let Some(connection_id) = consume_connect_secret_for { runtime .signer_manager()? @@ -250,18 +301,63 @@ fn restore_pending_auth_challenge_on_error( pending_request: RadrootsNostrSignerPendingRequest, error: MycError, ) -> MycError { + let summary = publish_failure_summary(&error); + let request_id = pending_request.request_message.id.clone(); match runtime.signer_manager().and_then(|manager| { manager - .restore_pending_auth_challenge(connection_id, pending_request) + .restore_pending_auth_challenge(connection_id, pending_request.clone()) .map_err(Into::into) }) { - Ok(_) => error, + Ok(_) => { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayRestore, + MycOperationAuditOutcome::Restored, + Some(connection_id), + Some(request_id.as_str()), + error + .publish_rejection_counts() + .map(|(relay_count, _)| relay_count) + .unwrap_or_default(), + error + .publish_rejection_counts() + .map(|(_, acknowledged)| acknowledged) + .unwrap_or_default(), + format!("restored pending auth challenge after replay failure: {summary}"), + )); + error + } Err(restore_error) => MycError::InvalidOperation(format!( "{error}; additionally failed to restore pending auth challenge: {restore_error}" )), } } +fn record_publish_audit( + runtime: &MycRuntime, + operation: MycOperationAuditKind, + outcome: MycOperationAuditOutcome, + connection_id: Option<&RadrootsNostrSignerConnectionId>, + request_id: Option<&str>, + publish_outcome: &MycPublishOutcome, +) { + runtime.record_operation_audit(&MycOperationAuditRecord::new( + operation, + outcome, + connection_id, + request_id, + publish_outcome.relay_count, + publish_outcome.acknowledged_relay_count, + publish_outcome.relay_outcome_summary.clone(), + )); +} + +fn publish_failure_summary(error: &MycError) -> String { + error + .publish_rejection_details() + .map(ToOwned::to_owned) + .unwrap_or_else(|| error.to_string()) +} + fn merge_relays( primary: &[nostr::RelayUrl], secondary: &[nostr::RelayUrl], diff --git a/src/transport.rs b/src/transport.rs @@ -26,6 +26,13 @@ pub struct MycTransportSnapshot { pub connect_timeout_secs: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MycPublishOutcome { + pub relay_count: usize, + pub acknowledged_relay_count: usize, + pub relay_outcome_summary: String, +} + impl MycNostrTransport { pub fn bootstrap( config: &MycTransportConfig, @@ -70,7 +77,7 @@ impl MycNostrTransport { relays: &[RadrootsNostrRelayUrl], connect_timeout_secs: u64, event: RadrootsNostrEventBuilder, - ) -> Result<(), MycError> { + ) -> Result<MycPublishOutcome, MycError> { if relays.is_empty() { return Err(MycError::InvalidOperation( "cannot publish without at least one relay".to_owned(), @@ -86,8 +93,7 @@ impl MycNostrTransport { .wait_for_connection(Duration::from_secs(connect_timeout_secs)) .await; let output = client.send_event_builder(event).await?; - let _ = ensure_publish_confirmed(output, "one-shot Nostr publish")?; - Ok(()) + ensure_publish_confirmed(output, "one-shot Nostr publish") } pub fn snapshot(&self) -> MycTransportSnapshot { @@ -102,29 +108,53 @@ impl MycNostrTransport { pub(crate) fn ensure_publish_confirmed<T>( output: RadrootsNostrOutput<T>, operation: &str, -) -> Result<RadrootsNostrOutput<T>, MycError> +) -> Result<MycPublishOutcome, MycError> where T: std::fmt::Debug, { + let relay_count = output.success.len() + output.failed.len(); + let acknowledged_relay_count = output.success.len(); + let relay_outcome_summary = summarize_publish_output(&output); + if !output.success.is_empty() { - return Ok(output); + return Ok(MycPublishOutcome { + relay_count, + acknowledged_relay_count, + relay_outcome_summary, + }); } - let details = if output.failed.is_empty() { - "no relay acknowledged the publish".to_owned() - } else { - output + Err(MycError::PublishRejected { + operation: operation.to_owned(), + relay_count, + acknowledged_relay_count, + details: relay_outcome_summary, + }) +} + +fn summarize_publish_output<T>(output: &RadrootsNostrOutput<T>) -> String +where + T: std::fmt::Debug, +{ + let relay_count = output.success.len() + output.failed.len(); + let acknowledged_relay_count = output.success.len(); + if relay_count == 0 { + return "no relay acknowledged the publish".to_owned(); + } + + let mut summary = + format!("{acknowledged_relay_count}/{relay_count} relays acknowledged publish"); + if !output.failed.is_empty() { + let failures = output .failed .iter() .map(|(relay, error)| format!("{relay}: {error}")) .collect::<Vec<_>>() - .join("; ") - }; - - Err(MycError::PublishRejected { - operation: operation.to_owned(), - details, - }) + .join("; "); + summary.push_str("; failures: "); + summary.push_str(&failures); + } + summary } impl MycTransportSnapshot { diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -20,6 +20,7 @@ use radroots_nostr_signer::prelude::{ use tokio::sync::broadcast; use crate::app::MycSignerContext; +use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::error::MycError; use crate::transport::{MycNostrTransport, ensure_publish_confirmed}; @@ -38,6 +39,7 @@ pub struct MycNip46Service { pub(crate) enum MycNip46HandledRequest { Respond { response: RadrootsNostrConnectResponse, + connection_id: Option<RadrootsNostrSignerConnectionId>, consume_connect_secret_for: Option<RadrootsNostrSignerConnectionId>, }, Ignore, @@ -203,20 +205,28 @@ impl MycNip46Handler { let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => Ok( - MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { - result: None, - error: reason, - }), - ), + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond( + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), )) } RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { - response_from_hint(&evaluation.connection, response_hint) - .map(MycNip46HandledRequest::respond) + response_from_hint(&evaluation.connection, response_hint).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(evaluation.connection.connection_id.clone()), + response, + ) + }) } } } @@ -236,20 +246,29 @@ impl MycNip46Handler { let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => Ok( - MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { - result: None, - error: reason, - }), - ), + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond( + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), )) } - RadrootsNostrSignerRequestAction::Allowed { .. } => self - .sign_event_response(unsigned_event) - .map(MycNip46HandledRequest::respond), + RadrootsNostrSignerRequestAction::Allowed { .. } => { + self.sign_event_response(unsigned_event).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + response, + ) + }) + } } } @@ -268,20 +287,29 @@ impl MycNip46Handler { let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => Ok( - MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { - result: None, - error: reason, - }), - ), + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond( + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), )) } - RadrootsNostrSignerRequestAction::Allowed { .. } => self - .crypto_response(request) - .map(MycNip46HandledRequest::respond), + RadrootsNostrSignerRequestAction::Allowed { .. } => { + self.crypto_response(request).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + response, + ) + }) + } } } @@ -454,7 +482,8 @@ impl MycNip46Service { }) } }; - let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts() + let Some((response, connection_id, consume_connect_secret_for)) = + handled_request.into_publish_parts() else { tracing::debug!( request_id = %request_id, @@ -466,7 +495,7 @@ impl MycNip46Service { let response_event = self.handler - .build_response_event(event.pubkey, request_id, response)?; + .build_response_event(event.pubkey, request_id.as_str(), response)?; let publish_output = match self .transport .client() @@ -475,13 +504,42 @@ impl MycNip46Service { { Ok(output) => output, Err(error) => { - tracing::warn!(error = %error, "failed to publish NIP-46 response"); + self.handler + .signer + .record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Rejected, + connection_id.as_ref(), + Some(request_id.as_str()), + self.transport.relays().len(), + 0, + error.to_string(), + )); continue; } }; if let Err(error) = ensure_publish_confirmed(publish_output, "NIP-46 response publish") { - tracing::warn!(error = %error, "failed to publish NIP-46 response"); + self.handler + .signer + .record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Rejected, + connection_id.as_ref(), + Some(request_id.as_str()), + error + .publish_rejection_counts() + .map(|(relay_count, _)| relay_count) + .unwrap_or(self.transport.relays().len()), + error + .publish_rejection_counts() + .map(|(_, acknowledged)| acknowledged) + .unwrap_or_default(), + error + .publish_rejection_details() + .map(ToOwned::to_owned) + .unwrap_or_else(|| error.to_string()), + )); continue; } if let Some(connection_id) = consume_connect_secret_for { @@ -504,8 +562,16 @@ impl MycNip46Service { impl MycNip46HandledRequest { fn respond(response: RadrootsNostrConnectResponse) -> Self { + Self::respond_for_connection(None, response) + } + + fn respond_for_connection( + connection_id: Option<RadrootsNostrSignerConnectionId>, + response: RadrootsNostrConnectResponse, + ) -> Self { Self::Respond { response, + connection_id, consume_connect_secret_for: None, } } @@ -515,12 +581,14 @@ impl MycNip46HandledRequest { ) -> Option<( RadrootsNostrConnectResponse, Option<RadrootsNostrSignerConnectionId>, + Option<RadrootsNostrSignerConnectionId>, )> { match self { Self::Respond { response, + connection_id, consume_connect_secret_for, - } => Some((response, consume_connect_secret_for)), + } => Some((response, connection_id, consume_connect_secret_for)), Self::Ignore => None, } } @@ -540,6 +608,7 @@ fn connect_response_outcome( let consume_connect_secret_for = secret.as_ref().map(|_| connection.connection_id.clone()); MycNip46HandledRequest::Respond { response: connect_response(secret), + connection_id: Some(connection.connection_id.clone()), consume_connect_secret_for, } }