myc

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

commit 95873b5a8ddd5bb643ce1c4b7cf5f14a020f5554
parent ba89cdabd338b76ff9da0f1550df2a71b107ac2e
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 02:23:52 +0000

audit: add bounded runtime audit retention

- add audit config for default read limits, active file size, and archive retention
- rotate operation audit JSONL files into numbered archives when the active file exceeds its bound
- read recent operation audit records from newest files first while preserving chronological output
- validate config and cover rotation, retention, and bounded reads with unit tests

Diffstat:
Msrc/app/runtime.rs | 8++++++--
Msrc/audit.rs | 282++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/config.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 4++--
4 files changed, 317 insertions(+), 29 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -3,7 +3,7 @@ use std::future::Future; use std::path::{Path, PathBuf}; use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore}; -use crate::config::MycConfig; +use crate::config::{MycAuditConfig, MycConfig}; use crate::error::MycError; use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; @@ -44,6 +44,7 @@ pub struct MycSignerContext { user_identity: RadrootsIdentity, signer_state_path: PathBuf, audit_dir: PathBuf, + audit_config: MycAuditConfig, connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, } @@ -63,6 +64,7 @@ impl MycRuntime { Self::prepare_filesystem_for(&paths)?; let signer = MycSignerContext::bootstrap( &paths, + config.audit.clone(), config .policy .connection_approval @@ -227,7 +229,7 @@ impl MycSignerContext { } pub fn operation_audit_store(&self) -> MycOperationAuditStore { - MycOperationAuditStore::new(&self.audit_dir) + MycOperationAuditStore::new(&self.audit_dir, self.audit_config.clone()) } pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) { @@ -253,6 +255,7 @@ impl MycSignerContext { fn bootstrap( paths: &MycRuntimePaths, + audit_config: MycAuditConfig, connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, ) -> Result<Self, MycError> { let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; @@ -278,6 +281,7 @@ impl MycSignerContext { user_identity, signer_state_path: paths.signer_state_path.clone(), audit_dir: paths.audit_dir.clone(), + audit_config, connection_approval_requirement, }) } diff --git a/src/audit.rs b/src/audit.rs @@ -6,9 +6,12 @@ use std::time::{SystemTime, UNIX_EPOCH}; use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; use serde::{Deserialize, Serialize}; +use crate::config::MycAuditConfig; use crate::error::MycError; const MYC_OPERATION_AUDIT_FILE_NAME: &str = "operations.jsonl"; +const MYC_OPERATION_AUDIT_ARCHIVE_PREFIX: &str = "operations."; +const MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX: &str = ".jsonl"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -43,7 +46,8 @@ pub struct MycOperationAuditRecord { #[derive(Debug, Clone)] pub struct MycOperationAuditStore { - path: PathBuf, + audit_dir: PathBuf, + config: MycAuditConfig, } impl MycOperationAuditRecord { @@ -70,57 +74,116 @@ impl MycOperationAuditRecord { } impl MycOperationAuditStore { - pub fn new(audit_dir: impl AsRef<Path>) -> Self { + pub fn new(audit_dir: impl AsRef<Path>, config: MycAuditConfig) -> Self { Self { - path: audit_dir.as_ref().join(MYC_OPERATION_AUDIT_FILE_NAME), + audit_dir: audit_dir.as_ref().to_path_buf(), + config, } } - pub fn path(&self) -> &Path { - self.path.as_path() + pub fn path(&self) -> PathBuf { + self.active_path() + } + + pub fn config(&self) -> &MycAuditConfig { + &self.config } pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> { + let active_path = self.active_path(); + let encoded = serde_json::to_vec(record).map_err(|source| MycError::AuditSerialize { + path: active_path.clone(), + source, + })?; + self.rotate_if_needed(encoded.len() as u64 + 1)?; + let mut file = OpenOptions::new() .create(true) .append(true) - .open(&self.path) + .open(&active_path) .map_err(|source| MycError::AuditIo { - path: self.path.clone(), + path: active_path.clone(), + source, + })?; + file.write_all(&encoded) + .map_err(|source| MycError::AuditIo { + path: active_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(), + path: active_path, source, })?; Ok(()) } pub fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> { - self.list_matching(|_| true) + self.list_with_limit(self.config.default_read_limit) + } + + pub fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_matching(limit, |_| 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())) + self.list_for_connection_with_limit(connection_id, self.config.default_read_limit) } - fn list_matching<F>(&self, predicate: F) -> Result<Vec<MycOperationAuditRecord>, MycError> + pub fn list_for_connection_with_limit( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + limit: usize, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + self.list_matching(limit, |record| { + record.connection_id.as_deref() == Some(connection_id.as_str()) + }) + } + + fn list_matching<F>( + &self, + limit: usize, + predicate: F, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> where F: Fn(&MycOperationAuditRecord) -> bool, { - if !self.path.exists() { + if limit == 0 { + return Ok(Vec::new()); + } + + let mut newest_records = Vec::new(); + for path in self.read_paths_newest_first()? { + let mut file_records = self.read_records_from_path(&path)?; + file_records.reverse(); + + for record in file_records { + if predicate(&record) { + newest_records.push(record); + if newest_records.len() == limit { + newest_records.reverse(); + return Ok(newest_records); + } + } + } + } + + newest_records.reverse(); + Ok(newest_records) + } + + fn read_records_from_path( + &self, + path: &Path, + ) -> Result<Vec<MycOperationAuditRecord>, MycError> { + if !path.exists() { return Ok(Vec::new()); } - let file = fs::File::open(&self.path).map_err(|source| MycError::AuditIo { - path: self.path.clone(), + let file = fs::File::open(path).map_err(|source| MycError::AuditIo { + path: path.to_path_buf(), source, })?; let reader = BufReader::new(file); @@ -128,27 +191,151 @@ impl MycOperationAuditStore { for (line_number, line) in reader.lines().enumerate() { let line = line.map_err(|source| MycError::AuditIo { - path: self.path.clone(), + path: path.to_path_buf(), source, })?; if line.trim().is_empty() { continue; } + let record = serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| { MycError::AuditParse { - path: self.path.clone(), + path: path.to_path_buf(), line_number: line_number + 1, source, } })?; - if predicate(&record) { - records.push(record); - } + records.push(record); } Ok(records) } + + fn rotate_if_needed(&self, additional_bytes: u64) -> Result<(), MycError> { + let active_path = self.active_path(); + let current_len = match fs::metadata(&active_path) { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, + Err(source) => { + return Err(MycError::AuditIo { + path: active_path, + source, + }); + } + }; + + if current_len == 0 + || current_len.saturating_add(additional_bytes) <= self.config.max_active_file_bytes + { + return Ok(()); + } + + self.rotate_active_file() + } + + fn rotate_active_file(&self) -> Result<(), MycError> { + for index in (1..=self.config.max_archived_files).rev() { + let archived_path = self.archive_path(index); + if !archived_path.exists() { + continue; + } + + if index == self.config.max_archived_files { + fs::remove_file(&archived_path).map_err(|source| MycError::AuditIo { + path: archived_path, + source, + })?; + } else { + let next_path = self.archive_path(index + 1); + fs::rename(&archived_path, &next_path).map_err(|source| MycError::AuditIo { + path: archived_path, + source, + })?; + } + } + + let active_path = self.active_path(); + if !active_path.exists() { + return Ok(()); + } + + if self.config.max_archived_files == 0 { + fs::remove_file(&active_path).map_err(|source| MycError::AuditIo { + path: active_path, + source, + })?; + return Ok(()); + } + + let first_archive = self.archive_path(1); + fs::rename(&active_path, &first_archive).map_err(|source| MycError::AuditIo { + path: active_path, + source, + })?; + Ok(()) + } + + fn read_paths_newest_first(&self) -> Result<Vec<PathBuf>, MycError> { + let mut paths = Vec::new(); + let active_path = self.active_path(); + if active_path.exists() { + paths.push(active_path); + } + + let mut archived = self.archived_paths()?; + archived.sort_by_key(|(_, index)| *index); + for (path, _) in archived { + paths.push(path); + } + + Ok(paths) + } + + fn archived_paths(&self) -> Result<Vec<(PathBuf, usize)>, MycError> { + let mut archived = Vec::new(); + if !self.audit_dir.exists() { + return Ok(archived); + } + + for entry in fs::read_dir(&self.audit_dir).map_err(|source| MycError::AuditIo { + path: self.audit_dir.clone(), + source, + })? { + let entry = entry.map_err(|source| MycError::AuditIo { + path: self.audit_dir.clone(), + source, + })?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let Some(index) = parse_archive_index(file_name) else { + continue; + }; + archived.push((entry.path(), index)); + } + + Ok(archived) + } + + fn active_path(&self) -> PathBuf { + self.audit_dir.join(MYC_OPERATION_AUDIT_FILE_NAME) + } + + fn archive_path(&self, index: usize) -> PathBuf { + self.audit_dir.join(format!( + "{MYC_OPERATION_AUDIT_ARCHIVE_PREFIX}{index}{MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX}" + )) + } +} + +fn parse_archive_index(file_name: &str) -> Option<usize> { + file_name + .strip_prefix(MYC_OPERATION_AUDIT_ARCHIVE_PREFIX)? + .strip_suffix(MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX)? + .parse() + .ok() } fn now_unix_secs() -> u64 { @@ -162,15 +349,25 @@ fn now_unix_secs() -> u64 { mod tests { use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId; + use crate::config::MycAuditConfig; + use super::{ MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycOperationAuditStore, }; + fn config() -> MycAuditConfig { + MycAuditConfig { + default_read_limit: 10, + max_active_file_bytes: 512, + max_archived_files: 2, + } + } + #[test] fn append_and_list_operation_audit_records() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path()); + let store = MycOperationAuditStore::new(temp.path(), config()); let connection_id = RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id"); @@ -218,8 +415,43 @@ mod tests { #[test] fn list_returns_empty_when_audit_file_is_missing() { let temp = tempfile::tempdir().expect("tempdir"); - let store = MycOperationAuditStore::new(temp.path()); + let store = MycOperationAuditStore::new(temp.path(), config()); assert!(store.list().expect("list missing records").is_empty()); } + + #[test] + fn rotation_and_bounded_reads_keep_recent_records() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = MycOperationAuditStore::new( + temp.path(), + MycAuditConfig { + default_read_limit: 3, + max_active_file_bytes: 180, + max_archived_files: 2, + }, + ); + + for index in 0..6 { + store + .append(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Rejected, + None, + Some(&format!("request-{index}")), + 1, + 0, + format!("failure-{index}"), + )) + .expect("append record"); + } + + let records = store.list().expect("list bounded records"); + assert_eq!(records.len(), 3); + assert_eq!(records[0].request_id.as_deref(), Some("request-3")); + assert_eq!(records[2].request_id.as_deref(), Some("request-5")); + assert!(temp.path().join("operations.1.jsonl").exists()); + assert!(temp.path().join("operations.2.jsonl").exists()); + assert!(!temp.path().join("operations.3.jsonl").exists()); + } } diff --git a/src/config.rs b/src/config.rs @@ -17,6 +17,7 @@ pub struct MycConfig { pub service: MycServiceConfig, pub logging: MycLoggingConfig, pub paths: MycPathsConfig, + pub audit: MycAuditConfig, pub policy: MycPolicyConfig, pub transport: MycTransportConfig, } @@ -43,6 +44,14 @@ pub struct MycPathsConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] +pub struct MycAuditConfig { + pub default_read_limit: usize, + pub max_active_file_bytes: u64, + pub max_archived_files: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct MycTransportConfig { pub enabled: bool, pub connect_timeout_secs: u64, @@ -68,6 +77,7 @@ impl Default for MycConfig { service: MycServiceConfig::default(), logging: MycLoggingConfig::default(), paths: MycPathsConfig::default(), + audit: MycAuditConfig::default(), policy: MycPolicyConfig::default(), transport: MycTransportConfig::default(), } @@ -110,6 +120,16 @@ impl Default for MycTransportConfig { } } +impl Default for MycAuditConfig { + fn default() -> Self { + Self { + default_read_limit: 200, + max_active_file_bytes: 262_144, + max_archived_files: 8, + } + } +} + impl Default for MycPolicyConfig { fn default() -> Self { Self { @@ -194,6 +214,18 @@ impl MycConfig { )); } + if self.audit.default_read_limit == 0 { + return Err(MycError::InvalidConfig( + "audit.default_read_limit must be greater than zero".to_owned(), + )); + } + + if self.audit.max_active_file_bytes == 0 { + return Err(MycError::InvalidConfig( + "audit.max_active_file_bytes must be greater than zero".to_owned(), + )); + } + if self.transport.connect_timeout_secs == 0 { return Err(MycError::InvalidConfig( "transport.connect_timeout_secs must be greater than zero".to_owned(), @@ -257,6 +289,9 @@ mod tests { config.policy.connection_approval, MycConnectionApproval::ExplicitUser ); + assert_eq!(config.audit.default_read_limit, 200); + assert_eq!(config.audit.max_active_file_bytes, 262_144); + assert_eq!(config.audit.max_archived_files, 8); assert!(!config.transport.enabled); assert_eq!(config.transport.connect_timeout_secs, 10); assert!(config.transport.relays.is_empty()); @@ -277,6 +312,11 @@ mod tests { signer_identity_path = "/tmp/myc-identity.json" user_identity_path = "/tmp/myc-user.json" + [audit] + default_read_limit = 50 + max_active_file_bytes = 4096 + max_archived_files = 3 + [policy] connection_approval = "not_required" @@ -299,6 +339,9 @@ mod tests { config.paths.user_identity_path, PathBuf::from("/tmp/myc-user.json") ); + assert_eq!(config.audit.default_read_limit, 50); + assert_eq!(config.audit.max_active_file_bytes, 4096); + assert_eq!(config.audit.max_archived_files, 3); assert_eq!( config.policy.connection_approval, MycConnectionApproval::NotRequired @@ -345,4 +388,13 @@ mod tests { let err = config.validate().expect_err("missing relays"); assert!(err.to_string().contains("transport.relays")); } + + #[test] + fn validate_rejects_zero_audit_read_limit() { + let mut config = MycConfig::default(); + config.audit.default_read_limit = 0; + + let err = config.validate().expect_err("invalid audit read limit"); + assert!(err.to_string().contains("audit.default_read_limit")); + } } diff --git a/src/lib.rs b/src/lib.rs @@ -15,8 +15,8 @@ pub use audit::{ MycOperationAuditStore, }; pub use config::{ - DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig, - MycPolicyConfig, MycServiceConfig, MycTransportConfig, + DEFAULT_CONFIG_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycLoggingConfig, + MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use error::MycError;