WizardConnect/packages/core/src/key-exchange.ts

217 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-02-26 11:19:47 +01:00
// 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.cauldron.quest";
export const DEFAULT_RELAY_PORT = 443;
export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss";
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,
};
}