// 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 { describe, it, expect } from "vitest"; import { encodeKeyExchangeURI, decodeKeyExchangeURI, generateKeyExchangeCredentials, DEFAULT_RELAY_URLS, DEFAULT_RELAY_HOSTNAME, } from "./key-exchange.js"; describe("key-exchange", () => { describe("generateKeyExchangeCredentials", () => { it("should generate valid credentials", () => { const credentials = generateKeyExchangeCredentials(); expect(credentials.privateKey).toHaveLength(64); // 32 bytes in hex expect(credentials.publicKey).toHaveLength(64); // 32 bytes in hex expect(credentials.secret).toHaveLength(16); // 8 bytes in hex // Verify all are valid hex strings expect(/^[0-9a-f]+$/.test(credentials.privateKey)).toBe(true); expect(/^[0-9a-f]+$/.test(credentials.publicKey)).toBe(true); expect(/^[0-9a-f]+$/.test(credentials.secret)).toBe(true); }); it("should generate different credentials on each call", () => { const cred1 = generateKeyExchangeCredentials(); const cred2 = generateKeyExchangeCredentials(); expect(cred1.privateKey).not.toBe(cred2.privateKey); expect(cred1.publicKey).not.toBe(cred2.publicKey); expect(cred1.secret).not.toBe(cred2.secret); }); }); describe("encodeKeyExchangeURI", () => { it("should encode valid public key and secret to URI", () => { const publicKey = "a".repeat(64); // 32 bytes in hex const secret = "b".repeat(16); // 8 bytes in hex const { uri } = encodeKeyExchangeURI(publicKey, secret); // With defaults, should use minimal URI format expect(uri).toMatch( /^wiz:\/\/\?p=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+&s=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/, ); expect(uri.startsWith("wiz://?")).toBe(true); expect(uri.includes("?p=")).toBe(true); expect(uri.includes("&s=")).toBe(true); }); it("should always produce lowercase bech32 encoding", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri } = encodeKeyExchangeURI(publicKey, secret); // Extract the bech32 parts (minimal URI format) const parts = uri.match(/^wiz:\/\/\?p=([^&]+)&s=(.+)$/); expect(parts).not.toBeNull(); if (parts) { expect(parts[1]).toBe(parts[1].toLowerCase()); expect(parts[2]).toBe(parts[2].toLowerCase()); } }); it("should throw error for invalid public key length", () => { // Use a hex string that decodes to wrong length (odd length hex = invalid) const invalidPublicKey = "a".repeat(62); // 31 bytes when decoded const secret = "b".repeat(16); expect(() => encodeKeyExchangeURI(invalidPublicKey, secret)).toThrow( "Invalid public key length", ); }); it("should throw error for invalid secret length", () => { const publicKey = "a".repeat(64); // Use a hex string that decodes to wrong length (odd length hex = invalid) const invalidSecret = "b".repeat(14); // 7 bytes when decoded expect(() => encodeKeyExchangeURI(publicKey, invalidSecret)).toThrow( "Invalid secret length", ); }); it("should handle invalid hex gracefully", () => { // hexToBin from libauth might be lenient with invalid hex // This test verifies the function doesn't crash, but the actual behavior // depends on libauth's hexToBin implementation const invalidPublicKey = "g".repeat(64); // 'g' is not valid hex const secret = "b".repeat(16); // The function should either throw or produce a result // If it doesn't throw, it means libauth's hexToBin is lenient // In that case, it will likely fail at length validation try { encodeKeyExchangeURI(invalidPublicKey, secret); // If it doesn't throw, that's also acceptable behavior } catch (error) { // If it throws, that's expected expect(error).toBeDefined(); } }); }); describe("encodeKeyExchangeURI — qrUri", () => { it("qrUri is fully uppercase", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret); expect(qrUri).toBe(qrUri.toUpperCase()); }); it("qrUri contains no ?, =, or &", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret); expect(qrUri).not.toContain("?"); expect(qrUri).not.toContain("="); expect(qrUri).not.toContain("&"); }); it("qrUri passes QR alphanumeric charset regex", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret); expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true); }); it("qrUri contains %3F, %3D, %26 for default relay", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret); expect(qrUri).toContain("%3F"); expect(qrUri).toContain("%3D"); expect(qrUri).toContain("%26"); }); it("custom relay encodes all separators", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret, { hostname: "example.com", port: 8080, protocol: "ws", }); expect(qrUri).not.toContain("?"); expect(qrUri).not.toContain("="); expect(qrUri).not.toContain("&"); expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true); }); }); describe("decodeKeyExchangeURI", () => { it("should decode valid URI to public key and secret", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri } = encodeKeyExchangeURI(publicKey, secret); const decoded = decodeKeyExchangeURI(uri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); }); it("should handle case-insensitive URIs", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri } = encodeKeyExchangeURI(publicKey, secret); // Test uppercase URI (but keep scheme lowercase as URL parsing requires it) const upperUri = uri.replace(/wiz:\/\/([^?]+)/, (match, host) => { return `wiz://${host.toUpperCase()}`; }); const decoded = decodeKeyExchangeURI(upperUri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); }); it("should handle mixed case URIs", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri } = encodeKeyExchangeURI(publicKey, secret); // Mix case const mixedUri = uri.replace(/r/i, "R").replace(/c/i, "C"); const decoded = decodeKeyExchangeURI(mixedUri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); }); it("should throw error for invalid URI format", () => { const invalidUri = "invalid://uri"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); }); it("should throw error for wrong scheme", () => { const invalidUri = "wrong://relay.riften.net?p=abc&s=def"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid URI scheme", ); }); it("should handle URIs with custom hostname and port", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri } = encodeKeyExchangeURI(publicKey, secret, { hostname: "example.com", port: 8080, protocol: "ws", }); expect(uri).toMatch(/^wiz:\/\/example\.com:8080\?p=/); const decoded = decodeKeyExchangeURI(uri); expect(decoded.hostname).toBe("example.com"); expect(decoded.port).toBe(8080); expect(decoded.protocol).toBe("ws"); }); it("should throw error for missing parameters", () => { const invalidUri = "wiz://relay.riften.net?p=abc"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid URI format", ); }); it("should throw error for invalid bech32 encoding", () => { // Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l) // This will fail at bech32 decoding const invalidUri = "wiz://relay.riften.net?p=invalid&s=chars"; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow( "Invalid bech32 encoding", ); }); it("should throw error for wrong public key length after decoding", () => { // Create a bech32 string that decodes to wrong length const shortBech32 = "q"; const { uri: validSecret } = encodeKeyExchangeURI( "a".repeat(64), "b".repeat(16), ); const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || ""; const invalidUri = `wiz://relay.riften.net?p=${shortBech32}&s=${secretPart}`; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); }); it("should throw error for wrong secret length after decoding", () => { const { uri: validPublicKey } = encodeKeyExchangeURI( "a".repeat(64), "b".repeat(16), ); const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || ""; const shortBech32 = "q"; const invalidUri = `wiz://relay.riften.net?p=${publicKeyPart}&s=${shortBech32}`; expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(); }); }); describe("decodeKeyExchangeURI — QR format", () => { it("decodes qrUri to same values as standard uri", () => { const publicKey = "0123456789abcdef".repeat(4); const secret = "fedcba9876543210"; const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret); const fromUri = decodeKeyExchangeURI(uri); const fromQr = decodeKeyExchangeURI(qrUri); expect(fromQr.publicKey).toBe(fromUri.publicKey); expect(fromQr.secret).toBe(fromUri.secret); expect(fromQr.hostname).toBe(fromUri.hostname); expect(fromQr.port).toBe(fromUri.port); expect(fromQr.protocol).toBe(fromUri.protocol); }); it("accepts lowercase version of qrUri", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret); const fromUri = decodeKeyExchangeURI(uri); const fromLowerQr = decodeKeyExchangeURI(qrUri.toLowerCase()); expect(fromLowerQr.publicKey).toBe(fromUri.publicKey); expect(fromLowerQr.secret).toBe(fromUri.secret); }); it("round-trip: encodeKeyExchangeURI → qrUri → decodeKeyExchangeURI", () => { const credentials = generateKeyExchangeCredentials(); const { qrUri } = encodeKeyExchangeURI( credentials.publicKey, credentials.secret, ); const decoded = decodeKeyExchangeURI(qrUri); expect(decoded.publicKey).toBe(credentials.publicKey); expect(decoded.secret).toBe(credentials.secret); }); it("round-trip with custom relay via qrUri", () => { const publicKey = "a".repeat(64); const secret = "b".repeat(16); const { qrUri } = encodeKeyExchangeURI(publicKey, secret, { hostname: "example.com", port: 8080, protocol: "ws", }); const decoded = decodeKeyExchangeURI(qrUri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); expect(decoded.hostname).toBe("example.com"); expect(decoded.port).toBe(8080); expect(decoded.protocol).toBe("ws"); }); }); describe("round-trip encoding/decoding", () => { it("should round-trip encode and decode correctly", () => { const publicKey = "0123456789abcdef".repeat(4); // 32 bytes const secret = "fedcba9876543210"; // 8 bytes const { uri } = encodeKeyExchangeURI(publicKey, secret); const decoded = decodeKeyExchangeURI(uri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); }); it("should round-trip with generated credentials", () => { const credentials = generateKeyExchangeCredentials(); const { uri } = encodeKeyExchangeURI( credentials.publicKey, credentials.secret, ); const decoded = decodeKeyExchangeURI(uri); expect(decoded.publicKey).toBe(credentials.publicKey); expect(decoded.secret).toBe(credentials.secret); }); it("should round-trip with random hex values", () => { const randomHex = (length: number) => { const chars = "0123456789abcdef"; let result = ""; for (let i = 0; i < length; i++) { result += chars[Math.floor(Math.random() * chars.length)]; } return result; }; const publicKey = randomHex(64); const secret = randomHex(16); const { uri } = encodeKeyExchangeURI(publicKey, secret); const decoded = decodeKeyExchangeURI(uri); expect(decoded.publicKey).toBe(publicKey); expect(decoded.secret).toBe(secret); }); }); describe("DEFAULT_RELAY_URLS", () => { it("has relay.riften.net as primary (first entry)", () => { expect(DEFAULT_RELAY_URLS[0]).toBe("wss://relay.riften.net:443"); }); it("contains relay.cauldron.quest as secondary", () => { expect(DEFAULT_RELAY_URLS).toContain("wss://relay.cauldron.quest:443"); }); it("primary URL matches DEFAULT_RELAY_HOSTNAME", () => { expect(DEFAULT_RELAY_URLS[0]).toContain(DEFAULT_RELAY_HOSTNAME); }); }); });