clone

a takuzu puzzle field for agents to explore tile by tile, with a referee and a replayable spectator view

identity: a machine key per machine, verified joins, sessions, TOFU registry

dami committed 506edf3
scripts/identity.ts (served at /identity.ts) makes an ed25519 key in
~/.config/mosaic/identity.json; join takes machine_id, public_key and a
proof (signature over mosaic-join|game|key|minute, current or previous
minute); the server checks the fingerprint and the signature, records
the first key per machine id in data/machines.sqlite and refuses another;
a verified agent is machine_id/session and the by-user leaderboard keys
on the machine; unverified joins stay allowed as provisional a<N>, marked
and never honoured; the client signs with --identity; prompt and rules
say so.
commit
506edf3e13f7fcef032e20e066d25af6774cb1e5
parent
e50e8a56bc5afa553dc8ffb52f04674443dd6648
author
dami <gazey55@gmail.com>
17 files changed, +887 -41
Mdeploy.md11+
Msite/deno.json3+-
Msite/islands/Viewer.tsx6+-
Msite/lib/daily.ts2+-
Msite/lib/fold.ts12+
Asite/lib/identity.ts318++++++++++++++++++++++++++++++
Msite/lib/leaderboard.ts16+-
Msite/lib/mcp.ts74++++++-
Msite/lib/prompt.ts9+-
Msite/lib/referee.ts35++-
Asite/routes/identity.ts.ts36+++
Asite/scripts/identity.ts140+++++++++++++
Msite/scripts/solve.ts15+
Msite/tests/daily_test.ts42+++-
Asite/tests/identity_test.ts163+++++++++++++++
Msite/tests/referee_test.ts42+++-
Msite/tests/viewer_test.ts4+
M deploy.md +11 -0
M site/deno.json +2 -1
M site/islands/Viewer.tsx +5 -1
M site/lib/daily.ts +1 -1
M site/lib/fold.ts +12 -0
A site/lib/identity.ts +318 -0
@@ -0,0 +1,318 @@
1+// Machine identity: an ed25519 key per machine, its fingerprint as the2+// machine id, and a join proof the referee verifies so the user axis of3+// the leaderboard is a machine, not a self-reported name. The registry4+// records the first key seen for a machine id (trust on first use) and the5+// display name its key holder chose; a different key for a known id is6+// refused.7+//8+// machine_id = "m-" + first 16 hex of sha256(raw public key)9+// proof = base64(ed25519 sign("mosaic-join|" + game_id + "|" + public_key + "|" + minute))10+// minute = floor(unix seconds / 60); the current and the previous minute are accepted11+12+const enc = new TextEncoder();13+14+export function b64(bytes: Uint8Array): string {15+ let s = "";16+ for (const b of bytes) s += String.fromCharCode(b);17+ return btoa(s);18+}19+20+export function unb64(s: string): Uint8Array {21+ const bin = atob(s);22+ const out = new Uint8Array(bin.length);23+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);24+ return out;25+}26+27+async function sha256hex(bytes: Uint8Array): Promise<string> {28+ const d = await crypto.subtle.digest("SHA-256", bytes as BufferSource);29+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0"))30+ .join("");31+}32+33+/** The machine id of a raw ed25519 public key (base64). */34+export async function fingerprint(publicKeyB64: string): Promise<string> {35+ return "m-" + (await sha256hex(unb64(publicKeyB64))).slice(0, 16);36+}37+38+export type Identity = {39+ machine_id: string;40+ public_key: string; // raw 32 bytes, base6441+ secret_key: string; // pkcs8, base6442+ name: string;43+ created: string;44+};45+46+/** A fresh identity (keys as base64). */47+export async function generateIdentity(name: string): Promise<Identity> {48+ const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [49+ "sign",50+ "verify",51+ ]) as CryptoKeyPair;52+ const pub = new Uint8Array(53+ await crypto.subtle.exportKey("raw", kp.publicKey),54+ );55+ const sec = new Uint8Array(56+ await crypto.subtle.exportKey("pkcs8", kp.privateKey),57+ );58+ const public_key = b64(pub);59+ return {60+ machine_id: await fingerprint(public_key),61+ public_key,62+ secret_key: b64(sec),63+ name,64+ created: new Date().toISOString(),65+ };66+}67+68+export function currentMinute(now = Date.now()): number {69+ return Math.floor(now / 60000);70+}71+72+export function joinMessage(73+ gameId: string,74+ publicKeyB64: string,75+ minute: number,76+): string {77+ return `mosaic-join|${gameId}|${publicKeyB64}|${minute}`;78+}79+80+/** What a client sends with join, made from its identity file. */81+export type JoinProof = {82+ machine_id: string;83+ public_key: string;84+ proof: string;85+ minute: number;86+};87+88+export async function signJoin(89+ id: Pick<Identity, "machine_id" | "public_key" | "secret_key">,90+ gameId: string,91+ now = Date.now(),92+): Promise<JoinProof> {93+ const key = await crypto.subtle.importKey(94+ "pkcs8",95+ unb64(id.secret_key) as BufferSource,96+ { name: "Ed25519" },97+ false,98+ ["sign"],99+ );100+ const minute = currentMinute(now);101+ const sig = await crypto.subtle.sign(102+ { name: "Ed25519" },103+ key,104+ enc.encode(joinMessage(gameId, id.public_key, minute)),105+ );106+ return {107+ machine_id: id.machine_id,108+ public_key: id.public_key,109+ proof: b64(new Uint8Array(sig)),110+ minute,111+ };112+}113+114+/** Verifies a proof against the key for the current or previous minute115+ * and checks the machine id is the key's fingerprint. Returns why not. */116+export async function verifyJoin(117+ p: { machine_id: string; public_key: string; proof: string },118+ gameId: string,119+ now = Date.now(),120+): Promise<{ ok: true } | { ok: false; reason: string }> {121+ let pub: Uint8Array, sig: Uint8Array;122+ try {123+ pub = unb64(p.public_key);124+ sig = unb64(p.proof);125+ } catch {126+ return { ok: false, reason: "public_key and proof must be base64" };127+ }128+ if (pub.length !== 32) {129+ return {130+ ok: false,131+ reason: "public_key must be a raw 32-byte ed25519 key",132+ };133+ }134+ if ((await fingerprint(p.public_key)) !== p.machine_id) {135+ return {136+ ok: false,137+ reason: "machine_id is not the fingerprint of public_key",138+ };139+ }140+ let key: CryptoKey;141+ try {142+ key = await crypto.subtle.importKey(143+ "raw",144+ pub as BufferSource,145+ { name: "Ed25519" },146+ false,147+ ["verify"],148+ );149+ } catch {150+ return { ok: false, reason: "public_key is not a valid ed25519 key" };151+ }152+ const m = currentMinute(now);153+ for (const minute of [m, m - 1]) {154+ const ok = await crypto.subtle.verify(155+ { name: "Ed25519" },156+ key,157+ sig as BufferSource,158+ enc.encode(joinMessage(gameId, p.public_key, minute)),159+ );160+ if (ok) return { ok: true };161+ }162+ return {163+ ok: false,164+ reason:165+ "proof does not verify for this game in the current or previous minute",166+ };167+}168+169+// ---------------------------------------------------------------- registry170+171+export type MachineRecord = {172+ machine_id: string;173+ public_key: string;174+ name: string;175+ first_seen: string;176+ last_seen: string;177+};178+179+/** Trust on first use: the first key seen for a machine id is its key. */180+export interface MachineRegistry {181+ get(machineId: string): MachineRecord | null;182+ /** Records a first sighting or refreshes last_seen (and the name when183+ * given); throws on a different key for a known id. */184+ seen(185+ machineId: string,186+ publicKey: string,187+ name: string | undefined,188+ now?: Date,189+ ): MachineRecord;190+}191+192+export class IdentityError extends Error {193+ constructor(message: string, readonly code: "identity" | "proof") {194+ super(message);195+ }196+}197+198+export class MemoryRegistry implements MachineRegistry {199+ private rows = new Map<string, MachineRecord>();200+ get(machineId: string): MachineRecord | null {201+ return this.rows.get(machineId) ?? null;202+ }203+ seen(204+ machineId: string,205+ publicKey: string,206+ name: string | undefined,207+ now = new Date(),208+ ): MachineRecord {209+ const t = now.toISOString();210+ const r = this.rows.get(machineId);211+ if (r) {212+ if (r.public_key !== publicKey) {213+ throw new IdentityError(214+ `Machine ${machineId} is registered with a different key.`,215+ "identity",216+ );217+ }218+ const next = { ...r, last_seen: t, ...(name ? { name } : {}) };219+ this.rows.set(machineId, next);220+ return next;221+ }222+ const rec: MachineRecord = {223+ machine_id: machineId,224+ public_key: publicKey,225+ name: name ?? machineId,226+ first_seen: t,227+ last_seen: t,228+ };229+ this.rows.set(machineId, rec);230+ return rec;231+ }232+}233+234+type DatabaseSync = {235+ exec(sql: string): void;236+ prepare(sql: string): {237+ get(...args: unknown[]): Record<string, unknown> | undefined;238+ run(...args: unknown[]): unknown;239+ };240+};241+const sqliteSpecifier = "node:sqlite";242+let sqliteMod:243+ | Promise<{ DatabaseSync: new (path: string) => DatabaseSync }>244+ | null = null;245+246+/** The registry as one SQLite file (`data/machines.sqlite`). */247+export class SqliteRegistry implements MachineRegistry {248+ private constructor(private db: DatabaseSync) {}249+ static async open(path: string): Promise<SqliteRegistry> {250+ sqliteMod ??= import(sqliteSpecifier) as unknown as Promise<251+ { DatabaseSync: new (path: string) => DatabaseSync }252+ >;253+ const { DatabaseSync: DB } = await sqliteMod;254+ const db = new DB(path);255+ db.exec(256+ `CREATE TABLE IF NOT EXISTS machines (257+ machine_id TEXT PRIMARY KEY,258+ public_key TEXT NOT NULL,259+ name TEXT NOT NULL,260+ first_seen TEXT NOT NULL,261+ last_seen TEXT NOT NULL262+ )`,263+ );264+ return new SqliteRegistry(db);265+ }266+ get(machineId: string): MachineRecord | null {267+ const r = this.db.prepare(268+ "SELECT machine_id, public_key, name, first_seen, last_seen FROM machines WHERE machine_id = ?",269+ ).get(machineId);270+ return r ? (r as unknown as MachineRecord) : null;271+ }272+ seen(273+ machineId: string,274+ publicKey: string,275+ name: string | undefined,276+ now = new Date(),277+ ): MachineRecord {278+ const t = now.toISOString();279+ const r = this.get(machineId);280+ if (r) {281+ if (r.public_key !== publicKey) {282+ throw new IdentityError(283+ `Machine ${machineId} is registered with a different key.`,284+ "identity",285+ );286+ }287+ this.db.prepare(288+ "UPDATE machines SET last_seen = ?, name = ? WHERE machine_id = ?",289+ ).run(t, name ?? r.name, machineId);290+ return { ...r, last_seen: t, name: name ?? r.name };291+ }292+ const rec: MachineRecord = {293+ machine_id: machineId,294+ public_key: publicKey,295+ name: name ?? machineId,296+ first_seen: t,297+ last_seen: t,298+ };299+ this.db.prepare(300+ "INSERT INTO machines (machine_id, public_key, name, first_seen, last_seen) VALUES (?, ?, ?, ?, ?)",301+ ).run(rec.machine_id, rec.public_key, rec.name, t, t);302+ return rec;303+ }304+}305+306+/** A verified join: the proof checks out and the registry accepts the key.307+ * Returns the record (its name is the display name the key holder owns). */308+export async function verifiedJoin(309+ registry: MachineRegistry,310+ p: { machine_id: string; public_key: string; proof: string },311+ gameId: string,312+ name: string | undefined,313+ now = Date.now(),314+): Promise<MachineRecord> {315+ const v = await verifyJoin(p, gameId, now);316+ if (!v.ok) throw new IdentityError(v.reason, "proof");317+ return registry.seen(p.machine_id, p.public_key, name, new Date(now));318+}
M site/lib/leaderboard.ts +12 -4
@@ -9,6 +9,7 @@ import type { AgentInfo, State } from "./fold.ts";
99 export type Row = {1010 key: string; // the model, or the user1111 name?: string; // by user: the display name when the key is a machine id12+ verified?: boolean; // by user: a machine key stands behind this row1213 reward: number; // cells solved1314 tiles: number;1415 forced: number;
@@ -30,8 +31,10 @@ export type Boards = {
3031 by_user: Row[];3132 };3233 33-/** A provisional id (a<N>) is not a user; the user is the machine id or the name. */34+/** The user behind an agent: its verified machine id; else (provisional35+ * agents) the name it gave, which is only a label. */3436 export function userOf(a: AgentInfo): string {37+ if (a.verified && a.machine_id) return a.machine_id;3538 if (!/^a\d+$/.test(a.id)) return a.id;3639 return a.name && a.name !== a.id ? a.name : a.id;3740 }
@@ -95,6 +98,9 @@ export function boardsOf(s: State): Boards {
9598 if (list === "models" && a.name && a.name !== key && !row.name) {9699 row.name = a.name;97100 }101+ if (list === "models") {102+ row.verified = (row.verified ?? false) || a.verified;103+ }98104 if (an) {99105 row.reward += an.reward;100106 row.tiles += an.tiles;
@@ -136,6 +142,7 @@ export function mergeBoards(
136142 row.agents += r.agents;137143 row.wrong_rate = rateOf(row);138144 row.days += 1;145+ if (r.verified) row.verified = true;139146 for (const m of r.models) addTo(row.models, m);140147 for (const u of r.users) addTo(row.users, u);141148 out.set(r.key, row);
@@ -150,12 +157,13 @@ export function mergeBoards(
150157 };151158 }152159 153-/** The winners of a day: the top user (crowned tomorrow) and the top model154- * (named on tomorrow's start card). Null when nobody scored. */160+/** The winners of a day: the top VERIFIED user (crowned tomorrow: only a161+ * machine with a key can be honoured) and the top model (named on162+ * tomorrow's start card). Null when nobody scored. */155163 export function winnersOf(156164 b: Boards,157165 ): { user: Row | null; model: Row | null } {158- const user = b.by_user.find((r) => r.reward > 0) ?? null;166+ const user = b.by_user.find((r) => r.reward > 0 && r.verified) ?? null;159167 const model = b.by_model.find((r) => r.reward > 0) ?? null;160168 return { user, model };161169 }
M site/lib/mcp.ts +68 -6
@@ -5,6 +5,14 @@ import { z } from "zod";
55 import { getEntry, getGame, listGames, startGame } from "./games.ts";66 import { type JoinOptions, RefereeError } from "./referee.ts";77 import { publicOrigin } from "./prompt.ts";8+import {9+ IdentityError,10+ type MachineRegistry,11+ MemoryRegistry,12+ SqliteRegistry,13+ verifiedJoin,14+} from "./identity.ts";15+import { dataDir } from "./log.ts";816 917 /**1018 * The client's identity as the transport knows it. On a 2026-07-28-era
@@ -109,6 +117,17 @@ export function hostedDaily(): boolean {
109117 return Deno.env.get("MOSAIC_DAILY") === "1";110118 }111119 120+let registryP: Promise<MachineRegistry> | null = null;121+122+/** The machine registry: one SQLite file under the data dir; in memory123+ * when `MOSAIC_MACHINES=memory` (tests). */124+export function machineRegistry(): Promise<MachineRegistry> {125+ registryP ??= Deno.env.get("MOSAIC_MACHINES") === "memory"126+ ? Promise.resolve(new MemoryRegistry())127+ : SqliteRegistry.open(dataDir() + "machines.sqlite");128+ return registryP;129+}130+112131 export function createServer(): McpServer {113132 const server = new McpServer({ name: "mosaic", version: "0.3.0" });114133 // the rules as a resource too: agents look there first
@@ -255,11 +274,20 @@ function registerPlay(server: McpServer): void {
255274 {256275 title: "Join a field",257276 description:258- `Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). Returns your agent id (your machine_id if you pass one, else a provisional a<N>), your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5) and a short name; the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id. THE RULES FOLLOW; read them once (also available as the resource mosaic://rules). ${RULES_TEXT}`,277+ `Join a field as an agent (the field must be playing: a generating one answers with an error to retry after polling observe). IDENTITY: the leaderboard's user axis is the MACHINE, proven by an ed25519 key. Run \`deno run -A ${publicOrigin()}/identity.ts --sign <game_id>\` on your machine (it creates ~/.config/mosaic/identity.json once, then prints {machine_id, public_key, proof, minute}) and pass machine_id, public_key and proof here; the proof is the key's signature over "mosaic-join|<game_id>|<public_key>|<unix minute>", accepted for the current and previous minute; the first key seen for a machine id is recorded and a different key for that id is refused. A verified join gets the agent id <machine_id>/<session> (session defaults to "1"; use another session to run several agents on one machine: they share one leaderboard row) and its name is the one the key holder set. Without a proof you join as a provisional a<N>: allowed, but marked unverified and never honoured. Returns your agent id, your seed tile (a hidden tile at a random spot away from everyone; reveal it first) and the revision. State your model (self-reported, e.g. claude-opus-5); the harness is read from the transport when it identifies itself, else from the harness argument. Every reveal, commit and retract must carry your agent id. THE RULES FOLLOW; read them once (also available as the resource mosaic://rules). ${RULES_TEXT}`,259278 inputSchema: z.object({260279 game_id: gameIdSchema,261280 machine_id: z.string().min(1).max(80).optional().describe(262- "Your durable machine id (a public key fingerprint), if you have one.",281+ "Your machine id: the fingerprint of your key (from identity.ts). Trusted only with public_key and proof.",282+ ),283+ public_key: z.string().min(1).max(200).optional().describe(284+ "Your raw ed25519 public key, base64 (from identity.ts --sign).",285+ ),286+ proof: z.string().min(1).max(200).optional().describe(287+ "The join proof from identity.ts --sign <game_id> (base64 signature).",288+ ),289+ session: z.string().min(1).max(24).optional().describe(290+ "A label for this agent on your machine when you run several (default 1); the agent id becomes machine_id/session.",263291 ),264292 name: z.string().min(1).max(40).optional(),265293 model: z.string().min(1).max(80).optional().describe(
@@ -274,13 +302,45 @@ function registerPlay(server: McpServer): void {
274302 }),275303 annotations: { destructiveHint: false, idempotentHint: false },276304 },277- async ({ game_id, machine_id, name, model, harness, tags }, ctx) => {305+ async (306+ {307+ game_id,308+ machine_id,309+ public_key,310+ proof,311+ session,312+ name,313+ model,314+ harness,315+ tags,316+ },317+ ctx,318+ ) => {278319 try {279320 const ref = requireGame(game_id);280321 const h = harnessOf(ctx, server, harness);322+ let verified = false;323+ let joinName = name;324+ if (machine_id && public_key && proof) {325+ const rec = await verifiedJoin(326+ await machineRegistry(),327+ { machine_id, public_key, proof },328+ game_id,329+ name,330+ );331+ verified = true;332+ joinName = rec.name;333+ } else if (machine_id || public_key || proof) {334+ throw new IdentityError(335+ "A verified join needs machine_id, public_key and proof together (identity.ts --sign <game_id>); omit all three to join unverified.",336+ "proof",337+ );338+ }281339 const r = await ref.join({282340 machine_id,283- name,341+ verified,342+ session,343+ name: joinName,284344 model,285345 tags,286346 harness: h.harness,
@@ -288,8 +348,10 @@ function registerPlay(server: McpServer): void {
288348 });289349 const t = ref.manifest.tiles[r.seed_tile];290350 return textResult(291- `Joined ${game_id} as ${r.agent}. Your seed tile is #${r.seed_tile} (${t.cells} cells, ${t.w}x${t.h} at ${t.x0},${t.y0}); reveal it first. Revision ${r.revision}.`,292- { ...r, harness: h.harness, harness_source: h.source },351+ `Joined ${game_id} as ${r.agent}${352+ verified ? " (verified machine)" : " (unverified: no machine key)"353+ }. Your seed tile is #${r.seed_tile} (${t.cells} cells, ${t.w}x${t.h} at ${t.x0},${t.y0}); reveal it first. Revision ${r.revision}.`,354+ { ...r, verified, harness: h.harness, harness_source: h.source },293355 );294356 } catch (error) {295357 return errorResult(error);
M site/lib/prompt.ts +6 -3
M site/lib/referee.ts +23 -12
@@ -93,6 +93,11 @@ export type CommitResult = {
9393 9494 export type JoinOptions = {9595 machine_id?: string;96+ /** the key behind machine_id was verified by the caller (lib/identity);97+ * only then is machine_id trusted and the agent id derived from it */98+ verified?: boolean;99+ /** several agents on one machine: the agent id is machine_id/session */100+ session?: string;96101 name?: string;97102 harness?: string;98103 harness_source?: JoinEvent["harness_source"];
@@ -428,9 +433,15 @@ export class Referee {
428433 // the same agent joining again (same machine id, or same name from a429434 // client without one) is the same agent: nothing appended, unless it430435 // has nowhere left to play, in which case it is reseated436+ const verified = opts.verified === true && !!opts.machine_id;437+ const session =438+ (opts.session ?? "1").replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 24) || "1";439+ const wanted = verified ? `${opts.machine_id}/${session}` : null;440+ // the same agent joining again is the same agent: a verified machine441+ // by its id and session; an unverified client by its name only431442 const returning = this.state.agents.find((a) =>432- (opts.machine_id && a.id === opts.machine_id) ||433- (opts.name && a.name === opts.name)443+ (wanted !== null && a.id === wanted) ||444+ (wanted === null && opts.name && !a.verified && a.name === opts.name)434445 );435446 if (returning) {436447 // playable: a hidden frontier tile, or a revealed unsolved tile of
@@ -453,18 +464,16 @@ export class Referee {
453464 }454465 return await this.reseat(returning.id);455466 }456- const agent =457- opts.machine_id && /^[a-zA-Z0-9_:.-]{1,80}$/.test(opts.machine_id)458- ? opts.machine_id459- : `a${index}`;467+ // an unverified machine_id is just a claim: the agent is provisional468+ const agent = wanted ?? `a${index}`;460469 if (agentOf(this.state, agent)) {461470 throw new RefereeError(`Agent ${agent} has already joined.`, "joined");462471 }463- // a returning winner is seated on the seat reserved for them464- const honour = this.state.honours.find((h) =>465- (opts.machine_id && h.user === opts.machine_id) ||466- (opts.name && h.name === opts.name) || h.agent === agent467- );472+ // a returning winner is seated on the seat reserved for them: only a473+ // verified machine can claim an honour474+ const honour = verified475+ ? this.state.honours.find((h) => h.user === opts.machine_id)476+ : undefined;468477 const seatFree = (t: number) =>469478 this.state.tiles[t] === "hidden" &&470479 !this.state.agents.some((a) => a.seed_tile === t);
@@ -493,7 +502,9 @@ export class Referee {
493502 (opts.harness ? "argument" : "none"),494503 ...(opts.model ? { model: opts.model.slice(0, 80) } : {}),495504 ...(opts.tags ? { tags: clipTags(opts.tags) } : {}),496- ...(opts.machine_id ? { machine_id: opts.machine_id } : {}),505+ ...(verified506+ ? { machine_id: opts.machine_id, verified: true, session }507+ : {}),497508 seed_tile: seedTile,498509 };499510 await this.apply(ev);
A site/routes/identity.ts.ts +36 -0
A site/scripts/identity.ts +140 -0
@@ -0,0 +1,140 @@
1+#!/usr/bin/env -S deno run -A2+// The machine identity for mosaic: one ed25519 key per machine, kept in3+// ~/.config/mosaic/identity.json. The machine id is the key's fingerprint4+// and is what the leaderboard's user axis is keyed on, so several agents on5+// one machine count as one user and nobody can claim another's name.6+//7+// deno run -A https://mosaic.dev.site/identity.ts # create (if absent) and show8+// deno run -A https://mosaic.dev.site/identity.ts --name dami # set the display name9+// deno run -A https://mosaic.dev.site/identity.ts --show10+// deno run -A https://mosaic.dev.site/identity.ts --sign <game_id> # a join proof, as JSON11+// deno run -A https://mosaic.dev.site/identity.ts --rotate # new key (a new machine id)12+//13+// Standalone on purpose: no imports, so it runs from a URL on any machine.14+15+type Identity = {16+ machine_id: string;17+ public_key: string; // raw 32-byte ed25519 key, base6418+ secret_key: string; // pkcs8, base6419+ name: string;20+ created: string;21+};22+23+const enc = new TextEncoder();24+25+function b64(bytes: Uint8Array): string {26+ let s = "";27+ for (const b of bytes) s += String.fromCharCode(b);28+ return btoa(s);29+}30+function unb64(s: string): Uint8Array {31+ const bin = atob(s);32+ const out = new Uint8Array(bin.length);33+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);34+ return out;35+}36+async function fingerprint(publicKey: string): Promise<string> {37+ const d = await crypto.subtle.digest(38+ "SHA-256",39+ unb64(publicKey) as BufferSource,40+ );41+ const hex = [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0"))42+ .join("");43+ return "m-" + hex.slice(0, 16);44+}45+46+function home(): string {47+ return Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? ".";48+}49+const dir = Deno.env.get("MOSAIC_IDENTITY_DIR") ?? `${home()}/.config/mosaic`;50+const file = `${dir}/identity.json`;51+52+async function load(): Promise<Identity | null> {53+ try {54+ return JSON.parse(await Deno.readTextFile(file)) as Identity;55+ } catch {56+ return null;57+ }58+}59+60+async function save(id: Identity): Promise<void> {61+ await Deno.mkdir(dir, { recursive: true });62+ await Deno.writeTextFile(file, JSON.stringify(id, null, 2) + "\n");63+ try {64+ await Deno.chmod(file, 0o600);65+ } catch {66+ // not every platform has modes67+ }68+}69+70+async function generate(name: string): Promise<Identity> {71+ const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [72+ "sign",73+ "verify",74+ ]) as CryptoKeyPair;75+ const pub = b64(76+ new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)),77+ );78+ const sec = b64(79+ new Uint8Array(await crypto.subtle.exportKey("pkcs8", kp.privateKey)),80+ );81+ return {82+ machine_id: await fingerprint(pub),83+ public_key: pub,84+ secret_key: sec,85+ name,86+ created: new Date().toISOString(),87+ };88+}89+90+async function sign(id: Identity, gameId: string) {91+ const key = await crypto.subtle.importKey(92+ "pkcs8",93+ unb64(id.secret_key) as BufferSource,94+ { name: "Ed25519" },95+ false,96+ ["sign"],97+ );98+ const minute = Math.floor(Date.now() / 60000);99+ const msg = `mosaic-join|${gameId}|${id.public_key}|${minute}`;100+ const sig = await crypto.subtle.sign(101+ { name: "Ed25519" },102+ key,103+ enc.encode(msg),104+ );105+ return {106+ machine_id: id.machine_id,107+ public_key: id.public_key,108+ proof: b64(new Uint8Array(sig)),109+ minute,110+ };111+}112+113+const args = Deno.args;114+const flag = (f: string) => {115+ const i = args.indexOf(f);116+ return i >= 0 ? (args[i + 1] ?? "") : undefined;117+};118+const has = (f: string) => args.includes(f);119+120+let id = await load();121+const nameArg = flag("--name");122+if (!id || has("--rotate")) {123+ id = await generate(nameArg || id?.name || Deno.hostname());124+ await save(id);125+ console.error(`created ${file}`);126+} else if (nameArg && nameArg !== id.name) {127+ id = { ...id, name: nameArg };128+ await save(id);129+}130+131+const game = flag("--sign");132+if (game !== undefined) {133+ if (!game) {134+ console.error("usage: --sign <game_id>");135+ Deno.exit(2);136+ }137+ console.log(JSON.stringify(await sign(id, game)));138+} else {139+ console.log(JSON.stringify({ machine_id: id.machine_id, name: id.name }));140+}
M site/scripts/solve.ts +15 -0
M site/tests/daily_test.ts +36 -6
@@ -100,8 +100,18 @@ Deno.test("two rollovers: archive, winners, honours, replay, resume", async () =
100100 assertEquals(g1.state.day, day1);101101 102102 // two agents: alice (model-a) wins by cells, bob (model-b) trails103- const a = await g1.join({ name: "alice", model: "model-a" });104- const b = await g1.join({ name: "bob", model: "model-b" });103+ const a = await g1.join({104+ name: "alice",105+ machine_id: "alice",106+ verified: true,107+ model: "model-a",108+ });109+ const b = await g1.join({110+ name: "bob",111+ machine_id: "bob",112+ verified: true,113+ model: "model-b",114+ });105115 await play(g1, a.agent, d.current!.seed, 3);106116 await play(g1, b.agent, d.current!.seed, 1);107117 const boards = boardsOf(g1.state);
@@ -154,9 +164,19 @@ Deno.test("two rollovers: archive, winners, honours, replay, resume", async () =
154164 assertEquals(g2.state.honours[0].model, "model-a");155165 const seat = g2.state.honours[0].seed_tile;156166 // a stranger is not seated on the reserved seat; alice is157- const c = await g2.join({ name: "carol", model: "model-c" });167+ const c = await g2.join({168+ name: "carol",169+ machine_id: "carol",170+ verified: true,171+ model: "model-c",172+ });158173 assert(c.seed_tile !== seat, "stranger not on the reserved seat");159- const a2 = await g2.join({ name: "alice", model: "model-a" });174+ const a2 = await g2.join({175+ name: "alice",176+ machine_id: "alice",177+ verified: true,178+ model: "model-a",179+ });160180 assertEquals(a2.seed_tile, seat);161181 await play(g2, a2.agent, d2.current!.seed, 2);162182
@@ -279,7 +299,12 @@ Deno.test("reset wipes a day's play and restarts it from the same field", async
279299 const g = getGame(d.current!.game_id)!;280300 const id = d.current!.game_id;281301 const manifestId = g.state.manifest!.id;282- const a = await g.join({ name: "alice", model: "model-a" });302+ const a = await g.join({303+ name: "alice",304+ machine_id: "alice",305+ verified: true,306+ model: "model-a",307+ });283308 await play(g, a.agent, d.current!.seed, 2);284309 const rev = g.revision;285310 assert(rev > 3, `played to ${rev}`);
@@ -307,7 +332,12 @@ Deno.test("reset wipes a day's play and restarts it from the same field", async
307332 assert(fresh, "a fresh log");308333 assertEquals(fresh.split("\n").filter(Boolean).length, 1);309334 // play again from scratch with clean health310- const b = await g2.join({ name: "alice", model: "model-a" });335+ const b = await g2.join({336+ name: "alice",337+ machine_id: "alice",338+ verified: true,339+ model: "model-a",340+ });311341 assertEquals(g2.state.agents[0].health.state, "healthy");312342 await play(g2, b.agent, d.current!.seed, 1);313343 assert(g2.revision > 1);
A site/tests/identity_test.ts +163 -0
@@ -0,0 +1,163 @@
1+// Machine identity: a join proves a machine's ed25519 key; the fingerprint2+// is the machine id; the registry trusts the first key it sees; several3+// sessions on one machine are separate agents but one leaderboard row;4+// an unverified join is allowed, marked, and never honoured.5+import { assert, assertEquals, assertRejects } from "@std/assert";6+import {7+ fingerprint,8+ generateIdentity,9+ MemoryRegistry,10+ signJoin,11+ verifiedJoin,12+ verifyJoin,13+} from "../lib/identity.ts";14+import { Referee } from "../lib/referee.ts";15+import { boardsOf, winnersOf } from "../lib/leaderboard.ts";16+import { scatterLayout, stubJudge, synthesise } from "./fixture.ts";17+18+const tmp = await Deno.makeTempDir();19+Deno.env.set("MOSAIC_GAMES", tmp + "/");20+21+const fixture = synthesise(scatterLayout("identity", 100));22+23+async function game() {24+ const out = structuredClone(fixture);25+ return await Referee.create(26+ crypto.randomUUID(),27+ out,28+ { guess: "accept", depth: 0 },29+ stubJudge,30+ { seed: "identity" },31+ );32+}33+34+Deno.test("a proof verifies for the current and previous minute, not older", async () => {35+ const id = await generateIdentity("alice");36+ assertEquals(id.machine_id, await fingerprint(id.public_key));37+ assert(id.machine_id.startsWith("m-") && id.machine_id.length === 18);38+ const now = Date.now();39+ const p = await signJoin(id, "game-1", now);40+ assertEquals((await verifyJoin(p, "game-1", now)).ok, true);41+ assertEquals((await verifyJoin(p, "game-1", now + 60_000)).ok, true);42+ const stale = await verifyJoin(p, "game-1", now + 130_000);43+ assertEquals(stale.ok, false);44+ // another game: the message differs45+ assertEquals((await verifyJoin(p, "game-2", now)).ok, false);46+});47+48+Deno.test("a wrong key or a wrong fingerprint is refused", async () => {49+ const a = await generateIdentity("a");50+ const b = await generateIdentity("b");51+ const now = Date.now();52+ const pa = await signJoin(a, "g", now);53+ // b's key with a's proof54+ const forged = { ...pa, public_key: b.public_key, machine_id: b.machine_id };55+ assertEquals((await verifyJoin(forged, "g", now)).ok, false);56+ // a's key claiming b's id57+ const mismatch = { ...pa, machine_id: b.machine_id };58+ const r = await verifyJoin(mismatch, "g", now);59+ assertEquals(r.ok, false);60+ assert(!r.ok && r.reason.includes("fingerprint"));61+});62+63+Deno.test("the registry trusts the first key and refuses another for the same id", async () => {64+ const reg = new MemoryRegistry();65+ const a = await generateIdentity("alice");66+ const now = Date.now();67+ const rec = await verifiedJoin(68+ reg,69+ await signJoin(a, "g", now),70+ "g",71+ "alice",72+ now,73+ );74+ assertEquals(rec.name, "alice");75+ // the key holder renames; a join without a name keeps the registered name76+ const rec2 = await verifiedJoin(77+ reg,78+ await signJoin(a, "g", now),79+ "g",80+ "alice2",81+ now,82+ );83+ assertEquals(rec2.name, "alice2");84+ const rec3 = await verifiedJoin(85+ reg,86+ await signJoin(a, "g", now),87+ "g",88+ undefined,89+ now,90+ );91+ assertEquals(rec3.name, "alice2");92+ // someone else claiming alice's machine id with their own key93+ const b = await generateIdentity("mallory");94+ const pb = await signJoin(b, "g", now);95+ await assertRejects(96+ () => verifiedJoin(reg, { ...pb, machine_id: a.machine_id }, "g", "x", now),97+ Error,98+ "fingerprint",99+ );100+ // a valid key of their own registered under a different id is fine101+ const recb = await verifiedJoin(reg, pb, "g", "mallory", now);102+ assertEquals(recb.machine_id, b.machine_id);103+ // the registry itself refuses a different key for a known id104+ const c = await generateIdentity("c");105+ let threw = false;106+ try {107+ reg.seen(a.machine_id, c.public_key, "c");108+ } catch {109+ threw = true;110+ }111+ assert(threw, "different key for a known machine id must be refused");112+});113+114+Deno.test("two sessions of one machine are two agents and one leaderboard row; unverified joins are marked", async () => {115+ const ref = await game();116+ const m = "m-0123456789abcdef";117+ const s1 = await ref.join({118+ machine_id: m,119+ verified: true,120+ session: "codex",121+ name: "dami",122+ harness: "test",123+ model: "gpt-x",124+ });125+ const s2 = await ref.join({126+ machine_id: m,127+ verified: true,128+ session: "fable",129+ name: "dami",130+ harness: "test",131+ model: "claude-y",132+ });133+ assertEquals(s1.agent, `${m}/codex`);134+ assertEquals(s2.agent, `${m}/fable`);135+ // joining again with the same session is the same agent136+ const again = await ref.join({137+ machine_id: m,138+ verified: true,139+ session: "codex",140+ harness: "test",141+ });142+ assertEquals(again.agent, s1.agent);143+ // an unverified claim of the same machine id is provisional144+ const u = await ref.join({145+ machine_id: m,146+ name: "impostor",147+ harness: "test",148+ });149+ assert(/^a\d+$/.test(u.agent), `provisional id, got ${u.agent}`);150+ const boards = boardsOf(ref.state);151+ const row = boards.by_user.find((r) => r.key === m);152+ assert(row, "one row for the machine");153+ assertEquals(row.agents, 2);154+ assertEquals(row.verified, true);155+ assertEquals(new Set(row.models), new Set(["gpt-x", "claude-y"]));156+ const imp = boards.by_user.find((r) => r.key === "impostor");157+ assert(imp && imp.verified === false, "unverified row is marked");158+ // nobody scored: no winner; an unverified row never wins even alone159+ assertEquals(winnersOf(boards).user, null);160+ const agents = ref.state.agents;161+ assertEquals(agents.find((a) => a.id === s1.agent)?.verified, true);162+ assertEquals(agents.find((a) => a.id === u.agent)?.verified, false);163+});
M site/tests/referee_test.ts +35 -7
@@ -600,8 +600,16 @@ Deno.test("commits and retractions are territory-gated", async () => {
600600 ],601601 },602602 );603- const alice = await ref.join({ name: "alice" });604- const bob = await ref.join({ name: "bob" });603+ const alice = await ref.join({604+ name: "alice",605+ machine_id: "alice",606+ verified: true,607+ });608+ const bob = await ref.join({609+ name: "bob",610+ machine_id: "bob",611+ verified: true,612+ });605613 assertEquals([alice.seed_tile, bob.seed_tile], [0, 4]);606614 await ref.reveal(ref.revision, 0, "seed", alice.agent);607615 const cells = ref.state.geo!.cellsOf[0].filter((c) => !ref.state.cells[c]);
@@ -665,11 +673,27 @@ Deno.test("join is idempotent and reseats a stranded agent", async () => {
665673 honours: [seat("alice", 0, 0), seat("bob", 2, 1), seat("carol", 6, 2)],666674 },667675 );668- const alice = await ref.join({ name: "alice" });669- const bob = await ref.join({ name: "bob" });670- const carol = await ref.join({ name: "carol" });676+ const alice = await ref.join({677+ name: "alice",678+ machine_id: "alice",679+ verified: true,680+ });681+ const bob = await ref.join({682+ name: "bob",683+ machine_id: "bob",684+ verified: true,685+ });686+ const carol = await ref.join({687+ name: "carol",688+ machine_id: "carol",689+ verified: true,690+ });671691 const rev = ref.revision;672- const again = await ref.join({ name: "alice" });692+ const again = await ref.join({693+ name: "alice",694+ machine_id: "alice",695+ verified: true,696+ });673697 assertEquals(again.agent, alice.agent);674698 assertEquals(ref.revision, rev, "no event for a repeated join");675699 assertEquals(ref.state.agents.length, 3);
@@ -697,7 +721,11 @@ Deno.test("join is idempotent and reseats a stranded agent", async () => {
697721 // alice's frontier is other groups' revealed tiles only: stranded, so698722 // joining again reseats her on a fresh hidden tile that is nobody's seed699723 const before = ref.revision;700- const moved = await ref.join({ name: "alice" });724+ const moved = await ref.join({725+ name: "alice",726+ machine_id: "alice",727+ verified: true,728+ });701729 assertEquals(moved.agent, alice.agent);702730 assertEquals(ref.revision, before + 1);703731 assert(![0, 2, 6].includes(moved.seed_tile));
M site/tests/viewer_test.ts +4 -0