fix(nip46): implement prepareRemoteSignerHandshake (generate-connect-link login)

NIP-46 login via a generated connect link failed with
"nostrClient.prepareRemoteSignerHandshake is not a function". The auth provider
(authProviders/nip46.js) calls nostrClient.prepareRemoteSignerHandshake() to mint
an ephemeral client keypair + nostrconnect:// URI before connecting, but that
method existed only on the unused Nip46Connector class — neither SignerManager
nor NostrClient exposed it.

Add SignerManager.prepareRemoteSignerHandshake (generates the keypair via
nostr-tools, resolves relays, builds the nostrconnect:// URI with relay/secret/
perms/metadata params) and proxy it on NostrClient next to connectRemoteSigner.

Test: tests/nostr/nip46-prepare-handshake.test.mjs. 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:35:11 -04:00
1 parent 93b3d6da22
commit 92d8fabfe5
3 files changed
+135

No files matched your search

+4
View File
@@ -2694,6 +2694,10 @@ export class NostrClient {
return this.signerManager.installNip46Client(client, options);
}
async prepareRemoteSignerHandshake(params) {
return this.signerManager.prepareRemoteSignerHandshake(params);
}
async connectRemoteSigner(params) {
return this.signerManager.connectRemoteSigner(params);
}
+79
View File
@@ -643,6 +643,85 @@ export class SignerManager {
});
}
/**
* Prepares a NIP-46 `nostrconnect://` handshake: generates an ephemeral client
* keypair and builds the connection URI the user shares with their remote
* signer (link/QR). The returned handshake is then handed to
* connectRemoteSigner(). This is the "generate connect link" login path; it was
* previously only implemented on the unused Nip46Connector, so the client's
* `prepareRemoteSignerHandshake` proxy had nothing to call.
*
* @param {object} [params]
* @param {object} [params.metadata]
* @param {string[]} [params.relays]
* @param {string} [params.secret]
* @param {string} [params.permissions]
* @returns {Promise<object>} handshake { connectionString, clientPrivateKey, clientPublicKey, relays, secret, permissions, metadata }
*/
async prepareRemoteSignerHandshake({ metadata, relays, secret, permissions } = {}) {
const tools = await ensureNostrTools();
if (
!tools ||
typeof tools.generateSecretKey !== "function" ||
typeof tools.getPublicKey !== "function"
) {
throw new Error(
"Nostr tools are unavailable; cannot prepare a remote signer handshake.",
);
}
const secretKey = tools.generateSecretKey();
const clientPrivateKey = Array.from(secretKey)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const clientPublicKey = tools.getPublicKey(secretKey);
const sanitizedMetadata = sanitizeNip46Metadata(metadata);
const requestedPermissions =
typeof permissions === "string" && permissions.trim()
? permissions.trim()
: "";
const resolvedRelays = resolveNip46Relays(relays, this.client?.relays);
const handshakeSecret =
typeof secret === "string" && secret.trim()
? secret.trim()
: generateNip46Secret();
const params = [];
for (const relay of resolvedRelays) {
params.push(`relay=${encodeURIComponent(relay)}`);
}
if (handshakeSecret) {
params.push(`secret=${encodeURIComponent(handshakeSecret)}`);
}
if (requestedPermissions) {
params.push(`perms=${encodeURIComponent(requestedPermissions)}`);
}
for (const [key, value] of Object.entries(sanitizedMetadata)) {
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
const query = params.length ? `?${params.join("&")}` : "";
const uri = `nostrconnect://${clientPublicKey}${query}`;
devLogger.debug("[nostr] Prepared remote signer handshake", {
clientPublicKey: summarizeHexForLog(clientPublicKey),
relays: resolvedRelays,
permissions: requestedPermissions || null,
});
return {
type: "client",
connectionString: uri,
uri,
clientPrivateKey,
clientPublicKey,
relays: resolvedRelays,
secret: handshakeSecret,
permissions: requestedPermissions,
metadata: sanitizedMetadata,
};
}
async connectRemoteSigner({
connectionString,
remember = true,
@@ -0,0 +1,52 @@
// Scenario (SCN-nip46-prepare-handshake):
// The "generate connect link" NIP-46 login path calls
// nostrClient.prepareRemoteSignerHandshake(...). That method existed only on
// the unused Nip46Connector, so login failed with
// "nostrClient.prepareRemoteSignerHandshake is not a function". It now lives on
// SignerManager (and is proxied by NostrClient). This asserts it produces a
// valid nostrconnect:// handshake (ephemeral keypair + URI carrying the
// relays/secret/permissions) ready to hand to connectRemoteSigner.
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");
function makeManager() {
// Minimal client stub — only relays are read (as the relay fallback).
return new SignerManager({ relays: ["wss://fallback.example"] });
}
test("prepareRemoteSignerHandshake builds a valid nostrconnect URI + ephemeral keypair", async () => {
const mgr = makeManager();
const hs = await mgr.prepareRemoteSignerHandshake({
relays: ["wss://relay.one", "wss://relay.two"],
permissions: "sign_event:1,nip44_decrypt",
metadata: { name: "bitvid" },
});
// Ephemeral keypair (hex), pubkey embedded in the URI.
assert.match(hs.clientPrivateKey, /^[0-9a-f]{64}$/);
assert.match(hs.clientPublicKey, /^[0-9a-f]{64}$/);
assert.match(hs.connectionString, /^nostrconnect:\/\/[0-9a-f]{64}\?/);
assert.ok(hs.connectionString.includes(hs.clientPublicKey), "URI carries the client pubkey");
// Carries the requested relays, a secret, and the permissions.
assert.ok(hs.connectionString.includes(`relay=${encodeURIComponent("wss://relay.one")}`));
assert.ok(hs.connectionString.includes(`relay=${encodeURIComponent("wss://relay.two")}`));
assert.ok(hs.secret && hs.connectionString.includes(`secret=${encodeURIComponent(hs.secret)}`));
assert.ok(
hs.connectionString.includes(`perms=${encodeURIComponent("sign_event:1,nip44_decrypt")}`),
);
assert.ok(hs.relays.includes("wss://relay.one") && hs.relays.includes("wss://relay.two"));
});
test("generates a fresh keypair each call", async () => {
const mgr = makeManager();
const a = await mgr.prepareRemoteSignerHandshake({ relays: ["wss://r"] });
const b = await mgr.prepareRemoteSignerHandshake({ relays: ["wss://r"] });
assert.notEqual(a.clientPublicKey, b.clientPublicKey, "each handshake is a new ephemeral key");
assert.notEqual(a.secret, b.secret);
});