fix(nip46): remote signers (Primal) stamp events with signer pubkey, breaking uploads/zaps/auth#452
Conversation
NDKNip46Signer initializes its remoteUser to the SIGNER (bunker) pubkey in the
constructor and never updates it — not even after a successful connect. user()
returns remoteUser, and NDKEvent.toNostrEvent() stamps every event's author
from signer.user(). For bunkers whose signer pubkey differs from the user
pubkey (e.g. Primal remote signing, which is valid per NIP-46), every signed
event is stamped with the signer's pubkey while the bunker signs with the
user's key — producing events whose signature fails verification.
This silently broke relay NIP-42 auth ('failed to authenticate'), NIP-98
uploads to nostr.build (401 'invalid nip-98 token'), zap requests, notes, and
reactions for those users. Local keys and Amber are unaffected because their
signer pubkey equals the user pubkey.
After learning the real user pubkey via get_public_key, bind it onto the
signer's remoteUser and ndk.activeUser at all three NIP-46 entry points (fresh
bunker connect, reconnect/restore, nostrconnect pairing). remotePubkey (the
RPC routing target) is intentionally left untouched.
Three components each carried their own copy of the nostr.build NIP-98 upload logic. Consolidate them onto mediaUpload.ts as the single source of truth and add resilience for remote signers: - warm the signer (blockUntilReady) before stamping the NIP-98 created_at, so a slow bunker connect/approval doesn't eat into the token's freshness window - retry once on an auth failure with a freshly-signed, freshly-stamped token - add an expiration tag (NIP-98 hygiene) - fix ImageUploader's signing hint, which only mentioned browser extensions MediaUploader, ImageUploader, and ProfileEditModal now call the shared helper; ProfileEditModal passes its profile/banner endpoint via the url option.
There was a problem hiding this comment.
Pull request overview
Fixes NIP-46 remote-signer sessions (notably Primal where signer pubkey ≠ user pubkey) by ensuring newly signed events are authored/stamped with the authenticated user pubkey rather than the bunker/signer pubkey, restoring validity for uploads, zaps, and relay auth. Also consolidates and hardens nostr.build upload handling behind a shared helper and documents the root cause + fix.
Changes:
- Bind the authenticated NIP-46 user onto the signer/NDK via
bindUserToSigner()sosigner.user()reflects the real user pubkey. - Consolidate nostr.build uploads into
src/lib/mediaUpload.tswith signer warm-up, per-attempt fresh NIP-98 auth headers, and one retry on auth-like failures. - Update UI components to use the shared upload helper; add troubleshooting documentation; bump package version.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/mediaUpload.ts | New shared nostr.build upload helper with signer warm-up + retry behavior for remote signers. |
| src/lib/authManager.ts | Binds authenticated NIP-46 user pubkey onto signer/NDK to prevent author/pubkey mismatch in signed events. |
| src/components/ProfileEditModal.svelte | Switch avatar/banner uploads to the shared nostr.build helper. |
| src/components/MediaUploader.svelte | Switch composer media uploads to the shared nostr.build helper. |
| src/components/ImageUploader.svelte | Delegate upload logic to shared helper and update signer-approval hint text. |
| package.json | Version bump. |
| docs/troubleshooting/NIP46_REMOTE_SIGNER_SIGNING.md | Write-up documenting symptoms, root cause, fix, and verification steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function warmSigner(ndk: NDK): Promise<void> { | ||
| const signer = ndk.signer as { blockUntilReady?: () => Promise<unknown> } | undefined; | ||
| if (!signer?.blockUntilReady) return; | ||
| try { | ||
| await Promise.race([ | ||
| signer.blockUntilReady(), | ||
| new Promise((_, reject) => | ||
| setTimeout(() => reject(new Error('signer warm timeout')), 30000) | ||
| ) | ||
| ]); | ||
| } catch { | ||
| // Ignore — proceed and let the sign attempt report the real problem. | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in 7e848bb — ran Prettier on mediaUpload.ts (now space-indented per useTabs:false). Note the other touched files (authManager.ts, the three Svelte components) already drift from Prettier on main, so I left their formatting alone to avoid unrelated reformatting churn in this PR.
| ['expiration', String(now + 60)] | ||
| ]; | ||
|
|
||
| await template.sign(); |
There was a problem hiding this comment.
Good catch — restored in 7e848bb. Added signWithTimeout() wrapping template.sign() in a 60s Promise.race so a blocked extension popup or unanswered remote-signer approval surfaces an error instead of hanging. Used 60s (vs the old 30s) since remote signers like Amber/Primal need manual approval, and warmSigner has already absorbed the connection latency before this point.
- Reformat mediaUpload.ts with Prettier (repo uses useTabs:false). - Wrap the NIP-98 sign step in a 60s timeout (signWithTimeout) so a blocked extension popup or unanswered remote-signer approval surfaces an error instead of hanging the upload indefinitely — restores the per-component timeout behavior that the shared helper had dropped.
Problem
Users logged in via a NIP-46 remote signer whose signer pubkey differs from their user pubkey — most notably Primal remote signing — had every signed action fail, usually silently:
401 Unauthorized, please provide a valid nip-98 token9734) is rejected (invalid signature)error: failed to authenticateLocal keys (nsec), NIP-07 extensions, and Amber are not affected.
Root cause
NDK's
NDKNip46SignerinitializesremoteUserto the signer (bunker) pubkey and never updates it — even after a successful connect.user()returnsremoteUser, andNDKEvent.toNostrEvent()stamps an event's author fromsigner.user(). So every signed event is stamped with the signer pubkey, while the bunker actually signs with the user key → the event claims one pubkey but carries a signature valid for another → every verifier (nostr.build, relays, LNURL callbacks) rejects it.NIP-46 explicitly allows signer pubkey ≠ user pubkey, which Primal uses. For signers where they're equal, stamping the "signer" pubkey happens to be correct, so the bug was invisible. Primal compounds it: its
connecthandshake is rejected (We don't accept connect requests with new secret) soblockUntilReady()times out; the app recovers viaget_public_keyto log the user in, but that pubkey was never propagated onto the signer object.Fix
After learning the real user pubkey, bind it onto the signer and NDK (
bindUserToSignerinauthManager.ts), at all three NIP-46 entry points (freshbunker://connect, reconnect/restore,nostrconnect://pairing).remotePubkey(the RPC routing target) is intentionally left untouched. BecausetoNostrEvent()resolves the author fromsigner.user(), this one fix repairs uploads, zaps, relay auth, notes, and reactions together.Also included (upload hardening)
The four duplicated nostr.build upload implementations are consolidated into
mediaUpload.tsand made more robust for remote signers: warm the signer before stamping the NIP-98created_at, retry once on an auth failure with a fresh token, add anexpirationtag, and fixImageUploader's extension-only signing hint. This is defense-in-depth; the signature mismatch above was the actual blocker.Verification
Verified live with a Primal remote signer: image uploads and zaps both succeed. A signed event now carries the user pubkey and
verifyEvent()passes.Docs
Full writeup:
docs/troubleshooting/NIP46_REMOTE_SIGNER_SIGNING.md