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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 07:01:39 -04:00
1 parent 3adf1be77d
commit ef66092727
2 files changed
+350 -1

No files matched your search

+238 -1
View File
@@ -39,6 +39,10 @@ import {
resolveNip46Relays,
encodeHexToNpub,
generateNip46Secret,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "../nip46Client.js";
import {
@@ -740,6 +744,203 @@ export class SignerManager {
}
}
/**
* Waits for a remote signer to acknowledge a `nostrconnect://` handshake.
* Subscribes to NIP-46 RPC events addressed to the client pubkey; the first
* acknowledgement (matching the handshake secret, or a generic ack) reveals
* the remote signer's pubkey, which is needed before the RPC client can be
* constructed. Ported from the (unused) Nip46Connector.
*
* @returns {Promise<{remotePubkey:string, eventPubkey:string, response:object, algorithm:string}>}
*/
async _waitForRemoteSignerHandshake({
clientPrivateKey,
clientPublicKey,
relays,
secret,
onAuthUrl,
onStatus,
timeoutMs,
} = {}) {
const normalizedClientPublicKey = normalizeNostrPubkey(clientPublicKey);
if (!normalizedClientPublicKey) {
throw new Error(
"A client public key is required to await the remote signer handshake.",
);
}
if (
!clientPrivateKey ||
typeof clientPrivateKey !== "string" ||
!HEX64_REGEX.test(clientPrivateKey)
) {
throw new Error(
"A client private key is required to await the remote signer handshake.",
);
}
const resolvedRelays = resolveNip46Relays(relays, this.client?.relays);
if (!resolvedRelays.length) {
throw new Error("No relays available to complete the remote signer handshake.");
}
const pool = await this.client.ensurePool();
const filters = [
{ kinds: [NIP46_RPC_KIND], "#p": [normalizedClientPublicKey] },
];
const waitTimeout =
Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: NIP46_HANDSHAKE_TIMEOUT_MS;
const coerceStructuredString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
for (const entry of value) {
const candidate = coerceStructuredString(entry);
if (candidate) return candidate;
}
return "";
}
if (value && typeof value === "object") {
for (const key of ["secret", "message", "status", "result", "url"]) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
const candidate = coerceStructuredString(value[key]);
if (candidate) return candidate;
}
}
}
return "";
};
return new Promise((resolve, reject) => {
let settled = false;
let subscription;
let timeoutId;
const cleanup = () => {
if (settled) return;
settled = true;
this.pendingHandshakeCancel = null;
try {
subscription?.unsub?.();
} catch (error) {
// ignore subscription cleanup failures
}
if (timeoutId) clearTimeout(timeoutId);
};
// Allow the UI (modal close / disconnect) to abort the wait.
this.pendingHandshakeCancel = () => {
cleanup();
const error = new Error("Remote signer connection cancelled.");
error.code = "login-cancelled";
reject(error);
};
timeoutId = setTimeout(() => {
cleanup();
const error = new Error(
"Timed out waiting for the remote signer to acknowledge the connection.",
);
error.code = "nip46-handshake-timeout";
reject(error);
}, waitTimeout);
try {
subscription = pool.sub(resolvedRelays, filters);
} catch (error) {
cleanup();
reject(error);
return;
}
subscription.on("event", (event) => {
if (settled || !event || event.kind !== NIP46_RPC_KIND) {
return;
}
const eventRemotePubkey = normalizeNostrPubkey(event.pubkey);
const candidateRemotePubkeys = eventRemotePubkey ? [eventRemotePubkey] : [];
const decryptHandshake =
this._decryptHandshakePayload || attemptDecryptNip46HandshakePayload;
Promise.resolve()
.then(() =>
decryptHandshake({
clientPrivateKey,
candidateRemotePubkeys,
ciphertext: event.content,
}),
)
.then((payloadResult) => {
let parsed;
try {
parsed = JSON.parse(payloadResult?.plaintext ?? "");
} catch (error) {
return;
}
const resultValue = coerceStructuredString(parsed?.result);
const errorValue = coerceStructuredString(parsed?.error);
// auth_url challenge: surface to the UI and keep waiting.
if (resultValue === "auth_url" && errorValue) {
if (typeof onAuthUrl === "function") {
try {
onAuthUrl(errorValue, {
phase: "handshake",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
requestId: typeof parsed?.id === "string" ? parsed.id : "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError);
}
}
return;
}
// Match the handshake secret (or accept a generic ack).
if (secret) {
const normalizedResult = resultValue ? resultValue.toLowerCase() : "";
if (resultValue !== secret && normalizedResult !== "ack") return;
} else if (resultValue) {
if (!["ack", "ok", "success"].includes(resultValue.toLowerCase())) return;
}
cleanup();
if (typeof onStatus === "function") {
try {
onStatus({
phase: "handshake",
state: "acknowledged",
message: "Remote signer acknowledged the connect request.",
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
});
} catch (callbackError) {
devLogger.warn("[nostr] Handshake status callback threw:", callbackError);
}
}
resolve({
remotePubkey: payloadResult?.remotePubkey || eventRemotePubkey || "",
eventPubkey: eventRemotePubkey || "",
response: parsed,
algorithm: normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
});
})
.catch((error) => {
devLogger.warn(
"[nostr] Failed to decrypt remote signer handshake payload:",
error,
);
});
});
subscription.on("eose", () => {
// no-op: handshake responses are push-based
});
});
}
async connectRemoteSigner({
connectionString,
remember = true,
@@ -833,11 +1034,39 @@ export class SignerManager {
await this.client.ensurePool();
// A nostrconnect:// link is client-initiated: the remote signer's pubkey is
// unknown until the user's signer scans the QR / opens the link and
// acknowledges. Wait for that ACK to learn the remote pubkey before building
// the RPC client (which requires it). bunker:// URIs already carry it.
let remotePubkey = parsed.remotePubkey;
if (!remotePubkey && parsed.type === "client") {
const clientPublicKey =
providedClientPublicKey || parsed.clientPublicKey || "";
handleStatus({
phase: "handshake",
state: "waiting",
message: "Waiting for your signer to approve the connection…",
});
const ack = await this._waitForRemoteSignerHandshake({
clientPrivateKey,
clientPublicKey,
relays: resolveNip46Relays(parsed.relays, providedRelays),
secret: providedSecret || parsed.secret,
onAuthUrl: handleAuthChallenge,
onStatus: handleStatus,
timeoutMs: handshakeTimeoutMs,
});
remotePubkey = ack.remotePubkey;
if (!remotePubkey) {
throw new Error("Remote signer did not provide a usable pubkey.");
}
}
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays: resolveNip46Relays(parsed.relays, providedRelays),
clientPrivateKey: clientPrivateKey,
remotePubkey: parsed.remotePubkey,
remotePubkey,
secret: providedSecret || parsed.secret,
signEvent: signEventWithPrivateKey,
});
@@ -1002,6 +1231,14 @@ export class SignerManager {
}
async disconnectRemoteSigner({ keepStored = false } = {}) {
// Abort an in-flight nostrconnect:// handshake wait, if any.
if (typeof this.pendingHandshakeCancel === "function") {
try {
this.pendingHandshakeCancel();
} catch (error) {
// ignore cancel errors
}
}
if (this.nip46Client) {
if (typeof this.nip46Client.destroy === 'function') {
this.nip46Client.destroy();
+112
View File
@@ -0,0 +1,112 @@
// 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");
});