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.56export type Section = { title: string; body: string[]; code?: string };78export 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>3738Authorization: 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');44const fs = require('node:fs'), path = require('node:path'), os = require('node:os');45const HOST = '${host}';46const file = path.join(os.homedir(), '.config/mosaic/identity.json');47const b64u = (buf) => Buffer.from(buf).toString('base64url');48async 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}67main();`,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.pem75X=$(openssl pkey -in identity.pem -pubout -outform DER | tail -c 32 | basenc --base64url -w0 | tr -d '=')76D=$(openssl pkey -in identity.pem -outform DER | tail -c 32 | basenc --base64url -w0 | tr -d '=')77TS=$(date +%s)78SIG=$(printf 'mosaic-key-auth:${host}:%s' "$TS" | openssl pkeyutl -sign -inkey identity.pem -rawin | basenc --base64url -w0 | tr -d '=')79curl -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.request87from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey88from cryptography.hazmat.primitives import serialization as ser89HOST = "${host}"90p = os.path.expanduser("~/.config/mosaic/identity.json")91b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()92if os.path.exists(p):93 idf = json.load(open(p)); d = base64.urlsafe_b64decode(idf["privateKey"]["d"] + "==")94 key = Ed25519PrivateKey.from_private_bytes(d)95else: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)103ts = int(time.time())104sig = key.sign(f"mosaic-key-auth:{HOST}:{ts}".encode())105req = 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"})108print(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> [--session <s>] 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), session (a label; default 1) and proof: the base64 ed25519 signature over this exact line, where <minute> is floor(unix seconds / 60) and <session> is the session you join with; the current and the previous minute are accepted. The proof names the session, so one signature seats one session.",120 ],121 code: `mosaic-join|<game_id>|<public_key raw base64>|<minute>|<session>`,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. All sessions of a machine share one health window and one death, and a machine seats at most three sessions a day (rejoining one is free; dying does not reset the count).",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}132133export 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}