mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
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:
1 parent
3adf1be77d
commit
ef66092727
2 files changed
+350
-1
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user