Files
bitvid/tests/nostr/nip46-wait-handshake.test.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 ef66092727 fix(nip46): implement the nostrconnect:// (QR) connect-link login flow
The NIP-46 login dialog is purely the QR / connect-link flow ("scan with Amber/
NsecBunker"), but it failed with "A remote signer pubkey is required." A
nostrconnect:// link carries the CLIENT pubkey; the remote signer's pubkey is
only learned after it scans the QR and acknowledges — and the active connect
path (SignerManager) only supported bunker:// (pubkey already in the URI). The
"wait for the signer to acknowledge" logic existed only on the unused
Nip46Connector.

Port it into SignerManager as _waitForRemoteSignerHandshake: subscribe to
kind-24133 events addressed to the client pubkey, decrypt the signer's response,
match the handshake secret (or a generic ack), surface auth_url challenges, and
resolve with the signer's pubkey (with a timeout + a cancel hook wired into
disconnectRemoteSigner). connectRemoteSigner now performs this wait for
client-initiated (nostrconnect) connections before constructing the RPC client;
bunker:// is unchanged.

Decryption uses the existing attemptDecryptNip46HandshakePayload (injectable via
_decryptHandshakePayload for testing). Test: tests/nostr/nip46-wait-handshake.test.mjs
(secret match resolves with signer pubkey; wrong secret ignored -> timeout;
auth_url surfaced without resolving). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 07:01:39 -04:00

113 lines
4.1 KiB
JavaScript

// Scenario (SCN-nip46-wait-handshake):
// The nostrconnect:// (QR) login path must WAIT for the remote signer to scan
// and acknowledge before it can know the signer's pubkey. _waitForRemoteSignerHandshake
// subscribes to NIP-46 RPC events addressed to the client pubkey and resolves
// with the signer's pubkey on a matching ACK (secret match), surfaces auth_url
// challenges without resolving, and times out otherwise. (Decryption itself is
// existing/tested code, injected here so we exercise the wait/match logic.)
import "../test-helpers/setup-localstorage.mjs";
import test from "node:test";
import assert from "node:assert/strict";
globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true;
const { SignerManager } = await import("../../js/nostr/managers/SignerManager.js");
const CLIENT_PUB = "a".repeat(64);
const CLIENT_PRIV = "b".repeat(64);
const SIGNER_PUB = "c".repeat(64);
// A fake pool whose sub() exposes the captured event handler so the test can
// simulate the remote signer publishing an acknowledgement.
function makeManagerWithPool() {
const captured = { handler: null, unsubbed: false, filters: null };
const pool = {
sub(relays, filters) {
captured.filters = filters;
return {
on(ev, cb) {
if (ev === "event") captured.handler = cb;
},
unsub() {
captured.unsubbed = true;
},
};
},
};
const client = { relays: ["wss://relay.one"], ensurePool: async () => pool };
const mgr = new SignerManager(client);
return { mgr, captured };
}
const fireEvent = (captured, content) =>
captured.handler({ kind: 24133, pubkey: SIGNER_PUB, content, id: "evt1" });
test("resolves with the signer pubkey when the ACK matches the secret", async () => {
const { mgr, captured } = makeManagerWithPool();
const secret = "handshake-secret-xyz";
// Inject the decryptor: pretend the event decrypts to a matching ACK.
mgr._decryptHandshakePayload = async () => ({
plaintext: JSON.stringify({ id: "1", result: secret }),
remotePubkey: SIGNER_PUB,
algorithm: "nip44",
});
const promise = mgr._waitForRemoteSignerHandshake({
clientPrivateKey: CLIENT_PRIV,
clientPublicKey: CLIENT_PUB,
relays: ["wss://relay.one"],
secret,
timeoutMs: 5000,
});
await new Promise((r) => setTimeout(r, 5)); // let the subscription register
assert.ok(captured.handler, "subscribed and registered an event handler");
assert.deepEqual(captured.filters, [{ kinds: [24133], "#p": [CLIENT_PUB] }]);
fireEvent(captured, "ciphertext");
const result = await promise;
assert.equal(result.remotePubkey, SIGNER_PUB);
assert.equal(captured.unsubbed, true, "unsubscribes after resolving");
});
test("ignores a non-matching secret and eventually times out", async () => {
const { mgr, captured } = makeManagerWithPool();
mgr._decryptHandshakePayload = async () => ({
plaintext: JSON.stringify({ result: "WRONG-secret" }),
remotePubkey: SIGNER_PUB,
});
const promise = mgr._waitForRemoteSignerHandshake({
clientPrivateKey: CLIENT_PRIV,
clientPublicKey: CLIENT_PUB,
relays: ["wss://relay.one"],
secret: "the-real-secret",
timeoutMs: 80,
});
await new Promise((r) => setTimeout(r, 5));
fireEvent(captured, "ciphertext"); // wrong secret => ignored
await assert.rejects(promise, (e) => e.code === "nip46-handshake-timeout");
});
test("surfaces an auth_url challenge without resolving", async () => {
const { mgr, captured } = makeManagerWithPool();
let authUrl = "";
mgr._decryptHandshakePayload = async () => ({
plaintext: JSON.stringify({ result: "auth_url", error: "https://signer.example/approve" }),
remotePubkey: SIGNER_PUB,
});
const promise = mgr._waitForRemoteSignerHandshake({
clientPrivateKey: CLIENT_PRIV,
clientPublicKey: CLIENT_PUB,
relays: ["wss://relay.one"],
secret: "s",
timeoutMs: 80,
onAuthUrl: (url) => {
authUrl = url;
},
});
await new Promise((r) => setTimeout(r, 5));
fireEvent(captured, "ciphertext");
await assert.rejects(promise, (e) => e.code === "nip46-handshake-timeout");
assert.equal(authUrl, "https://signer.example/approve", "auth_url surfaced to the UI");
});