Skip to content

fix(nip46): remote signers (Primal) stamp events with signer pubkey, breaking uploads/zaps/auth#452

Merged
spe1020 merged 5 commits into
zapcooking:mainfrom
dmnyc:fix/nip46-remote-signer-signing
Jun 18, 2026
Merged

fix(nip46): remote signers (Primal) stamp events with signer pubkey, breaking uploads/zaps/auth#452
spe1020 merged 5 commits into
zapcooking:mainfrom
dmnyc:fix/nip46-remote-signer-signing

Conversation

@dmnyc

@dmnyc dmnyc commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

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:

  • Image uploads to nostr.build → 401 Unauthorized, please provide a valid nip-98 token
  • Zaps → the zap request (kind 9734) is rejected (invalid signature)
  • Relay NIP-42 autherror: failed to authenticate
  • Notes / reactions would be rejected by relays

Local keys (nsec), NIP-07 extensions, and Amber are not affected.

Root cause

NDK's NDKNip46Signer initializes remoteUser to the signer (bunker) pubkey and never updates it — even after a successful connect. user() returns remoteUser, and NDKEvent.toNostrEvent() stamps an event's author from signer.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 connect handshake is rejected (We don't accept connect requests with new secret) so blockUntilReady() times out; the app recovers via get_public_key to 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 (bindUserToSigner in authManager.ts), at all three NIP-46 entry points (fresh bunker:// connect, reconnect/restore, nostrconnect:// pairing). remotePubkey (the RPC routing target) is intentionally left untouched. Because toNostrEvent() resolves the author from signer.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.ts and made more robust for remote signers: warm the signer before stamping the NIP-98 created_at, retry once on an auth failure with a fresh token, add an expiration tag, and fix ImageUploader'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

dmnyc added 3 commits June 17, 2026 21:25
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() so signer.user() reflects the real user pubkey.
  • Consolidate nostr.build uploads into src/lib/mediaUpload.ts with 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.

Comment thread src/lib/mediaUpload.ts
Comment on lines +18 to +31
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.
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/mediaUpload.ts Outdated
['expiration', String(now + 60)]
];

await template.sign();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@spe1020 spe1020 merged commit f8bfc90 into zapcooking:main Jun 18, 2026
4 checks passed
Sign up for free to 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.

3 participants