[auth] enforce access control on silent NIP-46 session restore

scheduleStoredRemoteSignerRestore reconnected a stored remote-signer session on
startup via useStoredRemoteSigner({ silent: true }) with NO validator, so a
pubkey that had since been blocked could silently reconnect — the same
access-control bypass class as the nsec fix. It now passes an access-control
validator (waits for the admin lists to load, then checks canAccess); since
useStoredRemoteSigner already clears the stored session when the validator
rejects, a blocked pubkey's session is forgotten on restore. The connect and
explicit "use stored" paths already enforced this; only the silent startup
restore was missing it.

Tests: the restore supplies a validator function; the validator allows
permitted pubkeys and rejects blocked ones. Mutation-verified (dropping the
validator fails the test).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-18 15:53:56 -04:00
1 parent 4a38e27150
commit ff5c641951
2 files changed
+114 -1

No files matched your search

+31 -1
View File
@@ -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);
}
@@ -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;
}
});