clone

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

identity the tribes way: JWK identity file, key-auth bearer tokens, shoo login, claim links

dami committed f7891db
~/.config/mosaic/identity.json {v, publicKey, privateKey (JWK Ed25519),
createdAt, name}; POST /api/keys/auth {publicKey, ts, sig} over
mosaic-key-auth:<host>:<ts> mints one msc_ bearer per key (TOFU registry
in data/identity.sqlite); join takes the bearer from the MCP request
(request scope) or the proof fields; /identity and /identity.md carry the
spec with node, openssl, python and deno ways; shoo.js login with
/api/session (jose against shoo's JWKS, audience origin:<origin>), HMAC
session cookie, /api/me, /api/logout; /api/keys/link-token and /link
claim a machine for the signed-in account; identity.ts --token/--link;
solve.ts --bearer; tests incl. an openssl-made signature.
commit
f7891dbca13d64fdca15ca90c0fe1c2f510fe897
parent
06386b9c680e2c2e926128e773c9ecde5b247195
author
dami <gazey55@gmail.com>
23 files changed, +1678 -334
Mdeploy.md28+-
Msite/deno.json3+-
Msite/deno.lock5+
Asite/islands/Login.tsx93++++
Msite/lib/identity.ts787++++++++++++++++++++++++------
Asite/lib/identity_spec.ts141+++++
Msite/lib/mcp.ts67++-
Msite/lib/prompt.ts13+-
Msite/routes/_app.tsx1+
Asite/routes/api/keys/auth.ts59++
Asite/routes/api/keys/link-token.ts27+
Asite/routes/api/logout.ts14+
Asite/routes/api/me.ts22+
Asite/routes/api/session.ts51++
Asite/routes/identity.md.ts11+
Asite/routes/identity.tsx41++
Msite/routes/index.tsx2+
Asite/routes/link.tsx85+++
Msite/routes/mcp.ts9+-
Asite/routes/shoo/callback.tsx23+
Msite/scripts/identity.ts203+++++---
Msite/scripts/solve.ts3+-
Msite/tests/identity_test.ts324++++++++++---
M deploy.md +18 -10
@@ -86,13 +86,21 @@ cd site && deno task solve -- --url https://mosaic-daily.fly.dev/mcp --join <gam
8686 8787 (`list_games` on the MCP endpoint names today's game: `day-<date>`.)8888 89-## Machine identity90-91-Agents prove their machine with an ed25519 key: `deno run -A92-https://mosaic.dev.site/identity.ts` creates `~/.config/mosaic/identity.json`93-(`--name <name>` sets the display name, `--sign <game_id>` prints the join94-proof). The server records the first key seen per machine id in95-`data/machines.sqlite` (trust on first use) and refuses a different key96-for a known id; the leaderboard's user axis is the machine id, several97-sessions on one machine (`session`) share one row, unverified joins are98-allowed but marked and never honoured. Tests use `MOSAIC_MACHINES=memory`.89+## Machine identity and login90+91+Identity follows the tribes convention: `~/.config/mosaic/identity.json`92+`{v:1, publicKey, privateKey (JWK Ed25519), createdAt, name?}`, mode 600;93+machine id = `m-` + sha256(raw public key)[:16]. `POST /api/keys/auth94+{publicKey, ts, sig}` (sig over `mosaic-key-auth:<host>:<ts>`) mints a95+`msc_` bearer token (one per key; re-minting replaces it) and records the96+key on first sight in `data/identity.sqlite`; the token goes on MCP97+requests as `Authorization: Bearer`. The spec agents read is `/identity`98+(`/identity.md` as text). `deno task identity` is one way to hold the file99+(`--name`, `--token <host>`, `--link <host>`, `--sign <game_id>`).100+101+Humans log in with shoo.dev (`shoo.js`, `POST /api/session {id_token}`102+verified against shoo's JWKS for `origin:<origin>`, HMAC cookie signed103+with the `SESSION_SECRET` Fly secret; `GET /api/me`, `POST /api/logout`).104+A machine is claimed by opening a single-use link (`POST105+/api/keys/link-token` with the bearer, 10 minutes) while logged in:106+`/link?t=...` binds the machine to the account.
M site/deno.json +2 -1
M site/deno.lock +5 -0
A site/islands/Login.tsx +93 -0
@@ -0,0 +1,93 @@
1+// login / logout, right of "copy prompt". shoo.js (in the page head)2+// runs the sign-in; after the callback it holds a signed id_token, which3+// this island exchanges for the session cookie at /api/session. Logged in,4+// the button reads "logout".5+import { useEffect, useState } from "preact/hooks";6+7+type ShooGlobal = {8+ startSignIn(opts?: { returnTo?: string; requestPii?: boolean }): void;9+ getIdentity(): { userId: string; token: string } | null;10+ clearIdentity(): void;11+};12+13+function shoo(): ShooGlobal | undefined {14+ return (globalThis as unknown as { Shoo?: ShooGlobal }).Shoo;15+}16+17+export default function Login(props: { returnTo?: string }) {18+ const [me, setMe] = useState<19+ { id: string; name: string | null } | null | undefined20+ >(21+ undefined,22+ );23+ useEffect(() => {24+ (async () => {25+ const r = await fetch("/api/me", { credentials: "same-origin" });26+ if (r.ok) {27+ setMe((await r.json()).account);28+ return;29+ }30+ // just back from shoo: exchange the id_token for a cookie31+ const id = shoo()?.getIdentity?.();32+ if (id?.token) {33+ const s = await fetch("/api/session", {34+ method: "POST",35+ headers: { "content-type": "application/json" },36+ body: JSON.stringify({ id_token: id.token }),37+ credentials: "same-origin",38+ });39+ if (s.ok) {40+ setMe((await s.json()).account);41+ // back from shoo: return to where the user was; a claim link42+ // re-renders server side once the cookie exists43+ if (location.pathname === "/shoo/callback") {44+ location.replace(props.returnTo ?? "/");45+ } else if (location.pathname === "/link") location.reload();46+ return;47+ }48+ }49+ setMe(null);50+ })().catch(() => setMe(null));51+ }, []);52+ if (me === undefined) {53+ return <span class="btn" style="visibility:hidden">login</span>;54+ }55+ if (me) {56+ return (57+ <button58+ type="button"59+ class="btn"60+ title={me.name ? `signed in as ${me.name}` : "signed in"}61+ onClick={async () => {62+ await fetch("/api/logout", {63+ method: "POST",64+ credentials: "same-origin",65+ });66+ shoo()?.clearIdentity?.();67+ setMe(null);68+ if (location.pathname === "/link") location.reload();69+ }}70+ >71+ logout72+ </button>73+ );74+ }75+ return (76+ <button77+ type="button"78+ class="btn"79+ onClick={() => {80+ const s = shoo();81+ if (!s) {82+ alert("sign-in is not available (shoo.js did not load)");83+ return;84+ }85+ s.startSignIn({86+ returnTo: props.returnTo ?? location.pathname + location.search,87+ });88+ }}89+ >90+ login91+ </button>92+ );93+}
M site/lib/identity.ts +638 -149
@@ -1,16 +1,24 @@
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.1+// Machine identity, the tribes way (see /identity for the spec agents read).72 //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 accepted3+// identity file ~/.config/mosaic/identity.json, mode 600:4+// {v: 1, publicKey: JWK OKP Ed25519, privateKey: JWK, createdAt, name?}5+// machine_id "m-" + first 16 hex of sha256(raw 32-byte public key)6+// key auth POST /api/keys/auth {publicKey (base64url raw), ts, sig}7+// sig = base64url(ed25519 sign("mosaic-key-auth:<host>:<ts>")), ts within 5 min8+// -> {token: "msc_..."}: one active token per key, re-minting replaces it;9+// the first key seen for a machine id is recorded (trust on first use)10+// join Authorization: Bearer msc_... on the MCP request (primary), or11+// {machine_id, public_key, proof} with proof = base64 ed25519 signature12+// over "mosaic-join|<game_id>|<public_key raw base64>|<unix minute>"13+// accounts a shoo.dev login (pairwise subject) can claim a machine through a14+// single-use link token; the machine's row then carries the account15+//16+// Only the answer matters here: nothing in this module knows about games.1117 1218 const enc = new TextEncoder();1319 20+// ---------------------------------------------------------------- encoding21+1422 export function b64(bytes: Uint8Array): string {1523 let s = "";1624 for (const b of bytes) s += String.fromCharCode(b);
@@ -18,66 +26,209 @@ export function b64(bytes: Uint8Array): string {
1826 }1927 2028 export function unb64(s: string): Uint8Array {21- const bin = atob(s);29+ const bin = atob(s.replace(/\s+/g, ""));2230 const out = new Uint8Array(bin.length);2331 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);2432 return out;2533 }2634 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("");35+export function b64u(bytes: Uint8Array): string {36+ return b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");37+}38+39+export function unb64u(s: string): Uint8Array {40+ const t = s.replace(/-/g, "+").replace(/_/g, "/");41+ return unb64(t + "=".repeat((4 - t.length % 4) % 4));42+}43+44+function hex(bytes: Uint8Array): string {45+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");46+}47+48+export async function sha256hex(data: Uint8Array | string): Promise<string> {49+ const bytes = typeof data === "string" ? enc.encode(data) : data;50+ return hex(51+ new Uint8Array(52+ await crypto.subtle.digest("SHA-256", bytes as BufferSource),53+ ),54+ );3155 }3256 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);57+/** The 12-byte DER prefix of an Ed25519 SubjectPublicKeyInfo. */58+const SPKI_PREFIX = "302a300506032b6570032100";59+60+/** PEM (any label) to DER bytes; null when it is not PEM. */61+export function pemToDer(text: string): Uint8Array | null {62+ const m = text.match(63+ /-----BEGIN [A-Z ]+-----([\s\S]*?)-----END [A-Z ]+-----/,64+ );65+ return m ? unb64(m[1]) : null;3666 }3767 68+/** A public key as given — raw 32 bytes (base64 or base64url), SPKI DER69+ * base64, or SPKI PEM — to the raw key bytes; null when it is none of those. */70+export function rawPublicKey(input: string): Uint8Array | null {71+ const t = input.trim();72+ let bytes: Uint8Array;73+ try {74+ bytes = pemToDer(t) ??75+ (/[-_]/.test(t) ? unb64u(t) : unb64(t.replace(/\s+/g, "")));76+ } catch {77+ return null;78+ }79+ if (bytes.length === 32) return bytes;80+ if (bytes.length === 44 && hex(bytes.slice(0, 12)) === SPKI_PREFIX) {81+ return bytes.slice(12);82+ }83+ return null;84+}85+86+/** The machine id of an ed25519 public key in any accepted form. */87+export async function fingerprint(publicKey: string): Promise<string> {88+ const raw = rawPublicKey(publicKey);89+ if (!raw) throw new IdentityError("not an ed25519 public key", "proof");90+ return "m-" + (await sha256hex(raw)).slice(0, 16);91+}92+93+// ---------------------------------------------------------------- the identity file94+95+export type Jwk = { kty: "OKP"; crv: "Ed25519"; x: string; d?: string };96+97+/** The identity file's shape (tribes convention). */3898 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;99+ v: 1;100+ publicKey: Jwk;101+ privateKey: Jwk;102+ createdAt: string;103+ name?: string;44104 };45105 46-/** A fresh identity (keys as base64). */47-export async function generateIdentity(name: string): Promise<Identity> {106+export async function generateIdentity(name?: string): Promise<Identity> {48107 const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [49108 "sign",50109 "verify",51110 ]) 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);111+ const pub = await crypto.subtle.exportKey("jwk", kp.publicKey);112+ const priv = await crypto.subtle.exportKey("jwk", kp.privateKey);59113 return {60- machine_id: await fingerprint(public_key),61- public_key,62- secret_key: b64(sec),63- name,64- created: new Date().toISOString(),114+ v: 1,115+ publicKey: { kty: "OKP", crv: "Ed25519", x: pub.x! },116+ privateKey: { kty: "OKP", crv: "Ed25519", x: priv.x!, d: priv.d! },117+ createdAt: new Date().toISOString(),118+ ...(name ? { name } : {}),65119 };66120 }67121 122+export function machineIdOf(id: Identity): Promise<string> {123+ return fingerprint(id.publicKey.x);124+}125+126+async function signingKey(id: Identity): Promise<CryptoKey> {127+ return await crypto.subtle.importKey(128+ "jwk",129+ id.privateKey,130+ { name: "Ed25519" },131+ false,132+ ["sign"],133+ );134+}135+136+async function verifyingKey(raw: Uint8Array): Promise<CryptoKey | null> {137+ try {138+ return await crypto.subtle.importKey(139+ "raw",140+ raw as BufferSource,141+ { name: "Ed25519" },142+ false,143+ ["verify"],144+ );145+ } catch {146+ return null;147+ }148+}149+150+async function sign(id: Identity, message: string): Promise<Uint8Array> {151+ return new Uint8Array(152+ await crypto.subtle.sign(153+ { name: "Ed25519" },154+ await signingKey(id),155+ enc.encode(message),156+ ),157+ );158+}159+160+// ---------------------------------------------------------------- key auth (bearer tokens)161+162+export const KEY_AUTH_WINDOW_S = 5 * 60;163+164+export function keyAuthMessage(host: string, ts: number): string {165+ return `mosaic-key-auth:${host}:${ts}`;166+}167+168+/** What the client posts to /api/keys/auth. */169+export type KeyAuth = { publicKey: string; ts: number; sig: string };170+171+export async function signKeyAuth(172+ id: Identity,173+ host: string,174+ now = Date.now(),175+): Promise<KeyAuth> {176+ const ts = Math.floor(now / 1000);177+ const sig = await sign(id, keyAuthMessage(host, ts));178+ return { publicKey: id.publicKey.x, ts, sig: b64u(sig) };179+}180+181+/** Verifies a key-auth proof for this host; returns the raw key. */182+export async function verifyKeyAuth(183+ p: KeyAuth,184+ host: string,185+ now = Date.now(),186+): Promise<{ ok: true; raw: Uint8Array } | { ok: false; reason: string }> {187+ const raw = rawPublicKey(p.publicKey);188+ if (!raw) return { ok: false, reason: "publicKey is not an ed25519 key" };189+ if (190+ !Number.isFinite(p.ts) || Math.abs(now / 1000 - p.ts) > KEY_AUTH_WINDOW_S191+ ) {192+ return { ok: false, reason: "ts is not within 5 minutes of server time" };193+ }194+ const key = await verifyingKey(raw);195+ if (!key) {196+ return { ok: false, reason: "publicKey is not a valid ed25519 key" };197+ }198+ let sig: Uint8Array;199+ try {200+ sig = /[-_]/.test(p.sig) ? unb64u(p.sig) : unb64(p.sig);201+ } catch {202+ return { ok: false, reason: "sig must be base64url" };203+ }204+ const ok = await crypto.subtle.verify(205+ { name: "Ed25519" },206+ key,207+ sig as BufferSource,208+ enc.encode(keyAuthMessage(host, p.ts)),209+ );210+ return ok ? { ok: true, raw } : { ok: false, reason: "sig does not verify" };211+}212+213+/** 'msc_' + 32 base64url chars (24 random bytes); shown once, stored hashed. */214+export function mintToken(prefix = "msc_"): string {215+ return prefix + b64u(crypto.getRandomValues(new Uint8Array(24)));216+}217+218+// ---------------------------------------------------------------- join proof (the alternative path)219+68220 export function currentMinute(now = Date.now()): number {69221 return Math.floor(now / 60000);70222 }71223 72224 export function joinMessage(73225 gameId: string,74- publicKeyB64: string,226+ publicKeyRawB64: string,75227 minute: number,76228 ): string {77- return `mosaic-join|${gameId}|${publicKeyB64}|${minute}`;229+ return `mosaic-join|${gameId}|${publicKeyRawB64}|${minute}`;78230 }79231 80-/** What a client sends with join, made from its identity file. */81232 export type JoinProof = {82233 machine_id: string;83234 public_key: string;
@@ -86,68 +237,52 @@ export type JoinProof = {
86237 };87238 88239 export async function signJoin(89- id: Pick<Identity, "machine_id" | "public_key" | "secret_key">,240+ id: Identity,90241 gameId: string,91242 now = Date.now(),92243 ): 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- );244+ const raw = unb64u(id.publicKey.x);245+ const rawB64 = b64(raw);100246 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- );247+ const sig = await sign(id, joinMessage(gameId, rawB64, minute));106248 return {107- machine_id: id.machine_id,108- public_key: id.public_key,109- proof: b64(new Uint8Array(sig)),249+ machine_id: await fingerprint(rawB64),250+ public_key: rawB64,251+ proof: b64(sig),110252 minute,111253 };112254 }113255 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. */256+/** Verifies a join proof for the current or previous minute and that the257+ * machine id is the key's fingerprint. */116258 export async function verifyJoin(117259 p: { machine_id: string; public_key: string; proof: string },118260 gameId: string,119261 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) {262+): Promise<{ ok: true; raw: Uint8Array } | { ok: false; reason: string }> {263+ const raw = rawPublicKey(p.public_key);264+ if (!raw) {129265 return {130266 ok: false,131- reason: "public_key must be a raw 32-byte ed25519 key",267+ reason:268+ "public_key must be an ed25519 public key: raw 32 bytes base64, SPKI DER base64, or SPKI PEM",132269 };133270 }134- if ((await fingerprint(p.public_key)) !== p.machine_id) {271+ if ((await fingerprint(b64(raw))) !== p.machine_id) {135272 return {136273 ok: false,137274 reason: "machine_id is not the fingerprint of public_key",138275 };139276 }140- let key: CryptoKey;277+ const key = await verifyingKey(raw);278+ if (!key) {279+ return { ok: false, reason: "public_key is not a valid ed25519 key" };280+ }281+ let sig: Uint8Array;141282 try {142- key = await crypto.subtle.importKey(143- "raw",144- pub as BufferSource,145- { name: "Ed25519" },146- false,147- ["verify"],148- );283+ sig = /[-_]/.test(p.proof) ? unb64u(p.proof) : unb64(p.proof);149284 } catch {150- return { ok: false, reason: "public_key is not a valid ed25519 key" };285+ return { ok: false, reason: "proof must be base64" };151286 }152287 const m = currentMinute(now);153288 for (const minute of [m, m - 1]) {
@@ -155,9 +290,9 @@ export async function verifyJoin(
155290 { name: "Ed25519" },156291 key,157292 sig as BufferSource,158- enc.encode(joinMessage(gameId, p.public_key, minute)),293+ enc.encode(joinMessage(gameId, b64(raw), minute)),159294 );160- if (ok) return { ok: true };295+ if (ok) return { ok: true, raw };161296 }162297 return {163298 ok: false,
@@ -166,75 +301,150 @@ export async function verifyJoin(
166301 };167302 }168303 169-// ---------------------------------------------------------------- registry304+// ---------------------------------------------------------------- the store305+306+export class IdentityError extends Error {307+ constructor(308+ message: string,309+ readonly code: "identity" | "proof" | "token" | "link" | "session",310+ ) {311+ super(message);312+ }313+}170314 171315 export type MachineRecord = {172316 machine_id: string;173- public_key: string;317+ public_key: string; // raw, base64url174318 name: string;319+ account: string | null; // shoo pairwise subject once claimed175320 first_seen: string;176321 last_seen: string;177322 };178323 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. */324+export type AccountRecord = {325+ id: string; // shoo pairwise subject326+ name: string | null; // display name when shoo provides one327+ first_seen: string;328+};329+330+export const LINK_TTL_MS = 10 * 60 * 1000;331+332+/** Machines (trust on first use), one bearer token per machine, single-use333+ * link tokens, accounts. */334+export interface IdentityStore {335+ getMachine(machineId: string): MachineRecord | null;336+ /** First sighting records the key; a different key for a known id throws. */184337 seen(185338 machineId: string,186- publicKey: string,339+ publicKeyB64u: string,187340 name: string | undefined,188341 now?: Date,189342 ): MachineRecord;343+ /** Replaces the machine's token; returns the new token's hash owner. */344+ setToken(machineId: string, tokenHash: string): void;345+ machineByToken(tokenHash: string): MachineRecord | null;346+ createLink(machineId: string, tokenHash: string, expiresAt: number): void;347+ /** Redeems once; null when unknown, used or expired. */348+ redeemLink(tokenHash: string, now?: number): string | null;349+ upsertAccount(id: string, name: string | null, now?: Date): AccountRecord;350+ getAccount(id: string): AccountRecord | null;351+ bindMachine(machineId: string, accountId: string): MachineRecord;352+ machinesOf(accountId: string): MachineRecord[];190353 }191354 192-export class IdentityError extends Error {193- constructor(message: string, readonly code: "identity" | "proof") {194- super(message);355+function assertKey(r: MachineRecord, publicKey: string) {356+ if (r.public_key !== publicKey) {357+ throw new IdentityError(358+ `Machine ${r.machine_id} is registered with a different key.`,359+ "identity",360+ );195361 }196362 }197363 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;364+export class MemoryStore implements IdentityStore {365+ private machines = new Map<string, MachineRecord>();366+ private tokens = new Map<string, string>(); // hash -> machine_id367+ private links = new Map<368+ string,369+ { machine_id: string; expires: number; used: boolean }370+ >();371+ private accounts = new Map<string, AccountRecord>();372+373+ getMachine(id: string) {374+ return this.machines.get(id) ?? null;202375 }203376 seen(204- machineId: string,377+ id: string,205378 publicKey: string,206379 name: string | undefined,207380 now = new Date(),208- ): MachineRecord {381+ ) {209382 const t = now.toISOString();210- const r = this.rows.get(machineId);383+ const r = this.machines.get(id);211384 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- }385+ assertKey(r, publicKey);218386 const next = { ...r, last_seen: t, ...(name ? { name } : {}) };219- this.rows.set(machineId, next);387+ this.machines.set(id, next);220388 return next;221389 }222390 const rec: MachineRecord = {223- machine_id: machineId,391+ machine_id: id,224392 public_key: publicKey,225- name: name ?? machineId,393+ name: name ?? id,394+ account: null,226395 first_seen: t,227396 last_seen: t,228397 };229- this.rows.set(machineId, rec);398+ this.machines.set(id, rec);230399 return rec;231400 }401+ setToken(machineId: string, hash: string) {402+ for (const [h, m] of this.tokens) {403+ if (m === machineId) this.tokens.delete(h);404+ }405+ this.tokens.set(hash, machineId);406+ }407+ machineByToken(hash: string) {408+ const m = this.tokens.get(hash);409+ return m ? this.getMachine(m) : null;410+ }411+ createLink(machineId: string, hash: string, expires: number) {412+ this.links.set(hash, { machine_id: machineId, expires, used: false });413+ }414+ redeemLink(hash: string, now = Date.now()) {415+ const l = this.links.get(hash);416+ if (!l || l.used || l.expires < now) return null;417+ l.used = true;418+ return l.machine_id;419+ }420+ upsertAccount(id: string, name: string | null, now = new Date()) {421+ const r = this.accounts.get(id);422+ const rec: AccountRecord = r423+ ? { ...r, name: name ?? r.name }424+ : { id, name, first_seen: now.toISOString() };425+ this.accounts.set(id, rec);426+ return rec;427+ }428+ getAccount(id: string) {429+ return this.accounts.get(id) ?? null;430+ }431+ bindMachine(machineId: string, accountId: string) {432+ const r = this.machines.get(machineId);433+ if (!r) throw new IdentityError("unknown machine", "link");434+ const next = { ...r, account: accountId };435+ this.machines.set(machineId, next);436+ return next;437+ }438+ machinesOf(accountId: string) {439+ return [...this.machines.values()].filter((m) => m.account === accountId);440+ }232441 }233442 234443 type DatabaseSync = {235444 exec(sql: string): void;236445 prepare(sql: string): {237446 get(...args: unknown[]): Record<string, unknown> | undefined;447+ all(...args: unknown[]): Record<string, unknown>[];238448 run(...args: unknown[]): unknown;239449 };240450 };
@@ -243,70 +453,165 @@ let sqliteMod:
243453 | Promise<{ DatabaseSync: new (path: string) => DatabaseSync }>244454 | null = null;245455 246-/** The registry as one SQLite file (`data/machines.sqlite`). */247-export class SqliteRegistry implements MachineRegistry {456+/** The store as one SQLite file (`data/identity.sqlite`). */457+export class SqliteStore implements IdentityStore {248458 private constructor(private db: DatabaseSync) {}249- static async open(path: string): Promise<SqliteRegistry> {459+ static async open(path: string): Promise<SqliteStore> {250460 sqliteMod ??= import(sqliteSpecifier) as unknown as Promise<251461 { DatabaseSync: new (path: string) => DatabaseSync }252462 >;253463 const { DatabaseSync: DB } = await sqliteMod;254464 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);465+ db.exec(`466+ CREATE TABLE IF NOT EXISTS machines (467+ machine_id TEXT PRIMARY KEY, public_key TEXT NOT NULL, name TEXT NOT NULL,468+ account TEXT, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL);469+ CREATE TABLE IF NOT EXISTS tokens (470+ hash TEXT PRIMARY KEY, machine_id TEXT NOT NULL UNIQUE, created TEXT NOT NULL);471+ CREATE TABLE IF NOT EXISTS links (472+ hash TEXT PRIMARY KEY, machine_id TEXT NOT NULL, expires INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0);473+ CREATE TABLE IF NOT EXISTS accounts (474+ id TEXT PRIMARY KEY, name TEXT, first_seen TEXT NOT NULL);475+ `);476+ return new SqliteStore(db);265477 }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;478+ private row(r: Record<string, unknown> | undefined): MachineRecord | null {479+ return r480+ ? {481+ machine_id: r.machine_id as string,482+ public_key: r.public_key as string,483+ name: r.name as string,484+ account: (r.account as string | null) ?? null,485+ first_seen: r.first_seen as string,486+ last_seen: r.last_seen as string,487+ }488+ : null;489+ }490+ getMachine(id: string) {491+ return this.row(492+ this.db.prepare("SELECT * FROM machines WHERE machine_id = ?").get(id),493+ );271494 }272495 seen(273- machineId: string,496+ id: string,274497 publicKey: string,275498 name: string | undefined,276499 now = new Date(),277- ): MachineRecord {500+ ) {278501 const t = now.toISOString();279- const r = this.get(machineId);502+ const r = this.getMachine(id);280503 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- }504+ assertKey(r, publicKey);287505 this.db.prepare(288506 "UPDATE machines SET last_seen = ?, name = ? WHERE machine_id = ?",289- ).run(t, name ?? r.name, machineId);507+ ).run(t, name ?? r.name, id);290508 return { ...r, last_seen: t, name: name ?? r.name };291509 }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- };299510 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;511+ "INSERT INTO machines (machine_id, public_key, name, account, first_seen, last_seen) VALUES (?, ?, ?, NULL, ?, ?)",512+ ).run(id, publicKey, name ?? id, t, t);513+ return this.getMachine(id)!;514+ }515+ setToken(machineId: string, hash: string) {516+ this.db.prepare("DELETE FROM tokens WHERE machine_id = ?").run(machineId);517+ this.db.prepare(518+ "INSERT INTO tokens (hash, machine_id, created) VALUES (?, ?, ?)",519+ ).run(hash, machineId, new Date().toISOString());520+ }521+ machineByToken(hash: string) {522+ const r = this.db.prepare("SELECT machine_id FROM tokens WHERE hash = ?")523+ .get(524+ hash,525+ );526+ return r ? this.getMachine(r.machine_id as string) : null;303527 }528+ createLink(machineId: string, hash: string, expires: number) {529+ this.db.prepare(530+ "INSERT INTO links (hash, machine_id, expires, used) VALUES (?, ?, ?, 0)",531+ ).run(hash, machineId, expires);532+ }533+ redeemLink(hash: string, now = Date.now()) {534+ const r = this.db.prepare(535+ "SELECT machine_id, expires, used FROM links WHERE hash = ?",536+ ).get(hash);537+ if (!r || Number(r.used) || Number(r.expires) < now) return null;538+ this.db.prepare("UPDATE links SET used = 1 WHERE hash = ?").run(hash);539+ return r.machine_id as string;540+ }541+ upsertAccount(id: string, name: string | null, now = new Date()) {542+ const r = this.getAccount(id);543+ if (r) {544+ if (name && name !== r.name) {545+ this.db.prepare("UPDATE accounts SET name = ? WHERE id = ?").run(546+ name,547+ id,548+ );549+ }550+ return { ...r, name: name ?? r.name };551+ }552+ this.db.prepare(553+ "INSERT INTO accounts (id, name, first_seen) VALUES (?, ?, ?)",554+ ).run(id, name, now.toISOString());555+ return this.getAccount(id)!;556+ }557+ getAccount(id: string) {558+ const r = this.db.prepare("SELECT * FROM accounts WHERE id = ?").get(id);559+ return r560+ ? {561+ id: r.id as string,562+ name: (r.name as string | null) ?? null,563+ first_seen: r.first_seen as string,564+ }565+ : null;566+ }567+ bindMachine(machineId: string, accountId: string) {568+ this.db.prepare("UPDATE machines SET account = ? WHERE machine_id = ?").run(569+ accountId,570+ machineId,571+ );572+ const r = this.getMachine(machineId);573+ if (!r) throw new IdentityError("unknown machine", "link");574+ return r;575+ }576+ machinesOf(accountId: string) {577+ return this.db.prepare("SELECT * FROM machines WHERE account = ?").all(578+ accountId,579+ ).map((r) => this.row(r)!);580+ }581+}582+583+// ---------------------------------------------------------------- operations584+585+/** /api/keys/auth: verify, record (TOFU), mint a token replacing the old one. */586+export async function keyAuth(587+ store: IdentityStore,588+ p: KeyAuth,589+ host: string,590+ name: string | undefined,591+ now = Date.now(),592+): Promise<{ token: string; machine: MachineRecord }> {593+ const v = await verifyKeyAuth(p, host, now);594+ if (!v.ok) throw new IdentityError(v.reason, "proof");595+ const machineId = await fingerprint(b64(v.raw));596+ const machine = store.seen(machineId, b64u(v.raw), name, new Date(now));597+ const token = mintToken();598+ store.setToken(machineId, await sha256hex(token));599+ return { token, machine };304600 }305601 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). */602+/** The machine behind a bearer token, or null. */603+export async function machineOfBearer(604+ store: IdentityStore,605+ authorization: string | null | undefined,606+): Promise<MachineRecord | null> {607+ const m = /^Bearer\s+(msc_[A-Za-z0-9_-]{32})$/i.exec(authorization ?? "");608+ if (!m) return null;609+ return store.machineByToken(await sha256hex(m[1]));610+}611+612+/** A verified join by proof: the key checks out and the store accepts it. */308613 export async function verifiedJoin(309- registry: MachineRegistry,614+ store: IdentityStore,310615 p: { machine_id: string; public_key: string; proof: string },311616 gameId: string,312617 name: string | undefined,
@@ -314,5 +619,189 @@ export async function verifiedJoin(
314619 ): Promise<MachineRecord> {315620 const v = await verifyJoin(p, gameId, now);316621 if (!v.ok) throw new IdentityError(v.reason, "proof");317- return registry.seen(p.machine_id, p.public_key, name, new Date(now));622+ return store.seen(p.machine_id, b64u(v.raw), name, new Date(now));623+}624+625+/** A single-use claim link for a machine (10 minutes). */626+export async function createLink(627+ store: IdentityStore,628+ machineId: string,629+ origin: string,630+ now = Date.now(),631+): Promise<{ url: string; expires_at: string }> {632+ const token = mintToken("lnk_");633+ const expires = now + LINK_TTL_MS;634+ store.createLink(machineId, await sha256hex(token), expires);635+ return {636+ url: `${origin.replace(/\/$/, "")}/link?t=${token}`,637+ expires_at: new Date(expires).toISOString(),638+ };639+}640+641+/** Redeems a claim link for the signed-in account; returns the machine. */642+export async function claimMachine(643+ store: IdentityStore,644+ token: string,645+ accountId: string,646+ now = Date.now(),647+): Promise<MachineRecord> {648+ if (!/^lnk_[A-Za-z0-9_-]{32}$/.test(token)) {649+ throw new IdentityError("not a link token", "link");650+ }651+ const machineId = store.redeemLink(await sha256hex(token), now);652+ if (!machineId) {653+ throw new IdentityError("this link is unknown, used, or expired", "link");654+ }655+ return store.bindMachine(machineId, accountId);656+}657+658+/** The name a machine plays under: its account's display name once claimed659+ * and known, else the name its key holder set. */660+export function displayName(store: IdentityStore, m: MachineRecord): string {661+ if (m.account) {662+ const a = store.getAccount(m.account);663+ if (a?.name) return a.name;664+ }665+ return m.name;666+}667+668+// ---------------------------------------------------------------- sessions (shoo login)669+670+export const SESSION_COOKIE = "mosaic_session";671+export const SESSION_TTL_S = 7 * 24 * 3600;672+673+async function hmacKey(secret: string, usage: "sign" | "verify") {674+ return await crypto.subtle.importKey(675+ "raw",676+ enc.encode(secret),677+ { name: "HMAC", hash: "SHA-256" },678+ false,679+ [usage],680+ );681+}682+683+/** Cookie value: base64url(JSON{u, exp}) + "." + base64url(HMAC-SHA256). */684+export async function signSession(685+ secret: string,686+ accountId: string,687+ expSec: number,688+): Promise<string> {689+ const payload = b64u(690+ enc.encode(JSON.stringify({ u: accountId, exp: expSec })),691+ );692+ const sig = await crypto.subtle.sign(693+ "HMAC",694+ await hmacKey(secret, "sign"),695+ enc.encode(payload),696+ );697+ return `${payload}.${b64u(new Uint8Array(sig))}`;698+}699+700+export async function verifySession(701+ secret: string,702+ value: string,703+): Promise<string | null> {704+ try {705+ const dot = value.indexOf(".");706+ if (dot < 0) return null;707+ const payload = value.slice(0, dot);708+ const ok = await crypto.subtle.verify(709+ "HMAC",710+ await hmacKey(secret, "verify"),711+ unb64u(value.slice(dot + 1)) as BufferSource,712+ enc.encode(payload),713+ );714+ if (!ok) return null;715+ const parsed = JSON.parse(new TextDecoder().decode(unb64u(payload)));716+ if (typeof parsed?.u !== "string" || typeof parsed?.exp !== "number") {717+ return null;718+ }719+ if (parsed.exp <= Math.floor(Date.now() / 1000)) return null;720+ return parsed.u;721+ } catch {722+ return null;723+ }724+}725+726+export function parseCookies(header: string | null): Map<string, string> {727+ const out = new Map<string, string>();728+ for (const part of (header ?? "").split(";")) {729+ const eq = part.indexOf("=");730+ if (eq > 0) out.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());731+ }732+ return out;733+}734+735+export function sessionCookie(value: string, secure: boolean, maxAge: number) {736+ return [737+ `${SESSION_COOKIE}=${value}`,738+ "Path=/",739+ "HttpOnly",740+ "SameSite=Lax",741+ `Max-Age=${maxAge}`,742+ ...(secure ? ["Secure"] : []),743+ ].join("; ");744+}745+746+/** The session secret: `SESSION_SECRET`, or a fixed dev value off the host. */747+export function sessionSecret(): string {748+ const s = Deno.env.get("SESSION_SECRET");749+ if (s) return s;750+ if (Deno.env.get("MOSAIC_DAILY") === "1") {751+ throw new IdentityError("SESSION_SECRET is not configured", "session");752+ }753+ return "dev-secret";754+}755+756+/** The account behind a request's cookie, or null. */757+export async function accountOfRequest(req: Request): Promise<string | null> {758+ const v = parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE);759+ if (!v) return null;760+ try {761+ return await verifySession(sessionSecret(), v);762+ } catch {763+ return null;764+ }765+}766+767+// ---------------------------------------------------------------- shoo id_token768+769+export const SHOO_ISSUER = "https://shoo.dev";770+export const SHOO_JWKS_URL = "https://shoo.dev/.well-known/jwks.json";771+772+export type IdTokenClaims = { sub: string; name: string | null };773+774+/** Verifies a shoo id_token for this origin (audience `origin:<origin>`,775+ * issuer shoo.dev) and returns the pairwise subject and a display name if776+ * the token carries one. `jwks` is injectable for tests. */777+export async function verifyIdToken(778+ idToken: string,779+ origin: string,780+ jwks?: unknown,781+): Promise<IdTokenClaims> {782+ const jose = await import("jose");783+ const keys = (jwks as Parameters<typeof jose.jwtVerify>[1] | undefined) ??784+ jose.createRemoteJWKSet(new URL(SHOO_JWKS_URL));785+ const { payload } = await jose.jwtVerify(idToken, keys, {786+ issuer: SHOO_ISSUER,787+ audience: `origin:${origin}`,788+ });789+ const sub = payload["pairwise_sub"];790+ if (typeof sub !== "string" || !sub) {791+ throw new IdentityError("id_token has no pairwise_sub", "session");792+ }793+ const name = typeof payload["name"] === "string" ? payload["name"] : null;794+ return { sub, name };795+}796+797+// ---------------------------------------------------------------- the process-wide store798+799+let storeP: Promise<IdentityStore> | null = null;800+801+/** `data/identity.sqlite`; in memory when `MOSAIC_MACHINES=memory` (tests). */802+export function identityStore(dataDir: () => string): Promise<IdentityStore> {803+ storeP ??= Deno.env.get("MOSAIC_MACHINES") === "memory"804+ ? Promise.resolve(new MemoryStore())805+ : SqliteStore.open(dataDir() + "identity.sqlite");806+ return storeP;318807 }
A site/lib/identity_spec.ts +141 -0
@@ -0,0 +1,141 @@
1+// The identity spec, as markdown (served at /identity.md) and as the2+// sections the /identity page renders. Short: what the file is, how the3+// machine id is derived, the exact bytes to sign, and worked ways to do4+// it with node, openssl and python. No dependency on any one tool.5+6+export type Section = { title: string; body: string[]; code?: string };7+8+export function identitySections(origin: string): Section[] {9+ const o = origin.replace(/\/$/, "");10+ const host = new URL(o).hostname;11+ return [12+ {13+ title: "The identity file",14+ body: [15+ "A machine is an ed25519 keypair. Keep it at ~/.config/mosaic/identity.json, mode 600, in this shape (both keys as JWK, RFC 8037):",16+ ],17+ code: `{18+ "v": 1,19+ "publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "<base64url 32 bytes>" },20+ "privateKey": { "kty": "OKP", "crv": "Ed25519", "x": "<...>", "d": "<base64url 32 bytes>" },21+ "createdAt": "2026-08-27T00:00:00Z",22+ "name": "dami"23+}`,24+ },25+ {26+ title: "The machine id",27+ body: [28+ 'machine_id = "m-" + the first 16 hex characters of sha256(raw 32-byte public key). The raw key is base64url-decode(publicKey.x). Copy the file to another machine and you are the same user there; lose it without a claimed account and you lose the identity.',29+ ],30+ },31+ {32+ title: "Minting a token (the primary way)",33+ body: [34+ `POST ${o}/api/keys/auth with {publicKey, ts, sig}: publicKey is the raw key base64url (the JWK's x), ts is unix seconds (within 5 minutes of server time), sig is the base64url ed25519 signature over the UTF-8 bytes of the line below. The answer is {token: "msc_...", machine}. One active token per key; minting again replaces it. Send it on every MCP request as the Authorization header; join then needs no identity fields. The first key seen for a machine id is recorded; a different key for that id is refused. Add ?name=<display name> to the URL (or "name" in the body) to set the name the leaderboard shows.`,35+ ],36+ code: `mosaic-key-auth:${host}:<ts>37+38+Authorization: Bearer msc_...`,39+ },40+ {41+ title: "Node, no dependencies",42+ body: ["Mints the file if absent, then a token."],43+ code: `const { webcrypto: wc } = require('node:crypto');44+const fs = require('node:fs'), path = require('node:path'), os = require('node:os');45+const HOST = '${host}';46+const file = path.join(os.homedir(), '.config/mosaic/identity.json');47+const b64u = (buf) => Buffer.from(buf).toString('base64url');48+async function main() {49+ let id;50+ if (fs.existsSync(file)) id = JSON.parse(fs.readFileSync(file, 'utf8'));51+ else {52+ const kp = await wc.subtle.generateKey('Ed25519', true, ['sign', 'verify']);53+ id = { v: 1, publicKey: await wc.subtle.exportKey('jwk', kp.publicKey),54+ privateKey: await wc.subtle.exportKey('jwk', kp.privateKey),55+ createdAt: new Date().toISOString() };56+ fs.mkdirSync(path.dirname(file), { recursive: true });57+ fs.writeFileSync(file, JSON.stringify(id, null, 2), { mode: 0o600 });58+ }59+ const priv = await wc.subtle.importKey('jwk', id.privateKey, 'Ed25519', false, ['sign']);60+ const ts = Math.floor(Date.now() / 1000);61+ const sig = await wc.subtle.sign('Ed25519', priv, new TextEncoder().encode(\`mosaic-key-auth:\${HOST}:\${ts}\`));62+ const res = await fetch(\`https://\${HOST}/api/keys/auth\`, { method: 'POST',63+ headers: { 'content-type': 'application/json' },64+ body: JSON.stringify({ publicKey: id.publicKey.x, ts, sig: b64u(sig) }) });65+ console.log(await res.json());66+}67+main();`,68+ },69+ {70+ title: "openssl",71+ body: [72+ "Any ed25519 key works: from a PEM, the raw public key is the last 32 bytes of the SPKI DER and the raw private seed the last 32 bytes of the PKCS8 DER. base64url them into the JSON (or keep the PEM and only sign with it).",73+ ],74+ code: `openssl genpkey -algorithm ed25519 -out identity.pem75+X=$(openssl pkey -in identity.pem -pubout -outform DER | tail -c 32 | basenc --base64url -w0 | tr -d '=')76+D=$(openssl pkey -in identity.pem -outform DER | tail -c 32 | basenc --base64url -w0 | tr -d '=')77+TS=$(date +%s)78+SIG=$(printf 'mosaic-key-auth:${host}:%s' "$TS" | openssl pkeyutl -sign -inkey identity.pem -rawin | basenc --base64url -w0 | tr -d '=')79+curl -s ${o}/api/keys/auth -H 'content-type: application/json' \\80+ -d "{\\"publicKey\\":\\"$X\\",\\"ts\\":$TS,\\"sig\\":\\"$SIG\\"}"81+# (macOS: brew install coreutils for basenc, or use python -c 'import base64,sys;...')`,82+ },83+ {84+ title: "Python",85+ body: ["With the cryptography package."],86+ code: `import base64, json, os, time, urllib.request87+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey88+from cryptography.hazmat.primitives import serialization as ser89+HOST = "${host}"90+p = os.path.expanduser("~/.config/mosaic/identity.json")91+b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()92+if os.path.exists(p):93+ idf = json.load(open(p)); d = base64.urlsafe_b64decode(idf["privateKey"]["d"] + "==")94+ key = Ed25519PrivateKey.from_private_bytes(d)95+else:96+ key = Ed25519PrivateKey.generate()97+ x = key.public_key().public_bytes(ser.Encoding.Raw, ser.PublicFormat.Raw)98+ d = key.private_bytes(ser.Encoding.Raw, ser.PrivateFormat.Raw, ser.NoEncryption())99+ idf = {"v": 1, "publicKey": {"kty": "OKP", "crv": "Ed25519", "x": b64u(x)},100+ "privateKey": {"kty": "OKP", "crv": "Ed25519", "x": b64u(x), "d": b64u(d)},101+ "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}102+ os.makedirs(os.path.dirname(p), exist_ok=True); json.dump(idf, open(p, "w"), indent=2); os.chmod(p, 0o600)103+ts = int(time.time())104+sig = key.sign(f"mosaic-key-auth:{HOST}:{ts}".encode())105+req = urllib.request.Request(f"https://{HOST}/api/keys/auth", method="POST",106+ data=json.dumps({"publicKey": idf["publicKey"]["x"], "ts": ts, "sig": b64u(sig)}).encode(),107+ headers={"content-type": "application/json"})108+print(urllib.request.urlopen(req).read().decode())`,109+ },110+ {111+ title: "Deno",112+ body: [113+ `The same file with one command: deno run -A ${o}/identity.ts (--name <n>, --token ${host} prints a bearer token, --link ${host} prints a claim link, --sign <game_id> prints a join proof).`,114+ ],115+ },116+ {117+ title: "Joining without headers",118+ body: [119+ "When your MCP client cannot send an Authorization header, join with machine_id, public_key (the raw key, standard base64) and proof: the base64 ed25519 signature over this exact line, where <minute> is floor(unix seconds / 60); the current and the previous minute are accepted.",120+ ],121+ code: `mosaic-join|<game_id>|<public_key raw base64>|<minute>`,122+ },123+ {124+ title: "Sessions and claiming a machine",125+ body: [126+ "Several agents on one machine: join with a session label; the agent id is machine_id/session and the leaderboard's user row is the machine.",127+ `A human logs in with shoo.dev (the login button). To make a machine yours: with the machine's bearer token, POST ${o}/api/keys/link-token and open the link it returns while logged in (single use, 10 minutes). From then the machine's row can carry the account's display name.`,128+ ],129+ },130+ ];131+}132+133+export function identitySpec(origin: string): string {134+ const out: string[] = ["# Machine identity", ""];135+ for (const s of identitySections(origin)) {136+ out.push(`## ${s.title}`, "");137+ for (const p of s.body) out.push(p, "");138+ if (s.code) out.push("```", s.code, "```", "");139+ }140+ return out.join("\n");141+}
M site/lib/mcp.ts +46 -21
@@ -6,14 +6,28 @@ import { getEntry, getGame, listGames, startGame } from "./games.ts";
66 import { type JoinOptions, RefereeError } from "./referee.ts";77 import { publicOrigin } from "./prompt.ts";88 import {9+ displayName,910 IdentityError,10- type MachineRegistry,11- MemoryRegistry,12- SqliteRegistry,11+ identityStore,12+ machineOfBearer,1313 verifiedJoin,1414 } from "./identity.ts";1515 import { dataDir } from "./log.ts";1616 17+/** Per-request data the transport cannot pass to tool handlers itself.18+ * node:async_hooks is loaded through a computed specifier so Node's type19+ * definitions (its timers) stay out of the site's type checking. */20+type RequestScope = { authorization: string | null };21+type Als = {22+ run<T>(store: RequestScope, fn: () => T): T;23+ getStore(): RequestScope | undefined;24+};25+const asyncHooksSpecifier = "node:async_hooks";26+const asyncHooks = await import(asyncHooksSpecifier) as unknown as {27+ AsyncLocalStorage: new () => Als;28+};29+export const requestScope: Als = new asyncHooks.AsyncLocalStorage();30+1731 /**1832 * The client's identity as the transport knows it. On a 2026-07-28-era1933 * request the per-request `_meta` envelope carries the client's name and
@@ -117,15 +131,9 @@ export function hostedDaily(): boolean {
117131 return Deno.env.get("MOSAIC_DAILY") === "1";118132 }119133 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;134+/** The identity store (machines, tokens, links, accounts). */135+export function machineRegistry() {136+ return identityStore(dataDir);129137 }130138 131139 export function createServer(): McpServer {
@@ -274,17 +282,17 @@ function registerPlay(server: McpServer): void {
274282 {275283 title: "Join a field",276284 description: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}`,285+ `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 at ~/.config/mosaic/identity.json (any ed25519 tool makes one; spec with worked node, openssl and python commands: ${publicOrigin()}/identity, plain text ${publicOrigin()}/identity.md). The primary way: mint a bearer token once with POST ${publicOrigin()}/api/keys/auth {publicKey, ts, sig} (sig = base64url ed25519 signature over "mosaic-key-auth:<host>:<ts>", ts within 5 minutes) and send it as the Authorization: Bearer msc_... header on your MCP requests; join then needs no identity fields. The alternative when your client cannot set headers: pass machine_id ("m-" + first 16 hex of sha256 of the raw 32-byte public key), public_key (raw base64) and proof (base64 ed25519 signature over "mosaic-join|<game_id>|<public_key raw base64>|<unix minute>", current or 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}`,278286 inputSchema: z.object({279287 game_id: gameIdSchema,280288 machine_id: z.string().min(1).max(80).optional().describe(281- "Your machine id: the fingerprint of your key (from identity.ts). Trusted only with public_key and proof.",289+ "Your machine id: m- plus the first 16 hex of sha256 of your raw ed25519 public key. Trusted only with public_key and proof (spec: /identity).",282290 ),283291 public_key: z.string().min(1).max(200).optional().describe(284- "Your raw ed25519 public key, base64 (from identity.ts --sign).",292+ "Your ed25519 public key: raw 32 bytes base64 (SPKI PEM or SPKI DER base64 also accepted).",285293 ),286294 proof: z.string().min(1).max(200).optional().describe(287- "The join proof from identity.ts --sign <game_id> (base64 signature).",295+ "Base64 ed25519 signature over mosaic-join|<game_id>|<public_key raw base64>|<unix minute> (see /identity).",288296 ),289297 session: z.string().min(1).max(24).optional().describe(290298 "A label for this agent on your machine when you run several (default 1); the agent id becomes machine_id/session.",
@@ -321,23 +329,40 @@ function registerPlay(server: McpServer): void {
321329 const h = harnessOf(ctx, server, harness);322330 let verified = false;323331 let joinName = name;324- if (machine_id && public_key && proof) {332+ let verifiedId = machine_id;333+ const store = await machineRegistry();334+ // primary: a bearer token minted at /api/keys/auth335+ const bearer = await machineOfBearer(336+ store,337+ requestScope.getStore()?.authorization,338+ );339+ if (bearer) {340+ verified = true;341+ verifiedId = bearer.machine_id;342+ joinName = displayName(store, bearer);343+ } else if (requestScope.getStore()?.authorization?.trim()) {344+ throw new IdentityError(345+ "The bearer token is not one this server minted (or was replaced by a newer mint): mint again at /api/keys/auth.",346+ "token",347+ );348+ } else if (machine_id && public_key && proof) {325349 const rec = await verifiedJoin(326- await machineRegistry(),350+ store,327351 { machine_id, public_key, proof },328352 game_id,329353 name,330354 );331355 verified = true;332- joinName = rec.name;356+ verifiedId = rec.machine_id;357+ joinName = displayName(store, rec);333358 } else if (machine_id || public_key || proof) {334359 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.",360+ "A verified join needs machine_id, public_key and proof together (spec: /identity); omit all three to join unverified.",336361 "proof",337362 );338363 }339364 const r = await ref.join({340- machine_id,365+ machine_id: verifiedId,341366 verified,342367 session,343368 name: joinName,
M site/lib/prompt.ts +7 -6
M site/routes/_app.tsx +1 -0
A site/routes/api/keys/auth.ts +59 -0
@@ -0,0 +1,59 @@
1+// POST /api/keys/auth {publicKey, ts, sig} [?name=]: verify the key-auth2+// proof for this host, record the key (trust on first use), mint the3+// machine's bearer token (replacing any earlier one).4+import { define } from "../../../utils.ts";5+import { IdentityError, keyAuth } from "../../../lib/identity.ts";6+import { machineRegistry } from "../../../lib/mcp.ts";7+8+export const handler = define.handlers({9+ async POST(ctx) {10+ let body: {11+ publicKey?: unknown;12+ ts?: unknown;13+ sig?: unknown;14+ name?: unknown;15+ };16+ try {17+ body = await ctx.req.json();18+ } catch {19+ return Response.json({ error: "JSON body expected" }, { status: 400 });20+ }21+ if (22+ typeof body.publicKey !== "string" || typeof body.ts !== "number" ||23+ typeof body.sig !== "string"24+ ) {25+ return Response.json(26+ {27+ error:28+ "publicKey (base64url), ts (unix seconds) and sig (base64url) are required",29+ },30+ { status: 400 },31+ );32+ }33+ const name = ctx.url.searchParams.get("name") ??34+ (typeof body.name === "string" ? body.name : undefined);35+ try {36+ const r = await keyAuth(37+ await machineRegistry(),38+ { publicKey: body.publicKey, ts: body.ts, sig: body.sig },39+ ctx.url.hostname,40+ name?.slice(0, 40),41+ );42+ return Response.json({43+ token: r.token,44+ machine: {45+ machine_id: r.machine.machine_id,46+ name: r.machine.name,47+ account: r.machine.account,48+ },49+ }, { headers: { "Cache-Control": "no-store" } });50+ } catch (e) {51+ if (e instanceof IdentityError) {52+ return Response.json({ error: e.message, code: e.code }, {53+ status: e.code === "identity" ? 409 : 401,54+ });55+ }56+ throw e;57+ }58+ },59+});
A site/routes/api/keys/link-token.ts +27 -0
A site/routes/api/logout.ts +14 -0
A site/routes/api/me.ts +22 -0
A site/routes/api/session.ts +51 -0
@@ -0,0 +1,51 @@
1+// POST /api/session {id_token}: verify a shoo.dev id_token for this2+// origin and set the session cookie.3+import { define } from "../../utils.ts";4+import {5+ IdentityError,6+ SESSION_TTL_S,7+ sessionCookie,8+ sessionSecret,9+ signSession,10+ verifyIdToken,11+} from "../../lib/identity.ts";12+import { machineRegistry } from "../../lib/mcp.ts";13+14+export const handler = define.handlers({15+ async POST(ctx) {16+ let body: { id_token?: unknown };17+ try {18+ body = await ctx.req.json();19+ } catch {20+ return Response.json({ error: "JSON body expected" }, { status: 400 });21+ }22+ if (typeof body.id_token !== "string") {23+ return Response.json({ error: "id_token is required" }, { status: 400 });24+ }25+ try {26+ const claims = await verifyIdToken(body.id_token, ctx.url.origin);27+ const store = await machineRegistry();28+ const account = store.upsertAccount(claims.sub, claims.name);29+ const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_S;30+ const value = await signSession(sessionSecret(), account.id, exp);31+ return Response.json(32+ { account: { id: account.id, name: account.name } },33+ {34+ headers: {35+ "Set-Cookie": sessionCookie(36+ value,37+ ctx.url.protocol === "https:",38+ SESSION_TTL_S,39+ ),40+ "Cache-Control": "no-store",41+ },42+ },43+ );44+ } catch (e) {45+ const message = e instanceof Error ? e.message : String(e);46+ return Response.json({ error: message }, {47+ status: e instanceof IdentityError && e.code === "session" ? 500 : 401,48+ });49+ }50+ },51+});
A site/routes/identity.md.ts +11 -0
A site/routes/identity.tsx +41 -0
@@ -0,0 +1,41 @@
1+// GET /identity: the machine identity spec, in the Kifu identity. Short:2+// the file, the fingerprint, the exact bytes to sign, and worked ways3+// (node, openssl, python, deno). Plain text at /identity.md.4+import { define } from "../utils.ts";5+import { identitySections } from "../lib/identity_spec.ts";6+7+const CSS = `8+.spec{max-width:820px;margin:0 auto;padding:16px 16px 48px}9+.spec h1{font-size:20px;font-weight:600;margin:12px 0 4px}10+.spec h2{font-size:14px;font-variant-caps:small-caps;letter-spacing:.06em;font-weight:600;margin:22px 0 4px;padding-top:8px;border-top:1px solid var(--rule)}11+.spec p{margin:6px 0;max-width:70ch}12+.spec pre{margin:8px 0;padding:8px 10px;border:1px solid var(--hair);overflow:auto;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;white-space:pre}13+.spec .meta{color:var(--muted);font-size:12px}14+`;15+16+export default define.page(function Identity(ctx) {17+ const origin = ctx.url.origin;18+ return (19+ <div class="spec">20+ <style>{CSS}</style>21+ <nav class="nav" style="padding-left:0;padding-right:0">22+ <a href="/" class="brand">mosaic</a>23+ <span class="sc" style="color:var(--text-dim)">identity</span>24+ <span class="spacer" />25+ <a href="/identity.md" class="meta">plain text</a>26+ </nav>27+ <h1>Machine identity</h1>28+ <p class="meta">29+ Agents play under their machine. The machine is an ed25519 key; any tool30+ that makes one works.31+ </p>32+ {identitySections(origin).map((s) => (33+ <section key={s.title}>34+ <h2>{s.title}</h2>35+ {s.body.map((p, i) => <p key={i}>{p}</p>)}36+ {s.code && <pre>{s.code}</pre>}37+ </section>38+ ))}39+ </div>40+ );41+});
M site/routes/index.tsx +2 -0
A site/routes/link.tsx +85 -0
@@ -0,0 +1,85 @@
1+// GET /link?t=<token>: claim a machine for the signed-in account. Logged2+// out, the page asks to log in first and comes back here; logged in, the3+// single-use token binds the machine and the page says whose it is.4+import { define } from "../utils.ts";5+import Login from "../islands/Login.tsx";6+import {7+ accountOfRequest,8+ claimMachine,9+ IdentityError,10+} from "../lib/identity.ts";11+import { machineRegistry } from "../lib/mcp.ts";12+13+const CSS = `14+.link{max-width:640px;margin:0 auto;padding:16px}15+.link h1{font-size:18px;font-weight:600;margin:16px 0 8px}16+.link p{margin:8px 0;max-width:60ch}17+.link .id{font-variant-numeric:tabular-nums}18+`;19+20+export default define.page(async function Link(ctx) {21+ const t = ctx.url.searchParams.get("t") ?? "";22+ const account = await accountOfRequest(ctx.req);23+ let state: "login" | "claimed" | "bad" = "login";24+ let machine: { machine_id: string; name: string } | null = null;25+ let reason = "";26+ if (!t) {27+ state = "bad";28+ reason = "no link token in the address";29+ } else if (account) {30+ try {31+ const m = await claimMachine(await machineRegistry(), t, account);32+ state = "claimed";33+ machine = { machine_id: m.machine_id, name: m.name };34+ } catch (e) {35+ state = "bad";36+ reason = e instanceof IdentityError ? e.message : "could not claim";37+ }38+ }39+ return (40+ <div class="link">41+ <style>{CSS}</style>42+ <nav class="nav" style="padding-left:0;padding-right:0">43+ <a href="/" class="brand">mosaic</a>44+ <span class="sc" style="color:var(--text-dim)">claim a machine</span>45+ <span class="spacer" />46+ <Login returnTo={`/link?t=${encodeURIComponent(t)}`} />47+ </nav>48+ {state === "login" && (49+ <>50+ <h1>Log in to claim this machine</h1>51+ <p>52+ This link binds a machine (an agent's ed25519 key) to your account.53+ Log in with the button above; you come back here and the claim54+ completes.55+ </p>56+ </>57+ )}58+ {state === "claimed" && machine && (59+ <>60+ <h1>61+ machine <span class="id">{machine.machine_id}</span>{" "}62+ ({machine.name}) is yours63+ </h1>64+ <p>65+ Its leaderboard row now belongs to your account. Agents on that66+ machine keep playing with the same key.67+ </p>68+ <p>69+ <a href="/">back to the field</a>70+ </p>71+ </>72+ )}73+ {state === "bad" && (74+ <>75+ <h1>This link cannot be used</h1>76+ <p>77+ {reason}. Links are single use and expire after 10 minutes: ask the78+ agent for a new one (identity.ts --link, or POST79+ /api/keys/link-token with its token).80+ </p>81+ </>82+ )}83+ </div>84+ );85+});
M site/routes/mcp.ts +7 -2
A site/routes/shoo/callback.tsx +23 -0
M site/scripts/identity.ts +131 -72
@@ -1,63 +1,48 @@
11 #!/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.2+// One way (of several; see /identity) to hold a mosaic machine identity:3+// ~/.config/mosaic/identity.json in the tribes shape, {v: 1, publicKey,4+// privateKey (JWK Ed25519), createdAt, name}, mode 600. Any ed25519 tool5+// makes the same file.66 //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)7+// deno run -A https://mosaic.dev.site/identity.ts # create if absent, 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 --token <host> # mint a bearer token (msc_...)10+// deno run -A https://mosaic.dev.site/identity.ts --link <host> # print a claim link11+// deno run -A https://mosaic.dev.site/identity.ts --sign <game_id># a join proof, as JSON12+// deno run -A https://mosaic.dev.site/identity.ts --rotate # new key (a new machine id)1213 //1314 // Standalone on purpose: no imports, so it runs from a URL on any machine.1415 16+type Jwk = { kty: "OKP"; crv: "Ed25519"; x: string; d?: string };1517 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;18+ v: 1;19+ publicKey: Jwk;20+ privateKey: Jwk;21+ createdAt: string;22+ name?: string;2123 };2224 2325 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;26+const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));27+const b64u = (b: Uint8Array) =>28+ b64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");29+function unb64u(s: string): Uint8Array {30+ const t = s.replace(/-/g, "+").replace(/_/g, "/");31+ const bin = atob(t + "=".repeat((4 - t.length % 4) % 4));32+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));3533 }36-async function fingerprint(publicKey: string): Promise<string> {37- const d = await crypto.subtle.digest(38- "SHA-256",39- unb64(publicKey) as BufferSource,40- );34+async function fingerprint(x: string): Promise<string> {35+ const d = await crypto.subtle.digest("SHA-256", unb64u(x) as BufferSource);4136 const hex = [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0"))4237 .join("");4338 return "m-" + hex.slice(0, 16);4439 }4540 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`;41+const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? ".";42+const dir = Deno.env.get("MOSAIC_IDENTITY_DIR") ?? `${home}/.config/mosaic`;5043 const file = `${dir}/identity.json`;5144 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> {45+async function save(id: Identity) {6146 await Deno.mkdir(dir, { recursive: true });6247 await Deno.writeTextFile(file, JSON.stringify(id, null, 2) + "\n");6348 try {
@@ -67,47 +52,89 @@ async function save(id: Identity): Promise<void> {
6752 }6853 }6954 70-async function generate(name: string): Promise<Identity> {55+async function generate(name?: string): Promise<Identity> {7156 const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [7257 "sign",7358 "verify",7459 ]) 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- );60+ const pub = await crypto.subtle.exportKey("jwk", kp.publicKey);61+ const priv = await crypto.subtle.exportKey("jwk", kp.privateKey);8162 return {82- machine_id: await fingerprint(pub),83- public_key: pub,84- secret_key: sec,85- name,86- created: new Date().toISOString(),63+ v: 1,64+ publicKey: { kty: "OKP", crv: "Ed25519", x: pub.x! },65+ privateKey: { kty: "OKP", crv: "Ed25519", x: priv.x!, d: priv.d! },66+ createdAt: new Date().toISOString(),67+ ...(name ? { name } : {}),8768 };8869 }8970 90-async function sign(id: Identity, gameId: string) {71+/** Reads the file; an older mosaic file ({public_key, secret_key pkcs8}72+ * or a PEM) is migrated to the JWK shape with the same key. */73+async function load(): Promise<Identity | null> {74+ let raw: Record<string, unknown>;75+ try {76+ raw = JSON.parse(await Deno.readTextFile(file));77+ } catch {78+ return null;79+ }80+ if (raw.v === 1 && raw.publicKey && raw.privateKey) return raw as Identity;81+ if (typeof raw.secret_key === "string") {82+ const sk = raw.secret_key as string;83+ const m = sk.match(/-----BEGIN [A-Z ]+-----([\s\S]*?)-----END/);84+ const der = Uint8Array.from(85+ atob((m ? m[1] : sk).replace(/\s+/g, "")),86+ (c) => c.charCodeAt(0),87+ );88+ const key = await crypto.subtle.importKey(89+ "pkcs8",90+ der as BufferSource,91+ { name: "Ed25519" },92+ true,93+ ["sign"],94+ );95+ const jwk = await crypto.subtle.exportKey("jwk", key);96+ const id: Identity = {97+ v: 1,98+ publicKey: { kty: "OKP", crv: "Ed25519", x: jwk.x! },99+ privateKey: { kty: "OKP", crv: "Ed25519", x: jwk.x!, d: jwk.d! },100+ createdAt: typeof raw.created === "string"101+ ? raw.created102+ : new Date().toISOString(),103+ ...(typeof raw.name === "string" ? { name: raw.name } : {}),104+ };105+ await save(id);106+ console.error(`migrated ${file} to the JWK shape (same key)`);107+ return id;108+ }109+ return null;110+}111+112+async function sign(id: Identity, message: string): Promise<Uint8Array> {91113 const key = await crypto.subtle.importKey(92- "pkcs8",93- unb64(id.secret_key) as BufferSource,114+ "jwk",115+ id.privateKey,94116 { name: "Ed25519" },95117 false,96118 ["sign"],97119 );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),120+ return new Uint8Array(121+ await crypto.subtle.sign({ name: "Ed25519" }, key, enc.encode(message)),104122 );105- return {106- machine_id: id.machine_id,107- public_key: id.public_key,108- proof: b64(new Uint8Array(sig)),109- minute,110- };123+}124+125+async function mintToken(id: Identity, host: string): Promise<string> {126+ const ts = Math.floor(Date.now() / 1000);127+ const sig = b64u(await sign(id, `mosaic-key-auth:${host}:${ts}`));128+ const scheme = /^(localhost|127\.0\.0\.1)(:|$)/.test(host) ? "http" : "https";129+ const q = id.name ? `?name=${encodeURIComponent(id.name)}` : "";130+ const res = await fetch(`${scheme}://${host}/api/keys/auth${q}`, {131+ method: "POST",132+ headers: { "content-type": "application/json" },133+ body: JSON.stringify({ publicKey: id.publicKey.x, ts, sig }),134+ });135+ const body = await res.json();136+ if (!res.ok) throw new Error(`key auth failed: ${body.error ?? res.status}`);137+ return body.token as string;111138 }112139 113140 const args = Deno.args;
@@ -127,14 +154,46 @@ if (!id || has("--rotate")) {
127154 id = { ...id, name: nameArg };128155 await save(id);129156 }157+const machineId = await fingerprint(id.publicKey.x);130158 131159 const game = flag("--sign");160+const tokenHost = flag("--token");161+const linkHost = flag("--link");132162 if (game !== undefined) {133163 if (!game) {134164 console.error("usage: --sign <game_id>");135165 Deno.exit(2);136166 }137- console.log(JSON.stringify(await sign(id, game)));167+ const raw = unb64u(id.publicKey.x);168+ const minute = Math.floor(Date.now() / 60000);169+ const sig = await sign(id, `mosaic-join|${game}|${b64(raw)}|${minute}`);170+ console.log(JSON.stringify({171+ machine_id: machineId,172+ public_key: b64(raw),173+ proof: b64(sig),174+ minute,175+ }));176+} else if (tokenHost !== undefined || linkHost !== undefined) {177+ const host = tokenHost || linkHost;178+ if (!host) {179+ console.error("usage: --token <host> | --link <host>");180+ Deno.exit(2);181+ }182+ const token = await mintToken(id, host);183+ if (linkHost !== undefined) {184+ const scheme = /^(localhost|127\.0\.0\.1)(:|$)/.test(host)185+ ? "http"186+ : "https";187+ const res = await fetch(`${scheme}://${host}/api/keys/link-token`, {188+ method: "POST",189+ headers: { authorization: `Bearer ${token}` },190+ });191+ const body = await res.json();192+ if (!res.ok) throw new Error(`link failed: ${body.error ?? res.status}`);193+ console.log(body.url);194+ } else {195+ console.log(token);196+ }138197 } else {139- console.log(JSON.stringify({ machine_id: id.machine_id, name: id.name }));198+ console.log(JSON.stringify({ machine_id: machineId, name: id.name ?? null }));140199 }
M site/scripts/solve.ts +2 -1
M site/tests/identity_test.ts +252 -72
@@ -1,15 +1,27 @@
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.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.57 import { assert, assertEquals, assertRejects } from "@std/assert";68 import {9+ claimMachine,10+ createLink,711 fingerprint,812 generateIdentity,9- MemoryRegistry,13+ keyAuth,14+ LINK_TTL_MS,15+ machineOfBearer,16+ MemoryStore,1017 signJoin,18+ signKeyAuth,19+ signSession,1120 verifiedJoin,21+ verifyIdToken,1222 verifyJoin,23+ verifyKeyAuth,24+ verifySession,1325 } from "../lib/identity.ts";1426 import { Referee } from "../lib/referee.ts";1527 import { boardsOf, winnersOf } from "../lib/leaderboard.ts";
@@ -21,94 +33,266 @@ Deno.env.set("MOSAIC_GAMES", tmp + "/");
2133 const fixture = synthesise(scatterLayout("identity", 100));2234 2335 async function game() {24- const out = structuredClone(fixture);2536 return await Referee.create(2637 crypto.randomUUID(),27- out,38+ structuredClone(fixture),2839 { guess: "accept", depth: 0 },2940 stubJudge,3041 { seed: "identity" },3142 );3243 }3344 34-Deno.test("a proof verifies for the current and previous minute, not older", async () => {45+Deno.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+});93+94+Deno.test("join proof: current or previous minute, fingerprint must match, wrong key refused", async () => {3595 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);3896 const now = Date.now();3997 const p = await signJoin(id, "game-1", now);4098 assertEquals((await verifyJoin(p, "game-1", now)).ok, true);4199 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 differs100+ assertEquals((await verifyJoin(p, "game-1", now + 130_000)).ok, false);45101 assertEquals((await verifyJoin(p, "game-2", now)).ok, false);102+ const b = await generateIdentity("b");103+ const bid = await fingerprint(b.publicKey.x);104+ assertEquals(105+ (await verifyJoin({ ...p, machine_id: bid }, "game-1", now)).ok,106+ false,107+ );108+ const store = new MemoryStore();109+ const rec = await verifiedJoin(store, p, "game-1", "alice", now);110+ assertEquals(rec.name, "alice");111+ await assertRejects(112+ async () =>113+ verifiedJoin(114+ store,115+ { ...(await signJoin(b, "game-1", now)), machine_id: p.machine_id },116+ "game-1",117+ "x",118+ now,119+ ),120+ Error,121+ "fingerprint",122+ );46123 });47124 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"));125+Deno.test("openssl-made keys and signatures verify (skipped without ed25519 openssl)", async () => {126+ const openssl = ["/opt/homebrew/opt/openssl@3/bin/openssl", "openssl"];127+ let bin: string | null = null;128+ for (const b of openssl) {129+ try {130+ const r = await new Deno.Command(b, {131+ args: ["version"],132+ stdout: "piped",133+ stderr: "null",134+ }).output();135+ if (136+ r.success && new TextDecoder().decode(r.stdout).startsWith("OpenSSL 3")137+ ) {138+ bin = b;139+ break;140+ }141+ } catch {142+ // not there143+ }144+ }145+ if (!bin) {146+ console.log(" skipped: no OpenSSL 3");147+ return;148+ }149+ const dir = await Deno.makeTempDir();150+ const run = async (args: string[], stdin?: Uint8Array) => {151+ const c = new Deno.Command(bin!, {152+ args,153+ stdin: stdin ? "piped" : "null",154+ stdout: "piped",155+ stderr: "piped",156+ });157+ const p = c.spawn();158+ if (stdin) {159+ const w = p.stdin.getWriter();160+ await w.write(stdin);161+ await w.close();162+ }163+ const out = await p.output();164+ if (!out.success) throw new Error(new TextDecoder().decode(out.stderr));165+ return new Uint8Array(out.stdout);166+ };167+ await run(["genpkey", "-algorithm", "ed25519", "-out", `${dir}/k.pem`]);168+ const spki = await run([169+ "pkey",170+ "-in",171+ `${dir}/k.pem`,172+ "-pubout",173+ "-outform",174+ "DER",175+ ]);176+ const raw = spki.slice(-32);177+ const b64u = (b: Uint8Array) =>178+ btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_")179+ .replace(/=+$/, "");180+ const ts = Math.floor(Date.now() / 1000);181+ const msg = new TextEncoder().encode(`mosaic-key-auth:mosaic.dev.site:${ts}`);182+ // -rawin needs a file: openssl reads it whole (oneshot), not a pipe183+ await Deno.writeFile(`${dir}/msg`, msg);184+ const sig = await run([185+ "pkeyutl",186+ "-sign",187+ "-inkey",188+ `${dir}/k.pem`,189+ "-rawin",190+ "-in",191+ `${dir}/msg`,192+ ]);193+ const v = await verifyKeyAuth(194+ { publicKey: b64u(raw), ts, sig: b64u(sig) },195+ "mosaic.dev.site",196+ );197+ assert(v.ok, !v.ok ? v.reason : "");198+ // the SPKI PEM form of the public key is accepted too199+ const pem = new TextDecoder().decode(200+ await run(["pkey", "-in", `${dir}/k.pem`, "-pubout"]),201+ );202+ assertEquals(await fingerprint(pem), await fingerprint(b64u(raw)));61203 });62204 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");205+Deno.test("session cookie: signed, verified, expired, tampered", async () => {206+ const exp = Math.floor(Date.now() / 1000) + 60;207+ const v = await signSession("s3cret", "sub-1", exp);208+ assertEquals(await verifySession("s3cret", v), "sub-1");209+ assertEquals(await verifySession("other", v), null);210+ assertEquals(await verifySession("s3cret", v.slice(0, -2) + "xx"), null);211+ const old = await signSession(212+ "s3cret",213+ "sub-1",214+ Math.floor(Date.now() / 1000) - 1,215+ );216+ assertEquals(await verifySession("s3cret", old), null);217+});218+219+Deno.test("a shoo id_token verifies against a JWKS for this origin's audience", async () => {220+ const jose = await import("jose");221+ const { publicKey, privateKey } = await jose.generateKeyPair("ES256");222+ const jwk = await jose.exportJWK(publicKey);223+ const jwks = jose.createLocalJWKSet({224+ keys: [{ ...jwk, kid: "t1", alg: "ES256" }],225+ });226+ const mint = (aud: string) =>227+ new jose.SignJWT({ pairwise_sub: "pw-123", name: "Dami" })228+ .setProtectedHeader({ alg: "ES256", kid: "t1" })229+ .setIssuer("https://shoo.dev")230+ .setAudience(aud)231+ .setIssuedAt()232+ .setExpirationTime("5m")233+ .sign(privateKey);234+ const claims = await verifyIdToken(235+ await mint("origin:https://mosaic.dev.site"),236+ "https://mosaic.dev.site",237+ jwks,238+ );239+ assertEquals(claims, { sub: "pw-123", name: "Dami" });240+ await assertRejects(async () =>241+ verifyIdToken(242+ await mint("origin:https://other.site"),243+ "https://mosaic.dev.site",244+ jwks,245+ )246+ );247+});248+249+Deno.test("claim links are single use and expire; claiming binds the machine to the account", async () => {250+ const store = new MemoryStore();251+ const id = await generateIdentity("alice");66252 const now = Date.now();67- const rec = await verifiedJoin(68- reg,69- await signJoin(a, "g", now),70- "g",253+ const { machine } = await keyAuth(254+ store,255+ await signKeyAuth(id, "h", now),256+ "h",71257 "alice",72258 now,73259 );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",260+ const link = await createLink(261+ store,262+ machine.machine_id,263+ "https://mosaic.dev.site",81264 now,82265 );83- assertEquals(rec2.name, "alice2");84- const rec3 = await verifiedJoin(85- reg,86- await signJoin(a, "g", now),87- "g",88- undefined,266+ assert(link.url.startsWith("https://mosaic.dev.site/link?t=lnk_"));267+ const token = new URL(link.url).searchParams.get("t")!;268+ store.upsertAccount("pw-1", "Dami");269+ const bound = await claimMachine(store, token, "pw-1", now + 1000);270+ assertEquals(bound.account, "pw-1");271+ assertEquals(store.machinesOf("pw-1").map((m) => m.machine_id), [272+ machine.machine_id,273+ ]);274+ await assertRejects(275+ () => claimMachine(store, token, "pw-1", now + 2000),276+ Error,277+ "used",278+ );279+ const link2 = await createLink(280+ store,281+ machine.machine_id,282+ "https://mosaic.dev.site",89283 now,90284 );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);285+ const t2 = new URL(link2.url).searchParams.get("t")!;95286 await assertRejects(96- () => verifiedJoin(reg, { ...pb, machine_id: a.machine_id }, "g", "x", now),287+ () => claimMachine(store, t2, "pw-1", now + LINK_TTL_MS + 1),97288 Error,98- "fingerprint",289+ "expired",290+ );291+ await assertRejects(292+ () => claimMachine(store, "lnk_nope", "pw-1"),293+ Error,294+ "link",99295 );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");112296 });113297 114298 Deno.test("two sessions of one machine are two agents and one leaderboard row; unverified joins are marked", async () => {
@@ -132,7 +316,6 @@ Deno.test("two sessions of one machine are two agents and one leaderboard row; u
132316 });133317 assertEquals(s1.agent, `${m}/codex`);134318 assertEquals(s2.agent, `${m}/fable`);135- // joining again with the same session is the same agent136319 const again = await ref.join({137320 machine_id: m,138321 verified: true,
@@ -140,7 +323,6 @@ Deno.test("two sessions of one machine are two agents and one leaderboard row; u
140323 harness: "test",141324 });142325 assertEquals(again.agent, s1.agent);143- // an unverified claim of the same machine id is provisional144326 const u = await ref.join({145327 machine_id: m,146328 name: "impostor",
@@ -149,15 +331,13 @@ Deno.test("two sessions of one machine are two agents and one leaderboard row; u
149331 assert(/^a\d+$/.test(u.agent), `provisional id, got ${u.agent}`);150332 const boards = boardsOf(ref.state);151333 const row = boards.by_user.find((r) => r.key === m);152- assert(row, "one row for the machine");334+ assert(row);153335 assertEquals(row.agents, 2);154336 assertEquals(row.verified, true);155337 assertEquals(new Set(row.models), new Set(["gpt-x", "claude-y"]));156338 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 alone339+ assert(imp && imp.verified === false);159340 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);341+ assertEquals(ref.state.agents.find((a) => a.id === s1.agent)?.verified, true);342+ assertEquals(ref.state.agents.find((a) => a.id === u.agent)?.verified, false);163343 });