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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 06:44:47 -04:00
1 parent 92d8fabfe5
commit 3adf1be77d
3 files changed
+82 -1

No files matched your search

+36 -1
View File
@@ -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;
@@ -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
});
+16
View File
@@ -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 (`<this commit>`).
- [ ] **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.