mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
92d8fabfe5
commit
3adf1be77d
3 files changed
+82
-1
No files matched your search
@@ -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
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user