From 23cba8194b6a8f3e41d0089aa84bda04ad9a1c68 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:01:02 -0300 Subject: [PATCH] feat(hosted): site tokens Adds Ed25519-signed site tokens for hosted v8 sites (issue and verify). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- apps/api/src/hosted/site-token.test.ts | 63 +++++++++++++++ apps/api/src/hosted/site-token.ts | 103 +++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 apps/api/src/hosted/site-token.test.ts create mode 100644 apps/api/src/hosted/site-token.ts diff --git a/apps/api/src/hosted/site-token.test.ts b/apps/api/src/hosted/site-token.test.ts new file mode 100644 index 0000000000..a7dd8acdd8 --- /dev/null +++ b/apps/api/src/hosted/site-token.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { memoryKv } from "./hosted-test-helpers"; +import { createSiteTokens, importSigningKey } from "./site-token"; + +async function keyPair() { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const pkcs8 = Buffer.from( + await crypto.subtle.exportKey("pkcs8", pair.privateKey), + ).toString("base64"); + return { pkcs8, publicKey: pair.publicKey }; +} + +function setup(pkcs8: string) { + return createSiteTokens({ + kv: memoryKv(), + signingKey: () => importSigningKey(pkcs8), + }); +} + +describe("site tokens", () => { + it("issues an EdDSA JWS of {site, kid, iat} the edge can verify", async () => { + const { pkcs8, publicKey } = await keyPair(); + const tokens = setup(pkcs8); + const { token, record } = await tokens.issue("org", "acme"); + const [h, p, sig] = token.split("."); + expect(JSON.parse(Buffer.from(h!, "base64url").toString())).toEqual({ + alg: "EdDSA", + typ: "JWT", + }); + expect(JSON.parse(Buffer.from(p!, "base64url").toString())).toEqual({ + site: "acme", + kid: record.kid, + iat: record.iat, + }); + expect( + await crypto.subtle.verify( + { name: "Ed25519" }, + publicKey, + Buffer.from(sig!, "base64url"), + new TextEncoder().encode(`${h}.${p}`), + ), + ).toBe(true); + expect(await tokens.list("org", "acme")).toEqual([record]); + expect(JSON.stringify(await tokens.list("org", "acme"))).not.toContain( + sig!, + ); + }); + + it("issues as many tokens as asked, each listed by kid", async () => { + const { pkcs8 } = await keyPair(); + const tokens = setup(pkcs8); + const issued = []; + for (let i = 0; i < 4; i++) issued.push(await tokens.issue("org", "acme")); + expect(new Set(issued.map((i) => i.record.kid)).size).toBe(4); + expect(await tokens.list("org", "acme")).toEqual( + issued.map((i) => i.record), + ); + expect(await tokens.list("org", "other")).toEqual([]); + }); +}); diff --git a/apps/api/src/hosted/site-token.ts b/apps/api/src/hosted/site-token.ts new file mode 100644 index 0000000000..e335c728ac --- /dev/null +++ b/apps/api/src/hosted/site-token.ts @@ -0,0 +1,103 @@ +/** + * Site tokens: what a hosted site passes as `createCMS({ token })` to send + * telemetry. A token is a JWS (compact JWT) signed by Studio with Ed25519: + * + * header {"alg":"EdDSA","typ":"JWT"} + * payload {"site":"","kid":"","iat":} + * + * No expiry and no revocation: the edge checks only the signature, and stamps + * the telemetry with the token's site. Issuing always works; Studio lists the + * tokens it issued (kid and time). The token itself is shown once and never + * stored. + */ + +import type { KVStorage } from "@/storage/kv"; + +export interface SiteTokenRecord { + kid: string; + /** Issued at, Unix seconds (the token's `iat`). */ + iat: number; +} + +// OPEN: O-S2 — token records live in the org KV per site; no migration. +function recordsKey(site: string): string { + return `site-tokens:${site}`; +} + +function parseRecords( + value: Record | null, +): SiteTokenRecord[] { + const tokens = value?.tokens; + if (!Array.isArray(tokens)) return []; + return tokens.filter( + (t): t is SiteTokenRecord => + typeof t === "object" && + t !== null && + typeof (t as SiteTokenRecord).kid === "string" && + typeof (t as SiteTokenRecord).iat === "number", + ); +} + +const b64u = (bytes: Uint8Array | ArrayBuffer) => + Buffer.from( + bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), + ).toString("base64url"); + +// OPEN: O-S6 — the signing key is base64 PKCS8 (`openssl genpkey -algorithm ed25519`). +export function importSigningKey(base64Pkcs8: string): Promise { + return crypto.subtle.importKey( + "pkcs8", + Buffer.from(base64Pkcs8, "base64"), + { name: "Ed25519" }, + false, + ["sign"], + ); +} + +async function signSiteToken( + key: CryptoKey, + payload: { site: string; kid: string; iat: number }, +): Promise { + const encode = (value: unknown) => + b64u(new TextEncoder().encode(JSON.stringify(value))); + const signingInput = `${encode({ alg: "EdDSA", typ: "JWT" })}.${encode({ + site: payload.site, + kid: payload.kid, + iat: payload.iat, + })}`; + const signature = await crypto.subtle.sign( + { name: "Ed25519" }, + key, + new TextEncoder().encode(signingInput), + ); + return `${signingInput}.${b64u(signature)}`; +} + +export function createSiteTokens(deps: { + kv: KVStorage; + signingKey: () => Promise; +}) { + const list = async (organizationId: string, site: string) => + parseRecords(await deps.kv.get(organizationId, recordsKey(site))); + + return { + list, + + async issue(organizationId: string, site: string) { + const records = await list(organizationId, site); + // OPEN: O-15 — kid is this token's id: 16 random bytes, base64url. + const record: SiteTokenRecord = { + kid: b64u(crypto.getRandomValues(new Uint8Array(16))), + iat: Math.floor(Date.now() / 1000), + }; + const token = await signSiteToken(await deps.signingKey(), { + site, + ...record, + }); + await deps.kv.set(organizationId, recordsKey(site), { + tokens: [...records, record], + }); + return { token, record }; + }, + }; +}