Skip to content

fix(signer): publish nostrconnect response to all URI relays - #3

Merged
DocNR merged 8 commits into
mainfrom
fix/nostrconnect-multi-relay
Apr 18, 2026
Merged

fix(signer): publish nostrconnect response to all URI relays#3
DocNR merged 8 commits into
mainfrom
fix/nostrconnect-multi-relay

Conversation

@DocNR

@DocNRDocNR commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes multiple bugs in Clave's nostrconnect:// handshake that caused login to silently fail with compliant multi-relay clients (plebsvszombies.cc, zap.cooking, any nostr-tools BunkerSigner.fromURI user with a non-relay.powr.build URI), plus two pre-existing concurrency bugs in LightRelay that caused hangs and crashes.

Nothing changes in the bunker:// flow or the NSE/APNs pipeline.

Commits (8)

  1. feat(signer): add parallel multi-relay helpers for nostrconnect handshakeconnectToRelays / publishEventToRelays / fetchEventsFromRelays on AppState, parallelize over URI relays with withTaskGroup. Unit tests for empty-input fast paths and unreachable-URL behavior.
  2. fix(signer): publish nostrconnect response to all URI relays — replace parsedURI.relays.first with publish-to-all; activity log "signed" if any relay accepts, "error" if all fail, "Could not connect to any relay" if zero connect.
  3. chore: bump build to 10
  4. fix(signer): publish RPC responses to URI relays during nostrconnect handshake — thread responseRelays: [LightRelay] through LightSigner.handleRequest so follow-up RPC responses (get_public_key, switch_relays, etc.) land where the client is listening. Falls back to SharedConstants.relayURL for the bunker/NSE path.
  5. fix(relay): mark LightRelay.init as nonisolated for Swift 6 compatibility — project has SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor, which inferred init as MainActor-bound; nonisolated init lets TaskGroup closures construct it off-main.
  6. fix(relay): cancel WebSocket on timeout to prevent sendPing/receive hang — previously, if a relay accepted the WebSocket but never responded to ping (e.g., nostr.wine's NIP-42 AUTH wait), the sendPing continuation leaked and withThrowingTaskGroup couldn't return. Timeout now calls ws.cancel() to force the callback to fire.
  7. fix(relay): guard sendPing continuation against double-resumeURLSessionWebSocketTask.sendPing can invoke its callback more than once when cancel happens shortly after pong; added thread-safe one-shot guard to prevent SWIFT TASK CONTINUATION MISUSE crash.
  8. fix(signer): keep listening after first client RPC so switch_relays can run — was breaking out of the retry loop on handshakeComplete=true and disconnecting, which stranded clients mid-handshake before switch_relays could migrate them to relay.powr.build. Now continues listening for the full ~15s window; handshakeComplete only suppresses republish.

Device verification

  • plebsvszombies.cc (nostr-tools): login ✓
  • zap.cooking (NDK + custom wrapper): login ✓
  • noStrudel with wss://relay.powr.build: regression ✓ (no change)
  • Nostur bunker pair + sign: regression ✓ (no change — didn't touch that path)

Known limitations (not regressions; will address in follow-ups)

  • Signing past the nostrconnect handshake requires the client to call switch_relays to migrate to relay.powr.build. Neither plebsvszombies.cc (older bundled nostr-tools that predates fromURI's switchRelays call) nor zap.cooking (custom wrapper that bypasses NDK's blockUntilReady) actually does this today. Tracked as a follow-up: either encourage upstream fixes or add proxy-per-client-relay subscriptions on the Dell (spawned task).
  • NIP-42 AUTH not implemented. Auth-gated relays (garden.zap.cooking, nostr.wine when in paid mode) reject Clave's publish. Spawned task.
  • connect RPC doesn't validate params[0] (remote-signer-pubkey). Secret check still gates, so not a security issue; hardening tracked as backlog.
  • Error responses omit result field. Spec shows {id, result, error}; we send {id, error}. Most clients don't care; backlog.
  • describe is a non-standard extension method. Harmless; documented for awareness.

Test plan

  • Unit tests: 27/27 pass on iOS 26.4 simulator
  • Release build: clean
  • Debug verification on device: all three scenarios above
  • TestFlight build 10 (internal): installed, login flow verified
  • NIP-46 spec compliance audit: no blockers, minor issues backlogged

Merge

Recommend squash-merge to consolidate the 8-commit history into a single feat(signer): nostrconnect multi-relay handshake commit on main.

🤖 Generated with Claude Code

DocNR added 8 commits April 18, 2026 08:35
Previously Clave only published the connect response to parsedURI.relays.first.
Clients (nostr-tools BunkerSigner.fromURI, NDK, etc.) subscribe on every relay
listed in the nostrconnect URI — if the chosen relay drops the ephemeral
kind:24133 event, the handshake silently times out even though Clave's
activity log shows 'signed'. Now publishes the same signed event to every
connected relay in parallel and fetches client follow-ups from all of them.
…handshake
LightSigner.handleRequest hardcoded relay.powr.build as the response relay.
That's correct for bunker:// (proxy subscribes there → APNs → NSE), but broke
nostrconnect:// follow-up RPCs: get_public_key, switch_relays, etc. went to
relay.powr.build while the client was still subscribed on its URI relays and
never saw the response.
Thread an optional responseRelays: [LightRelay] through handleRequest and
sendErrorResponse. When provided (from handleNostrConnect's already-connected
URI relays), publish to all of them in parallel, best-effort. When nil
(NSE/bunker path), keep original behavior.
This lets switch_relays reach the client on its URI relays, the client
then switches to relay.powr.build, and future RPCs flow the bunker path.
…lity
Project sets SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor, which inferred
LightRelay.init as @MainActor-bound. That caused a Swift 6 warning
when the new AppState multi-relay helpers create LightRelay instances
inside TaskGroup closures (off-main). LightRelay is already
@unchecked Sendable so marking init nonisolated is consistent.
Async methods already auto-hop and don't need the annotation.
LightRelay.connect races ws.sendPing with a Task.sleep timeout. When the
sleep wins, group.cancelAll() is called — but withCheckedThrowingContinuation
does not cooperate with Swift cancellation, so the ping callback never fires,
the continuation leaks, and the TaskGroup can never return. Same pattern in
publishEvent and fetchEvents with ws.receive().
Symptom before this fix: if a relay accepts the WebSocket handshake but stays
silent (e.g. nostr.wine waiting for NIP-42 AUTH before sending pong), the
containing handleNostrConnect hangs forever on the approval sheet's
'Connecting…' spinner.
Pre-existing bug, but the Task 2 multi-relay change makes it trigger reliably
for any URI containing a silent relay. Fix: force ws.cancel on timeout so
pending callbacks resolve with an error. Extracted receiveWithTimeout helper
for the two receive() call sites.
Previous fix called ws.cancel() on timeout to force the sendPing callback
to fire so the continuation could complete. But URLSessionWebSocketTask
can invoke the sendPing callback more than once when cancel happens
shortly after a pong arrives — once with success, then again with the
abort error from cancel. Resuming a CheckedContinuation twice is a
fatal error ("SWIFT TASK CONTINUATION MISUSE").
Fix: wrap the callback body in a thread-safe one-shot guard so any
subsequent invocations are silently dropped.
…an run
Previously handleNostrConnect broke out of its retry loop as soon as one
client reply arrived (handshakeComplete=true) and then disconnected. But
NDK's and nostr-tools' NIP-46 clients send a sequence of RPCs after the
initial connect response: connect(ack) → get_public_key → switch_relays.
Clave was processing only the first of these before disconnecting,
stranding the client on the URI relays instead of letting it migrate to
relay.powr.build via switch_relays. All subsequent sign_event RPCs then
went to relays the proxy doesn't watch, and signing silently failed.
Fix: after a reply is seen, suppress further republish of the connect
response (handshakeComplete guards that) but keep running the retry
loop's listen+fetch windows for the full ~15s budget. Dedupe events
across iterations via a persistent seenEventIds set.
Result: NDK's switch_relays call now receives the ['wss://relay.powr.build']
response and migrates its RPC layer accordingly, so future sign_events
flow through the normal bunker path (proxy → APNs → NSE).
@DocNR
DocNR marked this pull request as ready for review April 18, 2026 17:49
@DocNR
DocNR merged commit f551bdc into mainApr 18, 2026
@DocNR
DocNR deleted the fix/nostrconnect-multi-relay branch April 18, 2026 18:53
DocNR added a commit that referenced this pull request May 2, 2026
Brainstorm review of design-system.md against shipped code surfaced 9
inconsistencies + 1 anti-pattern still present. Fixed everything in one
batch so the next TestFlight archive carries it all.
Code:
- HomeView: drop .padding(.bottom, 8) on SlimIdentityBar invocation —
slim banner owns its outer bottom padding (12pt); stacking another 8pt
on top was double-counting (review #4)
- HomeView: drop .padding(.bottom, 8) inside statsRow — listSectionSpacing(0)
carries the gap to Connected Clients; the residual padding kept the
visible gap excessive after polish round 2 (review #2)
- AccountDetailView: avatarLarge letter fallback opacity 0.25 → 0.22 to
align with SlimIdentityBar's 0.22 (review #3)
- ConnectSheet: add .presentationBackground(Color(.systemGroupedBackground))
— was the last sheet still defaulting to translucent (review #9)
- ApprovalSheet: rename @State capExceeded → showConnectionCapAlert for
naming convention parity with HomeView (review #7)
design-system.md:
- New "Cross-platform applicability" section at the top — clarifies what
carries directly to clave.casa web companion (color tokens, displayLabel
rule, identity-vs-functional zone philosophy, avatar treatments, copy
patterns, anti-patterns) vs what's iOS-only (SwiftUI modifiers, haptics,
sheet/toolbar conventions)
- §3 Typography: corrected initial-letter font scale — AvatarView uses
size*0.35 mono (pubkey) or size*0.4 proportional (name); was wrongly
documented as a single 0.37 (review #1)
- §4 Avatars: added Treatment Selection Rule table (B on neutral bg,
C on saturated theme gradient) + clarified 1-vs-2 letter behavior
(review #5, #6)
- §4 Sizing scale: expanded table to include initial font + border
thickness per slot, with the ~5% border scaling rule (review #8)
- §5 Spacing: explicit "single source of truth" note on slim banner
bottom padding; new "Stats row" subsection capturing the
ultraThinMaterial-on-small-cards-OK rule (review gap #10, #4)
- §6 HomeView gradient: documented palette[0] defensive fallback when
currentAccount is nil (review #13)
- §7 Patterns: new "State variable naming" subsection with the
showCapAlert / showAccountCapAlert / showConnectionCapAlert
convention (review #7 doc side)
- §11 Anti-patterns: audit-point note that ConnectSheet was the last
surface to acquire .presentationBackground (review #9 doc side)
Build green on iOS Simulator 26.4. pbxproj still 41 — assumes user hasn't
yet archived 41; bump to 42 if needed before re-archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit that referenced this pull request Jun 7, 2026
Closes a spec-compliance gap surfaced by external code review of the
TypeScript port (same bug existed verbatim in the Swift port).
Spec algorithm step 4 says: before MAC verify, fail if the embedded
kind/scope on the wire don't match the caller-supplied expected
kind/scope. Our implementation skipped this check on the assumption
that MAC verify covered it — that's only half right.
Why MAC verify alone isn't sufficient:
- The MAC is computed over `nonce || u32_be(kind) || u32_be(scope_len)
|| scope || chacha20_ct`.
- At encrypt time, the encryptor uses (kind, scope) consistent with
what they signed in to the wire's embedded fields.
- On decrypt, our implementation computes the verification MAC using
the CALLER's `context.kind / context.scope`, NOT the embedded
`parts.kind / parts.scope` from `Ciphertext.decode`.
- For a legitimate wire, caller's context == embedded == encryptor's
choice, so MAC matches and decrypt succeeds.
- For a wire whose embedded kind/scope bytes are tampered IN TRANSIT
but whose MAC tag is left intact, our MAC computation still uses
the (untampered) caller context. If that context matches what the
encryptor originally signed in, MAC matches and decrypt
succeeds — silently accepting a tampered wire.
The exploit surface in current usage is narrow because we never expose
`parts.kind / parts.scope` from the public decrypt API (the caller
already knows kind+scope; they passed them as context). But it's a
real spec deviation and weakens the "embedded context is
authenticated" property the v3 design claims.
Fix: explicit `parts.kind == context.kind` check + constant-time
`parts.scope == context.scope` bytes equality, before MAC verify.
Both branches throw `.decryptionFailed` (same case as MAC failure)
so a network observer can't oracle which mismatch tripped it.
Constant-time helper duplicated inline rather than imported from
Encryption.swift because cross-file private exposure for a 12-line
helper adds little.
Two new XCTest cases in NIP44v3Tests.swift verify rejection:
- testDecryptRejectsTamperedEmbeddedKind — flips byte 68 of a valid
wire (low byte of u32 kind), MAC untouched, expects .decryptionFailed.
- testDecryptRejectsTamperedEmbeddedScope — flips byte 73 (first scope
byte) of a valid non-empty-scope wire, MAC untouched, expects
.decryptionFailed.
Both would have silently SUCCEEDED before this commit.
Build bumped 92 → 93. Companion Spectr-side fix lands in
DocNR/spectr@feat/nip44v3-port in a separate commit covering the
same defect + #2/#3/#4 from the same review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR