Files
bitvid/js/nostr/nip46Connector.js
T
google-labs-jules[bot]andPR0M3TH3AN ca7fb02171 Refactor NIP-46 connection logic into Nip46Connector helper
- 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]>
2026-02-08 03:58:39 +00:00

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();
}
}
}