myc

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

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:
M.env.example | 7+++++++
MREADME | 30++++++++++++++++++++++++++++++
Msrc/transport/nip46.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/nip46_e2e.rs | 308++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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);