1// Machine identity, the tribes way (see /identity for the spec agents read).2//3// 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.1718const enc = new TextEncoder();1920// ---------------------------------------------------------------- encoding2122export function b64(bytes: Uint8Array): string {23 let s = "";24 for (const b of bytes) s += String.fromCharCode(b);25 return btoa(s);26}2728export function unb64(s: string): Uint8Array {29 const bin = atob(s.replace(/\s+/g, ""));30 const out = new Uint8Array(bin.length);31 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);32 return out;33}3435export function b64u(bytes: Uint8Array): string {36 return b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");37}3839export function unb64u(s: string): Uint8Array {40 const t = s.replace(/-/g, "+").replace(/_/g, "/");41 return unb64(t + "=".repeat((4 - t.length % 4) % 4));42}4344function hex(bytes: Uint8Array): string {45 return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");46}4748export 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 );55}5657/** The 12-byte DER prefix of an Ed25519 SubjectPublicKeyInfo. */58const SPKI_PREFIX = "302a300506032b6570032100";5960/** PEM (any label) to DER bytes; null when it is not PEM. */61export 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;66}6768/** 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. */70export 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}8586/** The machine id of an ed25519 public key in any accepted form. */87export 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}9293// ---------------------------------------------------------------- the identity file9495export type Jwk = { kty: "OKP"; crv: "Ed25519"; x: string; d?: string };9697/** The identity file's shape (tribes convention). */98export type Identity = {99 v: 1;100 publicKey: Jwk;101 privateKey: Jwk;102 createdAt: string;103 name?: string;104};105106export async function generateIdentity(name?: string): Promise<Identity> {107 const kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [108 "sign",109 "verify",110 ]) as CryptoKeyPair;111 const pub = await crypto.subtle.exportKey("jwk", kp.publicKey);112 const priv = await crypto.subtle.exportKey("jwk", kp.privateKey);113 return {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 } : {}),119 };120}121122export function machineIdOf(id: Identity): Promise<string> {123 return fingerprint(id.publicKey.x);124}125126async 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}135136async 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}149150async 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}159160// ---------------------------------------------------------------- key auth (bearer tokens)161162export const KEY_AUTH_WINDOW_S = 5 * 60;163164export function keyAuthMessage(host: string, ts: number): string {165 return `mosaic-key-auth:${host}:${ts}`;166}167168/** What the client posts to /api/keys/auth. */169export type KeyAuth = { publicKey: string; ts: number; sig: string };170171export 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}180181/** Verifies a key-auth proof for this host; returns the raw key. */182export 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}212213/** 'msc_' + 32 base64url chars (24 random bytes); shown once, stored hashed. */214export function mintToken(prefix = "msc_"): string {215 return prefix + b64u(crypto.getRandomValues(new Uint8Array(24)));216}217218// ---------------------------------------------------------------- join proof (the alternative path)219220export function currentMinute(now = Date.now()): number {221 return Math.floor(now / 60000);222}223224export function joinMessage(225 gameId: string,226 publicKeyRawB64: string,227 minute: number,228 session = "1",229): string {230 return `mosaic-join|${gameId}|${publicKeyRawB64}|${minute}|${session}`;231}232233export type JoinProof = {234 machine_id: string;235 public_key: string;236 proof: string;237 minute: number;238};239240export async function signJoin(241 id: Identity,242 gameId: string,243 now = Date.now(),244 session = "1",245): Promise<JoinProof> {246 const raw = unb64u(id.publicKey.x);247 const rawB64 = b64(raw);248 const minute = currentMinute(now);249 const sig = await sign(id, joinMessage(gameId, rawB64, minute, session));250 return {251 machine_id: await fingerprint(rawB64),252 public_key: rawB64,253 proof: b64(sig),254 minute,255 };256}257258/** Verifies a join proof for the current or previous minute and that the259 * machine id is the key's fingerprint. */260export async function verifyJoin(261 p: { machine_id: string; public_key: string; proof: string },262 gameId: string,263 now = Date.now(),264 session = "1",265): Promise<{ ok: true; raw: Uint8Array } | { ok: false; reason: string }> {266 const raw = rawPublicKey(p.public_key);267 if (!raw) {268 return {269 ok: false,270 reason:271 "public_key must be an ed25519 public key: raw 32 bytes base64, SPKI DER base64, or SPKI PEM",272 };273 }274 if ((await fingerprint(b64(raw))) !== p.machine_id) {275 return {276 ok: false,277 reason: "machine_id is not the fingerprint of public_key",278 };279 }280 const key = await verifyingKey(raw);281 if (!key) {282 return { ok: false, reason: "public_key is not a valid ed25519 key" };283 }284 let sig: Uint8Array;285 try {286 sig = /[-_]/.test(p.proof) ? unb64u(p.proof) : unb64(p.proof);287 } catch {288 return { ok: false, reason: "proof must be base64" };289 }290 const m = currentMinute(now);291 for (const minute of [m, m - 1]) {292 const ok = await crypto.subtle.verify(293 { name: "Ed25519" },294 key,295 sig as BufferSource,296 enc.encode(joinMessage(gameId, b64(raw), minute, session)),297 );298 if (ok) return { ok: true, raw };299 }300 return {301 ok: false,302 reason:303 "proof does not verify for this game and session in the current or previous minute",304 };305}306307// ---------------------------------------------------------------- the store308309export class IdentityError extends Error {310 constructor(311 message: string,312 readonly code: "identity" | "proof" | "token" | "link" | "session",313 ) {314 super(message);315 }316}317318export type MachineRecord = {319 machine_id: string;320 public_key: string; // raw, base64url321 name: string;322 account: string | null; // shoo pairwise subject once claimed323 first_seen: string;324 last_seen: string;325};326327export type AccountRecord = {328 id: string; // shoo pairwise subject329 name: string | null; // display name when shoo provides one330 first_seen: string;331};332333export const LINK_TTL_MS = 10 * 60 * 1000;334335/** Machines (trust on first use), one bearer token per machine, single-use336 * link tokens, accounts. */337export interface IdentityStore {338 getMachine(machineId: string): MachineRecord | null;339 /** First sighting records the key; a different key for a known id throws. */340 seen(341 machineId: string,342 publicKeyB64u: string,343 name: string | undefined,344 now?: Date,345 ): MachineRecord;346 /** Replaces the machine's token; returns the new token's hash owner. */347 setToken(machineId: string, tokenHash: string): void;348 machineByToken(tokenHash: string): MachineRecord | null;349 createLink(machineId: string, tokenHash: string, expiresAt: number): void;350 /** Redeems once; null when unknown, used or expired. */351 redeemLink(tokenHash: string, now?: number): string | null;352 upsertAccount(id: string, name: string | null, now?: Date): AccountRecord;353 getAccount(id: string): AccountRecord | null;354 bindMachine(machineId: string, accountId: string): MachineRecord;355 machinesOf(accountId: string): MachineRecord[];356}357358function assertKey(r: MachineRecord, publicKey: string) {359 if (r.public_key !== publicKey) {360 throw new IdentityError(361 `Machine ${r.machine_id} is registered with a different key.`,362 "identity",363 );364 }365}366367export class MemoryStore implements IdentityStore {368 private machines = new Map<string, MachineRecord>();369 private tokens = new Map<string, string>(); // hash -> machine_id370 private links = new Map<371 string,372 { machine_id: string; expires: number; used: boolean }373 >();374 private accounts = new Map<string, AccountRecord>();375376 getMachine(id: string) {377 return this.machines.get(id) ?? null;378 }379 seen(380 id: string,381 publicKey: string,382 name: string | undefined,383 now = new Date(),384 ) {385 const t = now.toISOString();386 const r = this.machines.get(id);387 if (r) {388 assertKey(r, publicKey);389 const next = { ...r, last_seen: t, ...(name ? { name } : {}) };390 this.machines.set(id, next);391 return next;392 }393 const rec: MachineRecord = {394 machine_id: id,395 public_key: publicKey,396 name: name ?? id,397 account: null,398 first_seen: t,399 last_seen: t,400 };401 this.machines.set(id, rec);402 return rec;403 }404 setToken(machineId: string, hash: string) {405 for (const [h, m] of this.tokens) {406 if (m === machineId) this.tokens.delete(h);407 }408 this.tokens.set(hash, machineId);409 }410 machineByToken(hash: string) {411 const m = this.tokens.get(hash);412 return m ? this.getMachine(m) : null;413 }414 createLink(machineId: string, hash: string, expires: number) {415 this.links.set(hash, { machine_id: machineId, expires, used: false });416 }417 redeemLink(hash: string, now = Date.now()) {418 const l = this.links.get(hash);419 if (!l || l.used || l.expires < now) return null;420 l.used = true;421 return l.machine_id;422 }423 upsertAccount(id: string, name: string | null, now = new Date()) {424 const r = this.accounts.get(id);425 const rec: AccountRecord = r426 ? { ...r, name: name ?? r.name }427 : { id, name, first_seen: now.toISOString() };428 this.accounts.set(id, rec);429 return rec;430 }431 getAccount(id: string) {432 return this.accounts.get(id) ?? null;433 }434 bindMachine(machineId: string, accountId: string) {435 const r = this.machines.get(machineId);436 if (!r) throw new IdentityError("unknown machine", "link");437 const next = { ...r, account: accountId };438 this.machines.set(machineId, next);439 return next;440 }441 machinesOf(accountId: string) {442 return [...this.machines.values()].filter((m) => m.account === accountId);443 }444}445446type DatabaseSync = {447 exec(sql: string): void;448 prepare(sql: string): {449 get(...args: unknown[]): Record<string, unknown> | undefined;450 all(...args: unknown[]): Record<string, unknown>[];451 run(...args: unknown[]): unknown;452 };453};454const sqliteSpecifier = "node:sqlite";455let sqliteMod:456 | Promise<{ DatabaseSync: new (path: string) => DatabaseSync }>457 | null = null;458459/** The store as one SQLite file (`data/identity.sqlite`). */460export class SqliteStore implements IdentityStore {461 private constructor(private db: DatabaseSync) {}462 static async open(path: string): Promise<SqliteStore> {463 sqliteMod ??= import(sqliteSpecifier) as unknown as Promise<464 { DatabaseSync: new (path: string) => DatabaseSync }465 >;466 const { DatabaseSync: DB } = await sqliteMod;467 const db = new DB(path);468 db.exec(`469 CREATE TABLE IF NOT EXISTS machines (470 machine_id TEXT PRIMARY KEY, public_key TEXT NOT NULL, name TEXT NOT NULL,471 account TEXT, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL);472 CREATE TABLE IF NOT EXISTS tokens (473 hash TEXT PRIMARY KEY, machine_id TEXT NOT NULL UNIQUE, created TEXT NOT NULL);474 CREATE TABLE IF NOT EXISTS links (475 hash TEXT PRIMARY KEY, machine_id TEXT NOT NULL, expires INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0);476 CREATE TABLE IF NOT EXISTS accounts (477 id TEXT PRIMARY KEY, name TEXT, first_seen TEXT NOT NULL);478 `);479 return new SqliteStore(db);480 }481 private row(r: Record<string, unknown> | undefined): MachineRecord | null {482 return r483 ? {484 machine_id: r.machine_id as string,485 public_key: r.public_key as string,486 name: r.name as string,487 account: (r.account as string | null) ?? null,488 first_seen: r.first_seen as string,489 last_seen: r.last_seen as string,490 }491 : null;492 }493 getMachine(id: string) {494 return this.row(495 this.db.prepare("SELECT * FROM machines WHERE machine_id = ?").get(id),496 );497 }498 seen(499 id: string,500 publicKey: string,501 name: string | undefined,502 now = new Date(),503 ) {504 const t = now.toISOString();505 const r = this.getMachine(id);506 if (r) {507 assertKey(r, publicKey);508 this.db.prepare(509 "UPDATE machines SET last_seen = ?, name = ? WHERE machine_id = ?",510 ).run(t, name ?? r.name, id);511 return { ...r, last_seen: t, name: name ?? r.name };512 }513 this.db.prepare(514 "INSERT INTO machines (machine_id, public_key, name, account, first_seen, last_seen) VALUES (?, ?, ?, NULL, ?, ?)",515 ).run(id, publicKey, name ?? id, t, t);516 return this.getMachine(id)!;517 }518 setToken(machineId: string, hash: string) {519 this.db.prepare("DELETE FROM tokens WHERE machine_id = ?").run(machineId);520 this.db.prepare(521 "INSERT INTO tokens (hash, machine_id, created) VALUES (?, ?, ?)",522 ).run(hash, machineId, new Date().toISOString());523 }524 machineByToken(hash: string) {525 const r = this.db.prepare("SELECT machine_id FROM tokens WHERE hash = ?")526 .get(527 hash,528 );529 return r ? this.getMachine(r.machine_id as string) : null;530 }531 createLink(machineId: string, hash: string, expires: number) {532 this.db.prepare(533 "INSERT INTO links (hash, machine_id, expires, used) VALUES (?, ?, ?, 0)",534 ).run(hash, machineId, expires);535 }536 redeemLink(hash: string, now = Date.now()) {537 const r = this.db.prepare(538 "SELECT machine_id, expires, used FROM links WHERE hash = ?",539 ).get(hash);540 if (!r || Number(r.used) || Number(r.expires) < now) return null;541 this.db.prepare("UPDATE links SET used = 1 WHERE hash = ?").run(hash);542 return r.machine_id as string;543 }544 upsertAccount(id: string, name: string | null, now = new Date()) {545 const r = this.getAccount(id);546 if (r) {547 if (name && name !== r.name) {548 this.db.prepare("UPDATE accounts SET name = ? WHERE id = ?").run(549 name,550 id,551 );552 }553 return { ...r, name: name ?? r.name };554 }555 this.db.prepare(556 "INSERT INTO accounts (id, name, first_seen) VALUES (?, ?, ?)",557 ).run(id, name, now.toISOString());558 return this.getAccount(id)!;559 }560 getAccount(id: string) {561 const r = this.db.prepare("SELECT * FROM accounts WHERE id = ?").get(id);562 return r563 ? {564 id: r.id as string,565 name: (r.name as string | null) ?? null,566 first_seen: r.first_seen as string,567 }568 : null;569 }570 bindMachine(machineId: string, accountId: string) {571 this.db.prepare("UPDATE machines SET account = ? WHERE machine_id = ?").run(572 accountId,573 machineId,574 );575 const r = this.getMachine(machineId);576 if (!r) throw new IdentityError("unknown machine", "link");577 return r;578 }579 machinesOf(accountId: string) {580 return this.db.prepare("SELECT * FROM machines WHERE account = ?").all(581 accountId,582 ).map((r) => this.row(r)!);583 }584}585586// ---------------------------------------------------------------- operations587588/** /api/keys/auth: verify, record (TOFU), mint a token replacing the old one. */589export async function keyAuth(590 store: IdentityStore,591 p: KeyAuth,592 host: string,593 name: string | undefined,594 now = Date.now(),595): Promise<{ token: string; machine: MachineRecord }> {596 const v = await verifyKeyAuth(p, host, now);597 if (!v.ok) throw new IdentityError(v.reason, "proof");598 const machineId = await fingerprint(b64(v.raw));599 const machine = store.seen(machineId, b64u(v.raw), name, new Date(now));600 const token = mintToken();601 store.setToken(machineId, await sha256hex(token));602 return { token, machine };603}604605/** The machine behind a bearer token, or null. */606export async function machineOfBearer(607 store: IdentityStore,608 authorization: string | null | undefined,609): Promise<MachineRecord | null> {610 const m = /^Bearer\s+(msc_[A-Za-z0-9_-]{32})$/i.exec(authorization ?? "");611 if (!m) return null;612 return store.machineByToken(await sha256hex(m[1]));613}614615/** A verified join by proof: the key checks out and the store accepts it. */616export async function verifiedJoin(617 store: IdentityStore,618 p: { machine_id: string; public_key: string; proof: string },619 gameId: string,620 name: string | undefined,621 now = Date.now(),622 session = "1",623): Promise<MachineRecord> {624 const v = await verifyJoin(p, gameId, now, session);625 if (!v.ok) throw new IdentityError(v.reason, "proof");626 return store.seen(p.machine_id, b64u(v.raw), name, new Date(now));627}628629/** A single-use claim link for a machine (10 minutes). */630export async function createLink(631 store: IdentityStore,632 machineId: string,633 origin: string,634 now = Date.now(),635): Promise<{ url: string; expires_at: string }> {636 const token = mintToken("lnk_");637 const expires = now + LINK_TTL_MS;638 store.createLink(machineId, await sha256hex(token), expires);639 return {640 url: `${origin.replace(/\/$/, "")}/link?t=${token}`,641 expires_at: new Date(expires).toISOString(),642 };643}644645/** Redeems a claim link for the signed-in account; returns the machine. */646export async function claimMachine(647 store: IdentityStore,648 token: string,649 accountId: string,650 now = Date.now(),651): Promise<MachineRecord> {652 if (!/^lnk_[A-Za-z0-9_-]{32}$/.test(token)) {653 throw new IdentityError("not a link token", "link");654 }655 const machineId = store.redeemLink(await sha256hex(token), now);656 if (!machineId) {657 throw new IdentityError("this link is unknown, used, or expired", "link");658 }659 return store.bindMachine(machineId, accountId);660}661662/** The name a machine plays under: its account's display name once claimed663 * and known, else the name its key holder set. */664export function displayName(store: IdentityStore, m: MachineRecord): string {665 if (m.account) {666 const a = store.getAccount(m.account);667 if (a?.name) return a.name;668 }669 return m.name;670}671672// ---------------------------------------------------------------- sessions (shoo login)673674export const SESSION_COOKIE = "mosaic_session";675export const SESSION_TTL_S = 7 * 24 * 3600;676677async function hmacKey(secret: string, usage: "sign" | "verify") {678 return await crypto.subtle.importKey(679 "raw",680 enc.encode(secret),681 { name: "HMAC", hash: "SHA-256" },682 false,683 [usage],684 );685}686687/** Cookie value: base64url(JSON{u, exp}) + "." + base64url(HMAC-SHA256). */688export async function signSession(689 secret: string,690 accountId: string,691 expSec: number,692): Promise<string> {693 const payload = b64u(694 enc.encode(JSON.stringify({ u: accountId, exp: expSec })),695 );696 const sig = await crypto.subtle.sign(697 "HMAC",698 await hmacKey(secret, "sign"),699 enc.encode(payload),700 );701 return `${payload}.${b64u(new Uint8Array(sig))}`;702}703704export async function verifySession(705 secret: string,706 value: string,707): Promise<string | null> {708 try {709 const dot = value.indexOf(".");710 if (dot < 0) return null;711 const payload = value.slice(0, dot);712 const ok = await crypto.subtle.verify(713 "HMAC",714 await hmacKey(secret, "verify"),715 unb64u(value.slice(dot + 1)) as BufferSource,716 enc.encode(payload),717 );718 if (!ok) return null;719 const parsed = JSON.parse(new TextDecoder().decode(unb64u(payload)));720 if (typeof parsed?.u !== "string" || typeof parsed?.exp !== "number") {721 return null;722 }723 if (parsed.exp <= Math.floor(Date.now() / 1000)) return null;724 return parsed.u;725 } catch {726 return null;727 }728}729730export function parseCookies(header: string | null): Map<string, string> {731 const out = new Map<string, string>();732 for (const part of (header ?? "").split(";")) {733 const eq = part.indexOf("=");734 if (eq > 0) out.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());735 }736 return out;737}738739export function sessionCookie(value: string, secure: boolean, maxAge: number) {740 return [741 `${SESSION_COOKIE}=${value}`,742 "Path=/",743 "HttpOnly",744 "SameSite=Lax",745 `Max-Age=${maxAge}`,746 ...(secure ? ["Secure"] : []),747 ].join("; ");748}749750/** The session secret: `SESSION_SECRET`, or a fixed dev value off the host. */751export function sessionSecret(): string {752 const s = Deno.env.get("SESSION_SECRET");753 if (s) return s;754 if (Deno.env.get("MOSAIC_DAILY") === "1") {755 throw new IdentityError("SESSION_SECRET is not configured", "session");756 }757 return "dev-secret";758}759760/** The account behind a request's cookie, or null. */761export async function accountOfRequest(req: Request): Promise<string | null> {762 const v = parseCookies(req.headers.get("cookie")).get(SESSION_COOKIE);763 if (!v) return null;764 try {765 return await verifySession(sessionSecret(), v);766 } catch {767 return null;768 }769}770771// ---------------------------------------------------------------- shoo id_token772773export const SHOO_ISSUER = "https://shoo.dev";774export const SHOO_JWKS_URL = "https://shoo.dev/.well-known/jwks.json";775776export type IdTokenClaims = { sub: string; name: string | null };777778/** Verifies a shoo id_token for this origin (audience `origin:<origin>`,779 * issuer shoo.dev) and returns the pairwise subject and a display name if780 * the token carries one. `jwks` is injectable for tests. */781export async function verifyIdToken(782 idToken: string,783 origin: string,784 jwks?: unknown,785): Promise<IdTokenClaims> {786 const jose = await import("jose");787 const keys = (jwks as Parameters<typeof jose.jwtVerify>[1] | undefined) ??788 jose.createRemoteJWKSet(new URL(SHOO_JWKS_URL));789 const { payload } = await jose.jwtVerify(idToken, keys, {790 issuer: SHOO_ISSUER,791 audience: `origin:${origin}`,792 });793 const sub = payload["pairwise_sub"];794 if (typeof sub !== "string" || !sub) {795 throw new IdentityError("id_token has no pairwise_sub", "session");796 }797 const name = typeof payload["name"] === "string" ? payload["name"] : null;798 return { sub, name };799}800801// ---------------------------------------------------------------- the process-wide store802803let storeP: Promise<IdentityStore> | null = null;804805/** `data/identity.sqlite`; in memory when `MOSAIC_MACHINES=memory` (tests). */806export function identityStore(dataDir: () => string): Promise<IdentityStore> {807 storeP ??= Deno.env.get("MOSAIC_MACHINES") === "memory"808 ? Promise.resolve(new MemoryStore())809 : SqliteStore.open(dataDir() + "identity.sqlite");810 return storeP;811}