Files
bitvid/js/nostr/managers/SignerManager.js
T
thePR0M3TH3ANandClaude Opus 4.8 ef66092727 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]>
2026-06-17 07:01:39 -04:00

1486 lines
46 KiB
JavaScript

import {
isSessionActor,
readStoredSessionActorEntry,
decryptSessionPrivateKey,
encryptSessionPrivateKey,
persistSessionActor as persistSessionActorEntry,
clearStoredSessionActor as clearStoredSessionActorEntry,
} from "../sessionActor.js";
import {
waitForNip07Extension,
NIP07_EXTENSION_WAIT_TIMEOUT_MS,
requestEnablePermissions,
normalizePermissionMethod,
readStoredNip07Permissions,
writeStoredNip07Permissions,
clearStoredNip07Permissions,
runNip07WithRetry,
DEFAULT_NIP07_ENCRYPTION_METHODS,
DEFAULT_NIP07_CORE_METHODS,
DEFAULT_NIP07_PERMISSION_METHODS,
} from "../nip07Permissions.js";
import {
setActiveSigner as setActiveSignerInRegistry,
getActiveSigner as getActiveSignerFromRegistry,
clearActiveSigner as clearActiveSignerInRegistry,
logoutSigner as logoutSignerFromRegistry,
resolveActiveSigner as resolveActiveSignerFromRegistry,
} from "../../nostrClientRegistry.js";
import {
readStoredNip46Session,
writeStoredNip46Session,
clearStoredNip46Session,
Nip46RpcClient,
decryptNip46Session,
parseNip46ConnectionString,
sanitizeNip46Metadata,
normalizeNip46EncryptionAlgorithm,
resolveNip46Relays,
encodeHexToNpub,
generateNip46Secret,
NIP46_RPC_KIND,
NIP46_HANDSHAKE_TIMEOUT_MS,
attemptDecryptNip46HandshakePayload,
normalizeNostrPubkey,
} from "../nip46Client.js";
import {
summarizeHexForLog,
summarizeSecretForLog,
summarizeMetadataForLog,
summarizeUrlForLog,
} from "../nip46LoggingUtils.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import { HEX64_REGEX } from "../../utils/hex.js";
import { createPrivateKeyCipherClosures } from "../signerHelpers.js";
import { queueSignEvent } from "../signRequestQueue.js";
import { signEventWithPrivateKey } from "../publishHelpers.js";
import { ensureNostrTools, getCachedNostrTools } from "../toolkit.js";
export function resolveSignerCapabilities(signer) {
const fallback = {
sign: false,
nip44: false,
nip04: false,
};
if (!signer || typeof signer !== "object") {
return fallback;
}
const capabilities =
signer.capabilities && typeof signer.capabilities === "object"
? signer.capabilities
: {};
return {
sign:
(typeof capabilities.sign === "boolean" && capabilities.sign) ||
typeof signer.signEvent === "function",
nip44:
(typeof capabilities.nip44 === "boolean" && capabilities.nip44) ||
typeof signer.nip44Encrypt === "function" ||
typeof signer.nip44Decrypt === "function",
nip04:
(typeof capabilities.nip04 === "boolean" && capabilities.nip04) ||
typeof signer.nip04Encrypt === "function" ||
typeof signer.nip04Decrypt === "function",
};
}
export function hydrateExtensionSignerCapabilities(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const signerType = typeof signer.type === "string" ? signer.type : "";
if (signerType !== "extension" && signerType !== "nip07") {
return;
}
const extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension) {
return;
}
if (typeof signer.signEvent !== "function" && extension.signEvent) {
if (typeof extension.signEvent === "function") {
signer.signEvent = extension.signEvent.bind(extension);
}
}
if (!signer.nip04 && extension.nip04) {
signer.nip04 = extension.nip04;
}
if (!signer.nip44 && extension.nip44) {
signer.nip44 = extension.nip44;
}
}
export function attachNipMethodAliases(signer) {
if (!signer || typeof signer !== "object") {
return;
}
const nip04 =
signer && typeof signer.nip04 === "object" && signer.nip04 !== null
? signer.nip04
: null;
if (nip04) {
const encrypt =
typeof nip04.encrypt === "function" ? nip04.encrypt.bind(nip04) : null;
const decrypt =
typeof nip04.decrypt === "function" ? nip04.decrypt.bind(nip04) : null;
if (encrypt && typeof signer.nip04Encrypt !== "function") {
signer.nip04Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt && typeof signer.nip04Decrypt !== "function") {
signer.nip04Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
const nip44 =
signer && typeof signer.nip44 === "object" && signer.nip44 !== null
? signer.nip44
: null;
if (nip44) {
const v2 =
typeof nip44.v2 === "object" && nip44.v2 !== null ? nip44.v2 : null;
const encrypt = (() => {
if (typeof signer.nip44Encrypt === "function") {
return null;
}
if (typeof v2?.encrypt === "function") {
return v2.encrypt.bind(v2);
}
if (typeof nip44.encrypt === "function") {
return nip44.encrypt.bind(nip44);
}
return null;
})();
const decrypt = (() => {
if (typeof signer.nip44Decrypt === "function") {
return null;
}
if (typeof v2?.decrypt === "function") {
return v2.decrypt.bind(v2);
}
if (typeof nip44.decrypt === "function") {
return nip44.decrypt.bind(nip44);
}
return null;
})();
if (encrypt) {
signer.nip44Encrypt = (targetPubkey, plaintext) =>
encrypt(targetPubkey, plaintext);
}
if (decrypt) {
signer.nip44Decrypt = (actorPubkey, ciphertext) =>
decrypt(actorPubkey, ciphertext);
}
}
}
function resolveActiveSigner(pubkey) {
const signer = resolveActiveSignerFromRegistry(pubkey);
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
const PERMISSION_STATUS_AUTO_HIDE_MS = 12_000;
const ENCRYPTION_METHOD_PREFIXES = ["nip04.", "nip44."];
function hasEncryptionPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some((method) =>
ENCRYPTION_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix)),
);
}
function hasSigningPermissionMethods(methods) {
if (!Array.isArray(methods)) {
return false;
}
return methods.some(
(method) => method === "sign_event" || method === "get_public_key",
);
}
function resolvePermissionStatusMessage(methods, context) {
const normalizedContext =
typeof context === "string" ? context.trim().toLowerCase() : "";
if (normalizedContext === "dm") {
return "Approve the extension prompt to enable encrypted direct messages.";
}
if (normalizedContext === "lists") {
return "Approve the extension prompt to access your encrypted lists.";
}
const includesEncryption = hasEncryptionPermissionMethods(methods);
const includesSigning = hasSigningPermissionMethods(methods);
if (includesEncryption && includesSigning) {
return "Approve the extension prompt to enable signing and encrypted features (DMs, subscriptions, block lists).";
}
if (includesEncryption) {
return "Approve the extension prompt to enable encrypted features like DMs and private lists.";
}
if (includesSigning) {
return "Approve the extension prompt to enable signing.";
}
return "Approve the extension prompt to continue.";
}
export class SignerManager {
constructor(client) {
this.client = client;
this.pubkey = null;
this.sessionActor = null;
this.lockedSessionActor = null;
this.nip46Client = null;
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.extensionPermissionCache = new Map();
const storedPermissions = readStoredNip07Permissions();
if (storedPermissions && storedPermissions.size > 0) {
for (const method of storedPermissions) {
this.extensionPermissionCache.set(method, true);
}
}
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
this.remoteSignerListeners = new Set();
this.remoteSignerStatus = {};
}
setActiveSigner(signer) {
if (!signer || typeof signer !== "object") {
return;
}
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities");
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
setActiveSignerInRegistry(signer);
}
getActiveSigner() {
const signer = getActiveSignerFromRegistry();
hydrateExtensionSignerCapabilities(signer);
attachNipMethodAliases(signer);
if (signer && typeof signer === "object") {
const capsDescriptor = Object.getOwnPropertyDescriptor(
signer,
"capabilities",
);
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
if (!isGetter) {
signer.capabilities = resolveSignerCapabilities(signer);
}
}
return signer;
}
resolveActiveSigner(pubkey) {
return resolveActiveSigner(pubkey);
}
async ensureSessionActor(force = false) {
if (!force) {
if (this.sessionActor) {
return this.sessionActor.pubkey;
}
if (this.lockedSessionActor) {
return this.lockedSessionActor.pubkey;
}
const storedEntry = readStoredSessionActorEntry();
if (storedEntry) {
this.lockedSessionActor = storedEntry;
this.sessionActor = storedEntry;
return storedEntry.pubkey;
}
}
const tools = await ensureNostrTools();
if (!tools) {
return null;
}
let secret;
try {
if (typeof tools.generateSecretKey === "function") {
secret = tools.generateSecretKey();
} else if (typeof window !== "undefined" && window.crypto) {
secret = new Uint8Array(32);
window.crypto.getRandomValues(secret);
} else {
return null;
}
} catch (error) {
devLogger.warn("[nostr] Failed to generate session actor key:", error);
return null;
}
const hexKey = Array.from(secret)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const pubkey = tools.getPublicKey(secret);
const newActor = {
pubkey,
privateKey: hexKey,
source: "session",
createdAt: Date.now(),
};
this.sessionActor = newActor;
return newActor.pubkey;
}
clearStoredSessionActor() {
clearStoredSessionActorEntry();
this.sessionActor = null;
this.lockedSessionActor = null;
}
async ensureActiveSignerForPubkey(pubkey) {
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: "";
const existingSigner = this.resolveActiveSigner(normalizedPubkey);
if (existingSigner && typeof existingSigner.signEvent === "function") {
return existingSigner;
}
let extension =
typeof window !== "undefined" && window && window.nostr ? window.nostr : null;
if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) {
try {
await waitForNip07Extension(NIP07_EXTENSION_WAIT_TIMEOUT_MS);
extension = window.nostr;
} catch (error) {
// Fall through to existing signer check
}
}
const recheckedSigner = this.resolveActiveSigner(normalizedPubkey);
if (recheckedSigner && typeof recheckedSigner.signEvent === "function") {
return recheckedSigner;
}
if (!extension) {
return existingSigner;
}
let extensionPubkey = normalizedPubkey;
if (typeof extension.getPublicKey === "function") {
try {
const retrieved = await runNip07WithRetry(
() => extension.getPublicKey(),
{ label: "extension.getPublicKey" },
);
if (typeof retrieved === "string" && retrieved.trim()) {
extensionPubkey = retrieved.trim().toLowerCase();
}
} catch (error) {
devLogger.warn(
"[nostr] Failed to hydrate active signer from extension pubkey:",
error,
);
return existingSigner;
}
}
if (normalizedPubkey && extensionPubkey !== normalizedPubkey) {
return existingSigner;
}
const raceWinner = this.resolveActiveSigner(extensionPubkey || normalizedPubkey);
if (raceWinner && typeof raceWinner.signEvent === "function") {
return raceWinner;
}
return null;
}
async ensureExtensionPermissions(
requiredMethods = DEFAULT_NIP07_CORE_METHODS,
{ context = "general" } = {},
) {
const methods = Array.isArray(requiredMethods)
? requiredMethods
: [requiredMethods];
if (!methods.length) {
return { ok: true };
}
const cacheKey = methods.slice().sort().join(",");
if (this.extensionPermissionCache.has(cacheKey)) {
return { ok: true };
}
const cachedPermissions = readStoredNip07Permissions();
const missing = methods.filter(
(method) => !cachedPermissions.has(method),
);
if (missing.length === 0) {
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
for (const method of methods) {
this.extensionPermissionCache.set(method, true);
}
return { ok: true };
}
let extension = null;
try {
extension = await waitForNip07Extension();
} catch (error) {
return { ok: false, error: "extension-missing" };
}
if (!extension && typeof window !== "undefined") {
extension = window.nostr;
}
try {
const response = await requestEnablePermissions(extension, missing);
if (response?.ok) {
writeStoredNip07Permissions(missing);
this.extensionPermissionsGranted = true;
this.extensionPermissionCache.set(cacheKey, true);
for (const method of missing) {
this.extensionPermissionCache.set(method, true);
}
return { ok: true };
}
return {
ok: false,
error: response?.error || "permission-denied",
};
} catch (error) {
devLogger.warn("[nostr] Extension permission request failed:", error);
return { ok: false, error: "request-failed" };
}
}
async loginWithExtension(options = {}) {
let extension = null;
let attempts = 0;
const maxAttempts = 3;
if (this.extensionReady && typeof window !== "undefined") {
extension = window.nostr || null;
if (!extension) {
this.extensionReady = false;
}
}
if (!extension && typeof window !== "undefined" && window.nostr) {
extension = window.nostr;
this.extensionReady = true;
} else {
while (!extension && attempts < maxAttempts) {
try {
const timeout = attempts === 0 ? 1500 : 3000;
await waitForNip07Extension(timeout);
extension = window.nostr;
this.extensionReady = Boolean(extension);
} catch (waitError) {
devLogger.log(
`Timed out waiting for extension injection (attempt ${
attempts + 1
}/${maxAttempts}):`,
waitError,
);
}
if (extension) {
break;
}
attempts++;
if (attempts < maxAttempts) {
devLogger.log("Retrying NIP-07 detection...");
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (!extension) {
devLogger.log("No Nostr extension found");
throw new Error("Please install a Nostr extension (Alby, nos2x, etc.).");
}
const {
allowAccountSelection = false,
expectPubkey,
getPublicKeyTimeoutMs,
getPublicKeyRetryMultiplier,
} =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension.",
);
}
const permissionResult = await this.client.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
{ context: "login", logMetrics: true, showStatus: false },
);
if (!permissionResult.ok) {
const permissionErrorDetail =
permissionResult && typeof permissionResult === "object"
? permissionResult.error
: null;
const permissionDetailMessage =
permissionErrorDetail instanceof Error &&
typeof permissionErrorDetail.message === "string" &&
permissionErrorDetail.message.trim()
? permissionErrorDetail.message.trim()
: typeof permissionErrorDetail === "string" &&
permissionErrorDetail.trim()
? permissionErrorDetail.trim()
: "";
const denialMessage =
permissionDetailMessage
? `The NIP-07 extension denied the permission request (${permissionDetailMessage}). Please approve the prompt and try again.`
: 'The NIP-07 extension reported "permission denied". Please approve the prompt and try again.';
const denialError = new Error(denialMessage);
if (permissionResult.error) {
denialError.cause = permissionResult.error;
}
throw denialError;
}
const pubkey = await runNip07WithRetry(
() => extension.getPublicKey(),
{
label: "extension.getPublicKey",
timeoutMs: getPublicKeyTimeoutMs,
retryMultiplier: getPublicKeyRetryMultiplier,
},
);
if (!pubkey) {
throw new Error("Extension did not return a public key.");
}
const normalized = pubkey.toLowerCase();
if (normalizedExpectedPubkey && normalized !== normalizedExpectedPubkey) {
throw new Error("Extension returned a different public key than expected.");
}
this.pubkey = normalized;
const adapter = {
type: "extension",
pubkey: normalized,
signEvent: typeof extension.signEvent === "function" ? extension.signEvent.bind(extension) : undefined,
nip04: extension.nip04,
nip44: extension.nip44,
};
this.setActiveSigner(adapter);
return { pubkey: normalized, signer: adapter };
}
installNip46Client(nip46Client, { userPubkey } = {}) {
if (!nip46Client) {
return;
}
this.nip46Client = nip46Client;
if (userPubkey) {
this.pubkey = userPubkey;
this.setActiveSigner(nip46Client);
}
this.emitRemoteSignerChange({
state: "connected",
userPubkey: userPubkey || this.pubkey,
remotePubkey: nip46Client.remotePubkey,
});
}
/**
* 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,
};
}
/**
* Runs the access-control validator against a remote signer's resolved user
* pubkey. The validator (from the auth provider) throws on denial (blocked /
* not permitted) or returns false; either rejects the login. No-op when no
* validator is supplied.
*/
async _enforceRemoteSignerValidator(validator, userPubkey) {
if (typeof validator !== "function") {
return;
}
const isValid = await validator(userPubkey);
if (isValid === false) {
const error = new Error("Access denied.");
error.code = "remote-signer-access-denied";
throw error;
}
}
/**
* 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,
clientPrivateKey: providedClientPrivateKey = "",
clientPublicKey: providedClientPublicKey = "",
relays: providedRelays = [],
secret: providedSecret = "",
permissions: providedPermissions = "",
metadata: providedMetadata = {},
onAuthUrl,
onStatus,
handshakeTimeoutMs,
passphrase,
validator,
} = {}) {
const parsed = 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 = sanitizeNip46Metadata(parsed.metadata);
const overrideMetadata = 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),
});
let clientPrivateKey = providedClientPrivateKey || "";
if (!clientPrivateKey) {
const tools = await ensureNostrTools();
if (tools && typeof tools.generateSecretKey === 'function') {
const secret = tools.generateSecretKey();
// Convert to hex
clientPrivateKey = Array.from(secret).map(b => b.toString(16).padStart(2, '0')).join('');
} else if (typeof window !== 'undefined' && window.crypto) {
const randomBytes = new Uint8Array(32);
window.crypto.getRandomValues(randomBytes);
clientPrivateKey = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for non-secure contexts (not ideal but functional for test/dev)
clientPrivateKey = generateNip46Secret(64);
}
}
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] Auth challenge callback threw:", error);
}
};
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,
secret: providedSecret || parsed.secret,
signEvent: signEventWithPrivateKey,
});
// Subscribe to events from the NIP46 client?
// The previous code had onStatusChange in the constructor which Nip46RpcClient doesn't seem to support directly based on the read_file output of nip46Client.js.
// However, Nip46RpcClient has no public event emitter interface shown in the file read.
// We will just use it to connect.
this.emitRemoteSignerChange({ state: "connecting" });
try {
const result = await this.nip46Client.connect({
permissions: providedPermissions,
});
if (remember) {
await writeStoredNip46Session(
{
version: 1,
clientPrivateKey: this.nip46Client.clientPrivateKey,
clientPublicKey: this.nip46Client.clientPublicKey,
remotePubkey: this.nip46Client.remotePubkey,
relays: this.nip46Client.relays,
encryption: this.nip46Client.encryptionAlgorithm,
secret: this.nip46Client.secret,
permissions: this.nip46Client.permissions,
metadata: this.nip46Client.metadata,
userPubkey: this.nip46Client.userPubkey,
lastConnectedAt: Date.now(),
},
passphrase
);
}
const userPubkey = await this.nip46Client.getUserPubkey();
// Enforce access control BEFORE activating the signer, so a blocked /
// non-permitted pubkey can't establish a session via a remote signer.
await this._enforceRemoteSignerValidator(validator, userPubkey);
this.pubkey = userPubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, result, pubkey: userPubkey };
} catch(err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
throw err;
}
}
async useStoredRemoteSigner(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 = 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 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 = resolveNip46Relays(stored.relays, this.client.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.emitRemoteSignerChange({
state: "connecting",
relays,
remotePubkey: stored.remotePubkey,
});
await this.client.ensurePool();
this.nip46Client = new Nip46RpcClient({
nostrClient: this.client,
relays,
clientPrivateKey: stored.clientPrivateKey,
remotePubkey: stored.remotePubkey,
secret: stored.secret,
encryption: stored.encryption,
signEvent: signEventWithPrivateKey,
});
try {
// Just verify we can ping or get pubkey
await this.nip46Client.ensureSubscription();
const pubkey = await this.nip46Client.getUserPubkey();
// Re-check access control when restoring a stored session, so a pubkey
// that has since been blocked can't silently reconnect.
await this._enforceRemoteSignerValidator(validator, pubkey);
this.pubkey = pubkey;
this.setActiveSigner(this.nip46Client.getActiveSigner());
this.emitRemoteSignerChange();
return { ok: true, pubkey };
} catch (err) {
this.nip46Client = null;
this.emitRemoteSignerChange({ state: "error", error: err });
// Always forget a stored session that was rejected by access control —
// no point keeping a blocked pubkey's session to retry.
if (forgetOnError || err?.code === "remote-signer-access-denied") {
clearStoredNip46Session();
}
throw err;
}
}
async scheduleStoredRemoteSignerRestore() {
const stored = readStoredNip46Session();
if (!stored) {
return;
}
const decryptAndConnect = async () => {
try {
await this.useStoredRemoteSigner({ silent: true });
} catch (err) {
devLogger.warn("Failed to restore remote signer", err);
}
};
decryptAndConnect();
}
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();
}
this.nip46Client = null;
}
if (!keepStored) {
clearStoredNip46Session();
}
this.emitRemoteSignerChange();
}
getStoredNip46Metadata() {
const stored = readStoredNip46Session() || {};
const userPubkey =
typeof stored.userPubkey === "string" ? stored.userPubkey : "";
return {
hasSession: Boolean(stored.remotePubkey && stored.clientPrivateKey),
remotePubkey: stored.remotePubkey || "",
metadata: stored.metadata || {},
relays: stored.relays || [],
encryption: stored.encryption || "",
userPubkey,
userNpub: encodeHexToNpub(userPubkey),
};
}
getRemoteSignerStatus() {
return { ...this.remoteSignerStatus };
}
emitRemoteSignerChange(status = {}) {
const stored = this.getStoredNip46Metadata();
const nextState =
typeof status.state === "string" && status.state.trim()
? status.state.trim()
: this.nip46Client
? "connected"
: stored.hasSession
? "stored"
: "idle";
const remotePubkey =
(typeof status.remotePubkey === "string" && status.remotePubkey.trim()) ||
this.nip46Client?.remotePubkey ||
stored.remotePubkey ||
"";
const userPubkey =
(typeof status.userPubkey === "string" && status.userPubkey.trim()) ||
this.nip46Client?.userPubkey ||
stored.userPubkey ||
"";
const metadataCandidate =
status.metadata || this.nip46Client?.metadata || stored.metadata || {};
const metadata =
metadataCandidate && typeof metadataCandidate === "object"
? { ...metadataCandidate }
: {};
const relays = Array.isArray(status.relays)
? status.relays.slice()
: (this.nip46Client?.relays || stored.relays || []).slice();
const encryption =
(typeof status.encryption === "string" && status.encryption.trim()
? normalizeNip46EncryptionAlgorithm(status.encryption)
: "") || this.nip46Client?.encryptionAlgorithm || stored.encryption || "";
const snapshot = {
state: nextState,
remotePubkey,
userPubkey,
relays,
metadata,
encryption,
error: status.error || null,
};
this.remoteSignerStatus = snapshot;
for (const listener of this.remoteSignerListeners) {
try {
listener(snapshot);
} catch (error) {
devLogger.warn("[nostr] Remote signer listener threw:", error);
}
}
return snapshot;
}
onRemoteSignerChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this.remoteSignerListeners.add(listener);
return () => {
this.remoteSignerListeners.delete(listener);
};
}
logout() {
const previousPubkey = this.pubkey;
this.pubkey = null;
logoutSignerFromRegistry(previousPubkey);
const previousSessionActor = this.sessionActor;
this.sessionActor = null;
this.sessionActorCipherClosures = null;
this.sessionActorCipherClosuresPrivateKey = null;
if (this.nip46Client) {
this.disconnectRemoteSigner({ keepStored: true });
}
const shouldClearStoredSession =
previousSessionActor &&
previousSessionActor.source === "nsec" &&
previousSessionActor.persisted !== true;
if (shouldClearStoredSession) {
this.lockedSessionActor = null;
this.clearStoredSessionActor();
}
if (
this.extensionPermissionCache &&
typeof this.extensionPermissionCache.clear === "function"
) {
this.extensionPermissionCache.clear();
}
clearStoredNip07Permissions();
this.extensionReady = false;
this.extensionPermissionsGranted = false;
this.emitRemoteSignerChange();
}
installNip46Client(rpcClient, { userPubkey } = {}) {
this.nip46Client = rpcClient;
if (userPubkey) {
this.pubkey = userPubkey;
}
if (rpcClient && typeof rpcClient.getActiveSigner === "function") {
const signer = rpcClient.getActiveSigner();
if (signer) {
this.setActiveSigner(signer);
}
}
this.emitRemoteSignerChange();
}
async derivePrivateKeyFromSecret(secret) {
if (!secret || typeof secret !== "string") {
throw new Error("A private key or nsec string is required.");
}
const trimmed = secret.trim();
const tools = (await ensureNostrTools()) || getCachedNostrTools();
// Handle hex private key
if (HEX64_REGEX.test(trimmed)) {
const privateKeyHex = trimmed.toLowerCase();
if (!tools || typeof tools.getPublicKey !== "function") {
throw new Error("Nostr tools unavailable for key derivation.");
}
const pubkey = tools.getPublicKey(privateKeyHex);
return { privateKey: privateKeyHex, pubkey };
}
// Handle nsec bech32 encoding
if (trimmed.startsWith("nsec1")) {
const nip19 = tools?.nip19;
if (!nip19 || typeof nip19.decode !== "function") {
throw new Error("Nostr tools unavailable for nsec decoding.");
}
const decoded = nip19.decode(trimmed);
if (!decoded || decoded.type !== "nsec" || !decoded.data) {
throw new Error("Invalid nsec key.");
}
const secretBytes = decoded.data;
const privateKeyHex =
typeof secretBytes === "string"
? secretBytes
: Array.from(secretBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (!HEX64_REGEX.test(privateKeyHex)) {
throw new Error("Decoded nsec key is invalid.");
}
const pubkey = tools.getPublicKey(privateKeyHex);
return { privateKey: privateKeyHex, pubkey };
}
throw new Error("Unrecognized key format. Provide a hex private key or nsec string.");
}
async registerPrivateKeySigner({ privateKey, pubkey, persist, passphrase, validator } = {}) {
if (!privateKey || typeof privateKey !== "string" || !HEX64_REGEX.test(privateKey)) {
throw new Error("A valid hex private key is required.");
}
const normalizedPrivateKey = privateKey.toLowerCase();
const normalizedPubkey =
typeof pubkey === "string" && pubkey.trim()
? pubkey.trim().toLowerCase()
: null;
if (!normalizedPubkey || !HEX64_REGEX.test(normalizedPubkey)) {
throw new Error("A valid hex public key is required.");
}
if (typeof validator === "function") {
validator(normalizedPubkey);
}
const cipherClosures = await createPrivateKeyCipherClosures(normalizedPrivateKey);
const adapter = {
type: "nsec",
pubkey: normalizedPubkey,
signEvent: (event) => signEventWithPrivateKey(event, normalizedPrivateKey),
...cipherClosures,
};
this.setActiveSigner(adapter);
this.pubkey = normalizedPubkey;
if (persist && passphrase) {
try {
const encrypted = await encryptSessionPrivateKey(normalizedPrivateKey, passphrase);
persistSessionActorEntry({
pubkey: normalizedPubkey,
encrypted,
source: "nsec",
persisted: true,
});
this.sessionActor = { pubkey: normalizedPubkey, source: "nsec", persisted: true };
} catch (error) {
devLogger.warn("[nostr] Failed to persist private key signer:", error);
}
}
return normalizedPubkey;
}
}