1/// <reference types="@cloudflare/workers-types" />2/**3 * Keypair-first identity (see docs/auth.md — the agent-facing convention).4 *5 * The default credential is a device ed25519 keypair the agent mints itself:6 * POST /api/keys/auth {publicKey, ts, sig} -> {token: trb_..., user}7 * signing the UTF-8 string `tribes-key-auth:<host>:<ts>` (±5 min window).8 * The keypair maps to identity `key:<publicKey>`; the response token is a9 * normal API token, so everything downstream stays plain bearer auth.10 *11 * POST /api/keys/link (session-authed, same proof) merges the keypair user12 * into the signed-in account — one-way, idempotent.13 */14import type { AuthedUser } from '@tribes/protocol';15import type { Env } from './env';16import * as db from './db';17import { hashToken, mintTokenValue } from './auth';18import { base64UrlToBytes, nanoid, nowSec } from './util';1920/** Accepted clock skew for the signed timestamp, seconds. */21export const KEY_AUTH_WINDOW = 300;2223export interface KeyProof {24 publicKey: string; // base64url raw 32-byte ed25519 public key25 ts: number; // unix seconds26 sig: string; // base64url ed25519 signature over `tribes-key-auth:<host>:<ts>`27}2829export function parseKeyProof(body: unknown): KeyProof | null {30 if (typeof body !== 'object' || body === null) return null;31 const b = body as Record<string, unknown>;32 if (typeof b['publicKey'] !== 'string' || b['publicKey'].length > 64) return null;33 if (typeof b['ts'] !== 'number' || !Number.isInteger(b['ts'])) return null;34 if (typeof b['sig'] !== 'string' || b['sig'].length > 128) return null;35 return { publicKey: b['publicKey'], ts: b['ts'], sig: b['sig'] };36}3738/** Verify the proof; returns the identity id `key:<publicKey>` or null. */39export async function verifyKeyProof(proof: KeyProof, host: string): Promise<string | null> {40 if (Math.abs(nowSec() - proof.ts) > KEY_AUTH_WINDOW) return null;41 let pub: Uint8Array;42 let sig: Uint8Array;43 try {44 pub = base64UrlToBytes(proof.publicKey);45 sig = base64UrlToBytes(proof.sig);46 } catch {47 return null;48 }49 if (pub.length !== 32 || sig.length !== 64) return null;50 try {51 const key = await crypto.subtle.importKey('raw', pub, { name: 'Ed25519' }, false, ['verify']);52 const msg = new TextEncoder().encode(`tribes-key-auth:${host}:${proof.ts}`);53 const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, sig, msg);54 return ok ? `key:${proof.publicKey}` : null;55 } catch {56 return null; // malformed key material57 }58}5960/** Mint an API token for the keypair's user (creating the user on first use). */61export async function keyAuth(62 env: Env,63 proof: KeyProof,64 host: string,65): Promise<{ token: string; tokenId: string; user: db.UserRow } | null> {66 const identity = await verifyKeyProof(proof, host);67 if (identity === null) return null;68 const user = await db.upsertUser(env.DB, identity, null);69 const value = mintTokenValue();70 const id = 'tok_' + nanoid();71 const name = db.deviceTokenName(proof.publicKey);72 // One active wire token per device key: re-minting replaces the old one.73 await env.DB74 .prepare(`DELETE FROM api_tokens WHERE user_id = ?1 AND name = ?2`)75 .bind(user.id, name)76 .run();77 await db.insertToken(env.DB, {78 id,79 userId: user.id,80 tokenHash: await hashToken(value),81 name,82 createdAt: nowSec(),83 });84 return { token: value, tokenId: id, user };85}8687// ---------------------------------------------------------------------------88// Link codes (device pairing): the browser mints a code, the device redeems89// it. Words keep codes typo-robust and speakable.90// ---------------------------------------------------------------------------9192const CODE_WORDS = [93 'amber', 'birch', 'cairn', 'delta', 'ember', 'fjord', 'glyph', 'heron',94 'inlet', 'joust', 'krill', 'lumen', 'mesa', 'nadir', 'otter', 'pike',95 'quill', 'ridge', 'sable', 'tarn', 'umber', 'vale', 'wren', 'yarrow',96] as const;97export const LINK_CODE_TTL = 600; // seconds9899export function generateLinkCode(): string {100 const idx = crypto.getRandomValues(new Uint32Array(3));101 const tail = crypto.getRandomValues(new Uint8Array(2));102 const hex = Array.from(tail, (b) => b.toString(16).padStart(2, '0')).join('');103 return `${CODE_WORDS[idx[0]! % 24]}-${CODE_WORDS[idx[1]! % 24]}-${CODE_WORDS[idx[2]! % 24]}-${hex}`;104}105106/** Mint a fresh code for the user (replacing any outstanding one). */107export async function mintLinkCode(env: Env, userId: string): Promise<{ code: string; expiresAt: number }> {108 const code = generateLinkCode();109 const now = nowSec();110 await env.DB.batch([111 env.DB.prepare(`DELETE FROM link_codes WHERE user_id = ?1`).bind(userId),112 env.DB113 .prepare(`INSERT INTO link_codes (code_hash, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)`)114 .bind(await hashToken(code), userId, now, now + LINK_CODE_TTL),115 ]);116 return { code, expiresAt: now + LINK_CODE_TTL };117}118119/** Redeem a code: returns the target user id and consumes the code. */120export async function redeemLinkCode(env: Env, code: string): Promise<string | null> {121 const hash = await hashToken(code.trim().toLowerCase());122 const row = await env.DB123 .prepare(`DELETE FROM link_codes WHERE code_hash = ?1 AND expires_at > ?2 RETURNING user_id`)124 .bind(hash, nowSec())125 .first<{ user_id: string }>();126 return row?.user_id ?? null;127}128129/**130 * Merge the CALLING user (a device/key account, proven by its bearer token)131 * into the account that minted the code. The device side needs no session132 * and no extra signature — holding the token IS proof of the key.133 */134export async function linkByCode(135 env: Env,136 caller: AuthedUser,137 code: string,138): Promise<'linked' | 'already' | 'invalid'> {139 const targetId = await redeemLinkCode(env, code);140 if (targetId === null) return 'invalid';141 if (targetId === caller.userId) return 'already';142 await db.mergeUsers(env.DB, caller.userId, targetId);143 return 'linked';144}145146/**147 * Merge the keypair identity into the signed-in account. Everything the148 * key-user owned moves over; the key identity re-points to the target user149 * and keeps working as a credential. Idempotent: linking an already-linked150 * key to the same account is a no-op.151 */152export async function linkKey(153 env: Env,154 target: AuthedUser,155 proof: KeyProof,156 host: string,157): Promise<'linked' | 'already' | 'invalid'> {158 const identity = await verifyKeyProof(proof, host);159 if (identity === null) return 'invalid';160 const existing = await db.getIdentity(env.DB, identity);161 if (existing !== null) {162 if (existing.user_id === target.userId) return 'already';163 await db.mergeUsers(env.DB, existing.user_id, target.userId);164 return 'linked';165 }166 // Fresh key never used on its own: just attach the identity.167 await db.insertIdentity(env.DB, identity, target.userId, nowSec());168 return 'linked';169}