diff --git a/js/nostr/managers/SignerManager.js b/js/nostr/managers/SignerManager.js index 1f3323cf..b2c635b5 100644 --- a/js/nostr/managers/SignerManager.js +++ b/js/nostr/managers/SignerManager.js @@ -58,6 +58,7 @@ import { createPrivateKeyCipherClosures } from "../signerHelpers.js"; import { queueSignEvent } from "../signRequestQueue.js"; import { signEventWithPrivateKey } from "../publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js"; +import { accessControl } from "../../accessControl.js"; export function resolveSignerCapabilities(signer) { const fallback = { @@ -1213,6 +1214,29 @@ export class SignerManager { } } + // Access-control gate for restoring a stored signer. Waits for the admin + // lists to load (so the decision is accurate on a cold start) and throws if + // the pubkey is no longer allowed, matching the fresh-login/connect paths. + // useStoredRemoteSigner clears the stored session when this rejects. + buildAccessControlValidator() { + return async (pubkey) => { + try { + if (typeof accessControl.waitForReady === "function") { + await accessControl.waitForReady(); + } + } catch (error) { + // If readiness can't be confirmed, fall through to the live check. + } + if (!accessControl.canAccess(pubkey)) { + if (accessControl.isBlacklisted(pubkey)) { + throw new Error("Your account has been blocked on this platform."); + } + throw new Error("Access restricted to admins and moderators only."); + } + return true; + }; + } + async scheduleStoredRemoteSignerRestore() { const stored = readStoredNip46Session(); if (!stored) { @@ -1221,7 +1245,13 @@ export class SignerManager { const decryptAndConnect = async () => { try { - await this.useStoredRemoteSigner({ silent: true }); + // Enforce access control on the silent startup restore too — without a + // validator a since-blocked pubkey's stored session would reconnect + // unchecked. + await this.useStoredRemoteSigner({ + silent: true, + validator: this.buildAccessControlValidator(), + }); } catch (err) { devLogger.warn("Failed to restore remote signer", err); } diff --git a/tests/nip46-silent-restore-access-control.test.mjs b/tests/nip46-silent-restore-access-control.test.mjs new file mode 100644 index 00000000..668a61d9 --- /dev/null +++ b/tests/nip46-silent-restore-access-control.test.mjs @@ -0,0 +1,83 @@ +// The silent NIP-46 session restore on startup must enforce access control, +// so a since-blocked pubkey's stored remote-signer session can't reconnect +// unchecked. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { SignerManager } from "../js/nostr/managers/SignerManager.js"; +import { accessControl } from "../js/accessControl.js"; + +const NIP46_SESSION_KEY = "bitvid:nip46:session:v1"; + +function seedStoredSession() { + localStorage.setItem( + NIP46_SESSION_KEY, + JSON.stringify({ + version: 1, + remotePubkey: "a".repeat(64), + clientPrivateKey: "b".repeat(64), + relays: ["wss://relay.example"], + }), + ); +} + +test("scheduleStoredRemoteSignerRestore passes an access-control validator", async () => { + seedStoredSession(); + const manager = new SignerManager({ relays: ["wss://relay.example"] }); + + let capturedOptions = null; + manager.useStoredRemoteSigner = async (options) => { + capturedOptions = options; + return { ok: true, pubkey: "a".repeat(64) }; + }; + + await manager.scheduleStoredRemoteSignerRestore(); + // schedule fires the restore without awaiting it; let the microtask run. + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.ok(capturedOptions, "the stored signer restore was attempted"); + assert.equal( + capturedOptions.silent, + true, + "startup restore stays silent", + ); + assert.equal( + typeof capturedOptions.validator, + "function", + "a validator must be supplied so access control is enforced on restore", + ); + + localStorage.removeItem(NIP46_SESSION_KEY); +}); + +test("the restore validator allows permitted pubkeys and rejects blocked ones", async () => { + const manager = new SignerManager({ relays: [] }); + const validator = manager.buildAccessControlValidator(); + + const origWaitForReady = accessControl.waitForReady; + const origCanAccess = accessControl.canAccess; + const origIsBlacklisted = accessControl.isBlacklisted; + accessControl.waitForReady = async () => {}; + + try { + accessControl.canAccess = () => true; + accessControl.isBlacklisted = () => false; + await assert.doesNotReject( + () => validator("a".repeat(64)), + "an allowed pubkey passes the validator", + ); + + accessControl.canAccess = () => false; + accessControl.isBlacklisted = () => true; + await assert.rejects( + () => validator("c".repeat(64)), + /blocked/i, + "a blocked pubkey is rejected so the stored session is forgotten", + ); + } finally { + accessControl.waitForReady = origWaitForReady; + accessControl.canAccess = origCanAccess; + accessControl.isBlacklisted = origIsBlacklisted; + } +});