375 lines
13 KiB
TypeScript
375 lines
13 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 { describe, it, expect } from "vitest";
|
||
|
|
import {
|
||
|
|
encodeKeyExchangeURI,
|
||
|
|
decodeKeyExchangeURI,
|
||
|
|
generateKeyExchangeCredentials,
|
||
|
|
} 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.cauldron.quest?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.cauldron.quest?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.cauldron.quest?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.cauldron.quest?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.cauldron.quest?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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|