12// 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.6//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> [--session s] # a join proof, as JSON12// deno run -A https://mosaic.dev.site/identity.ts --rotate # new key (a new machine id)13//14// Standalone on purpose: no imports, so it runs from a URL on any machine.1516type Jwk = { kty: "OKP"; crv: "Ed25519"; x: string; d?: string };17type Identity = {18 v: 1;19 publicKey: Jwk;20 privateKey: Jwk;21 createdAt: string;22 name?: string;23};2425const enc = new TextEncoder();26const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));27const b64u = (b: Uint8Array) =>28 b64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");29function 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));33}34async function fingerprint(x: string): Promise<string> {35 const d = await crypto.subtle.digest("SHA-256", unb64u(x) as BufferSource);36 const hex = [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0"))37 .join("");38 return "m-" + hex.slice(0, 16);39}4041const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? ".";42const dir = Deno.env.get("MOSAIC_IDENTITY_DIR") ?? `${home}/.config/mosaic`;43const file = `${dir}/identity.json`;4445async function save(id: Identity) {46 await Deno.mkdir(dir, { recursive: true });47 await Deno.writeTextFile(file, JSON.stringify(id, null, 2) + "\n");48 try {49 await Deno.chmod(file, 0o600);50 } catch {51 // not every platform has modes52 }53}5455async function generate(name?: string): Promise<Identity> {56 const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [57 "sign",58 "verify",59 ]) as CryptoKeyPair;60 const pub = await crypto.subtle.exportKey("jwk", kp.publicKey);61 const priv = await crypto.subtle.exportKey("jwk", kp.privateKey);62 return {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 } : {}),68 };69}7071/** 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. */73async 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}111112async function sign(id: Identity, message: string): Promise<Uint8Array> {113 const key = await crypto.subtle.importKey(114 "jwk",115 id.privateKey,116 { name: "Ed25519" },117 false,118 ["sign"],119 );120 return new Uint8Array(121 await crypto.subtle.sign({ name: "Ed25519" }, key, enc.encode(message)),122 );123}124125async 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;138}139140const args = Deno.args;141const flag = (f: string) => {142 const i = args.indexOf(f);143 return i >= 0 ? (args[i + 1] ?? "") : undefined;144};145const has = (f: string) => args.includes(f);146147let id = await load();148const nameArg = flag("--name");149if (!id || has("--rotate")) {150 id = await generate(nameArg || id?.name || Deno.hostname());151 await save(id);152 console.error(`created ${file}`);153} else if (nameArg && nameArg !== id.name) {154 id = { ...id, name: nameArg };155 await save(id);156}157const machineId = await fingerprint(id.publicKey.x);158159const game = flag("--sign");160const tokenHost = flag("--token");161const linkHost = flag("--link");162if (game !== undefined) {163 if (!game) {164 console.error("usage: --sign <game_id>");165 Deno.exit(2);166 }167 const raw = unb64u(id.publicKey.x);168 const minute = Math.floor(Date.now() / 60000);169 const session = flag("--session") || "1";170 const sig = await sign(171 id,172 `mosaic-join|${game}|${b64(raw)}|${minute}|${session}`,173 );174 console.log(JSON.stringify({175 machine_id: machineId,176 public_key: b64(raw),177 proof: b64(sig),178 minute,179 session,180 }));181} else if (tokenHost !== undefined || linkHost !== undefined) {182 const host = tokenHost || linkHost;183 if (!host) {184 console.error("usage: --token <host> | --link <host>");185 Deno.exit(2);186 }187 const token = await mintToken(id, host);188 if (linkHost !== undefined) {189 const scheme = /^(localhost|127\.0\.0\.1)(:|$)/.test(host)190 ? "http"191 : "https";192 const res = await fetch(`${scheme}://${host}/api/keys/link-token`, {193 method: "POST",194 headers: { authorization: `Bearer ${token}` },195 });196 const body = await res.json();197 if (!res.ok) throw new Error(`link failed: ${body.error ?? res.status}`);198 console.log(body.url);199 } else {200 console.log(token);201 }202} else {203 console.log(JSON.stringify({ machine_id: machineId, name: id.name ?? null }));204}