commit 0cf069a40956afffeef3aa1e168efb8a9c796785
parent cce42c9550b26804234fa21eae665630dd0b8235
Author: triesap <tyson@radroots.org>
Date: Sun, 12 Apr 2026 19:20:43 +0000
nostr_signer: extract shared nip46 handler
Diffstat:
2 files changed, 873 insertions(+), 8 deletions(-)
diff --git a/crates/nostr_signer/src/lib.rs b/crates/nostr_signer/src/lib.rs
@@ -50,7 +50,9 @@ pub mod prelude {
RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
};
pub use crate::nip46::{
- RadrootsNostrSignerHandledRequest, RadrootsNostrSignerNip46Codec,
+ RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
+ RadrootsNostrSignerNip46Codec, RadrootsNostrSignerNip46ConnectDecision,
+ RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Policy,
RadrootsNostrSignerNip46Signer, connect_response_outcome, handled_request_for_action,
response_from_hint,
};
diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs
@@ -1,16 +1,27 @@
use nostr::UnsignedEvent;
+use radroots_identity::RadrootsIdentityPublic;
use radroots_nostr::prelude::{
RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind,
- RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp, radroots_nostr_filter_tag,
+ RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp,
+ radroots_nostr_filter_tag,
};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest,
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
};
+use crate::backend::RadrootsNostrSignerBackend;
use crate::error::RadrootsNostrSignerError;
-use crate::evaluation::{RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint};
-use crate::model::{RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord};
+use crate::evaluation::{
+ RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerRequestAction,
+ RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint,
+ RadrootsNostrSignerSessionLookup,
+};
+use crate::model::{
+ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionId,
+ RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord,
+ RadrootsNostrSignerRequestDecision,
+};
pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync {
fn signer_public_key_hex(&self) -> String;
@@ -24,7 +35,7 @@ pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync {
client_public_key: &RadrootsNostrPublicKey,
payload: &str,
) -> Result<String, RadrootsNostrSignerError>;
- fn user_public_key(&self) -> RadrootsNostrPublicKey;
+ fn user_identity(&self) -> RadrootsIdentityPublic;
fn sign_user_event(
&self,
unsigned_event: UnsignedEvent,
@@ -51,11 +62,62 @@ pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync {
) -> Result<String, RadrootsNostrSignerError>;
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsNostrSignerNip46ConnectDecision {
+ Allow,
+ RequireApproval,
+ Deny,
+}
+
+pub trait RadrootsNostrSignerNip46Policy<B: RadrootsNostrSignerBackend>:
+ Clone + Send + Sync
+{
+ fn connect_decision(
+ &self,
+ client_public_key: &RadrootsNostrPublicKey,
+ ) -> RadrootsNostrSignerNip46ConnectDecision;
+
+ fn connect_rate_limit_denied_reason(
+ &self,
+ client_public_key: &RadrootsNostrPublicKey,
+ ) -> Option<String>;
+
+ fn approval_requirement_for_client(
+ &self,
+ client_public_key: &RadrootsNostrPublicKey,
+ ) -> Option<RadrootsNostrSignerApprovalRequirement>;
+
+ fn filtered_requested_permissions(
+ &self,
+ requested_permissions: &RadrootsNostrConnectPermissions,
+ ) -> RadrootsNostrConnectPermissions;
+
+ fn auto_granted_permissions(
+ &self,
+ requested_permissions: &RadrootsNostrConnectPermissions,
+ ) -> RadrootsNostrConnectPermissions;
+
+ fn prepare_request(
+ &self,
+ backend: &B,
+ connection: &RadrootsNostrSignerConnectionRecord,
+ request_message: &RadrootsNostrConnectRequestMessage,
+ ) -> Result<Option<String>, RadrootsNostrSignerError>;
+}
+
#[derive(Clone)]
pub struct RadrootsNostrSignerNip46Codec<S> {
signer: S,
}
+#[derive(Clone)]
+pub struct RadrootsNostrSignerNip46Handler<B, P, S> {
+ backend: B,
+ policy: P,
+ relays: Vec<RadrootsNostrRelayUrl>,
+ codec: RadrootsNostrSignerNip46Codec<S>,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadrootsNostrSignerHandledRequest {
Respond {
@@ -66,6 +128,20 @@ pub enum RadrootsNostrSignerHandledRequest {
Ignore,
}
+#[derive(Debug, Clone)]
+pub struct RadrootsNostrSignerHandledRequestOutcome {
+ pub handled_request: RadrootsNostrSignerHandledRequest,
+ pub audit: Option<RadrootsNostrSignerRequestAuditRecord>,
+}
+
+enum RadrootsNostrSignerPreparedRequestEvaluation {
+ Denied {
+ reason: String,
+ audit: RadrootsNostrSignerRequestAuditRecord,
+ },
+ Evaluation(RadrootsNostrSignerRequestEvaluation),
+}
+
impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
pub fn new(signer: S) -> Self {
Self { signer }
@@ -113,8 +189,8 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
&self,
unsigned_event: UnsignedEvent,
) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> {
- let user_public_key = self.signer.user_public_key();
- if unsigned_event.pubkey != user_public_key {
+ let user_public_key = self.signer.user_identity().public_key_hex;
+ if unsigned_event.pubkey.to_hex() != user_public_key {
return Ok(RadrootsNostrConnectResponse::Error {
result: None,
error: "sign_event pubkey does not match the managed user identity".to_owned(),
@@ -183,6 +259,428 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> {
}
}
+impl<B, P, S> RadrootsNostrSignerNip46Handler<B, P, S>
+where
+ B: RadrootsNostrSignerBackend + Clone,
+ P: RadrootsNostrSignerNip46Policy<B>,
+ S: RadrootsNostrSignerNip46Signer,
+{
+ pub fn new(backend: B, policy: P, relays: Vec<RadrootsNostrRelayUrl>, signer: S) -> Self {
+ Self {
+ backend,
+ policy,
+ relays,
+ codec: RadrootsNostrSignerNip46Codec::new(signer),
+ }
+ }
+
+ pub fn filter(&self) -> Result<RadrootsNostrFilter, RadrootsNostrSignerError> {
+ self.codec.filter()
+ }
+
+ pub fn parse_request_event(
+ &self,
+ event: &RadrootsNostrEvent,
+ ) -> Result<RadrootsNostrConnectRequestMessage, RadrootsNostrSignerError> {
+ self.codec.parse_request_event(event)
+ }
+
+ pub fn build_response_event(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_id: impl Into<String>,
+ response: RadrootsNostrConnectResponse,
+ ) -> Result<RadrootsNostrEventBuilder, RadrootsNostrSignerError> {
+ self.codec
+ .build_response_event(client_public_key, request_id, response)
+ }
+
+ pub fn handle_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ match request_message.request.clone() {
+ RadrootsNostrConnectRequest::Connect { secret, .. } => {
+ self.handle_connect_request(client_public_key, request_message.request, secret)
+ }
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
+ self.handle_sign_event_request(client_public_key, request_message, unsigned_event)
+ }
+ RadrootsNostrConnectRequest::Nip04Encrypt { .. }
+ | RadrootsNostrConnectRequest::Nip04Decrypt { .. }
+ | RadrootsNostrConnectRequest::Nip44Encrypt { .. }
+ | RadrootsNostrConnectRequest::Nip44Decrypt { .. } => {
+ self.handle_crypto_request(client_public_key, request_message)
+ }
+ RadrootsNostrConnectRequest::GetPublicKey
+ | RadrootsNostrConnectRequest::GetSessionCapability
+ | RadrootsNostrConnectRequest::Ping
+ | RadrootsNostrConnectRequest::SwitchRelays => {
+ self.handle_base_request(client_public_key, request_message)
+ }
+ _ => Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ RadrootsNostrSignerHandledRequest::respond(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: format!(
+ "method `{}` is not implemented yet",
+ request_message.request.method()
+ ),
+ }),
+ None,
+ )),
+ }
+ }
+
+ pub fn handle_authorized_request_evaluation(
+ &self,
+ request_message: RadrootsNostrConnectRequestMessage,
+ evaluation: RadrootsNostrSignerRequestEvaluation,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ let audit = evaluation.audit.clone();
+ let handled_request = self.handled_request_for_evaluation(request_message, evaluation)?;
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ handled_request,
+ Some(audit),
+ ))
+ }
+
+ fn handle_connect_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request: RadrootsNostrConnectRequest,
+ secret: Option<String>,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ let connect_decision = self.policy.connect_decision(&client_public_key);
+ if let Some(connect_secret) = secret.as_deref() {
+ if let Some(connection) = self
+ .backend
+ .find_connection_by_connect_secret(connect_secret)?
+ {
+ if connection.connect_secret_is_consumed() {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::ignore());
+ }
+ }
+ }
+ if !matches!(
+ connect_decision,
+ RadrootsNostrSignerNip46ConnectDecision::Deny
+ ) {
+ if let Some(reason) = self
+ .policy
+ .connect_rate_limit_denied_reason(&client_public_key)
+ {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ));
+ }
+ }
+
+ let evaluation = self
+ .backend
+ .evaluate_connect_request(client_public_key.clone(), request)?;
+
+ match evaluation {
+ RadrootsNostrSignerConnectEvaluation::ExistingConnection(connection) => {
+ if secret.is_some() && connection.connect_secret_is_consumed() {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::ignore());
+ }
+ if matches!(
+ connect_decision,
+ RadrootsNostrSignerNip46ConnectDecision::Deny
+ ) {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "client public key denied by policy".to_owned(),
+ },
+ ));
+ }
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ connect_response_outcome(&connection, secret),
+ None,
+ ))
+ }
+ RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => {
+ let requested_permissions = self
+ .policy
+ .filtered_requested_permissions(&proposal.requested_permissions);
+ let Some(approval_requirement) = self
+ .policy
+ .approval_requirement_for_client(&client_public_key)
+ else {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "client public key denied by policy".to_owned(),
+ },
+ ));
+ };
+ let draft = proposal
+ .into_connection_draft(self.codec.signer.user_identity())
+ .with_requested_permissions(requested_permissions)
+ .with_relays(self.relays.clone())
+ .with_approval_requirement(approval_requirement);
+ let connection = self.backend.register_connection(draft)?;
+ if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired {
+ let granted_permissions = self
+ .policy
+ .auto_granted_permissions(&connection.requested_permissions);
+ let _ = self
+ .backend
+ .set_granted_permissions(&connection.connection_id, granted_permissions)?;
+ }
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ connect_response_outcome(&connection, secret),
+ None,
+ ))
+ }
+ }
+ }
+
+ fn handle_base_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ let connection = match self.lookup_connection(client_public_key)? {
+ Ok(connection) => connection,
+ Err(response) => {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
+ }
+ };
+
+ match self.evaluate_request_with_policy(&connection, request_message)? {
+ RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ RadrootsNostrSignerHandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ),
+ Some(audit),
+ ))
+ }
+ RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
+ let audit = evaluation.audit.clone();
+ let response_hint = match &evaluation.action {
+ RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
+ Some(response_hint.clone())
+ }
+ _ => None,
+ };
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ handled_request_for_action(&evaluation.connection, evaluation.action, || {
+ Ok(response_from_hint(
+ &evaluation.connection,
+ response_hint.expect("allowed action carries response hint"),
+ ))
+ })?,
+ Some(audit),
+ ))
+ }
+ }
+ }
+
+ fn handle_sign_event_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ unsigned_event: UnsignedEvent,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ let connection = match self.lookup_connection(client_public_key)? {
+ Ok(connection) => connection,
+ Err(response) => {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
+ }
+ };
+
+ match self.evaluate_request_with_policy(&connection, request_message)? {
+ RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ RadrootsNostrSignerHandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ),
+ Some(audit),
+ ))
+ }
+ RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ self.handled_request_for_authorized_action(
+ &evaluation.connection,
+ evaluation.action,
+ || self.codec.sign_event_response(unsigned_event),
+ )?,
+ Some(evaluation.audit),
+ ))
+ }
+ }
+ }
+
+ fn handle_crypto_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> {
+ let request = request_message.request.clone();
+ let connection = match self.lookup_connection(client_public_key)? {
+ Ok(connection) => connection,
+ Err(response) => {
+ return Ok(RadrootsNostrSignerHandledRequestOutcome::respond(response));
+ }
+ };
+
+ match self.evaluate_request_with_policy(&connection, request_message)? {
+ RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit } => {
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ RadrootsNostrSignerHandledRequest::respond_for_connection(
+ Some(connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: reason,
+ },
+ ),
+ Some(audit),
+ ))
+ }
+ RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(evaluation) => {
+ Ok(RadrootsNostrSignerHandledRequestOutcome::new(
+ self.handled_request_for_authorized_action(
+ &evaluation.connection,
+ evaluation.action,
+ || self.codec.crypto_response(request),
+ )?,
+ Some(evaluation.audit),
+ ))
+ }
+ }
+ }
+
+ fn handled_request_for_evaluation(
+ &self,
+ request_message: RadrootsNostrConnectRequestMessage,
+ evaluation: RadrootsNostrSignerRequestEvaluation,
+ ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError> {
+ match request_message.request.clone() {
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event) => self
+ .handled_request_for_authorized_action(
+ &evaluation.connection,
+ evaluation.action,
+ || self.codec.sign_event_response(unsigned_event),
+ ),
+ RadrootsNostrConnectRequest::Nip04Encrypt { .. }
+ | RadrootsNostrConnectRequest::Nip04Decrypt { .. }
+ | RadrootsNostrConnectRequest::Nip44Encrypt { .. }
+ | RadrootsNostrConnectRequest::Nip44Decrypt { .. } => self
+ .handled_request_for_authorized_action(
+ &evaluation.connection,
+ evaluation.action,
+ || self.codec.crypto_response(request_message.request),
+ ),
+ RadrootsNostrConnectRequest::GetPublicKey
+ | RadrootsNostrConnectRequest::GetSessionCapability
+ | RadrootsNostrConnectRequest::Ping
+ | RadrootsNostrConnectRequest::SwitchRelays => {
+ let response_hint = match &evaluation.action {
+ RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
+ Some(response_hint.clone())
+ }
+ _ => None,
+ };
+ self.handled_request_for_authorized_action(
+ &evaluation.connection,
+ evaluation.action,
+ || {
+ Ok(response_from_hint(
+ &evaluation.connection,
+ response_hint.expect("allowed action carries response hint"),
+ ))
+ },
+ )
+ }
+ other => Ok(RadrootsNostrSignerHandledRequest::respond_for_connection(
+ Some(evaluation.connection.connection_id.clone()),
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: format!("method `{}` is not implemented yet", other.method()),
+ },
+ )),
+ }
+ }
+
+ fn handled_request_for_authorized_action<F>(
+ &self,
+ connection: &RadrootsNostrSignerConnectionRecord,
+ action: RadrootsNostrSignerRequestAction,
+ on_allowed: F,
+ ) -> Result<RadrootsNostrSignerHandledRequest, RadrootsNostrSignerError>
+ where
+ F: FnOnce() -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError>,
+ {
+ handled_request_for_action(connection, action, on_allowed)
+ }
+
+ fn evaluate_request_with_policy(
+ &self,
+ connection: &RadrootsNostrSignerConnectionRecord,
+ request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<RadrootsNostrSignerPreparedRequestEvaluation, RadrootsNostrSignerError> {
+ if let Some(reason) =
+ self.policy
+ .prepare_request(&self.backend, connection, &request_message)?
+ {
+ let audit = self.backend.record_request(
+ &connection.connection_id,
+ &request_message.id,
+ request_message.request.method(),
+ RadrootsNostrSignerRequestDecision::Denied,
+ Some(reason.clone()),
+ )?;
+ return Ok(RadrootsNostrSignerPreparedRequestEvaluation::Denied { reason, audit });
+ }
+
+ Ok(RadrootsNostrSignerPreparedRequestEvaluation::Evaluation(
+ self.backend
+ .evaluate_request(&connection.connection_id, request_message)?,
+ ))
+ }
+
+ fn lookup_connection(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ ) -> Result<
+ Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrConnectResponse>,
+ RadrootsNostrSignerError,
+ > {
+ Ok(
+ match self.backend.lookup_session(&client_public_key, None)? {
+ RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(connection),
+ RadrootsNostrSignerSessionLookup::None => {
+ Err(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "unauthorized".to_owned(),
+ })
+ }
+ RadrootsNostrSignerSessionLookup::Ambiguous(_) => {
+ Err(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "ambiguous client sessions".to_owned(),
+ })
+ }
+ },
+ )
+ }
+}
+
impl RadrootsNostrSignerHandledRequest {
pub fn respond(response: RadrootsNostrConnectResponse) -> Self {
Self::respond_for_connection(None, response)
@@ -217,6 +715,26 @@ impl RadrootsNostrSignerHandledRequest {
}
}
+impl RadrootsNostrSignerHandledRequestOutcome {
+ pub fn new(
+ handled_request: RadrootsNostrSignerHandledRequest,
+ audit: Option<RadrootsNostrSignerRequestAuditRecord>,
+ ) -> Self {
+ Self {
+ handled_request,
+ audit,
+ }
+ }
+
+ pub fn respond(response: RadrootsNostrConnectResponse) -> Self {
+ Self::new(RadrootsNostrSignerHandledRequest::respond(response), None)
+ }
+
+ pub fn ignore() -> Self {
+ Self::new(RadrootsNostrSignerHandledRequest::Ignore, None)
+ }
+}
+
pub fn connect_response_outcome(
connection: &RadrootsNostrSignerConnectionRecord,
secret: Option<String>,
@@ -290,3 +808,348 @@ where
}
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ RadrootsNostrSignerHandledRequest, RadrootsNostrSignerNip46ConnectDecision,
+ RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Policy,
+ RadrootsNostrSignerNip46Signer,
+ };
+ use crate::backend::{RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend};
+ use crate::error::RadrootsNostrSignerError;
+ use crate::model::{RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState};
+ use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay};
+ use nostr::UnsignedEvent;
+ use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
+ use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrPublicKey};
+ use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
+ RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ };
+
+ #[derive(Clone)]
+ struct TestSigner {
+ signer_identity: RadrootsIdentity,
+ user_identity: RadrootsIdentity,
+ }
+
+ #[derive(Clone)]
+ struct TestPolicy;
+
+ impl RadrootsNostrSignerNip46Signer for TestSigner {
+ fn signer_public_key_hex(&self) -> String {
+ self.signer_identity.public_key().to_hex()
+ }
+
+ fn decrypt_request(
+ &self,
+ _client_public_key: &RadrootsNostrPublicKey,
+ ciphertext: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(ciphertext.to_owned())
+ }
+
+ fn encrypt_response(
+ &self,
+ _client_public_key: &RadrootsNostrPublicKey,
+ payload: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(payload.to_owned())
+ }
+
+ fn user_identity(&self) -> RadrootsIdentityPublic {
+ self.user_identity.to_public()
+ }
+
+ fn sign_user_event(
+ &self,
+ unsigned_event: UnsignedEvent,
+ ) -> Result<RadrootsNostrEvent, RadrootsNostrSignerError> {
+ let _ = unsigned_event;
+ Err(RadrootsNostrSignerError::Sign(
+ "test signer does not sign events".to_owned(),
+ ))
+ }
+
+ fn nip04_encrypt(
+ &self,
+ _public_key: &RadrootsNostrPublicKey,
+ plaintext: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(plaintext.to_owned())
+ }
+
+ fn nip04_decrypt(
+ &self,
+ _public_key: &RadrootsNostrPublicKey,
+ ciphertext: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(ciphertext.to_owned())
+ }
+
+ fn nip44_encrypt(
+ &self,
+ _public_key: &RadrootsNostrPublicKey,
+ plaintext: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(plaintext.to_owned())
+ }
+
+ fn nip44_decrypt(
+ &self,
+ _public_key: &RadrootsNostrPublicKey,
+ ciphertext: &str,
+ ) -> Result<String, RadrootsNostrSignerError> {
+ Ok(ciphertext.to_owned())
+ }
+ }
+
+ impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for TestPolicy {
+ fn connect_decision(
+ &self,
+ _client_public_key: &RadrootsNostrPublicKey,
+ ) -> RadrootsNostrSignerNip46ConnectDecision {
+ RadrootsNostrSignerNip46ConnectDecision::Allow
+ }
+
+ fn connect_rate_limit_denied_reason(
+ &self,
+ _client_public_key: &RadrootsNostrPublicKey,
+ ) -> Option<String> {
+ None
+ }
+
+ fn approval_requirement_for_client(
+ &self,
+ _client_public_key: &RadrootsNostrPublicKey,
+ ) -> Option<RadrootsNostrSignerApprovalRequirement> {
+ Some(RadrootsNostrSignerApprovalRequirement::NotRequired)
+ }
+
+ fn filtered_requested_permissions(
+ &self,
+ requested_permissions: &RadrootsNostrConnectPermissions,
+ ) -> RadrootsNostrConnectPermissions {
+ requested_permissions.clone()
+ }
+
+ fn auto_granted_permissions(
+ &self,
+ requested_permissions: &RadrootsNostrConnectPermissions,
+ ) -> RadrootsNostrConnectPermissions {
+ requested_permissions.clone()
+ }
+
+ fn prepare_request(
+ &self,
+ _backend: &B,
+ _connection: &crate::model::RadrootsNostrSignerConnectionRecord,
+ _request_message: &RadrootsNostrConnectRequestMessage,
+ ) -> Result<Option<String>, RadrootsNostrSignerError> {
+ Ok(None)
+ }
+ }
+
+ fn test_signer() -> TestSigner {
+ TestSigner {
+ signer_identity: RadrootsIdentity::from_secret_key_str(
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )
+ .expect("signer identity"),
+ user_identity: RadrootsIdentity::from_secret_key_str(
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ )
+ .expect("user identity"),
+ }
+ }
+
+ fn embedded_backend() -> RadrootsNostrEmbeddedSignerBackend {
+ RadrootsNostrEmbeddedSignerBackend::new(
+ crate::manager::RadrootsNostrSignerManager::new_in_memory(),
+ test_signer().signer_identity.clone(),
+ )
+ .expect("embedded backend")
+ }
+
+ fn handler_with_backend(
+ backend: RadrootsNostrEmbeddedSignerBackend,
+ ) -> RadrootsNostrSignerNip46Handler<RadrootsNostrEmbeddedSignerBackend, TestPolicy, TestSigner>
+ {
+ RadrootsNostrSignerNip46Handler::new(
+ backend,
+ TestPolicy,
+ vec![primary_relay()],
+ test_signer(),
+ )
+ }
+
+ fn connect_request(secret: Option<&str>) -> RadrootsNostrConnectRequestMessage {
+ let signer_public_key = test_signer().signer_identity.public_key();
+ RadrootsNostrConnectRequestMessage::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: signer_public_key,
+ secret: secret.map(ToOwned::to_owned),
+ requested_permissions: vec![RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ )]
+ .into(),
+ },
+ )
+ }
+
+ #[test]
+ fn handler_registers_connections_and_returns_audit_for_authorized_requests() {
+ let backend = embedded_backend();
+ let handler = handler_with_backend(backend.clone());
+ let client_public_key = fixture_carol_public_key();
+
+ let connect = handler
+ .handle_request(client_public_key, connect_request(None))
+ .expect("connect outcome");
+ assert!(connect.audit.is_none());
+ match connect.handled_request {
+ RadrootsNostrSignerHandledRequest::Respond { response, .. } => {
+ assert_eq!(response, RadrootsNostrConnectResponse::ConnectAcknowledged);
+ }
+ other => panic!("unexpected connect outcome: {other:?}"),
+ }
+
+ let ping = handler
+ .handle_request(
+ client_public_key,
+ RadrootsNostrConnectRequestMessage::new(
+ "req-ping",
+ RadrootsNostrConnectRequest::Ping,
+ ),
+ )
+ .expect("ping outcome");
+ match ping.handled_request {
+ RadrootsNostrSignerHandledRequest::Respond { response, .. } => {
+ assert_eq!(response, RadrootsNostrConnectResponse::Pong);
+ }
+ other => panic!("unexpected ping outcome: {other:?}"),
+ }
+ let audit = ping.audit.expect("audit");
+ assert_eq!(audit.request_id.as_str(), "req-ping");
+ assert_eq!(
+ backend
+ .find_connections_by_client_public_key(&client_public_key)
+ .expect("connections")
+ .len(),
+ 1
+ );
+ }
+
+ #[test]
+ fn handler_ignores_reused_consumed_connect_secrets() {
+ let backend = embedded_backend();
+ let handler = handler_with_backend(backend.clone());
+ let client_public_key = fixture_carol_public_key();
+ let secret = "connect-secret";
+
+ let first = handler
+ .handle_request(client_public_key, connect_request(Some(secret)))
+ .expect("first connect");
+ assert!(first.audit.is_none());
+
+ let connection = backend
+ .find_connections_by_client_public_key(&client_public_key)
+ .expect("connections")
+ .into_iter()
+ .next()
+ .expect("connection");
+ backend
+ .mark_connect_secret_consumed(&connection.connection_id)
+ .expect("consume secret");
+
+ let reused = handler_with_backend(backend)
+ .handle_request(client_public_key, connect_request(Some(secret)))
+ .expect("reused outcome");
+ assert_eq!(
+ reused.handled_request,
+ RadrootsNostrSignerHandledRequest::Ignore
+ );
+ }
+
+ #[test]
+ fn sign_event_response_rejects_wrong_user_pubkey() {
+ let codec = super::RadrootsNostrSignerNip46Codec::new(test_signer());
+ let response = codec
+ .sign_event_response(
+ serde_json::from_value(serde_json::json!({
+ "pubkey": fixture_alice_identity().public_key_hex,
+ "created_at": 1,
+ "kind": 1,
+ "tags": [],
+ "content": "hello",
+ }))
+ .expect("unsigned event"),
+ )
+ .expect("response");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "sign_event pubkey does not match the managed user identity".to_owned(),
+ }
+ );
+ }
+
+ #[test]
+ fn connect_decision_enum_covers_all_states() {
+ assert_eq!(
+ [
+ RadrootsNostrSignerNip46ConnectDecision::Allow,
+ RadrootsNostrSignerNip46ConnectDecision::RequireApproval,
+ RadrootsNostrSignerNip46ConnectDecision::Deny,
+ ]
+ .len(),
+ 3
+ );
+ }
+
+ #[test]
+ fn connect_request_keeps_requested_permissions() {
+ let request = connect_request(None);
+ assert_eq!(
+ request.request,
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: test_signer().signer_identity.public_key(),
+ secret: None,
+ requested_permissions: vec![RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::Nip04Encrypt,
+ )]
+ .into(),
+ }
+ );
+ }
+
+ #[test]
+ fn handler_registration_initializes_non_terminal_connection_state() {
+ let backend = embedded_backend();
+ let handler = handler_with_backend(backend.clone());
+ let _ = handler
+ .handle_request(fixture_carol_public_key(), connect_request(None))
+ .expect("connect");
+ let connection = backend
+ .find_connections_by_client_public_key(&fixture_carol_public_key())
+ .expect("connections")
+ .into_iter()
+ .next()
+ .expect("connection");
+ assert!(matches!(
+ connection.auth_state,
+ RadrootsNostrSignerAuthState::NotRequired
+ | RadrootsNostrSignerAuthState::Pending
+ | RadrootsNostrSignerAuthState::Authorized
+ ));
+ assert_eq!(
+ connection.user_identity.id,
+ test_signer().user_identity().id
+ );
+ }
+}