// Copyright (C) 2026 Whiterun LLC, // This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later. // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html import { generatePrivateKey, binToHex, hexToBin, binToBech32Padded, bech32PaddedToBin, } from "@bitauth/libauth"; import { deriveNostrPublicKey } from "./utilnostr.js"; export const DEFAULT_RELAY_HOSTNAME = "relay.riften.net"; export const DEFAULT_RELAY_PORT = 443; export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss"; /** All default relay URLs, primary first. */ export const DEFAULT_RELAY_URLS: readonly string[] = [ "wss://relay.riften.net:443", "wss://relay.cauldron.quest:443", ]; export interface KeyExchangeCredentials { privateKey: string; publicKey: string; secret: string; } export function generateKeyExchangeCredentials(): KeyExchangeCredentials { const privateKey = generatePrivateKey(); const privateKeyHex = binToHex(privateKey); const publicKeyHex = deriveNostrPublicKey(privateKey); const secretBytes = generatePrivateKey(); const secretShort = secretBytes.slice(0, 8); const secret = binToHex(secretShort); return { privateKey: privateKeyHex, publicKey: publicKeyHex, secret: secret, }; } export interface DecodedKeyExchangeURI { publicKey: string; secret: string; hostname: string; port: number; protocol: "ws" | "wss"; } export interface KeyExchangeURIOptions { hostname?: string; port?: number; protocol?: "ws" | "wss"; } export interface KeyExchangeURIResult { /** Standard URI for copy-paste and display: wiz://?p=...&s=... */ uri: string; /** Fully QR-alphanumeric-safe URI for QR code generation: WIZ://%3FP%3D... */ qrUri: string; } export function encodeKeyExchangeURI( publicKey: string, secret: string, options: KeyExchangeURIOptions = {}, ): KeyExchangeURIResult { const publicKeyBin = hexToBin(publicKey); const secretBin = hexToBin(secret); if (publicKeyBin.length !== 32) { throw new Error( `Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`, ); } if (secretBin.length !== 8) { throw new Error( `Invalid secret length: expected 8 bytes, got ${secretBin.length}`, ); } const publicKeyBech32 = binToBech32Padded(publicKeyBin).toLowerCase(); const secretBech32 = binToBech32Padded(secretBin).toLowerCase(); const hostname = options.hostname || DEFAULT_RELAY_HOSTNAME; const port = options.port ?? DEFAULT_RELAY_PORT; const protocol = options.protocol || DEFAULT_RELAY_PROTOCOL; const isDefaultHostname = hostname === DEFAULT_RELAY_HOSTNAME; const defaultPort = protocol === "wss" ? 443 : 80; const isDefaultPort = port === defaultPort; const isDefaultProtocol = protocol === DEFAULT_RELAY_PROTOCOL; let uri: string; if (isDefaultHostname && isDefaultPort && isDefaultProtocol) { uri = `wiz://?p=${publicKeyBech32}&s=${secretBech32}`; } else { const portPart = isDefaultPort ? "" : `:${port}`; const authority = `${hostname}${portPart}`; uri = `wiz://${authority}?p=${publicKeyBech32}&s=${secretBech32}`; if (!isDefaultProtocol) { uri += `&pr=${protocol}`; } } const qrUri = uri .toUpperCase() .replace("?", "%3F") .replace(/=/g, "%3D") .replace(/&/g, "%26"); return { uri, qrUri }; } export function decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI { let url: URL; try { const lower = uri.toLowerCase(); const isQr = lower.includes("%3f") && !lower.includes("?"); const toParse = isQr ? lower.replace("%3f", "?").replace(/%3d/g, "=").replace(/%26/g, "&") : lower; url = new URL(toParse); } catch (error) { throw new Error( `Invalid URI format: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }, ); } if (url.protocol !== "wiz:") { throw new Error("Invalid URI scheme. Expected: wiz://"); } let hostname = url.hostname || DEFAULT_RELAY_HOSTNAME; let port: number; if (url.port) { const parsedPort = parseInt(url.port, 10); if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) { throw new Error(`Invalid port number: ${url.port}`); } port = parsedPort; } else { port = DEFAULT_RELAY_PORT; } const publicKeyBech32 = url.searchParams.get("p"); const secretBech32 = url.searchParams.get("s"); if (!publicKeyBech32 || !secretBech32) { throw new Error( "Invalid URI format. Missing required parameters: p (public key) or s (secret)", ); } const protocol = url.searchParams.get("pr") as "ws" | "wss" | null; const publicKeyBech32Normalized = publicKeyBech32.toLowerCase(); const secretBech32Normalized = secretBech32.toLowerCase(); let publicKeyBin: Uint8Array; let secretBin: Uint8Array; try { const pubkeyResult = bech32PaddedToBin(publicKeyBech32Normalized); const secretResult = bech32PaddedToBin(secretBech32Normalized); if ( pubkeyResult instanceof Uint8Array && secretResult instanceof Uint8Array ) { publicKeyBin = pubkeyResult; secretBin = secretResult; } else { const pubkeyError = typeof pubkeyResult === "string" ? pubkeyResult : "Unknown error"; const secretError = typeof secretResult === "string" ? secretResult : "Unknown error"; throw new Error( `Bech32 decoding failed: pubkey=${pubkeyError}, secret=${secretError}`, ); } } catch (error) { throw new Error( `Invalid bech32 encoding: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }, ); } if (publicKeyBin.length !== 32) { throw new Error( `Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`, ); } if (secretBin.length !== 8) { throw new Error( `Invalid secret length: expected 8 bytes, got ${secretBin.length}`, ); } if (protocol && protocol !== "ws" && protocol !== "wss") { throw new Error(`Invalid protocol: ${protocol}. Must be 'ws' or 'wss'`); } if (!url.port && protocol === "ws") { port = 80; } return { publicKey: binToHex(publicKeyBin), secret: binToHex(secretBin), hostname: hostname, port: port, protocol: protocol || DEFAULT_RELAY_PROTOCOL, }; }