222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
// 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,
|
|
};
|
|
}
|