lib

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

commit 0ac6ce72dd8075441d52fd0ba46490c12c80f748
parent 26aea6f8e5a5659778d6239994a88b21e5b5fabf
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 20:20:43 +0000

nostr-signer: add request evaluation apis

- add typed signer evaluation models for session lookup, connect handling, request actions, and response hints
- extend the signer manager with transport-neutral connect and request evaluation flows backed by effective permissions and audit outcomes
- cover invalid, challenged, denied, and poisoned-state paths with deterministic unit tests across the new evaluation surface
- validate the crate at 100 percent lines functions regions and branches under the rr-rs coverage policy

Diffstat:
Acrates/nostr-signer/src/evaluation.rs | 541+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr-signer/src/lib.rs | 6++++++
Mcrates/nostr-signer/src/manager.rs | 821++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr-signer/src/model.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1413 insertions(+), 2 deletions(-)

diff --git a/crates/nostr-signer/src/evaluation.rs b/crates/nostr-signer/src/evaluation.rs @@ -0,0 +1,541 @@ +use crate::error::RadrootsNostrSignerError; +use crate::model::{ + RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId, +}; +use nostr::{PublicKey, RelayUrl}; +use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + RadrootsNostrConnectRequest, +}; + +#[derive(Debug, Clone)] +pub enum RadrootsNostrSignerSessionLookup { + None, + Connection(RadrootsNostrSignerConnectionRecord), + Ambiguous(Vec<RadrootsNostrSignerConnectionRecord>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsNostrSignerConnectProposal { + pub client_public_key: PublicKey, + pub connect_secret: Option<String>, + pub requested_permissions: RadrootsNostrConnectPermissions, +} + +#[derive(Debug, Clone)] +pub enum RadrootsNostrSignerConnectEvaluation { + ExistingConnection(RadrootsNostrSignerConnectionRecord), + RegistrationRequired(RadrootsNostrSignerConnectProposal), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrSignerRequestResponseHint { + None, + Pong, + UserPublicKey(PublicKey), + RelayList(Vec<RelayUrl>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrSignerRequestAction { + Allowed { + required_permission: Option<RadrootsNostrConnectPermission>, + response_hint: RadrootsNostrSignerRequestResponseHint, + }, + Denied { + reason: String, + }, + Challenged { + auth_challenge: RadrootsNostrSignerAuthChallenge, + pending_request: RadrootsNostrSignerPendingRequest, + }, +} + +#[derive(Debug, Clone)] +pub struct RadrootsNostrSignerRequestEvaluation { + pub request_id: RadrootsNostrSignerRequestId, + pub method: RadrootsNostrConnectMethod, + pub connection: RadrootsNostrSignerConnectionRecord, + pub audit: RadrootsNostrSignerRequestAuditRecord, + pub action: RadrootsNostrSignerRequestAction, +} + +impl RadrootsNostrSignerConnectProposal { + pub fn into_connection_draft( + self, + user_identity: RadrootsIdentityPublic, + ) -> RadrootsNostrSignerConnectionDraft { + let mut draft = + RadrootsNostrSignerConnectionDraft::new(self.client_public_key, user_identity) + .with_requested_permissions(self.requested_permissions); + if let Some(connect_secret) = self.connect_secret { + draft = draft.with_connect_secret(connect_secret); + } + draft + } +} + +impl RadrootsNostrSignerRequestEvaluation { + pub fn denied_reason(&self) -> Option<&str> { + match &self.action { + RadrootsNostrSignerRequestAction::Denied { reason } => Some(reason.as_str()), + _ => None, + } + } +} + +impl RadrootsNostrSignerRequestAction { + pub fn audit_message(&self) -> Option<String> { + match self { + Self::Allowed { .. } => None, + Self::Denied { reason } => Some(reason.clone()), + Self::Challenged { .. } => Some("auth challenge required".into()), + } + } +} + +pub(crate) fn required_permission_for_request( + request: &RadrootsNostrConnectRequest, +) -> Option<RadrootsNostrConnectPermission> { + match request { + RadrootsNostrConnectRequest::Connect { .. } + | RadrootsNostrConnectRequest::GetPublicKey + | RadrootsNostrConnectRequest::Ping => None, + RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { + Some(RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + format!("kind:{}", unsigned_event.kind.as_u16()), + )) + } + RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + ), + RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt), + ), + RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), + ), + RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt), + ), + RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::SwitchRelays, + )), + RadrootsNostrConnectRequest::Custom { method, .. } => { + Some(RadrootsNostrConnectPermission::new(method.clone())) + } + } +} + +pub(crate) fn request_allowed_by_permissions( + granted_permissions: &RadrootsNostrConnectPermissions, + request: &RadrootsNostrConnectRequest, +) -> bool { + let Some(required_permission) = required_permission_for_request(request) else { + return true; + }; + + granted_permissions + .as_slice() + .iter() + .any(|permission| permission_matches(permission, &required_permission)) +} + +pub(crate) fn response_hint_for_request( + connection: &RadrootsNostrSignerConnectionRecord, + request: &RadrootsNostrConnectRequest, +) -> Result<RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerError> { + match request { + RadrootsNostrConnectRequest::GetPublicKey => { + Ok(RadrootsNostrSignerRequestResponseHint::UserPublicKey( + identity_public_key(&connection.user_identity)?, + )) + } + RadrootsNostrConnectRequest::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong), + RadrootsNostrConnectRequest::SwitchRelays => Ok( + RadrootsNostrSignerRequestResponseHint::RelayList(connection.relays.clone()), + ), + _ => Ok(RadrootsNostrSignerRequestResponseHint::None), + } +} + +fn permission_matches( + granted_permission: &RadrootsNostrConnectPermission, + required_permission: &RadrootsNostrConnectPermission, +) -> bool { + if granted_permission.method != required_permission.method { + return false; + } + + match ( + &granted_permission.method, + granted_permission.parameter.as_deref(), + required_permission.parameter.as_deref(), + ) { + (RadrootsNostrConnectMethod::SignEvent, None, _) => true, + (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(required)) => { + parameter == required || parameter == sign_event_kind_suffix(required) + } + (_, None, _) => true, + (_, Some(parameter), Some(required)) => parameter == required, + (_, Some(_), None) => false, + } +} + +fn sign_event_kind_suffix(value: &str) -> &str { + value.strip_prefix("kind:").unwrap_or(value) +} + +fn identity_public_key( + identity: &RadrootsIdentityPublic, +) -> Result<PublicKey, RadrootsNostrSignerError> { + PublicKey::parse(identity.public_key_hex.as_str()) + .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str())) + .map_err(|_| { + RadrootsNostrSignerError::InvalidState("user identity public key is invalid".into()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{Keys, SecretKey, Timestamp, UnsignedEvent}; + use radroots_identity::RadrootsIdentity; + use serde_json::json; + + fn public_identity(secret_hex: &str) -> RadrootsIdentityPublic { + RadrootsIdentity::from_secret_key_str(secret_hex) + .expect("identity") + .to_public() + } + + fn public_key(secret_hex: &str) -> PublicKey { + let secret = SecretKey::from_hex(secret_hex).expect("secret"); + Keys::new(secret).public_key() + } + + fn relay(url: &str) -> RelayUrl { + RelayUrl::parse(url).expect("relay") + } + + fn unsigned_event(kind: u16) -> UnsignedEvent { + serde_json::from_value(json!({ + "pubkey": public_key("0000000000000000000000000000000000000000000000000000000000000001").to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": kind, + "tags": [], + "content": "hello" + })) + .expect("unsigned event") + } + + fn connection() -> RadrootsNostrSignerConnectionRecord { + RadrootsNostrSignerConnectionRecord::new( + crate::model::RadrootsNostrSignerConnectionId::new_v7(), + public_identity("0000000000000000000000000000000000000000000000000000000000000002"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000003"), + public_identity("0000000000000000000000000000000000000000000000000000000000000004"), + ) + .with_relays(vec![relay("wss://relay.example")]), + 1, + ) + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn assert_action_audit_message_none(action: &RadrootsNostrSignerRequestAction) { + assert_eq!(action.audit_message(), None); + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn assert_response_hint_none(hint: RadrootsNostrSignerRequestResponseHint) { + match hint { + RadrootsNostrSignerRequestResponseHint::None => {} + other => panic!("unexpected response hint: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn assert_response_hint_pong(hint: RadrootsNostrSignerRequestResponseHint) { + match hint { + RadrootsNostrSignerRequestResponseHint::Pong => {} + other => panic!("unexpected response hint: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn assert_response_hint_user_public_key(hint: RadrootsNostrSignerRequestResponseHint) { + match hint { + RadrootsNostrSignerRequestResponseHint::UserPublicKey(_) => {} + other => panic!("unexpected response hint: {other:?}"), + } + } + + #[test] + fn connect_proposal_builds_connection_draft() { + let requested_permissions: RadrootsNostrConnectPermissions = + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Nip04Encrypt, + )] + .into(); + let proposal = RadrootsNostrSignerConnectProposal { + client_public_key: public_key( + "0000000000000000000000000000000000000000000000000000000000000005", + ), + connect_secret: Some("secret".into()), + requested_permissions: requested_permissions.clone(), + }; + + let draft = proposal.into_connection_draft(public_identity( + "0000000000000000000000000000000000000000000000000000000000000006", + )); + + assert_eq!(draft.connect_secret.as_deref(), Some("secret")); + assert_eq!(draft.requested_permissions, requested_permissions); + + let no_secret = RadrootsNostrSignerConnectProposal { + client_public_key: public_key( + "0000000000000000000000000000000000000000000000000000000000000007", + ), + connect_secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + } + .into_connection_draft(public_identity( + "0000000000000000000000000000000000000000000000000000000000000008", + )); + assert!(no_secret.connect_secret.is_none()); + } + + #[test] + fn request_action_audit_message_and_denied_reason_cover_variants() { + let denied = RadrootsNostrSignerRequestAction::Denied { + reason: "unauthorized".into(), + }; + let challenged = RadrootsNostrSignerRequestAction::Challenged { + auth_challenge: crate::model::RadrootsNostrSignerAuthChallenge::new( + "https://auth.example", + 1, + ) + .expect("challenge"), + pending_request: crate::model::RadrootsNostrSignerPendingRequest::new( + radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( + "req-1", + RadrootsNostrConnectRequest::Ping, + ), + 1, + ) + .expect("pending"), + }; + let evaluation = RadrootsNostrSignerRequestEvaluation { + request_id: RadrootsNostrSignerRequestId::new_v7(), + method: RadrootsNostrConnectMethod::Ping, + connection: connection(), + audit: crate::model::RadrootsNostrSignerRequestAuditRecord::new( + RadrootsNostrSignerRequestId::new_v7(), + crate::model::RadrootsNostrSignerConnectionId::new_v7(), + RadrootsNostrConnectMethod::Ping, + crate::model::RadrootsNostrSignerRequestDecision::Denied, + Some("unauthorized".into()), + 1, + ), + action: denied.clone(), + }; + + assert_eq!(denied.audit_message().as_deref(), Some("unauthorized")); + assert_eq!( + challenged.audit_message().as_deref(), + Some("auth challenge required") + ); + assert_eq!(evaluation.denied_reason(), Some("unauthorized")); + assert_action_audit_message_none(&RadrootsNostrSignerRequestAction::Allowed { + required_permission: None, + response_hint: RadrootsNostrSignerRequestResponseHint::None, + }); + } + + #[test] + fn request_permission_matching_covers_generic_and_sign_event_forms() { + let kind_one = unsigned_event(1); + let kind_two = unsigned_event(2); + let sign_kind = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ); + let sign_numeric = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "1", + ); + let sign_all = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent); + let nip44 = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt); + + assert!(request_allowed_by_permissions( + &vec![sign_kind.clone()].into(), + &RadrootsNostrConnectRequest::SignEvent(kind_one.clone()), + )); + assert!(request_allowed_by_permissions( + &vec![sign_numeric].into(), + &RadrootsNostrConnectRequest::SignEvent(kind_one), + )); + assert!(request_allowed_by_permissions( + &vec![sign_all].into(), + &RadrootsNostrConnectRequest::SignEvent(kind_two), + )); + assert!(!request_allowed_by_permissions( + &vec![sign_kind, nip44].into(), + &RadrootsNostrConnectRequest::Nip04Encrypt { + public_key: public_key( + "0000000000000000000000000000000000000000000000000000000000000007", + ), + plaintext: "hello".into(), + }, + )); + assert!(request_allowed_by_permissions( + &RadrootsNostrConnectPermissions::default(), + &RadrootsNostrConnectRequest::Ping, + )); + assert!(!request_allowed_by_permissions( + &vec![RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::Custom("do_thing".into()), + "scoped", + )] + .into(), + &RadrootsNostrConnectRequest::Custom { + method: RadrootsNostrConnectMethod::Custom("do_thing".into()), + params: vec!["value".into()], + }, + )); + assert!(permission_matches( + &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + )); + assert!(permission_matches( + &RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::Custom("scoped".into()), + "alpha", + ), + &RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::Custom("scoped".into()), + "alpha", + ), + )); + } + + #[test] + fn required_permission_and_response_hint_cover_request_variants() { + let connection = connection(); + let public_key = + public_key("0000000000000000000000000000000000000000000000000000000000000008"); + let connect = RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: public_key, + secret: Some("secret".into()), + requested_permissions: RadrootsNostrConnectPermissions::default(), + }; + let ping = RadrootsNostrConnectRequest::Ping; + let get_public_key = RadrootsNostrConnectRequest::GetPublicKey; + let switch_relays = RadrootsNostrConnectRequest::SwitchRelays; + let sign_event = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7)); + let custom = RadrootsNostrConnectRequest::Custom { + method: RadrootsNostrConnectMethod::Custom("do_thing".into()), + params: vec!["a".into()], + }; + + assert!(required_permission_for_request(&connect).is_none()); + assert!(required_permission_for_request(&ping).is_none()); + assert!(required_permission_for_request(&get_public_key).is_none()); + assert_eq!( + required_permission_for_request(&RadrootsNostrConnectRequest::Nip04Decrypt { + public_key, + ciphertext: "cipher".into(), + }) + .expect("nip04 decrypt permission") + .to_string(), + "nip04_decrypt" + ); + assert_eq!( + required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Encrypt { + public_key, + plaintext: "hello".into(), + }) + .expect("nip44 encrypt permission") + .to_string(), + "nip44_encrypt" + ); + assert_eq!( + required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Decrypt { + public_key, + ciphertext: "cipher".into(), + }) + .expect("nip44 decrypt permission") + .to_string(), + "nip44_decrypt" + ); + assert_eq!( + required_permission_for_request(&switch_relays) + .expect("switch relays permission") + .to_string(), + "switch_relays" + ); + assert_eq!( + required_permission_for_request(&sign_event) + .expect("sign_event permission") + .to_string(), + "sign_event:kind:7" + ); + assert_eq!( + required_permission_for_request(&custom) + .expect("custom permission") + .to_string(), + "do_thing" + ); + + assert_response_hint_none( + response_hint_for_request( + &connection, + &RadrootsNostrConnectRequest::Nip04Decrypt { + public_key, + ciphertext: "cipher".into(), + }, + ) + .expect("nip04 response hint"), + ); + assert_response_hint_pong( + response_hint_for_request(&connection, &ping).expect("ping hint"), + ); + assert_response_hint_user_public_key( + response_hint_for_request(&connection, &get_public_key).expect("pubkey hint"), + ); + assert_eq!( + response_hint_for_request(&connection, &switch_relays).expect("relay hint"), + RadrootsNostrSignerRequestResponseHint::RelayList(vec![relay("wss://relay.example")]) + ); + } + + #[test] + fn invalid_identity_public_key_returns_invalid_state() { + let mut identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000009"); + identity.public_key_hex = "invalid".into(); + + let err = identity_public_key(&identity).expect_err("invalid identity"); + assert!( + err.to_string() + .contains("user identity public key is invalid") + ); + + let mut invalid_connection = connection(); + invalid_connection.user_identity.public_key_hex = "invalid".into(); + let err = response_hint_for_request( + &invalid_connection, + &RadrootsNostrConnectRequest::GetPublicKey, + ) + .expect_err("invalid get_public_key response hint"); + assert!( + err.to_string() + .contains("user identity public key is invalid") + ); + } +} diff --git a/crates/nostr-signer/src/lib.rs b/crates/nostr-signer/src/lib.rs @@ -2,12 +2,18 @@ #![forbid(unsafe_code)] pub mod error; +pub mod evaluation; pub mod manager; pub mod model; pub mod store; pub mod prelude { pub use crate::error::RadrootsNostrSignerError; + pub use crate::evaluation::{ + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, + RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation, + RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, + }; pub use crate::manager::RadrootsNostrSignerManager; pub use crate::model::{ RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, diff --git a/crates/nostr-signer/src/manager.rs b/crates/nostr-signer/src/manager.rs @@ -1,4 +1,10 @@ use crate::error::RadrootsNostrSignerError; +use crate::evaluation::{ + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, + RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestEvaluation, + RadrootsNostrSignerSessionLookup, request_allowed_by_permissions, + required_permission_for_request, response_hint_for_request, +}; use crate::model::{ RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerApprovalState, RadrootsNostrSignerAuthChallenge, @@ -14,7 +20,8 @@ use crate::store::{RadrootsNostrMemorySignerStore, RadrootsNostrSignerStore}; use nostr::{PublicKey, RelayUrl}; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ - RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequestMessage, + RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, }; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -134,6 +141,69 @@ impl RadrootsNostrSignerManager { .cloned()) } + pub fn lookup_session( + &self, + client_public_key: &PublicKey, + connect_secret: Option<&str>, + ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> { + if let Some(connect_secret) = connect_secret { + if let Some(connection) = self.find_connection_by_connect_secret(connect_secret)? { + if &connection.client_public_key != client_public_key { + return Err(RadrootsNostrSignerError::InvalidState( + "connect secret is bound to a different client public key".into(), + )); + } + return Ok(RadrootsNostrSignerSessionLookup::Connection(connection)); + } + } + + let mut matches = self.find_connections_by_client_public_key(client_public_key)?; + matches.retain(|record| !record.is_terminal()); + Ok(match matches.len() { + 0 => RadrootsNostrSignerSessionLookup::None, + 1 => RadrootsNostrSignerSessionLookup::Connection(matches.remove(0)), + _ => RadrootsNostrSignerSessionLookup::Ambiguous(matches), + }) + } + + pub fn evaluate_connect_request( + &self, + client_public_key: PublicKey, + request: RadrootsNostrConnectRequest, + ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> { + let RadrootsNostrConnectRequest::Connect { + remote_signer_public_key, + secret, + requested_permissions, + } = request + else { + return Err(RadrootsNostrSignerError::InvalidState( + "connect evaluation requires a connect request".into(), + )); + }; + + let (connect_secret, existing_connection) = + self.resolve_connect_request_context(remote_signer_public_key, secret)?; + if let Some(connection) = existing_connection { + if connection.client_public_key != client_public_key { + return Err(RadrootsNostrSignerError::InvalidState( + "connect secret is bound to a different client public key".into(), + )); + } + return Ok(RadrootsNostrSignerConnectEvaluation::ExistingConnection( + connection, + )); + } + + Ok(RadrootsNostrSignerConnectEvaluation::RegistrationRequired( + RadrootsNostrSignerConnectProposal { + client_public_key, + connect_secret, + requested_permissions: normalize_permissions(requested_permissions), + }, + )) + } + pub fn list_audit_records( &self, ) -> Result<Vec<RadrootsNostrSignerRequestAuditRecord>, RadrootsNostrSignerError> { @@ -434,6 +504,49 @@ impl RadrootsNostrSignerManager { }) } + pub fn evaluate_request( + &self, + connection_id: &RadrootsNostrSignerConnectionId, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError> { + if matches!( + request_message.request, + RadrootsNostrConnectRequest::Connect { .. } + ) { + return Err(RadrootsNostrSignerError::InvalidState( + "connect requests must be evaluated via evaluate_connect_request".into(), + )); + } + + self.update_state_with(|state| { + let request_at_unix = now_unix_secs(); + let request_id = RadrootsNostrSignerRequestId::parse(&request_message.id)?; + let record = find_connection_mut(state, connection_id)?; + let method = request_message.request.method(); + let action = evaluate_request_action(record, &request_message, request_at_unix)?; + record.mark_request(request_at_unix); + + let audit = RadrootsNostrSignerRequestAuditRecord::new( + request_id.clone(), + connection_id.clone(), + method.clone(), + request_decision(&action), + action.audit_message(), + request_at_unix, + ); + let connection = record.clone(); + state.audit_records.push(audit.clone()); + + Ok(RadrootsNostrSignerRequestEvaluation { + request_id, + method, + connection, + audit, + action, + }) + }) + } + pub fn record_request( &self, connection_id: &RadrootsNostrSignerConnectionId, @@ -492,6 +605,31 @@ impl RadrootsNostrSignerManager { *guard = next; Ok(value) } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn resolve_connect_request_context( + &self, + remote_signer_public_key: PublicKey, + secret: Option<String>, + ) -> Result< + (Option<String>, Option<RadrootsNostrSignerConnectionRecord>), + RadrootsNostrSignerError, + > { + let signer_identity = self + .signer_identity()? + .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?; + let signer_public_key = parse_identity_public_key(&signer_identity)?; + if remote_signer_public_key != signer_public_key { + return Err(RadrootsNostrSignerError::InvalidState( + "remote signer public key mismatch".into(), + )); + } + + let connect_secret = normalize_optional_string(secret); + let existing_connection = + self.find_connection_by_connect_secret(connect_secret.as_deref().unwrap_or_default())?; + Ok((connect_secret, existing_connection)) + } } fn find_connection_mut<'a>( @@ -537,6 +675,51 @@ fn validate_granted_permissions( Ok(()) } +fn evaluate_request_action( + record: &mut RadrootsNostrSignerConnectionRecord, + request_message: &RadrootsNostrConnectRequestMessage, + request_at_unix: u64, +) -> Result<RadrootsNostrSignerRequestAction, RadrootsNostrSignerError> { + if record.is_terminal() { + return Ok(RadrootsNostrSignerRequestAction::Denied { + reason: format!("connection is {}", status_label(record.status)), + }); + } + if record.status != RadrootsNostrSignerConnectionStatus::Active { + return Ok(RadrootsNostrSignerRequestAction::Denied { + reason: format!("connection is {}", status_label(record.status)), + }); + } + if record.auth_state == RadrootsNostrSignerAuthState::Pending { + let auth_challenge = + record + .auth_challenge + .clone() + .ok_or(RadrootsNostrSignerError::InvalidState( + "auth challenge missing for pending auth state".into(), + ))?; + let pending_request = + RadrootsNostrSignerPendingRequest::new(request_message.clone(), request_at_unix)?; + record.set_pending_request(pending_request.clone()); + return Ok(RadrootsNostrSignerRequestAction::Challenged { + auth_challenge, + pending_request, + }); + } + + let effective_permissions = record.effective_permissions(); + if !request_allowed_by_permissions(&effective_permissions, &request_message.request) { + return Ok(RadrootsNostrSignerRequestAction::Denied { + reason: format!("unauthorized {}", request_message.request.method()), + }); + } + + Ok(RadrootsNostrSignerRequestAction::Allowed { + required_permission: required_permission_for_request(&request_message.request), + response_hint: response_hint_for_request(record, &request_message.request)?, + }) +} + fn normalize_permissions( permissions: RadrootsNostrConnectPermissions, ) -> RadrootsNostrConnectPermissions { @@ -573,6 +756,32 @@ fn status_label(status: RadrootsNostrSignerConnectionStatus) -> &'static str { } } +fn request_decision( + action: &RadrootsNostrSignerRequestAction, +) -> RadrootsNostrSignerRequestDecision { + match action { + RadrootsNostrSignerRequestAction::Allowed { .. } => { + RadrootsNostrSignerRequestDecision::Allowed + } + RadrootsNostrSignerRequestAction::Denied { .. } => { + RadrootsNostrSignerRequestDecision::Denied + } + RadrootsNostrSignerRequestAction::Challenged { .. } => { + RadrootsNostrSignerRequestDecision::Challenged + } + } +} + +fn parse_identity_public_key( + identity: &RadrootsIdentityPublic, +) -> Result<PublicKey, RadrootsNostrSignerError> { + PublicKey::parse(identity.public_key_hex.as_str()) + .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str())) + .map_err(|_| { + RadrootsNostrSignerError::InvalidState("identity public key is invalid".into()) + }) +} + fn now_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -583,10 +792,15 @@ fn now_unix_secs() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::evaluation::{ + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction, + RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, + }; use crate::store::RadrootsNostrSignerStore; - use nostr::{Keys, SecretKey}; + use nostr::{Keys, SecretKey, Timestamp, UnsignedEvent}; use radroots_identity::RadrootsIdentity; use radroots_nostr_connect::prelude::RadrootsNostrConnectPermission; + use serde_json::json; use std::sync::Arc; use std::thread; @@ -633,6 +847,102 @@ mod tests { ) } + fn request_message_with_request( + id: &str, + request: RadrootsNostrConnectRequest, + ) -> RadrootsNostrConnectRequestMessage { + RadrootsNostrConnectRequestMessage::new(id, request) + } + + fn unsigned_event(kind: u16) -> UnsignedEvent { + serde_json::from_value(json!({ + "pubkey": public_key("00000000000000000000000000000000000000000000000000000000000000a1").to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": kind, + "tags": [], + "content": "hello" + })) + .expect("unsigned event") + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_connection_lookup( + lookup: RadrootsNostrSignerSessionLookup, + ) -> RadrootsNostrSignerConnectionRecord { + match lookup { + RadrootsNostrSignerSessionLookup::Connection(found) => found, + other => panic!("unexpected lookup result: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_ambiguous_lookup( + lookup: RadrootsNostrSignerSessionLookup, + ) -> Vec<RadrootsNostrSignerConnectionRecord> { + match lookup { + RadrootsNostrSignerSessionLookup::Ambiguous(found) => found, + other => panic!("unexpected ambiguous lookup result: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_existing_connect( + evaluation: RadrootsNostrSignerConnectEvaluation, + ) -> RadrootsNostrSignerConnectionRecord { + match evaluation { + RadrootsNostrSignerConnectEvaluation::ExistingConnection(found) => found, + other => panic!("unexpected existing connect result: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_registration_connect( + evaluation: RadrootsNostrSignerConnectEvaluation, + ) -> crate::evaluation::RadrootsNostrSignerConnectProposal { + match evaluation { + RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal, + other => panic!("unexpected registration connect result: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_none_lookup(lookup: RadrootsNostrSignerSessionLookup) { + match lookup { + RadrootsNostrSignerSessionLookup::None => {} + other => panic!("unexpected non-empty lookup result: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_allowed_user_public_key(action: &RadrootsNostrSignerRequestAction) { + match action { + RadrootsNostrSignerRequestAction::Allowed { + required_permission: None, + response_hint: RadrootsNostrSignerRequestResponseHint::UserPublicKey(_), + } => {} + other => panic!("unexpected allowed pubkey action: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_allowed_without_response_hint(action: &RadrootsNostrSignerRequestAction) { + match action { + RadrootsNostrSignerRequestAction::Allowed { + required_permission: Some(_), + response_hint: RadrootsNostrSignerRequestResponseHint::None, + } => {} + other => panic!("unexpected allowed no-hint action: {other:?}"), + } + } + + #[cfg_attr(coverage_nightly, coverage(off))] + fn expect_challenged_action(action: &RadrootsNostrSignerRequestAction) { + match action { + RadrootsNostrSignerRequestAction::Challenged { .. } => {} + other => panic!("unexpected challenged action: {other:?}"), + } + } + fn poison_manager_state(manager: &RadrootsNostrSignerManager) { let shared = manager.state.clone(); let _ = thread::spawn(move || { @@ -1565,6 +1875,12 @@ mod tests { let find_client_err = manager .find_connections_by_client_public_key(&client_public_key) .expect_err("poisoned client lookup"); + let lookup_secret_err = manager + .lookup_session(&client_public_key, Some("secret")) + .expect_err("poisoned session secret lookup"); + let lookup_client_err = manager + .lookup_session(&client_public_key, None) + .expect_err("poisoned session client lookup"); for err in [ get_err, @@ -1573,12 +1889,42 @@ mod tests { audit_for_connection_err, find_secret_err, find_client_err, + lookup_secret_err, + lookup_client_err, ] { assert!(err.to_string().contains("signer state lock poisoned")); } } #[test] + fn evaluate_connect_request_reports_poisoned_state_lock() { + let store = Arc::new(RadrootsNostrMemorySignerStore::new()); + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000057"); + let mut state = RadrootsNostrSignerStoreState::default(); + state.signer_identity = Some(signer_identity.clone()); + store.save(&state).expect("save state"); + + let manager = RadrootsNostrSignerManager::new(store).expect("manager"); + poison_manager_state(&manager); + + let err = manager + .evaluate_connect_request( + public_key("0000000000000000000000000000000000000000000000000000000000000058"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: PublicKey::parse( + signer_identity.public_key_hex.as_str(), + ) + .expect("signer public key"), + secret: Some("secret".into()), + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + .expect_err("poisoned connect evaluation"); + assert!(err.to_string().contains("signer state lock poisoned")); + } + + #[test] fn mutation_helpers_report_poisoned_state_lock() { let manager = RadrootsNostrSignerManager::new_in_memory(); poison_manager_state(&manager); @@ -1732,4 +2078,475 @@ mod tests { .matches_secret("reusable-secret") ); } + + #[test] + fn session_lookup_and_connect_evaluation_cover_new_paths() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000060"); + let signer_public_key = + PublicKey::parse(signer_identity.public_key_hex.as_str()).expect("signer public key"); + manager + .set_signer_identity(signer_identity) + .expect("set signer"); + + let client_public_key = + public_key("0000000000000000000000000000000000000000000000000000000000000061"); + let primary = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_public_key, + public_identity( + "0000000000000000000000000000000000000000000000000000000000000062", + ), + ) + .with_connect_secret("connect-secret"), + ) + .expect("register primary"); + + let single_lookup = manager + .lookup_session(&client_public_key, None) + .expect("lookup single"); + assert_same_connection(&expect_connection_lookup(single_lookup), &primary); + + let secret_lookup = manager + .lookup_session(&client_public_key, Some("connect-secret")) + .expect("lookup by secret"); + assert_same_connection(&expect_connection_lookup(secret_lookup), &primary); + let missing_secret_lookup = manager + .lookup_session(&client_public_key, Some("missing-secret")) + .expect("lookup missing secret"); + assert_same_connection(&expect_connection_lookup(missing_secret_lookup), &primary); + + let second = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_public_key, + public_identity( + "0000000000000000000000000000000000000000000000000000000000000063", + ), + ) + .with_connect_secret("second-secret"), + ) + .expect("register second"); + + let ambiguous_by_missing_secret = manager + .lookup_session(&client_public_key, Some("missing-secret")) + .expect("lookup missing secret after second"); + let found = expect_ambiguous_lookup(ambiguous_by_missing_secret); + assert_eq!(found.len(), 2); + assert_same_connection(&found[0], &primary); + assert_same_connection(&found[1], &second); + let ambiguous_lookup = manager + .lookup_session(&client_public_key, None) + .expect("lookup ambiguous"); + let found = expect_ambiguous_lookup(ambiguous_lookup); + assert_eq!(found.len(), 2); + assert_same_connection(&found[0], &primary); + assert_same_connection(&found[1], &second); + + let mismatch_secret = manager + .lookup_session( + &public_key("0000000000000000000000000000000000000000000000000000000000000064"), + Some("connect-secret"), + ) + .expect_err("secret mismatch"); + assert!( + mismatch_secret + .to_string() + .contains("different client public key") + ); + + let none_lookup = manager + .lookup_session( + &public_key("0000000000000000000000000000000000000000000000000000000000000065"), + None, + ) + .expect("lookup none"); + expect_none_lookup(none_lookup); + + let non_connect_err = manager + .evaluate_connect_request(client_public_key, RadrootsNostrConnectRequest::Ping) + .expect_err("non-connect evaluation"); + assert!( + non_connect_err + .to_string() + .contains("connect evaluation requires a connect request") + ); + + let missing_signer_err = RadrootsNostrSignerManager::new_in_memory() + .evaluate_connect_request( + client_public_key, + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + .expect_err("missing signer"); + assert_eq!(missing_signer_err.to_string(), "missing signer identity"); + + let signer_mismatch_err = manager + .evaluate_connect_request( + client_public_key, + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: public_key( + "0000000000000000000000000000000000000000000000000000000000000066", + ), + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + .expect_err("signer mismatch"); + assert!( + signer_mismatch_err + .to_string() + .contains("remote signer public key mismatch") + ); + + let existing_connect = manager + .evaluate_connect_request( + client_public_key, + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: Some(" connect-secret ".into()), + requested_permissions: vec![ + permission(RadrootsNostrConnectMethod::Ping, None), + permission(RadrootsNostrConnectMethod::Ping, None), + ] + .into(), + }, + ) + .expect("existing connect request"); + assert_same_connection(&expect_existing_connect(existing_connect), &primary); + + let registration_connect = manager + .evaluate_connect_request( + public_key("0000000000000000000000000000000000000000000000000000000000000067"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: Some(" fresh-secret ".into()), + requested_permissions: vec![ + permission(RadrootsNostrConnectMethod::Ping, None), + permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")), + permission(RadrootsNostrConnectMethod::Ping, None), + ] + .into(), + }, + ) + .expect("registration connect request"); + let proposal = expect_registration_connect(registration_connect); + assert_eq!( + proposal.client_public_key, + public_key("0000000000000000000000000000000000000000000000000000000000000067") + ); + assert_eq!(proposal.connect_secret.as_deref(), Some("fresh-secret")); + assert_eq!( + proposal.requested_permissions.as_slice(), + &[ + permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")), + permission(RadrootsNostrConnectMethod::Ping, None), + ] + ); + + let existing_secret_mismatch = manager + .evaluate_connect_request( + public_key("0000000000000000000000000000000000000000000000000000000000000068"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: Some("connect-secret".into()), + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + .expect_err("existing secret mismatch"); + assert!( + existing_secret_mismatch + .to_string() + .contains("different client public key") + ); + + let store = Arc::new(RadrootsNostrMemorySignerStore::new()); + let mut invalid_state = RadrootsNostrSignerStoreState::default(); + let mut invalid_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000069"); + invalid_identity.public_key_hex = "invalid".into(); + invalid_state.signer_identity = Some(invalid_identity); + store + .save(&invalid_state) + .expect("save invalid signer state"); + let invalid_manager = RadrootsNostrSignerManager::new(store).expect("invalid manager"); + let invalid_signer_err = invalid_manager + .evaluate_connect_request( + public_key("0000000000000000000000000000000000000000000000000000000000000070"), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + .expect_err("invalid signer public key"); + assert!( + invalid_signer_err + .to_string() + .contains("identity public key is invalid") + ); + } + + #[test] + fn evaluate_request_covers_allowed_denied_and_challenged_paths() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000071", + )) + .expect("set signer"); + + let active = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000072"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000073", + ), + ) + .with_requested_permissions( + vec![permission( + RadrootsNostrConnectMethod::SignEvent, + Some("kind:1"), + )] + .into(), + ), + ) + .expect("register active"); + + let get_public_key = manager + .evaluate_request( + &active.connection_id, + request_message_with_request("req-get", RadrootsNostrConnectRequest::GetPublicKey), + ) + .expect("evaluate get_public_key"); + expect_allowed_user_public_key(&get_public_key.action); + assert_eq!( + get_public_key.audit.decision, + RadrootsNostrSignerRequestDecision::Allowed + ); + assert!(get_public_key.denied_reason().is_none()); + + let allowed_sign = manager + .evaluate_request( + &active.connection_id, + request_message_with_request( + "req-sign-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)), + ), + ) + .expect("evaluate sign allowed"); + expect_allowed_without_response_hint(&allowed_sign.action); + + let denied_sign = manager + .evaluate_request( + &active.connection_id, + request_message_with_request( + "req-sign-2", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(2)), + ), + ) + .expect("evaluate sign denied"); + assert_eq!(denied_sign.denied_reason(), Some("unauthorized sign_event")); + assert_eq!( + denied_sign.audit.decision, + RadrootsNostrSignerRequestDecision::Denied + ); + + let pending = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000074"), + public_identity( + "0000000000000000000000000000000000000000000000000000000000000075", + ), + ) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register pending"); + let pending_eval = manager + .evaluate_request(&pending.connection_id, request_message("req-pending")) + .expect("evaluate pending"); + assert_eq!(pending_eval.denied_reason(), Some("connection is pending")); + + let challenged = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000076"), + public_identity("0000000000000000000000000000000000000000000000000000000000000077"), + )) + .expect("register challenged"); + manager + .require_auth_challenge(&challenged.connection_id, "https://auth.example") + .expect("require auth challenge"); + let challenged_eval = manager + .evaluate_request(&challenged.connection_id, request_message("req-auth")) + .expect("evaluate challenged"); + expect_challenged_action(&challenged_eval.action); + assert_eq!( + challenged_eval.audit.decision, + RadrootsNostrSignerRequestDecision::Challenged + ); + assert_eq!( + challenged_eval + .connection + .pending_request + .as_ref() + .expect("pending request") + .request_id() + .as_str(), + "req-auth" + ); + + let rejected = manager + .reject_connection(&challenged.connection_id, Some("closed".into())) + .expect("reject challenged"); + let rejected_eval = manager + .evaluate_request(&rejected.connection_id, request_message("req-rejected")) + .expect("evaluate rejected"); + assert_eq!( + rejected_eval.denied_reason(), + Some("connection is rejected") + ); + + let connect_eval_err = manager + .evaluate_request( + &active.connection_id, + request_message_with_request( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: active.client_public_key, + secret: None, + requested_permissions: RadrootsNostrConnectPermissions::default(), + }, + ), + ) + .expect_err("connect through evaluate_request"); + assert!( + connect_eval_err + .to_string() + .contains("evaluate_connect_request") + ); + } + + #[test] + fn evaluate_request_reports_invalid_corrupted_auth_state() { + let store = Arc::new(RadrootsNostrMemorySignerStore::new()); + let signer_identity = + public_identity("0000000000000000000000000000000000000000000000000000000000000078"); + let mut state = RadrootsNostrSignerStoreState::default(); + state.signer_identity = Some(signer_identity.clone()); + let mut record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::new_v7(), + signer_identity, + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000079"), + public_identity("0000000000000000000000000000000000000000000000000000000000000080"), + ), + 1, + ); + record.auth_state = RadrootsNostrSignerAuthState::Pending; + record.auth_challenge = None; + state.connections.push(record.clone()); + store.save(&state).expect("save corrupted auth state"); + + let manager = RadrootsNostrSignerManager::new(store).expect("manager"); + let err = manager + .evaluate_request(&record.connection_id, request_message("req-corrupt")) + .expect_err("corrupted auth evaluation"); + assert!(err.to_string().contains("auth challenge missing")); + } + + #[test] + fn evaluate_request_reports_invalid_request_id_and_missing_connection() { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity(public_identity( + "0000000000000000000000000000000000000000000000000000000000000081", + )) + .expect("set signer"); + + let active = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000082"), + public_identity("0000000000000000000000000000000000000000000000000000000000000083"), + )) + .expect("register active"); + + let invalid_request_id = manager + .evaluate_request( + &active.connection_id, + request_message_with_request(" ", RadrootsNostrConnectRequest::Ping), + ) + .expect_err("invalid request id"); + assert!( + invalid_request_id + .to_string() + .contains("invalid request id") + ); + + let missing_connection = manager + .evaluate_request( + &RadrootsNostrSignerConnectionId::new_v7(), + request_message("req-missing"), + ) + .expect_err("missing connection"); + assert!( + missing_connection + .to_string() + .contains("connection not found") + ); + } + + #[test] + fn evaluate_request_action_reports_pending_request_and_response_hint_errors() { + let mut pending_record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::new_v7(), + public_identity("0000000000000000000000000000000000000000000000000000000000000084"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000085"), + public_identity("0000000000000000000000000000000000000000000000000000000000000086"), + ), + 1, + ); + pending_record.status = RadrootsNostrSignerConnectionStatus::Active; + pending_record.auth_state = RadrootsNostrSignerAuthState::Pending; + pending_record.auth_challenge = Some( + RadrootsNostrSignerAuthChallenge::new("https://auth.example", 1).expect("challenge"), + ); + let invalid_pending = evaluate_request_action( + &mut pending_record, + &request_message_with_request(" ", RadrootsNostrConnectRequest::Ping), + 1, + ) + .expect_err("invalid pending request"); + assert!(invalid_pending.to_string().contains("invalid request id")); + + let mut invalid_user_record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::new_v7(), + public_identity("0000000000000000000000000000000000000000000000000000000000000087"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000088"), + public_identity("0000000000000000000000000000000000000000000000000000000000000089"), + ), + 1, + ); + invalid_user_record.status = RadrootsNostrSignerConnectionStatus::Active; + invalid_user_record.user_identity.public_key_hex = "invalid".into(); + let response_hint_err = evaluate_request_action( + &mut invalid_user_record, + &request_message_with_request("req-get", RadrootsNostrConnectRequest::GetPublicKey), + 1, + ) + .expect_err("invalid response hint"); + assert!( + response_hint_err + .to_string() + .contains("user identity public key is invalid") + ); + } } diff --git a/crates/nostr-signer/src/model.rs b/crates/nostr-signer/src/model.rs @@ -469,6 +469,17 @@ impl RadrootsNostrSignerConnectionRecord { .into() } + pub fn effective_permissions(&self) -> RadrootsNostrConnectPermissions { + let granted_permissions = self.granted_permissions(); + if !granted_permissions.is_empty() { + granted_permissions + } else if self.approval_state == RadrootsNostrSignerApprovalState::NotRequired { + self.requested_permissions.clone() + } else { + RadrootsNostrConnectPermissions::default() + } + } + pub fn is_terminal(&self) -> bool { matches!( self.status, @@ -796,6 +807,42 @@ mod tests { } #[test] + fn effective_permissions_prefers_grants_then_auto_requested_then_empty() { + let requested: RadrootsNostrConnectPermissions = vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Nip04Encrypt, + )] + .into(); + let auto_record = RadrootsNostrSignerConnectionRecord::new( + RadrootsNostrSignerConnectionId::new_v7(), + public_identity("0000000000000000000000000000000000000000000000000000000000000031"), + RadrootsNostrSignerConnectionDraft::new( + public_key("0000000000000000000000000000000000000000000000000000000000000032"), + public_identity("0000000000000000000000000000000000000000000000000000000000000033"), + ) + .with_requested_permissions(requested.clone()), + 1, + ); + assert_eq!(auto_record.effective_permissions(), requested); + + let mut granted_record = auto_record.clone(); + granted_record.granted_permissions = vec![RadrootsNostrSignerPermissionGrant::new( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), + 2, + )]; + assert_eq!( + granted_record.effective_permissions(), + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping + )] + .into() + ); + + let mut approved_without_grants = auto_record; + approved_without_grants.approval_state = RadrootsNostrSignerApprovalState::Approved; + assert!(approved_without_grants.effective_permissions().is_empty()); + } + + #[test] fn permission_serde_helpers_round_trip_through_wrapper() { #[derive(Debug, Serialize, Deserialize)] struct PermissionWrapper {