From 3adf1be77d91c4f6381ffd4ab938031aae26290b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:44:47 -0400 Subject: [PATCH] fix(nip46): enforce the access-control validator on remote-signer login connectRemoteSigner and useStoredRemoteSigner accepted but never used the `validator` the auth provider passes (accessControl.canAccess) -- so NIP-46 logins bypassed the block/allow gate that nsec/NIP-07 logins enforce. (The validation lived only on the unused Nip46Connector.) Both paths now run the validator against the remote signer's resolved user pubkey BEFORE activating the signer (via _enforceRemoteSignerValidator): the validator throws on a blocked/non-permitted pubkey, or returning false rejects with code remote-signer-access-denied. A stored session rejected by the validator is forgotten so a blocked pubkey can't keep retrying. Residual (noted in TODO): the silent startup auto-restore (scheduleStoredRemoteSignerRestore) still reconnects without a validator. Test: tests/nostr/nip46-prepare-handshake.test.mjs (validator throws / false / true / absent). Full suite green. Co-Authored-By: Claude Opus 4.8 --- js/nostr/managers/SignerManager.js | 37 +++++++++++++++++++- tests/nostr/nip46-prepare-handshake.test.mjs | 30 ++++++++++++++++ todo/TODO_2026-06-16_r2-followups.md | 16 +++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 1ec195d5..7271bad2 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -722,6 +722,24 @@ export class SignerManager { }; } + /** + * Runs the access-control validator against a remote signer's resolved user + * pubkey. The validator (from the auth provider) throws on denial (blocked / + * not permitted) or returns false; either rejects the login. No-op when no + * validator is supplied. + */ + async _enforceRemoteSignerValidator(validator, userPubkey) { + if (typeof validator !== "function") { + return; + } + const isValid = await validator(userPubkey); + if (isValid === false) { + const error = new Error("Access denied."); + error.code = "remote-signer-access-denied"; + throw error; + } + } + async connectRemoteSigner({ connectionString, remember = true, @@ -735,6 +753,7 @@ export class SignerManager { onStatus, handshakeTimeoutMs, passphrase, + validator, } = {}) { const parsed = parseNip46ConnectionString(connectionString); if (!parsed) { @@ -855,6 +874,11 @@ export class SignerManager { } const userPubkey = await this.nip46Client.getUserPubkey(); + + // Enforce access control BEFORE activating the signer, so a blocked / + // non-permitted pubkey can't establish a session via a remote signer. + await this._enforceRemoteSignerValidator(validator, userPubkey); + this.pubkey = userPubkey; this.setActiveSigner(this.nip46Client.getActiveSigner()); @@ -876,6 +900,10 @@ export class SignerManager { typeof normalizedOptions.passphrase === "string" ? normalizedOptions.passphrase : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; let stored = readStoredNip46Session(); if (!stored) { @@ -934,6 +962,11 @@ export class SignerManager { // Just verify we can ping or get pubkey await this.nip46Client.ensureSubscription(); const pubkey = await this.nip46Client.getUserPubkey(); + + // Re-check access control when restoring a stored session, so a pubkey + // that has since been blocked can't silently reconnect. + await this._enforceRemoteSignerValidator(validator, pubkey); + this.pubkey = pubkey; this.setActiveSigner(this.nip46Client.getActiveSigner()); @@ -942,7 +975,9 @@ export class SignerManager { } catch (err) { this.nip46Client = null; this.emitRemoteSignerChange({ state: "error", error: err }); - if (forgetOnError) { + // Always forget a stored session that was rejected by access control — + // no point keeping a blocked pubkey's session to retry. + if (forgetOnError || err?.code === "remote-signer-access-denied") { clearStoredNip46Session(); } throw err; diff --git a/tests/nostr/nip46-prepare-handshake.test.mjs b/tests/nostr/nip46-prepare-handshake.test.mjs index 90c8263b..215bacc7 100644 --- a/tests/nostr/nip46-prepare-handshake.test.mjs +++ b/tests/nostr/nip46-prepare-handshake.test.mjs @@ -50,3 +50,33 @@ test("generates a fresh keypair each call", async () => { assert.notEqual(a.clientPublicKey, b.clientPublicKey, "each handshake is a new ephemeral key"); assert.notEqual(a.secret, b.secret); }); + +// Scenario (SCN-nip46-access-control): remote-signer logins must enforce the +// access-control validator before activating the signer, so a blocked / +// non-permitted pubkey can't log in via a remote signer (this was dropped when +// the connection logic moved off the unused Nip46Connector into SignerManager). +test("a validator that throws (blocked pubkey) rejects the login", async () => { + const mgr = makeManager(); + await assert.rejects( + () => + mgr._enforceRemoteSignerValidator(() => { + throw new Error("Your account has been blocked on this platform."); + }, "blockedpub"), + /blocked/i, + ); +}); + +test("a validator returning false rejects with an access-denied code", async () => { + const mgr = makeManager(); + await assert.rejects( + () => mgr._enforceRemoteSignerValidator(() => false, "pub"), + (err) => err?.code === "remote-signer-access-denied", + ); +}); + +test("a passing validator (true) and no validator are both allowed", async () => { + const mgr = makeManager(); + await mgr._enforceRemoteSignerValidator(() => true, "pub"); // resolves + await mgr._enforceRemoteSignerValidator(null, "pub"); // no-op + await mgr._enforceRemoteSignerValidator(undefined, "pub"); // no-op +}); diff --git a/todo/TODO_2026-06-16_r2-followups.md b/todo/TODO_2026-06-16_r2-followups.md index ad35a20c..8b565269 100644 --- a/todo/TODO_2026-06-16_r2-followups.md +++ b/todo/TODO_2026-06-16_r2-followups.md @@ -55,6 +55,22 @@ Backlog from the R2 audit. Branch: `unstable` (promote down to beta/main later). - [ ] **Edit thumbnail cleanup**: currently skipped to avoid deleting a still-referenced thumbnail. Could diff old vs new thumbnail URL and clean the old one when it changed. +## NIP-46 (remote signer) login — 2026-06-17 +Two regressions (logic that lived only on the now-unused `Nip46Connector`, lost +when the connection moved into `SignerManager`): +- [x] **`prepareRemoteSignerHandshake` missing** — the "generate connect link" + path threw "is not a function". Implemented on `SignerManager` + proxied on + `NostrClient` (`92d8fabf`). +- [x] **Access-control validator dropped** — `connectRemoteSigner` / + `useStoredRemoteSigner` ignored the `validator`, so NIP-46 logins bypassed + the block/allow gate. Now enforced before the signer is activated, and a + blocked stored session is forgotten (``). +- [ ] **Silent auto-restore skips validation**: `scheduleStoredRemoteSignerRestore` + calls `useStoredRemoteSigner({ silent: true })` with NO validator, so on app + startup a since-blocked pubkey's stored session can reconnect unchecked. + Fix: have it pass an `accessControl`-backed validator (SignerManager would + need access to it). + ## Unrelated parking lot (from the infra tangent) - [ ] Vercel dashboard: set production branch `bitvid-unstable`→`unstable`, `bitvid-beta`→`beta` (so pushes deploy to prod, not preview). Then redeploy `bitvid-unstable` to current HEAD.