diff --git a/components/login-modal.html b/components/login-modal.html index 3e51c4bb..267c8402 100644 --- a/components/login-modal.html +++ b/components/login-modal.html @@ -58,13 +58,22 @@ type="button" data-provider-button > - + - + + + + Disconnect + + + + + + Connect remote signer + + Paste the nostrconnect:// or bunker:// link + from your signer to establish a Nostr Connect session. + + + + + + Connection string + + + + We will connect to the relays embedded in the link and request signing + permissions on your behalf. + + + + + + Remember this signer for future logins + + + Reconnect using the saved session + + + + Cancel + Connect + + + diff --git a/docs/nip46-remote-signers.md b/docs/nip46-remote-signers.md new file mode 100644 index 00000000..f7cbb079 --- /dev/null +++ b/docs/nip46-remote-signers.md @@ -0,0 +1,91 @@ +# NIP-46 Remote Signer Integration + +bitvid can delegate event signing to a remote NIP-46 compatible signer. This +flow lets operators keep long-lived keys on hardened devices while approving +publish/edit/delete actions from a separate browser session. + +## Connection strings + +bitvid currently expects `bunker://` style URIs. They should embed the remote +signer pubkey in the host segment and may include the following query +parameters: + +- `relay=` – one or more WSS relay URLs the signer watches for RPC requests. + Repeat the parameter to advertise multiple relays. +- `secret=` – optional shared secret that must echo back during the `connect` + handshake. +- `perms=` – optional permission string. When present bitvid forwards it during + the initial `connect` RPC. +- `name`, `url`, `image` – optional metadata shown in the login modal so users + can distinguish between saved signers. + +URIs without `relay=` parameters fall back to bitvid’s default relay bundle +(`wss://relay.damus.io`, `wss://nos.lol`, `wss://relay.snort.social`, +`wss://relay.primal.net`, `wss://relay.nostr.band`). Make sure your signer also +publishes to at least one of these relays so both the browser and signer see the +same NIP-46 frames. + +## Relay requirements + +The remote signer must subscribe to `kind 24133` events targeted at the client +public key (included in the connection URI). bitvid publishes RPC requests to +whichever relay list resolves from the URI + local fallback: + +1. Explicit `relay=` parameters from the bunker URI (WSS only, duplicates + removed and sanitized). +2. The relays persisted in a previous session, if any. +3. The default bitvid relay set listed above. + +For reliable operation: + +- Keep at least two relays online so publish attempts succeed within the + 8 second relay publish timeout. +- Ensure every relay allows inbound events from both the browser’s IP and the + remote signer. Corporate VPNs and Tor bridges frequently block public WSS + relays. +- Avoid private relays that block unsigned events from unknown clients; the + browser session publishes unsigned requests that the signer decrypts with the + shared secret. + +## RPC expectations and timeouts + +The RPC client issues the following methods: + +- `connect` (optionally with `secret` and `perms`) +- `get_public_key` +- `sign_event` for publish/edit/delete flows + +Each request is encrypted to the signer and published to the resolved relay set. +bitvid waits up to **8 seconds** for a relay to accept the event and up to +**15 seconds** for a response. `sign_event` calls allow up to +**20 seconds** before timing out. On transient failures the client retries +once (except when the signer returns an authentication URL). + +If the signer responds with `error: auth_url`, the UI surfaces the provided URL +so the user can complete any additional verification the signer requires. + +## Session persistence and restoration + +Successful connections write a session blob to +`localStorage` under the key `bitvid:nip46:session:v1`. The payload includes the +client key pair, remote pubkey, relay list, metadata, permissions, and the most +recent user pubkey returned by the signer. + +On page load bitvid reads the stored session, updates the login modal to show a +saved signer, and silently attempts to reconnect in the background. If the +attempt fails due to network timeouts or temporary relay outages the stored +session remains intact so the user can retry manually. Fatal errors (for example +shared secret mismatches) clear the saved session automatically. Users can also +invoke “Disconnect” from the login modal to remove the stored credentials. + +## Operational checklist + +- Issue bunker URIs that list WSS relays reachable by both the signer and the + browser session. +- Keep the signer online and subscribed to `kind 24133` events for the advertised + relays. +- Respond to `connect`, `get_public_key`, and `sign_event` requests within the + timeouts above. +- Rotate secrets or permissions as needed—bitvid surfaces signer metadata so + operators can confirm which device they are connecting to before approving new + sessions. diff --git a/js/app.js b/js/app.js index 2307e3b5..c33c6164 100644 --- a/js/app.js +++ b/js/app.js @@ -428,6 +428,7 @@ class Application { providers: authProviders, services: { authService: this.authService, + nostrClient, }, callbacks: { onProviderSelected: (providerId) => { diff --git a/js/nostr.js b/js/nostr.js index d4f23d97..1260052a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -187,6 +187,169 @@ function clearStoredNip07Permissions() { // ignore cleanup issues } } +const NIP46_RPC_KIND = 24_133; +const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; +const NIP46_PUBLISH_TIMEOUT_MS = 8_000; +const NIP46_RESPONSE_TIMEOUT_MS = 15_000; +const NIP46_SIGN_EVENT_TIMEOUT_MS = 20_000; +const NIP46_MAX_RETRIES = 1; + +function getNip46Storage() { + if (typeof localStorage !== "undefined" && localStorage) { + return localStorage; + } + + if (typeof globalThis !== "undefined" && globalThis?.localStorage) { + return globalThis.localStorage; + } + + return null; +} + +function sanitizeStoredNip46Session(candidate) { + if (!candidate || typeof candidate !== "object") { + return null; + } + + const version = Number(candidate.version) || 0; + if (version !== 1) { + return null; + } + + const clientPrivateKey = + typeof candidate.clientPrivateKey === "string" && HEX64_REGEX.test(candidate.clientPrivateKey) + ? candidate.clientPrivateKey.toLowerCase() + : ""; + const clientPublicKey = + typeof candidate.clientPublicKey === "string" && candidate.clientPublicKey.trim() + ? candidate.clientPublicKey.trim().toLowerCase() + : ""; + const remotePubkey = + typeof candidate.remotePubkey === "string" && candidate.remotePubkey.trim() + ? candidate.remotePubkey.trim().toLowerCase() + : ""; + + if (!clientPrivateKey || !remotePubkey) { + return null; + } + + const relays = Array.isArray(candidate.relays) + ? candidate.relays + .map((relay) => (typeof relay === "string" ? relay.trim() : "")) + .filter(Boolean) + : []; + + const metadata = + candidate.metadata && typeof candidate.metadata === "object" + ? { + name: + typeof candidate.metadata.name === "string" + ? candidate.metadata.name.trim() + : "", + url: + typeof candidate.metadata.url === "string" + ? candidate.metadata.url.trim() + : "", + image: + typeof candidate.metadata.image === "string" + ? candidate.metadata.image.trim() + : "", + } + : {}; + + return { + version: 1, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + secret: + typeof candidate.secret === "string" && candidate.secret.trim() + ? candidate.secret.trim() + : "", + permissions: + typeof candidate.permissions === "string" && candidate.permissions.trim() + ? candidate.permissions.trim() + : "", + metadata, + userPubkey: + typeof candidate.userPubkey === "string" && candidate.userPubkey.trim() + ? candidate.userPubkey.trim().toLowerCase() + : "", + lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) + ? candidate.lastConnectedAt + : Date.now(), + }; +} + +function readStoredNip46Session() { + const storage = getNip46Storage(); + if (!storage) { + return null; + } + + let raw = null; + try { + raw = storage.getItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + return null; + } + + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + return sanitizeStoredNip46Session(parsed); + } catch (error) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (cleanupError) { + devLogger.warn( + "[nostr] Failed to clear corrupt NIP-46 session entry:", + cleanupError, + ); + } + return null; + } +} + +function writeStoredNip46Session(payload) { + const storage = getNip46Storage(); + if (!storage) { + return; + } + + const normalized = sanitizeStoredNip46Session(payload); + if (!normalized) { + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup failures + } + return; + } + + try { + storage.setItem(NIP46_SESSION_STORAGE_KEY, JSON.stringify(normalized)); + } catch (error) { + // ignore persistence failures + } +} + +function clearStoredNip46Session() { + const storage = getNip46Storage(); + if (!storage) { + return; + } + + try { + storage.removeItem(NIP46_SESSION_STORAGE_KEY); + } catch (error) { + // ignore cleanup issues + } +} const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const SESSION_ACTOR_ENCRYPTION_VERSION = 1; const SESSION_ACTOR_KDF_ITERATIONS = 250_000; @@ -3113,6 +3276,606 @@ function cloneEventForCache(event) { return cloned; } +function createNip46RequestId() { + try { + if (typeof crypto !== "undefined" && crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + } catch (error) { + // fall through to timestamp-based id + } + + const timestamp = Date.now().toString(16); + const entropy = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0"); + return `${timestamp}${entropy}`; +} + +function normalizeNostrPubkey(candidate) { + if (typeof candidate !== "string") { + return ""; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + const decoded = decodeNpubToHex(trimmed); + if (decoded && HEX64_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + + return trimmed.toLowerCase(); +} + +function resolveNip46Relays(relays, fallbackRelays = []) { + const primary = sanitizeRelayList(Array.isArray(relays) ? relays : []); + if (primary.length) { + return primary; + } + + const fallback = sanitizeRelayList(Array.isArray(fallbackRelays) ? fallbackRelays : []); + if (fallback.length) { + return fallback; + } + + return Array.from(DEFAULT_RELAY_URLS); +} + +function parseNip46ConnectionString(uri) { + const value = typeof uri === "string" ? uri.trim() : ""; + if (!value) { + return null; + } + + let parsed; + try { + parsed = new URL(value); + } catch (error) { + return null; + } + + const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); + const params = parsed.searchParams || new URLSearchParams(); + + const relays = params + .getAll("relay") + .map((relay) => { + if (typeof relay !== "string") { + return ""; + } + try { + return decodeURIComponent(relay.trim()); + } catch (error) { + return relay.trim(); + } + }) + .filter(Boolean); + + const permissionsParam = params.get("perms") || params.get("permissions") || ""; + const metadata = { + name: "", + url: "", + image: "", + }; + + for (const key of ["name", "url", "image"]) { + const raw = params.get(key); + if (typeof raw === "string" && raw.trim()) { + try { + metadata[key] = decodeURIComponent(raw.trim()); + } catch (error) { + metadata[key] = raw.trim(); + } + } + } + + let remotePubkey = ""; + let clientPubkey = ""; + + if (scheme === "bunker") { + remotePubkey = parsed.hostname || ""; + if (!remotePubkey && parsed.pathname && parsed.pathname !== "/") { + remotePubkey = parsed.pathname.replace(/^\/+/, ""); + } + } else if (scheme === "nostrconnect" || scheme === "web+nostrconnect") { + clientPubkey = parsed.hostname || ""; + if (!clientPubkey && parsed.pathname && parsed.pathname !== "/") { + clientPubkey = parsed.pathname.replace(/^\/+/, ""); + } + remotePubkey = + params.get("remote") || + params.get("remotePubkey") || + params.get("signer") || + ""; + } + + const secretParam = params.get("secret") || ""; + + return { + scheme, + type: scheme === "bunker" ? "remote" : "client", + remotePubkey: normalizeNostrPubkey(remotePubkey), + clientPubkey: normalizeNostrPubkey(clientPubkey), + relays, + secret: typeof secretParam === "string" ? secretParam.trim() : "", + permissions: typeof permissionsParam === "string" ? permissionsParam.trim() : "", + metadata, + }; +} + +class Nip46RpcClient { + constructor({ + nostrClient, + clientPrivateKey, + clientPublicKey, + remotePubkey, + relays, + secret, + permissions, + metadata, + } = {}) { + this.nostrClient = nostrClient || null; + this.clientPrivateKey = + typeof clientPrivateKey === "string" && HEX64_REGEX.test(clientPrivateKey) + ? clientPrivateKey.toLowerCase() + : ""; + this.clientPublicKey = normalizeNostrPubkey(clientPublicKey); + this.remotePubkey = normalizeNostrPubkey(remotePubkey); + this.relays = resolveNip46Relays(relays, nostrClient?.relays || []); + this.secret = typeof secret === "string" ? secret.trim() : ""; + this.permissions = typeof permissions === "string" ? permissions.trim() : ""; + this.metadata = metadata && typeof metadata === "object" ? { ...metadata } : {}; + + if (!this.clientPrivateKey) { + throw new Error("A NIP-46 client private key is required."); + } + + if (!this.clientPublicKey) { + const tools = getCachedNostrTools(); + if (!tools || typeof tools.getPublicKey !== "function") { + throw new Error("Public key derivation is unavailable."); + } + this.clientPublicKey = tools.getPublicKey(this.clientPrivateKey); + if (!this.clientPublicKey || !HEX64_REGEX.test(this.clientPublicKey)) { + throw new Error("Failed to derive a valid public key for the remote signer session."); + } + this.clientPublicKey = this.clientPublicKey.toLowerCase(); + } + + if (!this.remotePubkey) { + throw new Error("A remote signer pubkey is required."); + } + + this.pendingRequests = new Map(); + this.subscription = null; + this.destroyed = false; + this.cipher = null; + this.lastSeen = 0; + this.userPubkey = ""; + this.activeSignerCache = null; + } + + get pool() { + return this.nostrClient?.pool || null; + } + + async ensurePool() { + if (this.pool) { + return this.pool; + } + + if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") { + throw new Error("Remote signer requires a nostr client pool."); + } + + await this.nostrClient.ensurePool(); + return this.pool; + } + + async ensureCipher() { + if (this.cipher) { + return this.cipher; + } + + const tools = (await ensureNostrTools()) || getCachedNostrTools(); + if (!tools) { + throw new Error("NostrTools helpers are unavailable for remote signing."); + } + + let encrypt = null; + let decrypt = null; + + if (tools?.nip44?.v2?.encrypt && tools?.nip44?.v2?.decrypt) { + encrypt = (priv, pub, payload) => tools.nip44.v2.encrypt(priv, pub, payload); + decrypt = (priv, pub, payload) => tools.nip44.v2.decrypt(priv, pub, payload); + } else if (tools?.nip44?.encrypt && tools?.nip44?.decrypt) { + encrypt = (priv, pub, payload) => tools.nip44.encrypt(priv, pub, payload); + decrypt = (priv, pub, payload) => tools.nip44.decrypt(priv, pub, payload); + } else if (tools?.nip04?.encrypt && tools?.nip04?.decrypt) { + encrypt = (priv, pub, payload) => tools.nip04.encrypt(priv, pub, payload); + decrypt = (priv, pub, payload) => tools.nip04.decrypt(priv, pub, payload); + } + + if (!encrypt || !decrypt) { + throw new Error("Remote signer encryption helpers are unavailable."); + } + + this.cipher = { encrypt, decrypt }; + return this.cipher; + } + + async encryptPayload(payload) { + const { encrypt } = await this.ensureCipher(); + const serialized = typeof payload === "string" ? payload : JSON.stringify(payload); + return encrypt(this.clientPrivateKey, this.remotePubkey, serialized); + } + + async decryptPayload(ciphertext) { + const { decrypt } = await this.ensureCipher(); + return decrypt(this.clientPrivateKey, this.remotePubkey, ciphertext); + } + + async ensureSubscription() { + if (this.subscription) { + return this.subscription; + } + + const pool = await this.ensurePool(); + const filters = [ + { + kinds: [NIP46_RPC_KIND], + authors: [this.remotePubkey], + "#p": [this.clientPublicKey], + }, + ]; + + const relays = this.relays.length ? this.relays : resolveNip46Relays([], this.nostrClient?.relays || []); + + const sub = pool.sub(relays, filters); + sub.on("event", (event) => { + this.handleEvent(event); + }); + sub.on("eose", () => { + // no-op; responses are push-based + }); + this.subscription = sub; + return sub; + } + + handleEvent(event) { + if (this.destroyed) { + return; + } + + if (!event || event.kind !== NIP46_RPC_KIND) { + return; + } + + if (typeof event.pubkey !== "string" || normalizeNostrPubkey(event.pubkey) !== this.remotePubkey) { + return; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + const targetsClient = tags.some( + (tag) => + Array.isArray(tag) && + tag[0] === "p" && + typeof tag[1] === "string" && + normalizeNostrPubkey(tag[1]) === this.clientPublicKey, + ); + + if (!targetsClient) { + return; + } + + Promise.resolve() + .then(() => this.decryptPayload(event.content)) + .then((payload) => { + let parsed; + try { + parsed = JSON.parse(payload); + } catch (error) { + devLogger.warn("[nostr] Remote signer returned malformed payload:", error); + return; + } + + if (!parsed || typeof parsed !== "object") { + return; + } + + const requestId = typeof parsed.id === "string" ? parsed.id : ""; + if (!requestId || !this.pendingRequests.has(requestId)) { + return; + } + + const pending = this.pendingRequests.get(requestId); + this.pendingRequests.delete(requestId); + clearTimeout(pending.timeoutId); + + this.lastSeen = Date.now(); + + if ( + parsed.result === "auth_url" && + typeof parsed.error === "string" && + parsed.error.trim() + ) { + const authError = new Error("Remote signer requires additional authentication."); + authError.code = "auth-challenge"; + authError.authUrl = parsed.error.trim(); + pending.reject(authError); + return; + } + + if (typeof parsed.error === "string" && parsed.error.trim()) { + const err = new Error(parsed.error.trim()); + err.code = "nip46-error"; + pending.reject(err); + return; + } + + pending.resolve(parsed.result ?? null); + }) + .catch((error) => { + devLogger.warn("[nostr] Failed to decrypt NIP-46 payload:", error); + }); + } + + rejectAllPending(error) { + for (const [id, pending] of this.pendingRequests.entries()) { + clearTimeout(pending.timeoutId); + try { + pending.reject(error); + } catch (rejectError) { + devLogger.warn("[nostr] Pending NIP-46 promise reject failed for", id, rejectError); + } + } + this.pendingRequests.clear(); + } + + async sendRpc(method, params = [], options = {}) { + if (this.destroyed) { + throw new Error("Remote signer session has been disposed."); + } + + const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 + ? options.timeoutMs + : NIP46_RESPONSE_TIMEOUT_MS; + const retries = Number.isFinite(options.retries) && options.retries >= 0 + ? options.retries + : NIP46_MAX_RETRIES; + + let lastError = null; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + await this.ensureSubscription(); + + const requestId = createNip46RequestId(); + const message = { + id: requestId, + method, + params: Array.isArray(params) ? params : [], + }; + + let event; + try { + const content = await this.encryptPayload(message); + event = signEventWithPrivateKey( + { + kind: NIP46_RPC_KIND, + pubkey: this.clientPublicKey, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", this.remotePubkey]], + content, + }, + this.clientPrivateKey, + ); + } catch (error) { + lastError = error; + break; + } + + const responsePromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId); + const timeoutError = new Error( + `Timed out waiting for remote signer response to ${method}.`, + ); + timeoutError.code = "nip46-timeout"; + reject(timeoutError); + }, timeoutMs); + + this.pendingRequests.set(requestId, { + resolve, + reject, + timeoutId, + method, + }); + }); + + try { + const publishResults = await publishEventToRelays( + await this.ensurePool(), + this.relays, + event, + { timeoutMs: NIP46_PUBLISH_TIMEOUT_MS }, + ); + assertAnyRelayAccepted(publishResults, { context: method }); + } catch (error) { + const pending = this.pendingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingRequests.delete(requestId); + } + lastError = error; + continue; + } + + try { + const result = await responsePromise; + return result; + } catch (error) { + lastError = error; + if (error?.code === "auth-challenge") { + throw error; + } + } + } + + if (lastError) { + throw lastError; + } + + throw new Error(`Remote signer request for ${method} failed.`); + } + + async connect({ permissions } = {}) { + const params = [this.remotePubkey]; + const requestedPermissions = permissions || this.permissions || ""; + + if (this.secret || requestedPermissions) { + params.push(this.secret || ""); + } + if (requestedPermissions) { + params.push(requestedPermissions); + } + + const result = await this.sendRpc("connect", params, { + timeoutMs: Math.max(NIP46_RESPONSE_TIMEOUT_MS, 12_000), + retries: 0, + }); + + if (this.secret) { + const normalizedResult = typeof result === "string" ? result.trim() : ""; + if (!normalizedResult || normalizedResult !== this.secret) { + const error = new Error("Remote signer secret mismatch. Rejecting connection."); + error.code = "nip46-secret-mismatch"; + throw error; + } + } + + return result; + } + + async getUserPubkey() { + const result = await this.sendRpc("get_public_key", [], { + timeoutMs: NIP46_RESPONSE_TIMEOUT_MS, + retries: 0, + }); + const pubkey = typeof result === "string" ? result.trim() : ""; + if (!pubkey) { + const error = new Error("Remote signer did not return a public key."); + error.code = "nip46-empty-pubkey"; + throw error; + } + this.userPubkey = normalizeNostrPubkey(pubkey); + return this.userPubkey; + } + + async ping() { + try { + const result = await this.sendRpc("ping", [], { + timeoutMs: 5000, + retries: 0, + }); + return typeof result === "string" && result.trim().toLowerCase() === "pong"; + } catch (error) { + return false; + } + } + + async signEvent(event, options = {}) { + if (!event || typeof event !== "object") { + throw new Error("A Nostr event is required for remote signing."); + } + + const unsigned = { + kind: event.kind, + created_at: event.created_at, + content: typeof event.content === "string" ? event.content : "", + tags: Array.isArray(event.tags) + ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) + : [], + pubkey: + typeof event.pubkey === "string" && event.pubkey.trim() + ? event.pubkey.trim() + : this.userPubkey, + }; + + const result = await this.sendRpc( + "sign_event", + [JSON.stringify(unsigned)], + { + timeoutMs: Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : NIP46_SIGN_EVENT_TIMEOUT_MS, + retries: Number.isFinite(options.retries) ? options.retries : NIP46_MAX_RETRIES, + }, + ); + + if (!result) { + const error = new Error("Remote signer returned an empty response."); + error.code = "nip46-empty-response"; + throw error; + } + + if (typeof result === "object") { + return result; + } + + try { + return JSON.parse(result); + } catch (error) { + const failure = new Error("Remote signer returned malformed signed event."); + failure.code = "nip46-invalid-response"; + failure.cause = error; + throw failure; + } + } + + getActiveSigner() { + if (!this.userPubkey) { + return null; + } + + if (!this.activeSignerCache) { + this.activeSignerCache = { + type: "nip46", + pubkey: this.userPubkey, + signEvent: (event) => this.signEvent(event), + }; + } + + return this.activeSignerCache; + } + + async destroy() { + if (this.destroyed) { + return; + } + this.destroyed = true; + + if (this.subscription && typeof this.subscription.unsub === "function") { + try { + this.subscription.unsub(); + } catch (error) { + devLogger.warn("[nostr] Failed to unsubscribe remote signer session:", error); + } + } + this.subscription = null; + + this.rejectAllPending(new Error("Remote signer session closed.")); + } +} + const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const BECH32_CHARSET_MAP = (() => { const map = new Map(); @@ -3292,6 +4055,39 @@ function decodeNpubToHex(npub) { return ""; } +function encodeHexToNpub(pubkey) { + if (typeof pubkey !== "string") { + return ""; + } + + const normalized = pubkey.trim().toLowerCase(); + if (!normalized || !HEX64_REGEX.test(normalized)) { + return ""; + } + + let toolkit = getCachedNostrTools(); + if (!toolkit || typeof toolkit?.nip19?.npubEncode !== "function") { + const fallbackToolkit = readToolkitFromScope(); + if (fallbackToolkit?.nip19?.npubEncode) { + toolkit = fallbackToolkit; + } + } + + const encoder = toolkit?.nip19?.npubEncode; + if (typeof encoder !== "function") { + return ""; + } + + try { + return encoder(normalized); + } catch (error) { + if (isDevMode) { + devLogger.warn("[nostr] Failed to encode npub:", error); + } + return ""; + } +} + const EXTENSION_MIME_MAP = { mp4: "video/mp4", m4v: "video/x-m4v", @@ -3615,6 +4411,24 @@ export class NostrClient { this.watchHistoryLastCreatedAt = 0; this.countRequestCounter = 0; this.countUnsupportedRelays = new Set(); + this.nip46Client = null; + this.remoteSignerListeners = new Set(); + const storedRemoteSigner = this.getStoredNip46Metadata(); + this.remoteSignerStatus = { + state: storedRemoteSigner.hasSession ? "stored" : "idle", + remotePubkey: storedRemoteSigner.remotePubkey || "", + remoteNpub: storedRemoteSigner.remoteNpub || "", + userPubkey: storedRemoteSigner.userPubkey || "", + userNpub: storedRemoteSigner.userNpub || "", + relays: storedRemoteSigner.relays || [], + metadata: storedRemoteSigner.metadata || {}, + label: + (storedRemoteSigner.metadata && storedRemoteSigner.metadata.name) || "", + message: null, + error: null, + hasStoredSession: storedRemoteSigner.hasSession, + }; + this.pendingRemoteSignerRestore = null; let storedPermissions = null; const hasLocalStorage = (typeof window !== "undefined" && @@ -3636,6 +4450,425 @@ export class NostrClient { storedPermissions instanceof Set ? storedPermissions : new Set(); } + getStoredNip46Metadata() { + const stored = readStoredNip46Session(); + if (!stored) { + return { hasSession: false }; + } + + const remotePubkey = stored.remotePubkey || ""; + const userPubkey = stored.userPubkey || ""; + + return { + hasSession: true, + remotePubkey, + remoteNpub: encodeHexToNpub(remotePubkey), + clientPublicKey: stored.clientPublicKey || "", + relays: Array.isArray(stored.relays) ? [...stored.relays] : [], + metadata: stored.metadata || {}, + 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 snapshot = { + state: nextState, + remotePubkey, + userPubkey, + relays, + metadata, + label: + status.label || + metadata?.name || + this.nip46Client?.metadata?.name || + stored.metadata?.name || + "", + remoteNpub: + (typeof status.remoteNpub === "string" && status.remoteNpub.trim()) || + encodeHexToNpub(remotePubkey) || + stored.remoteNpub || + "", + userNpub: + (typeof status.userNpub === "string" && status.userNpub.trim()) || + encodeHexToNpub(userPubkey) || + stored.userNpub || + "", + message: status.message || null, + error: status.error || null, + hasStoredSession: stored.hasSession, + }; + + this.remoteSignerStatus = snapshot; + + for (const listener of Array.from(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); + }; + } + + async createNip46KeyPair(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 ensureNostrTools()) || 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 = bytesToHex(generated); + } else if (Array.isArray(generated)) { + privateKey = 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 ensureNostrTools()) || 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() }; + } + + installNip46Client(client, { userPubkey } = {}) { + if (this.nip46Client && this.nip46Client !== client) { + try { + this.nip46Client.destroy(); + } catch (error) { + devLogger.warn("[nostr] Failed to dispose previous remote signer client:", error); + } + } + + this.nip46Client = client; + if (userPubkey) { + this.nip46Client.userPubkey = userPubkey; + } + + const signer = client.getActiveSigner(); + if (signer) { + setActiveSigner(signer); + } + + return signer; + } + + async connectRemoteSigner({ connectionString, remember = true } = {}) { + const parsed = parseNip46ConnectionString(connectionString); + if (!parsed || parsed.type !== "remote" || !parsed.remotePubkey) { + const error = new Error( + "Unsupported NIP-46 URI. Paste the bunker:// link provided by your signer.", + ); + error.code = "invalid-connection-string"; + throw error; + } + + const keyPair = await this.createNip46KeyPair(); + const relays = resolveNip46Relays(parsed.relays, this.relays); + + this.emitRemoteSignerChange({ + state: "connecting", + remotePubkey: parsed.remotePubkey, + relays, + metadata: parsed.metadata, + }); + + const client = new Nip46RpcClient({ + nostrClient: this, + clientPrivateKey: keyPair.privateKey, + clientPublicKey: keyPair.publicKey, + remotePubkey: parsed.remotePubkey, + relays, + secret: parsed.secret, + permissions: parsed.permissions, + metadata: parsed.metadata, + }); + + try { + await client.ensureSubscription(); + await client.connect({ permissions: parsed.permissions }); + const userPubkey = await client.getUserPubkey(); + client.metadata = parsed.metadata; + const signer = this.installNip46Client(client, { userPubkey }); + + if (remember) { + writeStoredNip46Session({ + version: 1, + clientPrivateKey: keyPair.privateKey, + clientPublicKey: keyPair.publicKey, + remotePubkey: parsed.remotePubkey, + relays, + secret: parsed.secret, + permissions: parsed.permissions, + metadata: parsed.metadata, + userPubkey, + lastConnectedAt: Date.now(), + }); + } else { + clearStoredNip46Session(); + } + + this.emitRemoteSignerChange({ + state: "connected", + remotePubkey: parsed.remotePubkey, + userPubkey, + relays, + metadata: parsed.metadata, + }); + + return { pubkey: userPubkey, signer }; + } catch (error) { + await client.destroy().catch(() => {}); + this.nip46Client = null; + if (!remember) { + clearStoredNip46Session(); + } + this.emitRemoteSignerChange({ + state: "error", + remotePubkey: parsed.remotePubkey, + relays, + metadata: parsed.metadata, + message: error?.message || "Remote signer connection failed.", + error, + }); + throw error; + } + } + + async useStoredRemoteSigner(options = {}) { + const normalizedOptions = + options && typeof options === "object" ? options : {}; + const silent = normalizedOptions.silent === true; + const forgetOnError = normalizedOptions.forgetOnError === true; + + const stored = readStoredNip46Session(); + if (!stored) { + const error = new Error("No remote signer session is stored on this device."); + error.code = "no-stored-session"; + throw error; + } + + const relays = resolveNip46Relays(stored.relays, this.relays); + + this.emitRemoteSignerChange({ + state: "connecting", + remotePubkey: stored.remotePubkey, + relays, + metadata: stored.metadata, + }); + + const client = new Nip46RpcClient({ + nostrClient: this, + clientPrivateKey: stored.clientPrivateKey, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays, + secret: stored.secret, + permissions: stored.permissions, + metadata: stored.metadata, + }); + + try { + await client.ensureSubscription(); + await client.connect({ permissions: stored.permissions }); + const userPubkey = await client.getUserPubkey(); + client.metadata = stored.metadata; + const signer = this.installNip46Client(client, { userPubkey }); + + writeStoredNip46Session({ + ...stored, + userPubkey, + lastConnectedAt: Date.now(), + }); + + this.emitRemoteSignerChange({ + state: "connected", + remotePubkey: stored.remotePubkey, + userPubkey, + relays, + metadata: stored.metadata, + }); + + return { pubkey: userPubkey, signer }; + } catch (error) { + await client.destroy().catch(() => {}); + this.nip46Client = null; + const fatalCodes = new Set([ + "nip46-secret-mismatch", + "invalid-private-key", + "invalid-connection-string", + ]); + const shouldForgetStored = forgetOnError || fatalCodes.has(error?.code); + if (shouldForgetStored) { + clearStoredNip46Session(); + } + + 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.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; + } + } + + scheduleStoredRemoteSignerRestore() { + if (this.nip46Client) { + return this.pendingRemoteSignerRestore || null; + } + + if (this.pendingRemoteSignerRestore) { + return this.pendingRemoteSignerRestore; + } + + const stored = this.getStoredNip46Metadata(); + if (!stored.hasSession) { + return null; + } + + const attempt = this.useStoredRemoteSigner({ silent: true }) + .catch(() => null) + .finally(() => { + this.pendingRemoteSignerRestore = null; + }); + + this.pendingRemoteSignerRestore = attempt; + return attempt; + } + + async disconnectRemoteSigner({ keepStored = true } = {}) { + this.pendingRemoteSignerRestore = null; + if (this.nip46Client) { + try { + await this.nip46Client.destroy(); + } catch (error) { + devLogger.warn("[nostr] Failed to tear down remote signer client:", error); + } + this.nip46Client = null; + } + + const activeSigner = getActiveSigner(); + if (activeSigner?.type === "nip46") { + clearActiveSigner(); + } + + if (!keepStored) { + clearStoredNip46Session(); + } + + const stored = keepStored ? this.getStoredNip46Metadata() : { hasSession: false }; + this.emitRemoteSignerChange({ + state: stored.hasSession ? "stored" : "idle", + remotePubkey: stored.remotePubkey || "", + userPubkey: stored.userPubkey || "", + relays: stored.relays || [], + metadata: stored.metadata || {}, + }); + } + recordTombstone(activeKey, createdAt) { const key = typeof activeKey === "string" ? activeKey.trim() : ""; if (!key) { @@ -6715,18 +7948,24 @@ export class NostrClient { this.restoreLocalData(); - try { - await this.ensurePool(); - const results = await this.connectToRelays(); - const successfulRelays = results - .filter((r) => r.success) - .map((r) => r.url); - if (successfulRelays.length === 0) { - throw new Error("No relays connected"); - } - devLogger.log( - `Connected to ${successfulRelays.length} relay(s)`, - ); + try { + this.scheduleStoredRemoteSignerRestore(); + } catch (error) { + devLogger.warn("[nostr] Failed to schedule remote signer restoration:", error); + } + + try { + await this.ensurePool(); + const results = await this.connectToRelays(); + const successfulRelays = results + .filter((r) => r.success) + .map((r) => r.url); + if (successfulRelays.length === 0) { + throw new Error("No relays connected"); + } + devLogger.log( + `Connected to ${successfulRelays.length} relay(s)`, + ); } catch (err) { userLogger.error("Nostr init failed:", err); throw err; @@ -6958,6 +8197,17 @@ export class NostrClient { const previousSessionActor = this.sessionActor; this.sessionActor = null; + if (this.nip46Client) { + Promise.resolve() + .then(() => this.disconnectRemoteSigner({ keepStored: true })) + .catch((error) => { + devLogger.warn( + "[nostr] Failed to disconnect remote signer during logout:", + error, + ); + }); + } + const shouldClearStoredSession = previousSessionActor && previousSessionActor.source === "nsec" && diff --git a/js/services/authProviders/index.js b/js/services/authProviders/index.js index e67f2d80..04aef44a 100644 --- a/js/services/authProviders/index.js +++ b/js/services/authProviders/index.js @@ -1,4 +1,5 @@ import nip07 from "./nip07.js"; +import nip46 from "./nip46.js"; import nsec from "./nsec.js"; function normalizeProviderId(provider, fallbackId) { @@ -37,7 +38,7 @@ function normalizeProviderMetadata(provider, fallbackId) { return Object.freeze({ id, label, description, badgeVariant }); } -const providerList = [nip07, nsec].filter(Boolean); +const providerList = [nip07, nip46, nsec].filter(Boolean); const providerMap = {}; const metadataMap = {}; diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js new file mode 100644 index 00000000..ab3e7b30 --- /dev/null +++ b/js/services/authProviders/nip46.js @@ -0,0 +1,117 @@ +const PROVIDER_ID = "nip46"; +const PROVIDER_LABEL = "Remote signer"; +const PROVIDER_DESCRIPTION = "Connect to a NIP-46 compatible signer via Nostr Connect."; +const PROVIDER_CAPABILITIES = Object.freeze([ + Object.freeze({ + id: "signing", + label: "Signs events remotely", + variant: "info", + }), + Object.freeze({ + id: "multi-device", + label: "Works across browsers", + variant: "neutral", + }), +]); +const PROVIDER_BUTTON = Object.freeze({ + variant: "outline", +}); +const PROVIDER_MESSAGES = Object.freeze({ + loading: "Connecting to remote signer…", + slow: "Waiting for the signer to approve…", + error: "Failed to connect to the remote signer. Please try again.", +}); + +function normalizePubkey(result) { + if (typeof result === "string") { + return result; + } + + if (!result || typeof result !== "object") { + return ""; + } + + if (typeof result.pubkey === "string") { + return result.pubkey; + } + + if (typeof result.publicKey === "string") { + return result.publicKey; + } + + return ""; +} + +function normalizeOptions(options) { + if (!options || typeof options !== "object") { + return { + connectionString: "", + remember: true, + reuseStored: false, + }; + } + + return { + connectionString: + typeof options.connectionString === "string" ? options.connectionString.trim() : "", + remember: options.remember !== false, + reuseStored: options.reuseStored === true, + }; +} + +export default { + id: PROVIDER_ID, + label: PROVIDER_LABEL, + description: PROVIDER_DESCRIPTION, + badgeVariant: "info", + capabilities: PROVIDER_CAPABILITIES, + button: PROVIDER_BUTTON, + messages: PROVIDER_MESSAGES, + errorMessage: PROVIDER_MESSAGES.error, + async login({ nostrClient, options } = {}) { + if (!nostrClient || typeof nostrClient !== "object") { + const error = new Error("Remote signer login is not available."); + error.code = "provider-unavailable"; + throw error; + } + + if (typeof nostrClient.connectRemoteSigner !== "function") { + const error = new Error("Remote signer support is unavailable in this environment."); + error.code = "provider-unavailable"; + throw error; + } + + const normalized = normalizeOptions(options); + let result = null; + + if (normalized.reuseStored) { + if (typeof nostrClient.useStoredRemoteSigner !== "function") { + const error = new Error("No stored remote signer is available on this device."); + error.code = "no-stored-session"; + throw error; + } + + result = await nostrClient.useStoredRemoteSigner(); + } else { + const uri = normalized.connectionString; + if (!uri) { + const error = new Error("A connection string is required to continue."); + error.code = "connection-required"; + throw error; + } + + result = await nostrClient.connectRemoteSigner({ + connectionString: uri, + remember: normalized.remember, + }); + } + + const pubkey = normalizePubkey(result); + + return { + authType: PROVIDER_ID, + pubkey, + signer: result && typeof result === "object" ? result.signer || null : null, + }; + }, +}; diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index ccf31d05..648ea246 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -202,6 +202,10 @@ export default class LoginModalController { services.authService && typeof services.authService === "object" ? services.authService : null, + nostrClient: + services.nostrClient && typeof services.nostrClient === "object" + ? services.nostrClient + : null, }; this.callbacks = { @@ -233,11 +237,18 @@ export default class LoginModalController { this.modalElement?.querySelector(".modal-body") || null; this.nsecTemplate = this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null; + this.nip46Template = + this.modalElement?.querySelector("template[data-login-nip46-dialog]") || null; this.template = null; this.providerEntries = new Map(); this.slowTimers = new Map(); this.boundClickHandler = (event) => this.handleContainerClick(event); this.activeNsecForm = null; + this.activeNip46Form = null; + this.remoteSignerUnsubscribe = null; + this.lastRemoteSignerStatus = null; + + this.initializeRemoteSignerStatus(); this.initialized = false; this.initialize(); @@ -260,11 +271,20 @@ export default class LoginModalController { this.renderProviders(); + if (typeof this.lastRemoteSignerStatus !== "undefined") { + this.applyRemoteSignerStatus(this.lastRemoteSignerStatus); + } + this.providerContainer.addEventListener("click", this.boundClickHandler); this.initialized = true; } getNostrClient() { + const direct = this.services?.nostrClient; + if (direct && typeof direct === "object") { + return direct; + } + const service = this.services?.authService; if (!service || typeof service !== "object") { return null; @@ -288,6 +308,143 @@ export default class LoginModalController { return null; } + getStoredNip46Metadata() { + const nostrClient = this.getNostrClient(); + if (!nostrClient || typeof nostrClient.getStoredNip46Metadata !== "function") { + return null; + } + + try { + return nostrClient.getStoredNip46Metadata(); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to inspect stored NIP-46 session:", + error, + ); + } + + return null; + } + + initializeRemoteSignerStatus() { + const nostrClient = this.getNostrClient(); + if (!nostrClient) { + this.applyRemoteSignerStatus(null); + return; + } + + if (typeof nostrClient.getRemoteSignerStatus === "function") { + try { + const status = nostrClient.getRemoteSignerStatus(); + this.applyRemoteSignerStatus(status); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to read initial remote signer status:", + error, + ); + this.applyRemoteSignerStatus(null); + } + } else { + this.applyRemoteSignerStatus(null); + } + + if (typeof nostrClient.onRemoteSignerChange === "function") { + try { + this.remoteSignerUnsubscribe = nostrClient.onRemoteSignerChange((status) => { + this.applyRemoteSignerStatus(status); + }); + } catch (error) { + devLogger.warn( + "[LoginModalController] Failed to subscribe to remote signer updates:", + error, + ); + } + } + } + + formatRemoteSignerLabel(status) { + if (!status || typeof status !== "object") { + return "remote signer"; + } + + const { label, remoteNpub, remotePubkey } = status; + if (typeof label === "string" && label.trim()) { + return label.trim(); + } + + if (typeof remoteNpub === "string" && remoteNpub.trim()) { + return remoteNpub.trim(); + } + + if (typeof remotePubkey === "string" && remotePubkey.trim()) { + const trimmed = remotePubkey.trim(); + if (trimmed.length <= 16) { + return trimmed; + } + return `${trimmed.slice(0, 8)}…${trimmed.slice(-6)}`; + } + + return "remote signer"; + } + + applyRemoteSignerStatus(status) { + this.lastRemoteSignerStatus = status || null; + const storedMetadata = this.getStoredNip46Metadata(); + const hasStoredSession = storedMetadata?.hasSession === true; + + const normalizedState = + typeof status?.state === "string" && status.state.trim() + ? status.state.trim() + : hasStoredSession + ? "stored" + : "idle"; + + let message = ""; + let showDisconnect = false; + + switch (normalizedState) { + case "connected": { + if (typeof status?.userNpub === "string" && status.userNpub.trim()) { + message = `Connected as ${status.userNpub.trim()}`; + } else { + const label = this.formatRemoteSignerLabel(status); + message = `Connected to ${label}`; + } + showDisconnect = true; + break; + } + case "connecting": { + message = "Connecting to remote signer…"; + break; + } + case "error": { + message = + typeof status?.message === "string" && status.message.trim() + ? status.message.trim() + : "Remote signer unavailable."; + break; + } + case "stored": { + if (hasStoredSession) { + const label = this.formatRemoteSignerLabel(status || storedMetadata); + message = `Saved remote signer (${label}) ready to connect.`; + } + break; + } + default: { + message = ""; + break; + } + } + + this.setProviderStatus("nip46", { + message, + reset: !message, + showDisconnect, + statusState: normalizedState, + }); + } + async promptForNsecOptions() { if (!this.modalBody || !this.nsecTemplate) { userLogger.warn("[LoginModalController] Direct key login is unavailable in this build."); @@ -552,6 +709,160 @@ export default class LoginModalController { }); } + async promptForNip46Options() { + if (!this.modalBody || !this.nip46Template) { + userLogger.warn( + "[LoginModalController] Remote signer login is unavailable in this build.", + ); + return null; + } + + if (this.activeNip46Form) { + return null; + } + + const fragment = this.nip46Template.content + ? this.nip46Template.content.cloneNode(true) + : null; + if (!fragment) { + return null; + } + + const form = fragment.querySelector("[data-nip46-form]"); + if (!(form instanceof HTMLFormElement)) { + return null; + } + + const connectInput = form.querySelector("[data-nip46-connect-uri]"); + const rememberCheckbox = form.querySelector("[data-nip46-remember]"); + const reuseButton = form.querySelector("[data-nip46-reuse]"); + const cancelButton = form.querySelector("[data-nip46-cancel]"); + const errorNode = form.querySelector("[data-nip46-error]"); + + const storedMetadata = this.getStoredNip46Metadata(); + const hasStoredSession = storedMetadata?.hasSession === true; + + if (reuseButton instanceof HTMLButtonElement) { + reuseButton.classList.toggle("hidden", !hasStoredSession); + } + + const elementsToHide = []; + for (const child of Array.from(this.modalBody.children)) { + if (child instanceof HTMLElement && child !== form) { + if (child.tagName === "TEMPLATE") { + continue; + } + const wasHidden = child.classList.contains("hidden"); + if (!wasHidden) { + child.classList.add("hidden"); + } + elementsToHide.push({ element: child, wasHidden }); + } + } + + this.modalBody.appendChild(form); + this.activeNip46Form = form; + + const setError = (message) => { + if (!(errorNode instanceof HTMLElement)) { + return; + } + const normalized = typeof message === "string" ? message.trim() : ""; + if (normalized) { + errorNode.textContent = normalized; + errorNode.classList.remove("hidden"); + } else { + errorNode.textContent = ""; + errorNode.classList.add("hidden"); + } + }; + + setError(""); + if (connectInput instanceof HTMLTextAreaElement) { + connectInput.focus(); + } + + let submitHandler = null; + let cancelHandler = null; + let reuseHandler = null; + + const cleanup = () => { + if (submitHandler) { + form.removeEventListener("submit", submitHandler); + } + if (cancelHandler && cancelButton instanceof HTMLButtonElement) { + cancelButton.removeEventListener("click", cancelHandler); + } + if (reuseHandler && reuseButton instanceof HTMLButtonElement) { + reuseButton.removeEventListener("click", reuseHandler); + } + + if (form.parentElement) { + form.parentElement.removeChild(form); + } + for (const entry of elementsToHide) { + if (!(entry?.element instanceof HTMLElement)) { + continue; + } + if (!entry.wasHidden) { + entry.element.classList.remove("hidden"); + } + } + this.activeNip46Form = null; + }; + + return new Promise((resolve) => { + let settled = false; + const finish = (detail) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(detail); + }; + + submitHandler = (event) => { + event.preventDefault(); + setError(""); + + if (!(connectInput instanceof HTMLTextAreaElement)) { + setError("Enter the remote signer connection link to continue."); + return; + } + + const raw = connectInput.value.trim(); + if (!raw) { + setError("Paste the nostrconnect:// or bunker:// link from your signer."); + connectInput.focus(); + return; + } + + const remember = + rememberCheckbox instanceof HTMLInputElement ? rememberCheckbox.checked : true; + finish({ connectionString: raw, remember: remember !== false }); + }; + + form.addEventListener("submit", submitHandler); + + if (cancelButton instanceof HTMLButtonElement) { + cancelHandler = (event) => { + event.preventDefault(); + finish(null); + }; + cancelButton.addEventListener("click", cancelHandler); + } + + if (reuseButton instanceof HTMLButtonElement) { + reuseHandler = (event) => { + event.preventDefault(); + finish({ reuseStored: true }); + }; + reuseButton.addEventListener("click", reuseHandler); + } + }); + } + resolveTemplate() { if (!this.modalElement) { return null; @@ -660,6 +971,17 @@ export default class LoginModalController { statusNode.classList.add("hidden"); } + const statusRow = button.querySelector("[data-provider-status-row]"); + if (statusRow instanceof HTMLElement) { + statusRow.classList.add("hidden"); + } + + const disconnectButton = button.querySelector("[data-provider-disconnect]"); + if (disconnectButton instanceof HTMLButtonElement) { + disconnectButton.dataset.providerId = provider.id; + disconnectButton.classList.add("hidden"); + } + const capabilitiesNode = button.querySelector( "[data-provider-capabilities]", ); @@ -702,6 +1024,9 @@ export default class LoginModalController { button, descriptionNode: descriptionNode instanceof HTMLElement ? descriptionNode : null, statusNode: statusNode instanceof HTMLElement ? statusNode : null, + statusRow: statusRow instanceof HTMLElement ? statusRow : null, + disconnectButton: + disconnectButton instanceof HTMLButtonElement ? disconnectButton : null, defaultDescription: provider.description, }, }; @@ -713,6 +1038,17 @@ export default class LoginModalController { return; } + const disconnectButton = target.closest("[data-provider-disconnect]"); + if (disconnectButton instanceof HTMLButtonElement) { + const providerId = disconnectButton.dataset.providerId; + if (providerId) { + this.handleProviderDisconnect(providerId); + } + event.preventDefault(); + event.stopPropagation(); + return; + } + const button = target.closest("[data-provider-button]"); if (!(button instanceof HTMLButtonElement)) { return; @@ -726,10 +1062,60 @@ export default class LoginModalController { this.handleProviderSelection(providerId); } + async handleProviderDisconnect(providerId) { + if (providerId !== "nip46") { + return; + } + + const nostrClient = this.getNostrClient(); + if (!nostrClient || typeof nostrClient.disconnectRemoteSigner !== "function") { + return; + } + + try { + await nostrClient.disconnectRemoteSigner({ keepStored: false }); + } catch (error) { + devLogger.warn("[LoginModalController] Failed to disconnect remote signer:", error); + } + } + getProviderEntry(providerId) { return this.providerEntries.get(providerId) || null; } + setProviderStatus(providerId, options = {}) { + const entry = this.getProviderEntry(providerId); + if (!entry) { + return; + } + + const { + message = "", + reset = false, + showDisconnect = false, + disableDisconnect = false, + statusState = null, + } = options || {}; + + if (statusState) { + entry.statusState = statusState; + } else { + delete entry.statusState; + } + + this.updateStatusMessage(entry, message, reset); + + const shouldShowRow = !reset && typeof message === "string" && message.trim(); + if (entry.statusRow instanceof HTMLElement) { + entry.statusRow.classList.toggle("hidden", !shouldShowRow); + } + + if (entry.disconnectButton instanceof HTMLButtonElement) { + entry.disconnectButton.classList.toggle("hidden", !showDisconnect); + entry.disconnectButton.disabled = !!disableDisconnect; + } + } + setLoadingState(providerId, isLoading) { const entry = this.getProviderEntry(providerId); if (!entry) { @@ -741,10 +1127,14 @@ export default class LoginModalController { entry.button.dataset.state = "loading"; entry.button.setAttribute("aria-busy", "true"); - this.updateStatusMessage( - entry, - entry.provider.messages.loading || "Connecting…", - ); + const loadingMessage = entry.provider.messages.loading || "Connecting…"; + this.setProviderStatus(providerId, { + message: loadingMessage, + reset: false, + showDisconnect: false, + disableDisconnect: true, + statusState: "loading", + }); this.startSlowTimer(providerId, entry); } else { const shouldRemainDisabled = entry.provider.disabled === true; @@ -758,7 +1148,7 @@ export default class LoginModalController { delete entry.button.dataset.state; entry.button.removeAttribute("aria-busy"); this.stopSlowTimer(providerId); - this.updateStatusMessage(entry, "", true); + this.setProviderStatus(providerId, { message: "", reset: true }); } } @@ -812,7 +1202,13 @@ export default class LoginModalController { const slowMessage = entry.provider.messages.slow || "Waiting for the provider…"; - this.updateStatusMessage(entry, slowMessage); + this.setProviderStatus(providerId, { + message: slowMessage, + reset: false, + showDisconnect: false, + statusState: "waiting", + disableDisconnect: true, + }); }, timeout); this.slowTimers.set(providerId, timerId); @@ -877,6 +1273,26 @@ export default class LoginModalController { ); return; } + } else if (entry.provider.id === "nip46") { + try { + const nip46Options = await this.promptForNip46Options(); + if (!nip46Options) { + devLogger.log( + "[LoginModalController] Remote signer login cancelled by the user.", + ); + if (entry.button instanceof HTMLButtonElement) { + entry.button.focus(); + } + return; + } + providerOptions = nip46Options; + } catch (promptError) { + devLogger.error( + "[LoginModalController] Failed to collect remote signer connection:", + promptError, + ); + return; + } } this.setLoadingState(providerId, true); @@ -942,6 +1358,18 @@ export default class LoginModalController { this.stopSlowTimer(providerId); } + if (typeof this.remoteSignerUnsubscribe === "function") { + try { + this.remoteSignerUnsubscribe(); + } catch (error) { + devLogger.warn( + "[LoginModalController] Remote signer unsubscribe handler threw:", + error, + ); + } + } + this.remoteSignerUnsubscribe = null; + this.providerEntries.clear(); this.initialized = false; }
+ Paste the nostrconnect:// or bunker:// link + from your signer to establish a Nostr Connect session. +
nostrconnect://
bunker://
+ We will connect to the relays embedded in the link and request signing + permissions on your behalf. +