myc

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

commit fb8bf4344e8a1d7395182dc1f97e2d21a82440f7
parent 67408a968d91d5ebc38540fe3ebdbd199291b790
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 01:55:47 +0000

audit: add runtime operation audit store

- add a repo-local JSONL operation audit model under audit_dir
- persist typed operation records with connection, request, relay, and outcome fields
- expose runtime helpers for writing and reading operation audit records
- cover append and query behavior with unit tests

Diffstat:
Msrc/app/runtime.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/audit.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/error.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 5+++++
4 files changed, 333 insertions(+), 1 deletion(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -2,6 +2,7 @@ use std::fs; use std::future::Future; use std::path::{Path, PathBuf}; +use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore}; use crate::config::MycConfig; use crate::error::MycError; use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot}; @@ -42,6 +43,7 @@ pub struct MycSignerContext { signer_identity: RadrootsIdentity, user_identity: RadrootsIdentity, signer_state_path: PathBuf, + audit_dir: PathBuf, connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, } @@ -108,6 +110,14 @@ impl MycRuntime { self.transport.as_ref() } + pub fn operation_audit_store(&self) -> MycOperationAuditStore { + self.signer.operation_audit_store() + } + + pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { + self.signer.record_operation_audit(record); + } + pub(crate) fn signer_context(&self) -> MycSignerContext { self.signer.clone() } @@ -216,6 +226,27 @@ impl MycSignerContext { Self::load_signer_manager_from_path(&self.signer_state_path) } + pub fn operation_audit_store(&self) -> MycOperationAuditStore { + MycOperationAuditStore::new(&self.audit_dir) + } + + pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { + emit_operation_audit_trace(record); + if let Err(error) = self.operation_audit_store().append(record) { + tracing::error!( + operation = ?record.operation, + outcome = ?record.outcome, + connection_id = record.connection_id.as_deref().unwrap_or(""), + request_id = record.request_id.as_deref().unwrap_or(""), + relay_count = record.relay_count, + acknowledged_relay_count = record.acknowledged_relay_count, + relay_outcome_summary = %record.relay_outcome_summary, + error = %error, + "failed to persist myc operation audit record" + ); + } + } + pub fn connection_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { self.connection_approval_requirement } @@ -246,6 +277,7 @@ impl MycSignerContext { signer_identity, user_identity, signer_state_path: paths.signer_state_path.clone(), + audit_dir: paths.audit_dir.clone(), connection_approval_requirement, }) } @@ -257,6 +289,32 @@ impl MycSignerContext { } } +fn emit_operation_audit_trace(record: &MycOperationAuditRecord) { + match record.outcome { + crate::audit::MycOperationAuditOutcome::Succeeded => tracing::info!( + operation = ?record.operation, + outcome = ?record.outcome, + connection_id = record.connection_id.as_deref().unwrap_or(""), + request_id = record.request_id.as_deref().unwrap_or(""), + relay_count = record.relay_count, + acknowledged_relay_count = record.acknowledged_relay_count, + relay_outcome_summary = %record.relay_outcome_summary, + "recorded myc operation audit" + ), + crate::audit::MycOperationAuditOutcome::Rejected + | crate::audit::MycOperationAuditOutcome::Restored => tracing::warn!( + operation = ?record.operation, + outcome = ?record.outcome, + connection_id = record.connection_id.as_deref().unwrap_or(""), + request_id = record.request_id.as_deref().unwrap_or(""), + relay_count = record.relay_count, + acknowledged_relay_count = record.acknowledged_relay_count, + relay_outcome_summary = %record.relay_outcome_summary, + "recorded myc operation audit" + ), + } +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/src/audit.rs b/src/audit.rs @@ -0,0 +1,225 @@ +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; +use serde::{Deserialize, Serialize}; + +use crate::error::MycError; + +const MYC_OPERATION_AUDIT_FILE_NAME: &str = "operations.jsonl"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycOperationAuditKind { + ListenerResponsePublish, + ConnectAcceptPublish, + AuthReplayPublish, + AuthReplayRestore, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MycOperationAuditOutcome { + Succeeded, + Rejected, + Restored, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MycOperationAuditRecord { + pub recorded_at_unix: u64, + pub operation: MycOperationAuditKind, + pub outcome: MycOperationAuditOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connection_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option<String>, + pub relay_count: usize, + pub acknowledged_relay_count: usize, + pub relay_outcome_summary: String, +} + +#[derive(Debug, Clone)] +pub struct MycOperationAuditStore { + path: PathBuf, +} + +impl MycOperationAuditRecord { + pub fn new( + operation: MycOperationAuditKind, + outcome: MycOperationAuditOutcome, + connection_id: Option<&RadrootsNostrSignerConnectionId>, + request_id: Option<&str>, + relay_count: usize, + acknowledged_relay_count: usize, + relay_outcome_summary: impl Into<String>, + ) -> Self { + Self { + recorded_at_unix: now_unix_secs(), + operation, + outcome, + connection_id: connection_id.map(ToString::to_string), + request_id: request_id.map(ToOwned::to_owned), + relay_count, + acknowledged_relay_count, + relay_outcome_summary: relay_outcome_summary.into(), + } + } +} + +impl MycOperationAuditStore { + pub fn new(audit_dir: impl AsRef<Path>) -> Self { + Self { + path: audit_dir.as_ref().join(MYC_OPERATION_AUDIT_FILE_NAME), + } + } + + pub fn path(&self) -> &Path { + self.path.as_path() + } + + pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .map_err(|source| MycError::AuditIo { + path: self.path.clone(), + source, + })?; + serde_json::to_writer(&mut file, record).map_err(|source| MycError::AuditSerialize { + path: self.path.clone(), + source, + })?; + file.write_all(b"\n").map_err(|source| MycError::AuditIo { + path: self.path.clone(), + source, + })?; + Ok(()) + } + + pub fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_matching(|_| true) + } + + pub fn list_for_connection( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_matching(|record| record.connection_id.as_deref() == Some(connection_id.as_str())) + } + + fn list_matching<F>(&self, predicate: F) -> Result<Vec<MycOperationAuditRecord>, MycError> + where + F: Fn(&MycOperationAuditRecord) -> bool, + { + if !self.path.exists() { + return Ok(Vec::new()); + } + + let file = fs::File::open(&self.path).map_err(|source| MycError::AuditIo { + path: self.path.clone(), + source, + })?; + let reader = BufReader::new(file); + let mut records = Vec::new(); + + for (line_number, line) in reader.lines().enumerate() { + let line = line.map_err(|source| MycError::AuditIo { + path: self.path.clone(), + source, + })?; + if line.trim().is_empty() { + continue; + } + let record = + serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| { + MycError::AuditParse { + path: self.path.clone(), + line_number: line_number + 1, + source, + } + })?; + if predicate(&record) { + records.push(record); + } + } + + Ok(records) + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is before unix epoch") + .as_secs() +} + +#[cfg(test)] +mod tests { + use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; + + use super::{ + MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, + MycOperationAuditStore, + }; + + #[test] + fn append_and_list_operation_audit_records() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = MycOperationAuditStore::new(temp.path()); + let connection_id = + RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id"); + + store + .append(&MycOperationAuditRecord::new( + MycOperationAuditKind::ConnectAcceptPublish, + MycOperationAuditOutcome::Rejected, + Some(&connection_id), + Some("request-1"), + 2, + 0, + "0/2 relays acknowledged publish; failures: relay-a: rejected", + )) + .expect("append rejected record"); + store + .append(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayRestore, + MycOperationAuditOutcome::Restored, + Some(&connection_id), + Some("request-1"), + 0, + 0, + "restored pending auth challenge after replay publish rejection", + )) + .expect("append restored record"); + + let records = store.list().expect("list records"); + assert_eq!(records.len(), 2); + assert_eq!( + records[0].operation, + MycOperationAuditKind::ConnectAcceptPublish + ); + assert_eq!(records[0].outcome, MycOperationAuditOutcome::Rejected); + assert_eq!(records[0].connection_id.as_deref(), Some("connection-1")); + assert_eq!(records[0].request_id.as_deref(), Some("request-1")); + assert_eq!(records[0].relay_count, 2); + assert_eq!(records[0].acknowledged_relay_count, 0); + + let connection_records = store + .list_for_connection(&connection_id) + .expect("list connection records"); + assert_eq!(connection_records, records); + } + + #[test] + fn list_returns_empty_when_audit_file_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = MycOperationAuditStore::new(temp.path()); + + assert!(store.list().expect("list missing records").is_empty()); + } +} diff --git a/src/error.rs b/src/error.rs @@ -38,6 +38,25 @@ pub enum MycError { #[source] source: std::io::Error, }, + #[error("audit io error at {path}: {source}")] + AuditIo { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("audit parse error at {path}:{line_number}: {source}")] + AuditParse { + path: PathBuf, + line_number: usize, + #[source] + source: serde_json::Error, + }, + #[error("failed to serialize audit record at {path}: {source}")] + AuditSerialize { + path: PathBuf, + #[source] + source: serde_json::Error, + }, #[error(transparent)] Identity(#[from] IdentityError), #[error(transparent)] @@ -55,7 +74,12 @@ pub enum MycError { #[error("NIP-46 listener notifications closed")] Nip46ListenerClosed, #[error("Nostr publish failed for {operation}: {details}")] - PublishRejected { operation: String, details: String }, + PublishRejected { + operation: String, + relay_count: usize, + acknowledged_relay_count: usize, + details: String, + }, #[error( "configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}" )] @@ -66,3 +90,23 @@ pub enum MycError { persisted_identity_id: String, }, } + +impl MycError { + pub fn publish_rejection_details(&self) -> Option<&str> { + match self { + Self::PublishRejected { details, .. } => Some(details.as_str()), + _ => None, + } + } + + pub fn publish_rejection_counts(&self) -> Option<(usize, usize)> { + match self { + Self::PublishRejected { + relay_count, + acknowledged_relay_count, + .. + } => Some((*relay_count, *acknowledged_relay_count)), + _ => None, + } + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] pub mod app; +pub mod audit; pub mod cli; pub mod config; pub mod control; @@ -9,6 +10,10 @@ pub mod logging; pub mod transport; pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; +pub use audit::{ + MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, + MycOperationAuditStore, +}; pub use config::{ DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig,