mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 02:42:28 +00:00
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:
1 parent
93b3d6da22
commit
92d8fabfe5
3 files changed
+135
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user