commit dcc81ee6a5c50a20868555a233ea27221a6e3af5
parent 5aa6c81c78652e503fa2160356ca67d8d83c2a38
Author: triesap <tyson@radroots.org>
Date: Fri, 17 Jul 2026 22:38:58 +0000
nip46: harden relay request handling
- validate request signatures, recipients, and normalized identifiers
- reject duplicate event delivery with a bounded replay cache
- prove signer identity, permission ceilings, and relay switching
- document public approval, metadata, and logout behavior
Diffstat:
4 files changed, 415 insertions(+), 3 deletions(-)
diff --git a/.env.example b/.env.example
@@ -77,6 +77,8 @@ MYC_DISCOVERY_METADATA_WEBSITE=https://radroots.org
MYC_DISCOVERY_METADATA_PICTURE=
MYC_POLICY_CONNECTION_APPROVAL=explicit_user
+# Client-supplied connect metadata is display-only and never changes approval,
+# authentication, permission grants, or signing authorization.
# comma-separated nostr pubkeys that should auto-connect
# MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=
# comma-separated nostr pubkeys that should always be denied
@@ -100,7 +102,12 @@ MYC_POLICY_AUTH_PENDING_TTL_SECS=900
MYC_TRANSPORT_ENABLED=true
MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10
+# Ordered comma-separated NIP-46 relay URLs. Relay hosting and lifecycle remain
+# external to Myc; production operators should use their approved secure relay
+# transport rather than copying the loopback example.
MYC_TRANSPORT_RELAY_URLS=ws://127.0.0.1:8080
+# Logout acknowledgement delivery follows this policy and is finalized before
+# session revocation; failed acknowledgement delivery is retried on restart.
MYC_TRANSPORT_DELIVERY_POLICY=any
# set MYC_TRANSPORT_DELIVERY_QUORUM when MYC_TRANSPORT_DELIVERY_POLICY=quorum
# MYC_TRANSPORT_DELIVERY_QUORUM=2
diff --git a/README b/README
@@ -3,6 +3,36 @@
This is the README for `myc` which provides a Nostr remote signer for
standalone and application-embedded clients.
+## NIP-46 runtime contract
+
+Myc listens for encrypted kind-24133 requests on the ordered relay set in
+`MYC_TRANSPORT_RELAY_URLS`. The signer transport identity authors and encrypts
+protocol responses; the separate user identity is returned by
+`get_public_key` and signs user events. Client-supplied connect metadata is a
+bounded, display-only hint and never changes approval, authentication, or
+permissions.
+
+The public approval default is `explicit_user`. Trusted and denied clients,
+permission ceilings, allowed signing kinds, auth challenges, relay switching,
+and delivery policy are configured explicitly through the variables documented
+in `.env.example`. A successful `logout` publishes its acknowledgement before
+revoking the session. Failed acknowledgement delivery remains recoverable on
+restart, while requests from a revoked session remain unauthorized until a new
+connect is approved.
+
+Myc rejects invalid signatures, wrong recipient tags, empty request IDs,
+malformed ciphertext, and duplicate event delivery before dispatch. Runtime
+state and audit output do not log client private keys or raw connection URIs.
+
+Use the repository-owned Nix lanes for validation:
+
+```text
+nix run .#fmt
+nix run .#check
+nix run .#test
+nix run .#release-acceptance
+```
+
## Copyright
Except as otherwise noted, all files in the `myc` distribution are
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -1,3 +1,4 @@
+use std::collections::{HashSet, VecDeque};
use std::future::Future;
use std::sync::Arc;
@@ -14,7 +15,7 @@ use radroots_nostr_signer::prelude::{
RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome,
RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Signer,
RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestEvaluation,
- RadrootsNostrSignerSessionLookup, RadrootsNostrSignerWorkflowId,
+ RadrootsNostrSignerRequestId, RadrootsNostrSignerSessionLookup, RadrootsNostrSignerWorkflowId,
};
use tokio::sync::broadcast;
@@ -45,6 +46,37 @@ pub struct MycNip46Service {
type MycNip46HandledOutcome = RadrootsNostrSignerHandledRequestOutcome;
+const NIP46_REPLAY_CACHE_CAPACITY: usize = 4_096;
+
+struct MycNip46ReplayGuard {
+ capacity: usize,
+ event_ids: HashSet<String>,
+ insertion_order: VecDeque<String>,
+}
+
+impl MycNip46ReplayGuard {
+ fn new(capacity: usize) -> Self {
+ Self {
+ capacity,
+ event_ids: HashSet::with_capacity(capacity),
+ insertion_order: VecDeque::with_capacity(capacity),
+ }
+ }
+
+ fn accept(&mut self, event_id: String) -> bool {
+ if self.capacity == 0 || !self.event_ids.insert(event_id.clone()) {
+ return false;
+ }
+ self.insertion_order.push_back(event_id);
+ while self.insertion_order.len() > self.capacity {
+ if let Some(expired) = self.insertion_order.pop_front() {
+ self.event_ids.remove(expired.as_str());
+ }
+ }
+ true
+ }
+}
+
#[derive(Clone)]
struct MycNip46Signer {
signer: MycSignerContext,
@@ -171,7 +203,29 @@ impl MycNip46Handler {
&self,
event: &RadrootsNostrEvent,
) -> Result<RadrootsNostrConnectRequestMessage, MycError> {
- self.handler.parse_request_event(event).map_err(Into::into)
+ if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
+ return Err(MycError::InvalidOperation(
+ "NIP-46 request event has the wrong kind".to_owned(),
+ ));
+ }
+ event.verify().map_err(|_| {
+ MycError::InvalidOperation(
+ "NIP-46 request event has an invalid id or signature".to_owned(),
+ )
+ })?;
+
+ let signer_public_key = self.signer.signer_identity().public_key();
+ let mut recipients = event.tags.public_keys();
+ if recipients.next() != Some(&signer_public_key) || recipients.next().is_some() {
+ return Err(MycError::InvalidOperation(
+ "NIP-46 request event must have exactly one signer recipient".to_owned(),
+ ));
+ }
+
+ let mut request_message = self.handler.parse_request_event(event)?;
+ request_message.id =
+ RadrootsNostrSignerRequestId::parse(request_message.id.as_str())?.into_string();
+ Ok(request_message)
}
pub fn build_response_event(
@@ -322,6 +376,7 @@ impl MycNip46Service {
let filter = self.handler.filter()?;
let mut notifications = self.transport.client().notifications();
let subscription = self.transport.client().subscribe(filter, None).await?;
+ let mut replay_guard = MycNip46ReplayGuard::new(NIP46_REPLAY_CACHE_CAPACITY);
tracing::info!(
subscription_id = %subscription.val,
relay_count = self.transport.relays().len(),
@@ -356,6 +411,10 @@ impl MycNip46Service {
continue;
}
};
+ if !replay_guard.accept(event.id.to_hex()) {
+ tracing::warn!(event_id = %event.id, "discarding replayed NIP-46 request event");
+ continue;
+ }
let request_id = request_message.id.clone();
let handled_outcome = match self.handler.handle_request(event.pubkey, request_message) {
@@ -976,6 +1035,16 @@ mod tests {
}
#[test]
+ fn replay_guard_rejects_duplicates_and_bounds_retention() {
+ let mut guard = super::MycNip46ReplayGuard::new(2);
+ assert!(guard.accept("event-1".to_owned()));
+ assert!(!guard.accept("event-1".to_owned()));
+ assert!(guard.accept("event-2".to_owned()));
+ assert!(guard.accept("event-3".to_owned()));
+ assert!(guard.accept("event-1".to_owned()));
+ }
+
+ #[test]
fn connect_registers_client_and_echoes_secret() {
let runtime = runtime();
let handler = handler(&runtime);
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -580,6 +580,22 @@ fn build_request_event(
request_message: RadrootsNostrConnectRequestMessage,
created_at_unix: u64,
) -> Event {
+ build_request_event_with_recipient(
+ client_identity,
+ signer_public_key,
+ signer_public_key,
+ request_message,
+ created_at_unix,
+ )
+}
+
+fn build_request_event_with_recipient(
+ client_identity: &RadrootsIdentity,
+ signer_public_key: PublicKey,
+ recipient_public_key: PublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ created_at_unix: u64,
+) -> Event {
let payload = serde_json::to_string(&request_message).expect("request payload");
let ciphertext = nip44::encrypt(
client_identity.keys().secret_key(),
@@ -589,7 +605,7 @@ fn build_request_event(
)
.expect("encrypt request");
EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
- .tags([Tag::public_key(signer_public_key)])
+ .tags([Tag::public_key(recipient_public_key)])
.custom_created_at(Timestamp::from(created_at_unix))
.sign_with_keys(client_identity.keys())
.expect("sign request event")
@@ -939,6 +955,296 @@ async fn live_listener_rejects_denied_clients_without_registering_connection() -
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn live_listener_discards_malformed_and_replayed_request_events() -> TestResult<()> {
+ let relay = TestRelay::spawn().await?;
+ let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired);
+ let runtime = test_runtime.runtime.clone();
+ let signer_public_key = runtime.signer_identity().public_key();
+ let client_identity =
+ identity("3434343434343434343434343434343434343434343434343434343434343434");
+ let other_identity =
+ identity("3535353535353535353535353535353535353535353535353535353535353535");
+ let base_created_at = Timestamp::now().as_secs();
+
+ let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
+ let service_runtime = runtime.clone();
+ let listener_task = tokio::spawn(async move {
+ service_runtime
+ .run_until(async {
+ let _ = shutdown_rx.await;
+ })
+ .await
+ });
+ relay.wait_for_subscription_count(1).await?;
+
+ let mut invalid_author = build_request_event(
+ &client_identity,
+ signer_public_key,
+ connect_request_message("invalid-author", signer_public_key, "invalid-author-secret"),
+ base_created_at,
+ );
+ invalid_author.pubkey = other_identity.public_key();
+ publish_event(relay.url(), &invalid_author).await?;
+
+ let wrong_recipient = build_request_event_with_recipient(
+ &client_identity,
+ signer_public_key,
+ other_identity.public_key(),
+ connect_request_message(
+ "wrong-recipient",
+ signer_public_key,
+ "wrong-recipient-secret",
+ ),
+ base_created_at + 1,
+ );
+ publish_event(relay.url(), &wrong_recipient).await?;
+
+ let invalid_request_id = build_request_event(
+ &client_identity,
+ signer_public_key,
+ connect_request_message("", signer_public_key, "invalid-request-id-secret"),
+ base_created_at + 2,
+ );
+ publish_event(relay.url(), &invalid_request_id).await?;
+
+ let malformed_ciphertext = EventBuilder::new(
+ Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND),
+ "not-nip44-ciphertext",
+ )
+ .tags([Tag::public_key(signer_public_key)])
+ .custom_created_at(Timestamp::from(base_created_at + 3))
+ .sign_with_keys(client_identity.keys())?;
+ publish_event(relay.url(), &malformed_ciphertext).await?;
+
+ sleep(Duration::from_millis(200)).await;
+ assert!(runtime.signer_manager()?.list_connections()?.is_empty());
+ assert!(
+ relay
+ .published_events_by_author(signer_public_key)
+ .await
+ .is_empty()
+ );
+
+ let valid_request = build_request_event(
+ &client_identity,
+ signer_public_key,
+ connect_request_message("valid-after-invalid", signer_public_key, "valid-secret"),
+ base_created_at + 4,
+ );
+ publish_event(relay.url(), &valid_request).await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 1)
+ .await?;
+ assert_eq!(responses.len(), 1);
+ assert_eq!(
+ decrypt_response(&client_identity, signer_public_key, &responses[0]).id,
+ "valid-after-invalid"
+ );
+ wait_for_connection_count(&runtime, 1).await?;
+
+ publish_event(relay.url(), &valid_request).await?;
+ sleep(Duration::from_millis(200)).await;
+ assert_eq!(
+ relay
+ .published_events_by_author(signer_public_key)
+ .await
+ .len(),
+ 1
+ );
+ assert_eq!(runtime.delivery_outbox_store().list_all()?.len(), 1);
+
+ let _ = shutdown_tx.send(());
+ listener_task.await??;
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn live_listener_enforces_signing_ceiling_and_switch_relay_permission() -> TestResult<()> {
+ let relay = TestRelay::spawn().await?;
+ let test_runtime = MycTestRuntime::new_with_transport_config(
+ &[relay.url()],
+ MycConnectionApproval::NotRequired,
+ |config| {
+ config.policy.permission_ceiling = "get_public_key,sign_event:1,switch_relays"
+ .parse()
+ .expect("permission ceiling");
+ config.policy.allowed_sign_event_kinds = vec![1];
+ },
+ );
+ let runtime = test_runtime.runtime.clone();
+ let signer_public_key = runtime.signer_identity().public_key();
+ let user_public_key = runtime.user_identity().public_key();
+ assert_ne!(signer_public_key, user_public_key);
+ let client_identity =
+ identity("3636363636363636363636363636363636363636363636363636363636363636");
+ let base_created_at = Timestamp::now().as_secs();
+
+ let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
+ let service_runtime = runtime.clone();
+ let listener_task = tokio::spawn(async move {
+ service_runtime
+ .run_until(async {
+ let _ = shutdown_rx.await;
+ })
+ .await
+ });
+ relay.wait_for_subscription_count(1).await?;
+
+ let connect_request = RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: signer_public_key,
+ secret: None,
+ requested_permissions: "get_public_key,sign_event:1,sign_event:7,switch_relays".parse()?,
+ client_metadata: None,
+ };
+ publish_event(
+ relay.url(),
+ &build_request_event(
+ &client_identity,
+ signer_public_key,
+ RadrootsNostrConnectRequestMessage::new("policy-connect", connect_request.clone()),
+ base_created_at,
+ ),
+ )
+ .await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 1)
+ .await?;
+ let connect_response = RadrootsNostrConnectResponse::from_envelope(
+ &connect_request.method(),
+ decrypt_response(&client_identity, signer_public_key, &responses[0]),
+ )?;
+ assert_eq!(
+ connect_response,
+ RadrootsNostrConnectResponse::ConnectAcknowledged
+ );
+ let connection = runtime
+ .signer_manager()?
+ .list_connections()?
+ .into_iter()
+ .next()
+ .expect("connection");
+ assert_eq!(
+ connection.granted_permissions().to_string(),
+ "get_public_key,sign_event:1,switch_relays"
+ );
+
+ let get_public_key_request = RadrootsNostrConnectRequest::GetPublicKey;
+ publish_event(
+ relay.url(),
+ &build_request_event(
+ &client_identity,
+ signer_public_key,
+ RadrootsNostrConnectRequestMessage::new(
+ "policy-get-public-key",
+ get_public_key_request.clone(),
+ ),
+ base_created_at + 1,
+ ),
+ )
+ .await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 2)
+ .await?;
+ assert_eq!(responses[1].pubkey, signer_public_key);
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &get_public_key_request.method(),
+ decrypt_response(&client_identity, signer_public_key, &responses[1]),
+ )?,
+ RadrootsNostrConnectResponse::UserPublicKey(user_public_key)
+ );
+
+ let unsigned_event = |kind: u16, content: &str| -> TestResult<UnsignedEvent> {
+ Ok(serde_json::from_value(serde_json::json!({
+ "pubkey": user_public_key.to_hex(),
+ "created_at": base_created_at,
+ "kind": kind,
+ "tags": [],
+ "content": content
+ }))?)
+ };
+ let allowed_sign_request =
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event(1, "allowed")?);
+ publish_event(
+ relay.url(),
+ &build_request_event(
+ &client_identity,
+ signer_public_key,
+ RadrootsNostrConnectRequestMessage::new(
+ "policy-sign-allowed",
+ allowed_sign_request.clone(),
+ ),
+ base_created_at + 2,
+ ),
+ )
+ .await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 3)
+ .await?;
+ let allowed_response = RadrootsNostrConnectResponse::from_envelope(
+ &allowed_sign_request.method(),
+ decrypt_response(&client_identity, signer_public_key, &responses[2]),
+ )?;
+ let RadrootsNostrConnectResponse::SignedEvent(signed_event) = allowed_response else {
+ panic!("expected signed event response");
+ };
+ assert_eq!(signed_event.pubkey, user_public_key);
+ signed_event.verify()?;
+
+ let denied_sign_request = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7, "denied")?);
+ publish_event(
+ relay.url(),
+ &build_request_event(
+ &client_identity,
+ signer_public_key,
+ RadrootsNostrConnectRequestMessage::new(
+ "policy-sign-denied",
+ denied_sign_request.clone(),
+ ),
+ base_created_at + 3,
+ ),
+ )
+ .await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 4)
+ .await?;
+ assert!(matches!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &denied_sign_request.method(),
+ decrypt_response(&client_identity, signer_public_key, &responses[3]),
+ )?,
+ RadrootsNostrConnectResponse::Error { error, .. }
+ if error.contains("outside the configured policy ceiling")
+ ));
+
+ let switch_request = RadrootsNostrConnectRequest::SwitchRelays;
+ publish_event(
+ relay.url(),
+ &build_request_event(
+ &client_identity,
+ signer_public_key,
+ RadrootsNostrConnectRequestMessage::new("policy-switch", switch_request.clone()),
+ base_created_at + 4,
+ ),
+ )
+ .await?;
+ let responses = relay
+ .wait_for_published_events_by_author(signer_public_key, 5)
+ .await?;
+ assert_eq!(
+ RadrootsNostrConnectResponse::from_envelope(
+ &switch_request.method(),
+ decrypt_response(&client_identity, signer_public_key, &responses[4]),
+ )?,
+ RadrootsNostrConnectResponse::RelayList(vec![relay.url().parse()?])
+ );
+
+ let _ = shutdown_tx.send(());
+ listener_task.await??;
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn external_nostr_client_compatibility_covers_connect_and_base_methods() -> TestResult<()> {
let relay = TestRelay::spawn().await?;
let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired);