Files
overlord/Overlord-Server/src/purgatory.test.ts
T

614 lines
23 KiB
TypeScript
Raw Normal View History

2026-03-20 22:38:17 -05:00
import { describe, expect, test, beforeEach } from "bun:test";
import { encodeMessage, decodeMessage, type Hello, type EnrollmentChallenge, type EnrollmentStatusMsg } from "./protocol";
async function generateKeypair() {
const kp = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
return {
publicKey: kp.publicKey,
privateKey: kp.privateKey,
publicKeyBase64: Buffer.from(pubRaw).toString("base64"),
publicKeyBytes: new Uint8Array(pubRaw),
};
}
async function sign(privateKey: CryptoKey, data: Uint8Array): Promise<string> {
2026-05-06 20:07:15 -05:00
const sig = await crypto.subtle.sign("Ed25519", privateKey, data as any);
2026-03-20 22:38:17 -05:00
return Buffer.from(sig).toString("base64");
}
async function verifyEd25519(publicKeyBase64: string, signatureBase64: string, nonceBase64: string): Promise<boolean> {
try {
const pubKeyBytes = Buffer.from(publicKeyBase64, "base64");
const sigBytes = Buffer.from(signatureBase64, "base64");
const nonceBytes = Buffer.from(nonceBase64, "base64");
if (pubKeyBytes.length !== 32 || sigBytes.length !== 64) return false;
const key = await crypto.subtle.importKey("raw", pubKeyBytes, { name: "Ed25519" }, false, ["verify"]);
return await crypto.subtle.verify("Ed25519", key, sigBytes, nonceBytes);
} catch {
return false;
}
}
function computeKeyFingerprint(publicKeyBase64: string): string {
const bytes = Buffer.from(publicKeyBase64, "base64");
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
}
function createMockWs(overrides: Partial<{ clientId: string; role: string; ip: string; enrollmentNonce: string }> = {}) {
const sent: Uint8Array[] = [];
return {
data: {
clientId: overrides.clientId ?? "test-client-1",
role: overrides.role ?? "client",
ip: overrides.ip ?? "127.0.0.1",
enrollmentNonce: overrides.enrollmentNonce ?? undefined,
sessionId: "sess-1",
},
sent,
2026-05-06 20:07:15 -05:00
closedCode: null as number | null,
closedReason: null as string | null,
2026-03-20 22:38:17 -05:00
send(msg: Uint8Array) { sent.push(msg); },
close(code: number, reason: string) { this.closedCode = code; this.closedReason = reason; },
decodeSent(index: number) { return decodeMessage(sent[index]) as any; },
};
}
// tests (AI)
describe("purgatory — Ed25519 signature verification", () => {
test("valid signature from matching keypair passes", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
const sigB64 = await sign(kp.privateKey, nonce);
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64)).toBe(true);
});
test("signature from wrong keypair is rejected", async () => {
const kp1 = await generateKeypair();
const kp2 = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// Sign with kp1 but verify with kp2's public key
const sigB64 = await sign(kp1.privateKey, nonce);
expect(await verifyEd25519(kp2.publicKeyBase64, sigB64, nonceB64)).toBe(false);
});
test("signature over wrong nonce is rejected", async () => {
const kp = await generateKeypair();
const nonce1 = new Uint8Array(32);
const nonce2 = new Uint8Array(32);
crypto.getRandomValues(nonce1);
crypto.getRandomValues(nonce2);
// Sign nonce1 but try to verify against nonce2
const sigB64 = await sign(kp.privateKey, nonce1);
expect(await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"))).toBe(false);
});
test("empty public key is rejected", async () => {
expect(await verifyEd25519("", "AAAA", "AAAA")).toBe(false);
});
test("empty signature is rejected", async () => {
const kp = await generateKeypair();
expect(await verifyEd25519(kp.publicKeyBase64, "", "AAAA")).toBe(false);
});
test("malformed public key (wrong length) is rejected", async () => {
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// 16-byte key instead of 32
const badKey = Buffer.from(new Uint8Array(16)).toString("base64");
const fakeSig = Buffer.from(new Uint8Array(64)).toString("base64");
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
});
test("malformed signature (wrong length) is rejected", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// 32-byte signature instead of 64
const badSig = Buffer.from(new Uint8Array(32)).toString("base64");
expect(await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64)).toBe(false);
});
test("garbage base64 does not crash — returns false", async () => {
expect(await verifyEd25519("not!!valid!!base64", "also!!bad", "nonce!!bad")).toBe(false);
});
});
describe("purgatory — key fingerprint", () => {
test("fingerprint is a 64-char hex SHA-256 of the raw public key bytes", async () => {
const kp = await generateKeypair();
const fp = computeKeyFingerprint(kp.publicKeyBase64);
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
test("different keys produce different fingerprints", async () => {
const kp1 = await generateKeypair();
const kp2 = await generateKeypair();
expect(computeKeyFingerprint(kp1.publicKeyBase64)).not.toBe(computeKeyFingerprint(kp2.publicKeyBase64));
});
test("same key always produces the same fingerprint", async () => {
const kp = await generateKeypair();
expect(computeKeyFingerprint(kp.publicKeyBase64)).toBe(computeKeyFingerprint(kp.publicKeyBase64));
});
});
describe("purgatory — enrollment challenge protocol", () => {
test("enrollment_challenge message encodes/decodes with nonce", () => {
const nonce = Buffer.from(new Uint8Array(32)).toString("base64");
const msg = encodeMessage({ type: "enrollment_challenge", nonce });
const decoded = decodeMessage(msg) as EnrollmentChallenge;
expect(decoded.type).toBe("enrollment_challenge");
expect(decoded.nonce).toBe(nonce);
});
test("enrollment_status message encodes/decodes with status", () => {
for (const status of ["pending", "approved", "denied"] as const) {
const msg = encodeMessage({ type: "enrollment_status", status });
const decoded = decodeMessage(msg) as EnrollmentStatusMsg;
expect(decoded.type).toBe("enrollment_status");
expect(decoded.status).toBe(status);
}
});
test("hello message with publicKey and signature round-trips", async () => {
const kp = await generateKeypair();
const hello: Hello = {
type: "hello",
id: "client-abc",
host: "DESKTOP-1",
os: "windows",
arch: "amd64",
version: "1.0.0",
user: "testuser",
monitors: 2,
country: "US",
publicKey: kp.publicKeyBase64,
signature: "fakesig==",
};
const encoded = encodeMessage(hello);
const decoded = decodeMessage(encoded) as Hello;
expect(decoded.publicKey).toBe(kp.publicKeyBase64);
expect(decoded.signature).toBe("fakesig==");
});
});
describe("purgatory — client rejection scenarios", () => {
test("client with no publicKey is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
const payload: any = {
type: "hello",
id: "bad-client",
host: "evil",
os: "windows",
arch: "amd64",
version: "1.0",
user: "hacker",
monitors: 1,
publicKey: "",
signature: "something",
};
// Simulate the server's check: missing publicKey/signature/nonce
const publicKey = typeof payload.publicKey === "string" ? payload.publicKey : "";
const signature = typeof payload.signature === "string" ? payload.signature : "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
expect(ws.closedReason).toBe("invalid_signature");
});
test("client with no signature is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: Buffer.from(new Uint8Array(32)).toString("base64") });
const publicKey = "validkeyhere";
const signature = "";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
});
test("client with no nonce stored on ws is rejected (close 4002)", () => {
const ws = createMockWs({ enrollmentNonce: undefined });
const publicKey = "somekey";
const signature = "somesig";
const nonce = ws.data.enrollmentNonce || "";
if (!publicKey || !signature || !nonce) {
ws.close(4002, "invalid_signature");
}
expect(ws.closedCode).toBe(4002);
});
test("client with invalid Ed25519 signature is rejected", async () => {
const kp = await generateKeypair();
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
const nonceB64 = Buffer.from(nonce).toString("base64");
// Construct a bogus signature (not signed by this key)
const badSig = Buffer.from(new Uint8Array(64).fill(0xAB)).toString("base64");
const valid = await verifyEd25519(kp.publicKeyBase64, badSig, nonceB64);
expect(valid).toBe(false);
// Server would close with 4002
});
test("replay attack: reusing a nonce with a valid signature on a different nonce fails", async () => {
const kp = await generateKeypair();
const nonce1 = new Uint8Array(32);
const nonce2 = new Uint8Array(32);
crypto.getRandomValues(nonce1);
crypto.getRandomValues(nonce2);
// Client signed nonce1
const sigB64 = await sign(kp.privateKey, nonce1);
// Server presents nonce2 — verify against nonce2 should fail
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, Buffer.from(nonce2).toString("base64"));
expect(valid).toBe(false);
});
test("nonce is cleared after use — cannot be reused", () => {
const ws = createMockWs({ enrollmentNonce: "someNonce==" });
// Server clears nonce after successful verification
ws.data.enrollmentNonce = undefined;
expect(ws.data.enrollmentNonce).toBeUndefined();
// Second attempt with no nonce → rejected
const nonce = ws.data.enrollmentNonce || "";
expect(nonce).toBe("");
});
test("denied client gets close code 4003", () => {
const ws = createMockWs();
// Simulate denied enrollment status
const enrollmentStatus = "denied";
if (enrollmentStatus === "denied") {
ws.send(encodeMessage({ type: "enrollment_status", status: "denied" }));
ws.close(4003, "denied");
}
expect(ws.closedCode).toBe(4003);
expect(ws.closedReason).toBe("denied");
const msg = ws.decodeSent(0);
expect(msg.type).toBe("enrollment_status");
expect(msg.status).toBe("denied");
});
test("pending client gets close code 4001", () => {
const ws = createMockWs();
const enrollmentStatus = "pending";
if (enrollmentStatus === "pending") {
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
ws.close(4001, "pending");
}
expect(ws.closedCode).toBe(4001);
expect(ws.closedReason).toBe("pending");
const msg = ws.decodeSent(0);
expect(msg.type).toBe("enrollment_status");
expect(msg.status).toBe("pending");
});
test("approved client gets hello_ack (not closed)", async () => {
const ws = createMockWs();
const enrollmentStatus = "approved";
if (enrollmentStatus === "approved") {
ws.send(encodeMessage({
type: "hello_ack",
id: ws.data.clientId,
notification: { keywords: [], minIntervalMs: 8000, clipboardEnabled: false },
}));
}
expect(ws.closedCode).toBeNull();
const msg = ws.decodeSent(0);
expect(msg.type).toBe("hello_ack");
expect(msg.id).toBe("test-client-1");
});
});
describe("purgatory — enrollment timeout", () => {
test("timeout constant is 30 seconds", () => {
// This just documents the expected timeout value
const ENROLLMENT_TIMEOUT_MS = 30_000;
expect(ENROLLMENT_TIMEOUT_MS).toBe(30_000);
});
test("client that never sends hello would be closed with 4002 on timeout", () => {
const ws = createMockWs();
// Simulate timeout firing
ws.close(4002, "enrollment_timeout");
expect(ws.closedCode).toBe(4002);
expect(ws.closedReason).toBe("enrollment_timeout");
});
});
describe("purgatory — nonce generation", () => {
test("nonce is 32 bytes of randomness", () => {
const nonce = new Uint8Array(32);
crypto.getRandomValues(nonce);
expect(nonce.length).toBe(32);
// Extremely unlikely all zeros
expect(nonce.some((b) => b !== 0)).toBe(true);
});
test("sequential nonces are unique", () => {
const nonces = new Set<string>();
for (let i = 0; i < 100; i++) {
const n = new Uint8Array(32);
crypto.getRandomValues(n);
nonces.add(Buffer.from(n).toString("base64"));
}
expect(nonces.size).toBe(100);
});
});
describe("purgatory — end-to-end challenge-response", () => {
test("legitimate client completes full handshake", async () => {
// 1. Server generates nonce
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 2. Client receives challenge and signs
const kp = await generateKeypair();
const sigB64 = await sign(kp.privateKey, nonceBytes);
// 3. Server verifies
const valid = await verifyEd25519(kp.publicKeyBase64, sigB64, nonceB64);
expect(valid).toBe(true);
// 4. Server computes fingerprint
const fp = computeKeyFingerprint(kp.publicKeyBase64);
expect(fp).toMatch(/^[0-9a-f]{64}$/);
});
test("attacker who intercepts nonce cannot sign without private key", async () => {
// 1. Server generates nonce
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 2. Attacker has the real client's public key but not private key
const realClient = await generateKeypair();
const attacker = await generateKeypair();
// 3. Attacker tries to sign with their own key but present real client's pubkey
const attackerSig = await sign(attacker.privateKey, nonceBytes);
const valid = await verifyEd25519(realClient.publicKeyBase64, attackerSig, nonceB64);
expect(valid).toBe(false);
});
test("attacker with own keypair gets new identity — not existing client", async () => {
// Attacker generates valid keypair and signs correctly
const attacker = await generateKeypair();
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
const sigB64 = await sign(attacker.privateKey, nonceBytes);
// Signature is valid...
const valid = await verifyEd25519(attacker.publicKeyBase64, sigB64, nonceB64);
expect(valid).toBe(true);
// ...but fingerprint won't match any existing approved client
const realClient = await generateKeypair();
expect(computeKeyFingerprint(attacker.publicKeyBase64)).not.toBe(computeKeyFingerprint(realClient.publicKeyBase64));
});
test("all-zero signature is rejected", async () => {
const kp = await generateKeypair();
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
const zeroSig = Buffer.from(new Uint8Array(64)).toString("base64");
expect(await verifyEd25519(kp.publicKeyBase64, zeroSig, nonceB64)).toBe(false);
});
test("random 32-byte value that is not a valid Ed25519 point is rejected", async () => {
const nonceBytes = new Uint8Array(32);
crypto.getRandomValues(nonceBytes);
const nonceB64 = Buffer.from(nonceBytes).toString("base64");
// 0xFF-filled is not a valid Ed25519 public key point
const badKey = Buffer.from(new Uint8Array(32).fill(0xFF)).toString("base64");
const fakeSig = Buffer.from(new Uint8Array(64).fill(0x42)).toString("base64");
expect(await verifyEd25519(badKey, fakeSig, nonceB64)).toBe(false);
});
});
describe("purgatory — duplicate connection prevention", () => {
test("superseded socket gets close code 4004", () => {
// Simulate: old socket is active, then new socket arrives for same clientId
const oldWs = createMockWs({ clientId: "dup-client" });
const newWs = createMockWs({ clientId: "dup-client" });
// Simulated client manager (Map<string, { ws: any }>)
const clients = new Map<string, { ws: any }>();
clients.set("dup-client", { ws: oldWs });
// New connection arrives — kick old
const existing = clients.get("dup-client");
if (existing && existing.ws !== newWs) {
existing.ws.close(4004, "superseded");
clients.delete("dup-client");
}
clients.set("dup-client", { ws: newWs });
expect(oldWs.closedCode).toBe(4004);
expect(oldWs.closedReason).toBe("superseded");
expect(newWs.closedCode).toBeNull();
expect(clients.get("dup-client")!.ws).toBe(newWs);
});
test("superseded socket close handler skips cleanup", () => {
// Simulate: old socket fires close, but manager already has the new socket
const oldWs = createMockWs({ clientId: "dup-client" });
const newWs = createMockWs({ clientId: "dup-client" });
const clients = new Map<string, { ws: any }>();
clients.set("dup-client", { ws: newWs }); // new one already registered
// Old socket close handler fires
const current = clients.get("dup-client");
const shouldSkipCleanup = current && current.ws !== oldWs;
expect(shouldSkipCleanup).toBe(true);
// New socket should stay in place
expect(clients.get("dup-client")!.ws).toBe(newWs);
});
test("normal close handler cleans up when socket matches", () => {
const ws = createMockWs({ clientId: "normal-client" });
const clients = new Map<string, { ws: any }>();
clients.set("normal-client", { ws });
// Close handler — socket matches current
const current = clients.get("normal-client");
const shouldSkipCleanup = current && current.ws !== ws;
expect(shouldSkipCleanup).toBe(false);
// Should proceed with cleanup
clients.delete("normal-client");
expect(clients.has("normal-client")).toBe(false);
});
test("same socket reconnecting is not kicked (identity check)", () => {
const ws = createMockWs({ clientId: "same-client" });
const clients = new Map<string, { ws: any }>();
clients.set("same-client", { ws });
// Same ws object reconnects — should NOT kick itself
const existing = clients.get("same-client");
if (existing && existing.ws !== ws) {
existing.ws.close(4004, "superseded");
}
// Should not have been closed
expect(ws.closedCode).toBeNull();
});
});
describe("purgatory — IP banning", () => {
test("IP validation regex accepts valid IPv4", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("192.168.1.1")).toBe(true);
expect(ipRegex.test("10.0.0.1")).toBe(true);
expect(ipRegex.test("255.255.255.255")).toBe(true);
});
test("IP validation regex accepts valid IPv6", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("::1")).toBe(true);
expect(ipRegex.test("fe80::1")).toBe(true);
expect(ipRegex.test("2001:db8::1")).toBe(true);
});
test("IP validation regex rejects dangerous input", () => {
const ipRegex = /^[0-9a-fA-F:.]{3,64}$/;
expect(ipRegex.test("")).toBe(false);
expect(ipRegex.test("a")).toBe(false); // too short
expect(ipRegex.test("'; DROP TABLE--")).toBe(false);
expect(ipRegex.test("192.168.1.1; rm -rf /")).toBe(false);
expect(ipRegex.test("<script>alert(1)</script>")).toBe(false);
});
test("banned client gets close code 4003", () => {
const ws = createMockWs({ ip: "1.2.3.4" });
// Simulate banning: deny enrollment + close socket
ws.close(4003, "banned");
expect(ws.closedCode).toBe(4003);
expect(ws.closedReason).toBe("banned");
});
test("ban reason is truncated to 200 chars", () => {
const longReason = "A".repeat(500);
const truncated = longReason.slice(0, 200);
expect(truncated.length).toBe(200);
expect(truncated).toBe("A".repeat(200));
});
});
describe("purgatory — HWID collision prevention", () => {
test("two different public keys produce different key fingerprints", async () => {
const kpA = await generateKeypair();
const kpB = await generateKeypair();
const fpA = computeKeyFingerprint(kpA.publicKeyBase64);
const fpB = computeKeyFingerprint(kpB.publicKeyBase64);
expect(fpA).not.toBe(fpB);
// fingerprints are 64-char hex
expect(fpA).toMatch(/^[a-f0-9]{64}$/);
expect(fpB).toMatch(/^[a-f0-9]{64}$/);
});
test("key fingerprint is deterministic for same public key", async () => {
const kp = await generateKeypair();
const fp1 = computeKeyFingerprint(kp.publicKeyBase64);
const fp2 = computeKeyFingerprint(kp.publicKeyBase64);
expect(fp1).toBe(fp2);
});
test("collision detection assigns different IDs to same-HWID machines", async () => {
// Simulate: Machine A and Machine B share HWID "colliding-hwid"
// but have different key pairs. After collision detection,
// Machine B should get the key fingerprint as its ID.
const kpA = await generateKeypair();
const kpB = await generateKeypair();
const sharedHwid = "colliding-hwid";
const fpA = computeKeyFingerprint(kpA.publicKeyBase64);
const fpB = computeKeyFingerprint(kpB.publicKeyBase64);
// Machine A: lookupClientByPublicKey → null, no existing client at ID → keeps HWID
const wsA = createMockWs({ clientId: sharedHwid });
// Simulate server logic: no existing key at this ID → keep URL ID
const existingPkAtId: string | null = null; // DB empty
if (existingPkAtId && existingPkAtId !== kpA.publicKeyBase64) {
wsA.data.clientId = fpA;
}
expect(wsA.data.clientId).toBe(sharedHwid); // Machine A keeps HWID
// Machine B: lookupClientByPublicKey → null, but ID already taken by PK_A
const wsB = createMockWs({ clientId: sharedHwid });
const existingPkAtIdForB: string | null = kpA.publicKeyBase64; // Machine A's key is stored at this ID
if (existingPkAtIdForB && existingPkAtIdForB !== kpB.publicKeyBase64) {
wsB.data.clientId = fpB;
}
expect(wsB.data.clientId).toBe(fpB); // Machine B gets reassigned
expect(wsB.data.clientId).not.toBe(sharedHwid);
});
test("no collision when same machine reconnects with same public key", async () => {
const kp = await generateKeypair();
const hwid = "same-machine-hwid";
const fp = computeKeyFingerprint(kp.publicKeyBase64);
const ws = createMockWs({ clientId: hwid });
// Same key already at this ID → no collision
const existingPkAtId: string | null = kp.publicKeyBase64;
if (existingPkAtId && existingPkAtId !== kp.publicKeyBase64) {
ws.data.clientId = fp;
}
expect(ws.data.clientId).toBe(hwid); // keeps original ID
});
});