1// Machine identity: key auth mints a bearer token (one per key), the2// fingerprint is the machine id, the store trusts the first key; join by3// bearer or by proof; sessions from a shoo id_token (local JWKS); claim4// links are single use and expire; two sessions of one machine share a5// leaderboard row; unverified joins are marked and never honoured; a6// signature produced by openssl verifies.7import { assert, assertEquals, assertRejects } from "@std/assert";8import {9 claimMachine,10 createLink,11 fingerprint,12 generateIdentity,13 keyAuth,14 LINK_TTL_MS,15 machineOfBearer,16 MemoryStore,17 signJoin,18 signKeyAuth,19 signSession,20 verifiedJoin,21 verifyIdToken,22 verifyJoin,23 verifyKeyAuth,24 verifySession,25} from "../lib/identity.ts";26import { Referee } from "../lib/referee.ts";27import { boardsOf, winnersOf } from "../lib/leaderboard.ts";28import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";2930const tmp = await Deno.makeTempDir();31Deno.env.set("MOSAIC_GAMES", tmp + "/");3233const fixture = synthesise(scatterLayout("identity", 100));3435async function game() {36 return await Referee.create(37 crypto.randomUUID(),38 structuredClone(fixture),39 { guess: "accept", depth: 0 },40 stubJudge,41 { seed: "identity" },42 );43}4445Deno.test("key auth: a signed timestamp mints one token per key; re-minting replaces it", async () => {46 const store = new MemoryStore();47 const id = await generateIdentity("alice");48 const now = Date.now();49 const p = await signKeyAuth(id, "mosaic.dev.site", now);50 const v = await verifyKeyAuth(p, "mosaic.dev.site", now);51 assert(v.ok);52 // another host, or a stale timestamp, does not verify53 assertEquals((await verifyKeyAuth(p, "example.com", now)).ok, false);54 assertEquals(55 (await verifyKeyAuth(p, "mosaic.dev.site", now + 6 * 60_000)).ok,56 false,57 );58 const a = await keyAuth(store, p, "mosaic.dev.site", "alice", now);59 assert(a.token.startsWith("msc_") && a.token.length === 36);60 assertEquals(a.machine.machine_id, await fingerprint(id.publicKey.x));61 assertEquals(62 (await machineOfBearer(store, `Bearer ${a.token}`))?.machine_id,63 a.machine.machine_id,64 );65 const b = await keyAuth(66 store,67 await signKeyAuth(id, "mosaic.dev.site", now),68 "mosaic.dev.site",69 undefined,70 now,71 );72 assert(b.token !== a.token);73 assertEquals(74 await machineOfBearer(store, `Bearer ${a.token}`),75 null,76 "old token replaced",77 );78 assertEquals(79 (await machineOfBearer(store, `Bearer ${b.token}`))?.name,80 "alice",81 );82 assertEquals(await machineOfBearer(store, "Bearer nope"), null);83 // a different key claiming the same machine id is refused by the store84 const other = await generateIdentity("mallory");85 let threw = false;86 try {87 store.seen(a.machine.machine_id, other.publicKey.x, "m");88 } catch {89 threw = true;90 }91 assert(threw);92});9394Deno.test("join proof: current or previous minute, fingerprint must match, wrong key refused", async () => {95 const id = await generateIdentity("alice");96 const now = Date.now();97 const p = await signJoin(id, "game-1", now);98 assertEquals((await verifyJoin(p, "game-1", now)).ok, true);99 assertEquals((await verifyJoin(p, "game-1", now + 60_000)).ok, true);100 assertEquals((await verifyJoin(p, "game-1", now + 130_000)).ok, false);101 assertEquals((await verifyJoin(p, "game-2", now)).ok, false);102 // the session is bound into the proof103 const pa = await signJoin(id, "game-1", now, "a");104 assertEquals((await verifyJoin(pa, "game-1", now, "a")).ok, true);105 assertEquals((await verifyJoin(pa, "game-1", now, "b")).ok, false);106 assertEquals((await verifyJoin(pa, "game-1", now)).ok, false);107 const b = await generateIdentity("b");108 const bid = await fingerprint(b.publicKey.x);109 assertEquals(110 (await verifyJoin({ ...p, machine_id: bid }, "game-1", now)).ok,111 false,112 );113 const store = new MemoryStore();114 const rec = await verifiedJoin(store, p, "game-1", "alice", now);115 assertEquals(rec.name, "alice");116 await assertRejects(117 async () =>118 verifiedJoin(119 store,120 { ...(await signJoin(b, "game-1", now)), machine_id: p.machine_id },121 "game-1",122 "x",123 now,124 ),125 Error,126 "fingerprint",127 );128});129130Deno.test("openssl-made keys and signatures verify (skipped without ed25519 openssl)", async () => {131 const openssl = ["/opt/homebrew/opt/openssl@3/bin/openssl", "openssl"];132 let bin: string | null = null;133 for (const b of openssl) {134 try {135 const r = await new Deno.Command(b, {136 args: ["version"],137 stdout: "piped",138 stderr: "null",139 }).output();140 if (141 r.success && new TextDecoder().decode(r.stdout).startsWith("OpenSSL 3")142 ) {143 bin = b;144 break;145 }146 } catch {147 // not there148 }149 }150 if (!bin) {151 console.log(" skipped: no OpenSSL 3");152 return;153 }154 const dir = await Deno.makeTempDir();155 const run = async (args: string[], stdin?: Uint8Array) => {156 const c = new Deno.Command(bin!, {157 args,158 stdin: stdin ? "piped" : "null",159 stdout: "piped",160 stderr: "piped",161 });162 const p = c.spawn();163 if (stdin) {164 const w = p.stdin.getWriter();165 await w.write(stdin);166 await w.close();167 }168 const out = await p.output();169 if (!out.success) throw new Error(new TextDecoder().decode(out.stderr));170 return new Uint8Array(out.stdout);171 };172 await run(["genpkey", "-algorithm", "ed25519", "-out", `${dir}/k.pem`]);173 const spki = await run([174 "pkey",175 "-in",176 `${dir}/k.pem`,177 "-pubout",178 "-outform",179 "DER",180 ]);181 const raw = spki.slice(-32);182 const b64u = (b: Uint8Array) =>183 btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_")184 .replace(/=+$/, "");185 const ts = Math.floor(Date.now() / 1000);186 const msg = new TextEncoder().encode(`mosaic-key-auth:mosaic.dev.site:${ts}`);187 // -rawin needs a file: openssl reads it whole (oneshot), not a pipe188 await Deno.writeFile(`${dir}/msg`, msg);189 const sig = await run([190 "pkeyutl",191 "-sign",192 "-inkey",193 `${dir}/k.pem`,194 "-rawin",195 "-in",196 `${dir}/msg`,197 ]);198 const v = await verifyKeyAuth(199 { publicKey: b64u(raw), ts, sig: b64u(sig) },200 "mosaic.dev.site",201 );202 assert(v.ok, !v.ok ? v.reason : "");203 // the SPKI PEM form of the public key is accepted too204 const pem = new TextDecoder().decode(205 await run(["pkey", "-in", `${dir}/k.pem`, "-pubout"]),206 );207 assertEquals(await fingerprint(pem), await fingerprint(b64u(raw)));208});209210Deno.test("session cookie: signed, verified, expired, tampered", async () => {211 const exp = Math.floor(Date.now() / 1000) + 60;212 const v = await signSession("s3cret", "sub-1", exp);213 assertEquals(await verifySession("s3cret", v), "sub-1");214 assertEquals(await verifySession("other", v), null);215 assertEquals(await verifySession("s3cret", v.slice(0, -2) + "xx"), null);216 const old = await signSession(217 "s3cret",218 "sub-1",219 Math.floor(Date.now() / 1000) - 1,220 );221 assertEquals(await verifySession("s3cret", old), null);222});223224Deno.test("a shoo id_token verifies against a JWKS for this origin's audience", async () => {225 const jose = await import("jose");226 const { publicKey, privateKey } = await jose.generateKeyPair("ES256");227 const jwk = await jose.exportJWK(publicKey);228 const jwks = jose.createLocalJWKSet({229 keys: [{ ...jwk, kid: "t1", alg: "ES256" }],230 });231 const mint = (aud: string) =>232 new jose.SignJWT({ pairwise_sub: "pw-123", name: "Dami" })233 .setProtectedHeader({ alg: "ES256", kid: "t1" })234 .setIssuer("https://shoo.dev")235 .setAudience(aud)236 .setIssuedAt()237 .setExpirationTime("5m")238 .sign(privateKey);239 const claims = await verifyIdToken(240 await mint("origin:https://mosaic.dev.site"),241 "https://mosaic.dev.site",242 jwks,243 );244 assertEquals(claims, { sub: "pw-123", name: "Dami" });245 await assertRejects(async () =>246 verifyIdToken(247 await mint("origin:https://other.site"),248 "https://mosaic.dev.site",249 jwks,250 )251 );252});253254Deno.test("claim links are single use and expire; claiming binds the machine to the account", async () => {255 const store = new MemoryStore();256 const id = await generateIdentity("alice");257 const now = Date.now();258 const { machine } = await keyAuth(259 store,260 await signKeyAuth(id, "h", now),261 "h",262 "alice",263 now,264 );265 const link = await createLink(266 store,267 machine.machine_id,268 "https://mosaic.dev.site",269 now,270 );271 assert(link.url.startsWith("https://mosaic.dev.site/link?t=lnk_"));272 const token = new URL(link.url).searchParams.get("t")!;273 store.upsertAccount("pw-1", "Dami");274 const bound = await claimMachine(store, token, "pw-1", now + 1000);275 assertEquals(bound.account, "pw-1");276 assertEquals(store.machinesOf("pw-1").map((m) => m.machine_id), [277 machine.machine_id,278 ]);279 await assertRejects(280 () => claimMachine(store, token, "pw-1", now + 2000),281 Error,282 "used",283 );284 const link2 = await createLink(285 store,286 machine.machine_id,287 "https://mosaic.dev.site",288 now,289 );290 const t2 = new URL(link2.url).searchParams.get("t")!;291 await assertRejects(292 () => claimMachine(store, t2, "pw-1", now + LINK_TTL_MS + 1),293 Error,294 "expired",295 );296 await assertRejects(297 () => claimMachine(store, "lnk_nope", "pw-1"),298 Error,299 "link",300 );301});302303Deno.test("two sessions of one machine are two agents and one leaderboard row; unverified joins are marked", async () => {304 const ref = await game();305 const m = "m-0123456789abcdef";306 const s1 = await ref.join({307 machine_id: m,308 verified: true,309 session: "codex",310 name: "dami",311 harness: "test",312 model: "gpt-x",313 });314 const s2 = await ref.join({315 machine_id: m,316 verified: true,317 session: "fable",318 name: "dami",319 harness: "test",320 model: "claude-y",321 });322 assertEquals(s1.agent, `${m}/codex`);323 assertEquals(s2.agent, `${m}/fable`);324 const again = await ref.join({325 machine_id: m,326 verified: true,327 session: "codex",328 harness: "test",329 });330 assertEquals(again.agent, s1.agent);331 const u = await ref.join({332 machine_id: m,333 name: "impostor",334 harness: "test",335 });336 assert(/^a\d+$/.test(u.agent), `provisional id, got ${u.agent}`);337 const boards = boardsOf(ref.state);338 const row = boards.by_user.find((r) => r.key === m);339 assert(row);340 assertEquals(row.agents, 2);341 assertEquals(row.verified, true);342 assertEquals(new Set(row.models), new Set(["gpt-x", "claude-y"]));343 const imp = boards.by_user.find((r) => r.key === "impostor");344 assert(imp && imp.verified === false);345 assertEquals(winnersOf(boards).user, null);346 assertEquals(ref.state.agents.find((a) => a.id === s1.agent)?.verified, true);347 assertEquals(ref.state.agents.find((a) => a.id === u.agent)?.verified, false);348});