mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
- Extract handshake and connection logic to js/nostr/nip46Connector.js - Delegate NIP-46 operations in NostrClient to the new connector - Add unit tests for Nip46Connector with dependency injection support Co-authored-by: PR0M3TH3AN <[email protected]>
1094 lines
35 KiB
JavaScript
1094 lines
35 KiB
JavaScript
// js/nostr/nip46Connector.js
|
|
|
|
import { isDevMode } from "../config.js";
|
|
import { HEX64_REGEX } from "../utils/hex.js";
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { ensureNostrTools, getCachedNostrTools } from "./toolkit.js";
|
|
import { signEventWithPrivateKey } from "./publishHelpers.js";
|
|
import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
|
|
import {
|
|
normalizeNostrPubkey,
|
|
encodeHexToNpub,
|
|
generateNip46Secret,
|
|
sanitizeNip46Metadata,
|
|
resolveNip46Relays,
|
|
normalizeNip46EncryptionAlgorithm,
|
|
parseNip46ConnectionString,
|
|
attemptDecryptNip46HandshakePayload,
|
|
readStoredNip46Session,
|
|
writeStoredNip46Session,
|
|
clearStoredNip46Session,
|
|
decryptNip46Session,
|
|
NIP46_RPC_KIND,
|
|
NIP46_HANDSHAKE_TIMEOUT_MS,
|
|
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS,
|
|
Nip46RpcClient,
|
|
} from "./nip46Client.js";
|
|
import {
|
|
summarizeHexForLog,
|
|
summarizeSecretForLog,
|
|
summarizeMetadataForLog,
|
|
summarizeUrlForLog,
|
|
} from "./nip46LoggingUtils.js";
|
|
|
|
const DEFAULT_DEPS = {
|
|
ensureNostrTools,
|
|
getCachedNostrTools,
|
|
signEventWithPrivateKey,
|
|
bytesToHex,
|
|
normalizeNostrPubkey,
|
|
encodeHexToNpub,
|
|
generateNip46Secret,
|
|
sanitizeNip46Metadata,
|
|
resolveNip46Relays,
|
|
normalizeNip46EncryptionAlgorithm,
|
|
parseNip46ConnectionString,
|
|
attemptDecryptNip46HandshakePayload,
|
|
readStoredNip46Session,
|
|
writeStoredNip46Session,
|
|
clearStoredNip46Session,
|
|
decryptNip46Session,
|
|
Nip46RpcClient,
|
|
NIP46_RPC_KIND,
|
|
NIP46_HANDSHAKE_TIMEOUT_MS,
|
|
NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS,
|
|
};
|
|
|
|
/**
|
|
* Manages the connection lifecycle for NIP-46 Remote Signers.
|
|
* Encapsulates handshake, connection establishment, and reconnection logic.
|
|
*/
|
|
export class Nip46Connector {
|
|
/**
|
|
* @param {import("./client.js").NostrClient} nostrClient
|
|
* @param {object} [deps] - Optional dependencies for testing
|
|
*/
|
|
constructor(nostrClient, deps = {}) {
|
|
this.nostrClient = nostrClient;
|
|
this.deps = { ...DEFAULT_DEPS, ...deps };
|
|
this.pendingHandshakeCancel = null;
|
|
this.pendingRemoteSignerRestore = null;
|
|
}
|
|
|
|
/**
|
|
* Creates a new key pair for the NIP-46 client session.
|
|
* If existing keys are provided, they are validated and returned.
|
|
*
|
|
* @param {string} [existingPrivateKey]
|
|
* @param {string} [existingPublicKey]
|
|
* @returns {Promise<{privateKey: string, publicKey: string}>}
|
|
*/
|
|
async createKeyPair(existingPrivateKey = "", existingPublicKey = "") {
|
|
let privateKey =
|
|
typeof existingPrivateKey === "string" && existingPrivateKey.trim()
|
|
? existingPrivateKey.trim().toLowerCase()
|
|
: "";
|
|
|
|
if (privateKey && !HEX64_REGEX.test(privateKey)) {
|
|
const error = new Error("Invalid remote signer private key.");
|
|
error.code = "invalid-private-key";
|
|
throw error;
|
|
}
|
|
|
|
if (!privateKey) {
|
|
const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools();
|
|
if (!tools) {
|
|
throw new Error("Unable to generate a remote signer key pair.");
|
|
}
|
|
|
|
let generated = null;
|
|
if (typeof tools.generateSecretKey === "function") {
|
|
generated = tools.generateSecretKey();
|
|
}
|
|
|
|
if (generated instanceof Uint8Array) {
|
|
privateKey = this.deps.bytesToHex(generated);
|
|
} else if (Array.isArray(generated)) {
|
|
privateKey = this.deps.bytesToHex(Uint8Array.from(generated));
|
|
} else if (typeof generated === "string") {
|
|
privateKey = generated.trim().toLowerCase();
|
|
}
|
|
|
|
if (!privateKey || !HEX64_REGEX.test(privateKey)) {
|
|
throw new Error("Generated remote signer key is invalid.");
|
|
}
|
|
|
|
privateKey = privateKey.toLowerCase();
|
|
}
|
|
|
|
let publicKey =
|
|
typeof existingPublicKey === "string" && existingPublicKey.trim()
|
|
? existingPublicKey.trim().toLowerCase()
|
|
: "";
|
|
|
|
if (!publicKey) {
|
|
const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools();
|
|
if (!tools || typeof tools.getPublicKey !== "function") {
|
|
throw new Error("Public key derivation is unavailable for remote signing.");
|
|
}
|
|
publicKey = tools.getPublicKey(privateKey);
|
|
}
|
|
|
|
if (!publicKey || !HEX64_REGEX.test(publicKey)) {
|
|
throw new Error("Derived remote signer public key is invalid.");
|
|
}
|
|
|
|
return { privateKey, publicKey: publicKey.toLowerCase() };
|
|
}
|
|
|
|
/**
|
|
* Prepares the parameters for a NIP-46 handshake (generation of connection URI).
|
|
*
|
|
* @param {object} params
|
|
* @param {object} [params.metadata]
|
|
* @param {string[]} [params.relays]
|
|
* @param {string} [params.secret]
|
|
* @param {string} [params.permissions]
|
|
* @returns {Promise<object>}
|
|
*/
|
|
async prepareHandshake({ metadata, relays, secret, permissions } = {}) {
|
|
const keyPair = await this.createKeyPair();
|
|
const sanitizedMetadata = this.deps.sanitizeNip46Metadata(metadata);
|
|
const requestedPermissions =
|
|
typeof permissions === "string" && permissions.trim() ? permissions.trim() : "";
|
|
|
|
const resolvedRelays = this.deps.resolveNip46Relays(relays, this.nostrClient.relays);
|
|
const handshakeSecret =
|
|
typeof secret === "string" && secret.trim() ? secret.trim() : this.deps.generateNip46Secret();
|
|
|
|
devLogger.debug("[nostr] Preparing remote signer handshake", {
|
|
clientPublicKey: summarizeHexForLog(keyPair.publicKey),
|
|
relays: resolvedRelays,
|
|
permissions: requestedPermissions || null,
|
|
secret: summarizeSecretForLog(handshakeSecret),
|
|
metadataKeys: summarizeMetadataForLog(sanitizedMetadata),
|
|
});
|
|
|
|
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://${keyPair.publicKey}${query}`;
|
|
|
|
devLogger.debug("[nostr] Prepared nostrconnect URI", {
|
|
uri,
|
|
relayCount: resolvedRelays.length,
|
|
metadataKeys: summarizeMetadataForLog(sanitizedMetadata),
|
|
});
|
|
|
|
return {
|
|
type: "client",
|
|
connectionString: uri,
|
|
uri,
|
|
clientPrivateKey: keyPair.privateKey,
|
|
clientPublicKey: keyPair.publicKey,
|
|
relays: resolvedRelays,
|
|
secret: handshakeSecret,
|
|
permissions: requestedPermissions,
|
|
metadata: sanitizedMetadata,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Waits for a remote signer to acknowledge the handshake.
|
|
*
|
|
* @param {object} params
|
|
* @returns {Promise<{remotePubkey: string, eventPubkey: string, response: object, algorithm: string}>}
|
|
*/
|
|
async waitForHandshake({
|
|
clientPrivateKey,
|
|
clientPublicKey,
|
|
relays,
|
|
secret,
|
|
onAuthUrl,
|
|
onStatus,
|
|
timeoutMs,
|
|
expectedRemotePubkey,
|
|
} = {}) {
|
|
const normalizedClientPublicKey = this.deps.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 = this.deps.resolveNip46Relays(relays, this.nostrClient.relays);
|
|
if (!resolvedRelays.length) {
|
|
throw new Error("No relays available to complete the remote signer handshake.");
|
|
}
|
|
|
|
devLogger.debug("[nostr] Waiting for remote signer handshake", {
|
|
clientPubkey: summarizeHexForLog(normalizedClientPublicKey),
|
|
relays: resolvedRelays,
|
|
secret: summarizeSecretForLog(secret),
|
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
|
|
expectedRemotePubkey: summarizeHexForLog(expectedRemotePubkey || ""),
|
|
});
|
|
|
|
const pool = await this.nostrClient.ensurePool();
|
|
const filters = [
|
|
{
|
|
kinds: [this.deps.NIP46_RPC_KIND],
|
|
"#p": [normalizedClientPublicKey],
|
|
},
|
|
];
|
|
|
|
const waitTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : this.deps.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") {
|
|
const preferredKeys = [
|
|
"secret",
|
|
"message",
|
|
"status",
|
|
"reason",
|
|
"detail",
|
|
"description",
|
|
"value",
|
|
"result",
|
|
"url",
|
|
];
|
|
for (const key of preferredKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
const candidate = coerceStructuredString(value[key]);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
for (const entry of Object.values(value)) {
|
|
const candidate = coerceStructuredString(entry);
|
|
if (candidate) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (this.pendingHandshakeCancel === cancelHandshake) {
|
|
this.pendingHandshakeCancel = null;
|
|
}
|
|
try {
|
|
subscription?.unsub?.();
|
|
} catch (error) {
|
|
// ignore subscription cleanup failures
|
|
}
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
const cancelHandshake = () => {
|
|
cleanup();
|
|
const error = new Error("Remote signer connection cancelled.");
|
|
error.code = "login-cancelled";
|
|
reject(error);
|
|
};
|
|
|
|
this.pendingHandshakeCancel = cancelHandshake;
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
cleanup();
|
|
const error = new Error("Timed out waiting for the remote signer to acknowledge the connection.");
|
|
error.code = "nip46-handshake-timeout";
|
|
devLogger.warn("[nostr] Handshake wait timed out", {
|
|
clientPubkey: summarizeHexForLog(normalizedClientPublicKey),
|
|
relays: resolvedRelays,
|
|
});
|
|
reject(error);
|
|
}, waitTimeout);
|
|
|
|
let subscription;
|
|
try {
|
|
subscription = pool.sub(resolvedRelays, filters);
|
|
devLogger.debug("[nostr] Handshake subscription established", {
|
|
relays: resolvedRelays,
|
|
filters,
|
|
});
|
|
} catch (error) {
|
|
cleanup();
|
|
devLogger.warn("[nostr] Failed to subscribe for handshake responses", error);
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
subscription.on("event", (event) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
if (!event || event.kind !== this.deps.NIP46_RPC_KIND) {
|
|
devLogger.debug("[nostr] Ignoring non-handshake event during wait", event);
|
|
return;
|
|
}
|
|
|
|
const eventRemotePubkey = this.deps.normalizeNostrPubkey(event.pubkey);
|
|
const candidateRemotePubkeys = [];
|
|
if (expectedRemotePubkey) {
|
|
candidateRemotePubkeys.push(expectedRemotePubkey);
|
|
}
|
|
if (eventRemotePubkey) {
|
|
candidateRemotePubkeys.push(eventRemotePubkey);
|
|
}
|
|
|
|
devLogger.debug("[nostr] Processing handshake event", {
|
|
eventId: typeof event.id === "string" ? event.id : "",
|
|
eventPubkey: summarizeHexForLog(eventRemotePubkey || ""),
|
|
candidateRemotePubkeys: candidateRemotePubkeys.map((key) =>
|
|
summarizeHexForLog(key),
|
|
),
|
|
contentLength: typeof event.content === "string" ? event.content.length : 0,
|
|
});
|
|
|
|
Promise.resolve()
|
|
.then(() =>
|
|
this.deps.attemptDecryptNip46HandshakePayload({
|
|
clientPrivateKey,
|
|
candidateRemotePubkeys,
|
|
ciphertext: event.content,
|
|
}),
|
|
)
|
|
.then((payloadResult) => {
|
|
const plaintext = payloadResult?.plaintext ?? "";
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(plaintext);
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to parse remote signer handshake payload:", error);
|
|
return;
|
|
}
|
|
|
|
devLogger.debug("[nostr] Handshake payload parsed", {
|
|
remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || ""),
|
|
eventPubkey: summarizeHexForLog(eventRemotePubkey || ""),
|
|
algorithm: payloadResult?.algorithm || null,
|
|
requestId: typeof parsed?.id === "string" ? parsed.id : "",
|
|
hasResult: parsed?.result !== undefined,
|
|
hasError: parsed?.error !== undefined,
|
|
});
|
|
|
|
const resultValue = coerceStructuredString(parsed?.result);
|
|
const errorValue = coerceStructuredString(parsed?.error);
|
|
|
|
if (resultValue === "auth_url" && errorValue) {
|
|
const handshakeRemotePubkey = payloadResult?.remotePubkey || eventRemotePubkey || "";
|
|
devLogger.debug("[nostr] Handshake provided auth_url challenge", {
|
|
eventId: typeof parsed?.id === "string" ? parsed.id : "",
|
|
remotePubkey: summarizeHexForLog(handshakeRemotePubkey),
|
|
url: summarizeUrlForLog(errorValue),
|
|
});
|
|
if (typeof onAuthUrl === "function") {
|
|
try {
|
|
onAuthUrl(errorValue, {
|
|
phase: "handshake",
|
|
remotePubkey: handshakeRemotePubkey,
|
|
requestId: typeof parsed?.id === "string" ? parsed.id : "",
|
|
});
|
|
} catch (callbackError) {
|
|
devLogger.warn("[nostr] Handshake auth_url callback threw:", callbackError);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (secret) {
|
|
const normalizedResult = resultValue ? resultValue.toLowerCase() : "";
|
|
if (resultValue !== secret && normalizedResult !== "ack") {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!secret && resultValue) {
|
|
const normalized = resultValue.toLowerCase();
|
|
if (!["ack", "ok", "success"].includes(normalized)) {
|
|
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: this.deps.normalizeNip46EncryptionAlgorithm(payloadResult?.algorithm),
|
|
});
|
|
|
|
devLogger.debug("[nostr] Handshake wait resolved", {
|
|
remotePubkey: summarizeHexForLog(payloadResult?.remotePubkey || eventRemotePubkey || ""),
|
|
eventPubkey: summarizeHexForLog(eventRemotePubkey || ""),
|
|
result: resultValue || null,
|
|
hasSecret: Boolean(secret),
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
devLogger.warn("[nostr] Failed to decrypt remote signer handshake payload:", error);
|
|
});
|
|
});
|
|
|
|
subscription.on("eose", () => {
|
|
// no-op: handshake responses are push-based
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Establishes a NIP-46 connection.
|
|
*
|
|
* @param {object} params
|
|
* @returns {Promise<{client: Nip46RpcClient, signer: object, userPubkey: string}>}
|
|
*/
|
|
async connect({
|
|
connectionString,
|
|
remember = true,
|
|
clientPrivateKey: providedClientPrivateKey = "",
|
|
clientPublicKey: providedClientPublicKey = "",
|
|
relays: providedRelays = [],
|
|
secret: providedSecret = "",
|
|
permissions: providedPermissions = "",
|
|
metadata: providedMetadata = {},
|
|
onAuthUrl,
|
|
onStatus,
|
|
handshakeTimeoutMs,
|
|
passphrase,
|
|
validator,
|
|
} = {}) {
|
|
const parsed = this.deps.parseNip46ConnectionString(connectionString);
|
|
if (!parsed) {
|
|
const error = new Error(
|
|
"Unsupported NIP-46 URI. Provide a nostrconnect:// handshake or bunker:// pointer.",
|
|
);
|
|
error.code = "invalid-connection-string";
|
|
throw error;
|
|
}
|
|
|
|
const baseMetadata = this.deps.sanitizeNip46Metadata(parsed.metadata);
|
|
const overrideMetadata = this.deps.sanitizeNip46Metadata(providedMetadata);
|
|
const metadata = { ...baseMetadata, ...overrideMetadata };
|
|
|
|
devLogger.debug("[nostr] Connecting to remote signer", {
|
|
connectionType: parsed.type,
|
|
parsedRemotePubkey: summarizeHexForLog(parsed.remotePubkey || ""),
|
|
providedClientPublicKey: summarizeHexForLog(providedClientPublicKey),
|
|
providedClientPrivateKey: summarizeSecretForLog(
|
|
typeof providedClientPrivateKey === "string" ? providedClientPrivateKey : "",
|
|
),
|
|
providedSecret: summarizeSecretForLog(providedSecret),
|
|
providedPermissions: providedPermissions || null,
|
|
handshakeTimeoutMs,
|
|
parsedRelays: parsed.relays,
|
|
overrideRelayCount: Array.isArray(providedRelays) ? providedRelays.length : 0,
|
|
metadataKeys: summarizeMetadataForLog(metadata),
|
|
});
|
|
|
|
const handleStatus = (status) => {
|
|
if (typeof onStatus !== "function") {
|
|
return;
|
|
}
|
|
try {
|
|
onStatus(status);
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Remote signer status callback threw:", error);
|
|
}
|
|
};
|
|
|
|
const handleAuthChallenge = async (url, context = {}) => {
|
|
if (typeof onAuthUrl !== "function" || !url) {
|
|
return;
|
|
}
|
|
try {
|
|
devLogger.debug("[nostr] Remote signer auth challenge surfaced", {
|
|
url: summarizeUrlForLog(url),
|
|
context,
|
|
});
|
|
const result = onAuthUrl(url, context);
|
|
if (result && typeof result.then === "function") {
|
|
await result.catch((error) => {
|
|
devLogger.warn("[nostr] Auth challenge callback promise rejected:", error);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Remote signer auth callback threw:", error);
|
|
}
|
|
};
|
|
|
|
const mergedRelaysSource = parsed.relays.length ? parsed.relays : providedRelays;
|
|
const relays = this.deps.resolveNip46Relays(mergedRelaysSource, this.nostrClient.relays);
|
|
|
|
let secret = typeof providedSecret === "string" && providedSecret.trim() ? providedSecret.trim() : parsed.secret;
|
|
let permissions =
|
|
typeof providedPermissions === "string" && providedPermissions.trim()
|
|
? providedPermissions.trim()
|
|
: parsed.permissions;
|
|
|
|
let clientPrivateKey = "";
|
|
let clientPublicKey = "";
|
|
let remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey);
|
|
let handshakeAlgorithm = "";
|
|
|
|
if (parsed.type === "client") {
|
|
clientPrivateKey =
|
|
typeof providedClientPrivateKey === "string" && providedClientPrivateKey.trim()
|
|
? providedClientPrivateKey.trim().toLowerCase()
|
|
: "";
|
|
|
|
if (!clientPrivateKey || !HEX64_REGEX.test(clientPrivateKey)) {
|
|
const error = new Error(
|
|
"Remote signer handshake requires the generated client private key.",
|
|
);
|
|
error.code = "missing-client-private-key";
|
|
throw error;
|
|
}
|
|
|
|
clientPublicKey = this.deps.normalizeNostrPubkey(providedClientPublicKey) || parsed.clientPubkey;
|
|
if (!clientPublicKey) {
|
|
const tools = (await this.deps.ensureNostrTools()) || this.deps.getCachedNostrTools();
|
|
if (!tools || typeof tools.getPublicKey !== "function") {
|
|
throw new Error("Public key derivation is unavailable for the remote signer handshake.");
|
|
}
|
|
clientPublicKey = this.deps.normalizeNostrPubkey(tools.getPublicKey(clientPrivateKey));
|
|
}
|
|
|
|
if (!clientPublicKey || !HEX64_REGEX.test(clientPublicKey)) {
|
|
const error = new Error("Invalid client public key for the remote signer handshake.");
|
|
error.code = "invalid-client-public-key";
|
|
throw error;
|
|
}
|
|
|
|
if (parsed.clientPubkey && this.deps.normalizeNostrPubkey(parsed.clientPubkey) !== clientPublicKey) {
|
|
const error = new Error("Handshake public key mismatch detected.");
|
|
error.code = "client-public-key-mismatch";
|
|
throw error;
|
|
}
|
|
|
|
if (!secret) {
|
|
secret = this.deps.generateNip46Secret();
|
|
}
|
|
|
|
handleStatus({
|
|
phase: "handshake",
|
|
state: "waiting",
|
|
message: "Waiting for the signer to acknowledge the connection…",
|
|
relays,
|
|
});
|
|
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "connecting",
|
|
relays,
|
|
metadata,
|
|
remotePubkey: parsed.remotePubkey || "",
|
|
userPubkey: parsed.userPubkeyHint || "",
|
|
message: "Waiting for the signer to acknowledge the connection.",
|
|
});
|
|
|
|
let handshakeResult;
|
|
try {
|
|
handshakeResult = await this.waitForHandshake({
|
|
clientPrivateKey,
|
|
clientPublicKey,
|
|
relays,
|
|
secret,
|
|
onAuthUrl: (url, context) => handleAuthChallenge(url, context),
|
|
onStatus: handleStatus,
|
|
timeoutMs: handshakeTimeoutMs,
|
|
expectedRemotePubkey: parsed.remotePubkey,
|
|
});
|
|
devLogger.debug("[nostr] Remote signer handshake completed", {
|
|
remotePubkey: summarizeHexForLog(handshakeResult?.remotePubkey || parsed.remotePubkey || ""),
|
|
algorithm: handshakeResult?.algorithm || null,
|
|
relays,
|
|
secret: summarizeSecretForLog(secret),
|
|
});
|
|
} catch (error) {
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "error",
|
|
relays,
|
|
metadata,
|
|
message: error?.message || "Remote signer handshake failed.",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
remotePubkey = this.deps.normalizeNostrPubkey(handshakeResult?.remotePubkey);
|
|
handshakeAlgorithm = this.deps.normalizeNip46EncryptionAlgorithm(
|
|
handshakeResult?.algorithm,
|
|
);
|
|
if (!remotePubkey) {
|
|
const error = new Error("Remote signer did not return a valid public key.");
|
|
error.code = "missing-remote-pubkey";
|
|
throw error;
|
|
}
|
|
devLogger.debug("[nostr] Remote signer handshake provided final pubkey", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
algorithm: handshakeAlgorithm || null,
|
|
clientPublicKey: summarizeHexForLog(clientPublicKey),
|
|
});
|
|
} else {
|
|
const keyPair = await this.createKeyPair(
|
|
providedClientPrivateKey,
|
|
providedClientPublicKey,
|
|
);
|
|
clientPrivateKey = keyPair.privateKey;
|
|
clientPublicKey = keyPair.publicKey;
|
|
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "connecting",
|
|
remotePubkey,
|
|
relays,
|
|
metadata,
|
|
userPubkey: parsed.userPubkeyHint || "",
|
|
});
|
|
devLogger.debug("[nostr] Using bunker URI for remote signer connect", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey || parsed.remotePubkey || ""),
|
|
relays,
|
|
metadataKeys: summarizeMetadataForLog(metadata),
|
|
});
|
|
}
|
|
|
|
if (!remotePubkey) {
|
|
remotePubkey = this.deps.normalizeNostrPubkey(parsed.remotePubkey);
|
|
}
|
|
|
|
if (!remotePubkey) {
|
|
const error = new Error("Remote signer pubkey is required to establish the session.");
|
|
error.code = "missing-remote-pubkey";
|
|
throw error;
|
|
}
|
|
|
|
const client = new this.deps.Nip46RpcClient({
|
|
nostrClient: this.nostrClient,
|
|
clientPrivateKey,
|
|
clientPublicKey,
|
|
remotePubkey,
|
|
relays,
|
|
secret,
|
|
permissions,
|
|
metadata,
|
|
encryption: handshakeAlgorithm,
|
|
signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey),
|
|
});
|
|
|
|
devLogger.debug("[nostr] Remote signer RPC client created", {
|
|
clientPublicKey: summarizeHexForLog(client.clientPublicKey),
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
relays,
|
|
permissions,
|
|
secret: summarizeSecretForLog(secret),
|
|
metadataKeys: summarizeMetadataForLog(metadata),
|
|
handshakeAlgorithm,
|
|
});
|
|
|
|
try {
|
|
await client.ensureSubscription();
|
|
|
|
devLogger.debug("[nostr] Remote signer subscription ready", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
relayCount: relays.length,
|
|
});
|
|
|
|
handleStatus({
|
|
phase: "connect",
|
|
state: "request",
|
|
message: "Requesting approval from the remote signer…",
|
|
remotePubkey,
|
|
});
|
|
|
|
let attempts = 0;
|
|
// Attempt the connect RPC, handling auth challenges when provided.
|
|
for (;;) {
|
|
try {
|
|
devLogger.debug("[nostr] Sending NIP-46 connect request", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
attempt: attempts + 1,
|
|
permissions: permissions || null,
|
|
});
|
|
await client.connect({ permissions });
|
|
break;
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Connect RPC attempt failed", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
attempt: attempts + 1,
|
|
code: error?.code || null,
|
|
message: error?.message || String(error),
|
|
});
|
|
if (error?.code === "auth-challenge" && error.authUrl) {
|
|
attempts += 1;
|
|
handleStatus({
|
|
phase: "auth",
|
|
state: "waiting",
|
|
message: "Complete the authentication challenge in your signer…",
|
|
remotePubkey,
|
|
attempt: attempts,
|
|
});
|
|
await handleAuthChallenge(error.authUrl, {
|
|
phase: "connect",
|
|
remotePubkey,
|
|
attempt: attempts,
|
|
});
|
|
|
|
if (attempts >= this.deps.NIP46_AUTH_CHALLENGE_MAX_ATTEMPTS) {
|
|
throw error;
|
|
}
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const userPubkey = await client.getUserPubkey();
|
|
devLogger.debug("[nostr] Retrieved user pubkey from remote signer", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
userPubkey: summarizeHexForLog(userPubkey),
|
|
});
|
|
|
|
if (typeof validator === "function") {
|
|
try {
|
|
const isValid = await validator(userPubkey);
|
|
if (!isValid) {
|
|
throw new Error("Access denied.");
|
|
}
|
|
} catch (validationError) {
|
|
const message =
|
|
validationError instanceof Error
|
|
? validationError.message
|
|
: "Access denied.";
|
|
const error = new Error(message);
|
|
error.code = "access-denied";
|
|
client.destroy(); // Cleanup client since we won't use it
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
client.metadata = metadata;
|
|
|
|
if (remember) {
|
|
devLogger.debug("[nostr] Persisting remote signer session", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
relays,
|
|
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
|
|
permissions: permissions || null,
|
|
});
|
|
await this.deps.writeStoredNip46Session(
|
|
{
|
|
version: 1,
|
|
clientPublicKey,
|
|
remotePubkey,
|
|
relays,
|
|
encryption: client.encryptionAlgorithm || handshakeAlgorithm || "",
|
|
permissions,
|
|
metadata,
|
|
userPubkey,
|
|
lastConnectedAt: Date.now(),
|
|
clientPrivateKey,
|
|
secret,
|
|
},
|
|
passphrase,
|
|
);
|
|
} else {
|
|
devLogger.debug(
|
|
"[nostr] Clearing stored remote signer session per request",
|
|
);
|
|
this.deps.clearStoredNip46Session();
|
|
}
|
|
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "connected",
|
|
remotePubkey,
|
|
userPubkey,
|
|
relays,
|
|
metadata,
|
|
});
|
|
|
|
handleStatus({
|
|
phase: "connected",
|
|
state: "ready",
|
|
message: "Remote signer connected successfully.",
|
|
remotePubkey,
|
|
userPubkey,
|
|
});
|
|
|
|
devLogger.debug("[nostr] Remote signer connection established", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
userPubkey: summarizeHexForLog(userPubkey),
|
|
relays,
|
|
permissions: permissions || null,
|
|
secret: summarizeSecretForLog(secret),
|
|
});
|
|
|
|
return { pubkey: userPubkey, client };
|
|
} catch (error) {
|
|
devLogger.error("[nostr] Remote signer connection failed", {
|
|
remotePubkey: summarizeHexForLog(remotePubkey),
|
|
relays,
|
|
permissions: permissions || null,
|
|
secret: summarizeSecretForLog(secret),
|
|
message: error?.message || String(error),
|
|
code: error?.code || null,
|
|
});
|
|
client.destroy();
|
|
if (!remember) {
|
|
this.deps.clearStoredNip46Session();
|
|
}
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "error",
|
|
remotePubkey,
|
|
relays,
|
|
metadata,
|
|
message: error?.message || "Remote signer connection failed.",
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconnects to a stored session.
|
|
*
|
|
* @param {object} [options]
|
|
* @returns {Promise<{pubkey: string, client: Nip46RpcClient}>}
|
|
*/
|
|
async reconnectStored(options = {}) {
|
|
const normalizedOptions =
|
|
options && typeof options === "object" ? options : {};
|
|
const silent = normalizedOptions.silent === true;
|
|
const forgetOnError = normalizedOptions.forgetOnError === true;
|
|
const passphrase =
|
|
typeof normalizedOptions.passphrase === "string"
|
|
? normalizedOptions.passphrase
|
|
: null;
|
|
const validator =
|
|
typeof normalizedOptions.validator === "function"
|
|
? normalizedOptions.validator
|
|
: null;
|
|
|
|
let stored = this.deps.readStoredNip46Session();
|
|
if (!stored) {
|
|
const error = new Error(
|
|
"No remote signer session is stored on this device.",
|
|
);
|
|
error.code = "no-stored-session";
|
|
throw error;
|
|
}
|
|
|
|
if (stored.encryptedSecrets) {
|
|
if (!passphrase) {
|
|
const error = new Error("Passphrase required to unlock session.");
|
|
error.code = "passphrase-required";
|
|
throw error;
|
|
}
|
|
try {
|
|
stored = await this.deps.decryptNip46Session(stored, passphrase);
|
|
} catch (decryptError) {
|
|
const error = new Error("Failed to decrypt stored session.");
|
|
error.code = "decrypt-failed";
|
|
error.cause = decryptError;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const relays = this.deps.resolveNip46Relays(stored.relays, this.nostrClient.relays);
|
|
|
|
devLogger.debug("[nostr] Reconnecting to stored remote signer", {
|
|
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
|
|
relays,
|
|
encryption: stored.encryption || "",
|
|
permissions: stored.permissions || null,
|
|
hasCredentials: Boolean(stored.clientPrivateKey && stored.secret),
|
|
});
|
|
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "connecting",
|
|
remotePubkey: stored.remotePubkey,
|
|
relays,
|
|
metadata: stored.metadata,
|
|
});
|
|
|
|
if (!stored.clientPrivateKey || !stored.secret) {
|
|
const error = new Error(
|
|
"Stored remote signer credentials are unavailable.",
|
|
);
|
|
error.code = "stored-session-missing-credentials";
|
|
throw error;
|
|
}
|
|
|
|
const client = new this.deps.Nip46RpcClient({
|
|
nostrClient: this.nostrClient,
|
|
clientPrivateKey: stored.clientPrivateKey,
|
|
clientPublicKey: stored.clientPublicKey,
|
|
remotePubkey: stored.remotePubkey,
|
|
relays,
|
|
encryption: stored.encryption,
|
|
secret: stored.secret,
|
|
permissions: stored.permissions,
|
|
metadata: stored.metadata,
|
|
signEvent: (event, privateKey) => this.deps.signEventWithPrivateKey(event, privateKey),
|
|
});
|
|
|
|
try {
|
|
await client.ensureSubscription();
|
|
await client.connect({ permissions: stored.permissions });
|
|
const userPubkey = await client.getUserPubkey();
|
|
|
|
if (validator) {
|
|
try {
|
|
const isValid = await validator(userPubkey);
|
|
if (!isValid) {
|
|
throw new Error("Access denied.");
|
|
}
|
|
} catch (validationError) {
|
|
const message =
|
|
validationError instanceof Error
|
|
? validationError.message
|
|
: "Access denied.";
|
|
const error = new Error(message);
|
|
error.code = "access-denied";
|
|
client.destroy();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
client.metadata = stored.metadata;
|
|
|
|
await this.deps.writeStoredNip46Session(
|
|
{
|
|
version: 1,
|
|
clientPublicKey: stored.clientPublicKey,
|
|
remotePubkey: stored.remotePubkey,
|
|
relays: stored.relays,
|
|
encryption: client.encryptionAlgorithm || stored.encryption || "",
|
|
permissions: stored.permissions,
|
|
metadata: stored.metadata,
|
|
userPubkey,
|
|
lastConnectedAt: Date.now(),
|
|
clientPrivateKey: stored.clientPrivateKey,
|
|
secret: stored.secret,
|
|
},
|
|
passphrase,
|
|
);
|
|
|
|
devLogger.debug("[nostr] Stored remote signer session refreshed", {
|
|
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
|
|
userPubkey: summarizeHexForLog(userPubkey),
|
|
encryption: client.encryptionAlgorithm || stored.encryption || "",
|
|
});
|
|
|
|
this.nostrClient.emitRemoteSignerChange({
|
|
state: "connected",
|
|
remotePubkey: stored.remotePubkey,
|
|
userPubkey,
|
|
relays,
|
|
metadata: stored.metadata,
|
|
});
|
|
|
|
return { pubkey: userPubkey, client };
|
|
} catch (error) {
|
|
await client.destroy().catch(() => {});
|
|
const fatalCodes = new Set([
|
|
"nip46-secret-mismatch",
|
|
"invalid-private-key",
|
|
"invalid-connection-string",
|
|
]);
|
|
const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code);
|
|
if (shouldForgetStored) {
|
|
this.deps.clearStoredNip46Session();
|
|
devLogger.warn("[nostr] Stored remote signer session cleared after failure", {
|
|
remotePubkey: summarizeHexForLog(stored.remotePubkey || ""),
|
|
error: error?.message || String(error),
|
|
code: error?.code || null,
|
|
});
|
|
}
|
|
|
|
const status = {
|
|
state: shouldForgetStored ? "idle" : silent ? "stored" : "error",
|
|
remotePubkey: stored.remotePubkey,
|
|
relays,
|
|
metadata: stored.metadata,
|
|
};
|
|
|
|
if (!silent || shouldForgetStored) {
|
|
status.message =
|
|
error?.message || "Failed to reconnect to the remote signer.";
|
|
}
|
|
status.error = error;
|
|
|
|
this.nostrClient.emitRemoteSignerChange(status);
|
|
|
|
if (silent) {
|
|
devLogger.log("[nostr] Silent remote signer restore failed:", error);
|
|
} else {
|
|
devLogger.warn(
|
|
"[nostr] Stored remote signer reconnection failed:",
|
|
error,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async abort() {
|
|
if (this.pendingHandshakeCancel) {
|
|
try {
|
|
this.pendingHandshakeCancel();
|
|
} catch (error) {
|
|
devLogger.warn("[nostr] Failed to cancel pending handshake:", error);
|
|
}
|
|
this.pendingHandshakeCancel = null;
|
|
}
|
|
}
|
|
|
|
async disconnect({ keepStored = true } = {}) {
|
|
await this.abort();
|
|
|
|
if (!keepStored) {
|
|
this.deps.clearStoredNip46Session();
|
|
}
|
|
}
|
|
}
|