lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 240d6b2a8dcee40383d00a755f25b53cf297f335
parent a2a4161f68fd772450558427f06d9516dc0482a1
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 09:14:57 +0000

nostr_signer: close semantic NIP-46 coverage

- Reject auth replay audit replacements with changed connection or method identity.
- Propagate policy-denial audit persistence failures through the NIP-46 handler.
- Construct the static p-tag filter without an impossible runtime parser branch.
- Preserve public result facades while making infallible response construction explicit.

Diffstat:
Mcrates/nostr_signer/src/manager.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_signer/src/nip46.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 139 insertions(+), 17 deletions(-)

diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -1451,6 +1451,44 @@ mod tests { } #[test] + fn auth_replay_audit_replacement_rejects_identity_mismatches() { + let audit = |connection_id: &str, method: RadrootsNostrConnectMethod| { + RadrootsNostrSignerRequestAuditRecord::new( + RadrootsNostrSignerRequestId::parse("req-auth-replay").expect("request id"), + RadrootsNostrSignerConnectionId::parse(connection_id).expect("connection id"), + method, + RadrootsNostrSignerRequestDecision::Allowed, + None, + 1, + ) + }; + let mut state = RadrootsNostrSignerStoreState::default(); + replace_or_insert_auth_replay_audit( + &mut state, + audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping), + ) + .expect("insert audit"); + replace_or_insert_auth_replay_audit( + &mut state, + audit("conn-auth-replay", RadrootsNostrConnectMethod::Ping), + ) + .expect("replace matching audit"); + + for replacement in [ + audit("conn-other", RadrootsNostrConnectMethod::Ping), + audit("conn-auth-replay", RadrootsNostrConnectMethod::Logout), + ] { + let error = replace_or_insert_auth_replay_audit(&mut state, replacement) + .expect_err("reject mismatched audit"); + assert!( + error + .to_string() + .contains("auth replay audit does not match the original request") + ); + } + } + + #[test] fn manager_new_in_memory_and_invalid_schema_paths() { let manager = RadrootsNostrSignerManager::new_in_memory(); assert!( diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -1,9 +1,11 @@ -use nostr::UnsignedEvent; +use nostr::{ + UnsignedEvent, + filter::{Alphabet, SingleLetterTag}, +}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp, - radroots_nostr_filter_tag, }; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectError, RadrootsNostrConnectPermissions, @@ -151,11 +153,10 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { let filter = RadrootsNostrFilter::new() .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) .since(RadrootsNostrTimestamp::now()); - Ok(radroots_nostr_filter_tag( - filter, - "p", + Ok(filter.custom_tags( + SingleLetterTag::lowercase(Alphabet::P), vec![self.signer.signer_public_key_hex()], - )?) + )) } pub fn parse_request_event( @@ -187,20 +188,27 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, unsigned_event: UnsignedEvent, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> { + Ok(self.sign_event_response_value(unsigned_event)) + } + + fn sign_event_response_value( + &self, + unsigned_event: UnsignedEvent, + ) -> RadrootsNostrConnectResponse { let user_public_key = self.signer.user_identity().public_key_hex; if unsigned_event.pubkey.to_hex() != user_public_key { - return Ok(RadrootsNostrConnectResponse::Error { + return RadrootsNostrConnectResponse::Error { result: None, error: "sign_event pubkey does not match the managed user identity".to_owned(), - }); + }; } match self.signer.sign_user_event(unsigned_event) { - Ok(event) => Ok(RadrootsNostrConnectResponse::SignedEvent(event)), - Err(error) => Ok(RadrootsNostrConnectResponse::Error { + Ok(event) => RadrootsNostrConnectResponse::SignedEvent(event), + Err(error) => RadrootsNostrConnectResponse::Error { result: None, error: format!("failed to sign event: {error}"), - }), + }, } } @@ -208,7 +216,14 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, request: RadrootsNostrConnectRequest, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> { - Ok(match request { + Ok(self.crypto_response_value(request)) + } + + fn crypto_response_value( + &self, + request: RadrootsNostrConnectRequest, + ) -> RadrootsNostrConnectResponse { + match request { RadrootsNostrConnectRequest::Nip04Encrypt { public_key, plaintext, @@ -253,7 +268,7 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { result: None, error: format!("request `{}` is not a crypto method", other.method()), }, - }) + } } } @@ -511,7 +526,7 @@ where self.handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.sign_event_response(unsigned_event), + || Ok(self.codec.sign_event_response_value(unsigned_event)), )?, Some(evaluation.audit), )) @@ -551,7 +566,7 @@ where self.handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.crypto_response(request), + || Ok(self.codec.crypto_response_value(request)), )?, Some(evaluation.audit), )) @@ -569,7 +584,7 @@ where .handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.sign_event_response(unsigned_event), + || Ok(self.codec.sign_event_response_value(unsigned_event)), ), RadrootsNostrConnectRequest::Nip04Encrypt { .. } | RadrootsNostrConnectRequest::Nip04Decrypt { .. } @@ -578,7 +593,7 @@ where .handled_request_for_authorized_action( &evaluation.connection, evaluation.action, - || self.codec.crypto_response(request_message.request), + || Ok(self.codec.crypto_response_value(request_message.request)), ), RadrootsNostrConnectRequest::GetPublicKey | RadrootsNostrConnectRequest::GetSessionCapability @@ -818,11 +833,14 @@ mod tests { use crate::evaluation::{ RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint, }; + use crate::manager::RadrootsNostrSignerManager; use crate::model::{ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerStoreState, }; + use crate::store::RadrootsNostrSignerStore; use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay}; use nostr::{Keys, Timestamp, UnsignedEvent}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; @@ -836,6 +854,10 @@ mod tests { RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; + use std::sync::{ + Arc, RwLock, + atomic::{AtomicBool, Ordering}, + }; #[derive(Clone)] struct TestSigner { @@ -853,6 +875,36 @@ mod tests { prepare_denial: Option<&'static str>, } + #[derive(Clone, Default)] + struct ToggleSaveStore { + state: Arc<RwLock<RadrootsNostrSignerStoreState>>, + fail_saves: Arc<AtomicBool>, + } + + impl RadrootsNostrSignerStore for ToggleSaveStore { + fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> { + self.state + .read() + .map(|state| state.clone()) + .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) + } + + fn save( + &self, + state: &RadrootsNostrSignerStoreState, + ) -> Result<(), RadrootsNostrSignerError> { + if self.fail_saves.load(Ordering::SeqCst) { + return Err(RadrootsNostrSignerError::Store( + "test store save failure".into(), + )); + } + self.state + .write() + .map(|mut stored| *stored = state.clone()) + .map_err(|_| RadrootsNostrSignerError::Store("test store lock poisoned".into())) + } + } + impl Default for TestPolicy { fn default() -> Self { Self { @@ -1604,6 +1656,38 @@ mod tests { } #[test] + fn policy_denial_propagates_audit_persistence_failures() { + let store = ToggleSaveStore::default(); + let manager = RadrootsNostrSignerManager::new(Arc::new(store.clone())) + .expect("manager with toggle store"); + let backend = + RadrootsNostrEmbeddedSignerBackend::new(manager, test_signer().signer_identity.clone()) + .expect("embedded backend"); + let client_public_key = fixture_carol_public_key(); + connect_with_permissions( + &handler_with_backend(backend.clone()), + client_public_key, + Vec::new(), + ); + store.fail_saves.store(true, Ordering::SeqCst); + + let handler = handler_with_policy( + backend, + TestPolicy { + prepare_denial: Some("policy blocked"), + ..TestPolicy::default() + }, + ); + let error = handler + .handle_request( + client_public_key, + request_message("req-audit-save", RadrootsNostrConnectRequest::Ping), + ) + .expect_err("audit persistence failure"); + assert!(error.to_string().contains("test store save failure")); + } + + #[test] fn handler_rejects_unauthorized_base_sign_and_crypto_requests() { let handler = handler_with_backend(embedded_backend()); let client_public_key = fixture_carol_public_key();