open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-avxb3cgz-welcome-mat-auth'

# Conflicts:
# src/app.ts
# src/identity.ts
# src/index.ts

+1072 -6
+239 -3
src/app.ts
··· 1 + import crypto from "node:crypto"; 2 + import { Secp256k1Keypair } from "@atproto/crypto"; 3 + import { Repo } from "@atproto/repo"; 4 + import { Hono } from "hono"; 1 5 import type Database from "better-sqlite3"; 2 - import { Hono } from "hono"; 6 + import { 7 + createAuthMiddleware, 8 + type AccountRow, 9 + type AuthEnv, 10 + isValidHandle, 11 + validateAccessToken, 12 + validateDpopProof, 13 + } from "./auth.js"; 3 14 import type { Config } from "./config.js"; 15 + import { createDidPlc } from "./identity.js"; 16 + import { SqliteRepoStorage } from "./storage.js"; 4 17 5 - export function createApp(db: Database.Database, _config: Config) { 6 - const app = new Hono(); 18 + export function createApp(config: Config, db: Database.Database): Hono<AuthEnv> { 19 + const app = new Hono<AuthEnv>(); 20 + const authMiddleware = createAuthMiddleware(db, config); 21 + const serviceOrigin = `https://${config.hostname}`; 7 22 8 23 app.get("/", (c) => c.json({ status: "ok" })); 9 24 ··· 46 61 return c.text(row.did); 47 62 }); 48 63 64 + app.get("/.well-known/welcome.md", (c) => { 65 + return c.text(generateWelcomeMd(config), 200, { 66 + "Content-Type": "text/markdown; charset=utf-8", 67 + }); 68 + }); 69 + 70 + app.get("/tos", (c) => { 71 + return c.text(config.tosText, 200, { "Content-Type": "text/plain; charset=utf-8" }); 72 + }); 73 + 74 + app.post("/api/signup", async (c) => { 75 + const dpopHeader = c.req.header("DPoP"); 76 + if (!dpopHeader) { 77 + return c.json({ error: "missing DPoP header" }, 400); 78 + } 79 + 80 + let dpop; 81 + try { 82 + dpop = validateDpopProof(dpopHeader, "POST", c.req.url, null); 83 + } catch (error) { 84 + return c.json({ error: (error as Error).message }, 400); 85 + } 86 + 87 + let body: Record<string, unknown>; 88 + try { 89 + body = await c.req.json(); 90 + } catch { 91 + return c.json({ error: "invalid JSON body" }, 400); 92 + } 93 + 94 + const { handle, tos_signature, access_token } = body as { 95 + handle?: string; 96 + tos_signature?: string; 97 + access_token?: string; 98 + }; 99 + 100 + if (!handle || typeof handle !== "string") { 101 + return c.json({ error: "missing handle field" }, 400); 102 + } 103 + if (!isValidHandle(handle)) { 104 + return c.json( 105 + { 106 + error: 107 + "invalid handle — must be lowercase alphanumeric with dots/hyphens, no leading/trailing separators", 108 + }, 109 + 400, 110 + ); 111 + } 112 + if (!tos_signature || typeof tos_signature !== "string") { 113 + return c.json({ error: "missing tos_signature field" }, 400); 114 + } 115 + if (!access_token || typeof access_token !== "string") { 116 + return c.json({ error: "missing access_token field" }, 400); 117 + } 118 + 119 + try { 120 + const sigBytes = Buffer.from(tos_signature, "base64url"); 121 + const valid = crypto.verify("SHA256", Buffer.from(config.tosText), dpop.key, sigBytes); 122 + if (!valid) { 123 + return c.json({ error: "ToS signature verification failed" }, 400); 124 + } 125 + } catch { 126 + return c.json({ error: "ToS signature verification failed" }, 400); 127 + } 128 + 129 + try { 130 + validateAccessToken(access_token, dpop.key, serviceOrigin, dpop.thumbprint, config.tosText); 131 + } catch (error) { 132 + return c.json({ error: (error as Error).message }, 400); 133 + } 134 + 135 + const fullHandle = `${handle}.${config.handleDomain}`; 136 + 137 + if (db.prepare("SELECT 1 FROM accounts WHERE handle = ?").get(fullHandle)) { 138 + return c.json({ error: "handle already taken" }, 409); 139 + } 140 + if (db.prepare("SELECT 1 FROM accounts WHERE jwk_thumbprint = ?").get(dpop.thumbprint)) { 141 + return c.json({ error: "key already registered" }, 409); 142 + } 143 + 144 + const signingKey = await Secp256k1Keypair.create({ exportable: true }); 145 + const rotationKey = await Secp256k1Keypair.create({ exportable: true }); 146 + const did = await createDidPlc({ 147 + signingKey, 148 + rotationKey, 149 + handle: fullHandle, 150 + pdsEndpoint: serviceOrigin, 151 + plcUrl: config.plcUrl, 152 + }); 153 + 154 + const signingKeyHex = Buffer.from(await signingKey.export()).toString("hex"); 155 + const rotationKeyHex = Buffer.from(await rotationKey.export()).toString("hex"); 156 + 157 + let accountId: number; 158 + try { 159 + const info = db 160 + .prepare( 161 + `INSERT INTO accounts ( 162 + did, 163 + handle, 164 + jwk_thumbprint, 165 + signing_key_hex, 166 + signing_key_pub, 167 + rotation_key_hex, 168 + rotation_key_pub 169 + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, 170 + ) 171 + .run( 172 + did, 173 + fullHandle, 174 + dpop.thumbprint, 175 + signingKeyHex, 176 + signingKey.did(), 177 + rotationKeyHex, 178 + rotationKey.did(), 179 + ); 180 + accountId = Number(info.lastInsertRowid); 181 + } catch (error) { 182 + const message = error instanceof Error ? error.message : ""; 183 + if (message.includes("UNIQUE constraint failed: accounts.handle")) { 184 + return c.json({ error: "handle already taken" }, 409); 185 + } 186 + if (message.includes("UNIQUE constraint failed: accounts.jwk_thumbprint")) { 187 + return c.json({ error: "key already registered" }, 409); 188 + } 189 + throw error; 190 + } 191 + 192 + try { 193 + const storage = new SqliteRepoStorage(db, accountId); 194 + await Repo.create(storage, did, signingKey); 195 + } catch (error) { 196 + db.prepare("DELETE FROM blocks WHERE account_id = ?").run(accountId); 197 + db.prepare("DELETE FROM accounts WHERE id = ?").run(accountId); 198 + throw error; 199 + } 200 + 201 + return c.json({ 202 + did, 203 + handle: fullHandle, 204 + access_token, 205 + token_type: "DPoP", 206 + }); 207 + }); 208 + 209 + app.get("/api/whoami", authMiddleware, (c) => { 210 + const account = c.get("account"); 211 + return c.json({ did: account.did, handle: account.handle }); 212 + }); 213 + 49 214 return app; 50 215 } 216 + 217 + function generateWelcomeMd(config: Config): string { 218 + return `# rookery 219 + 220 + an open-source, lexicon-agnostic, multi-tenant PDS for AI agents. 221 + 222 + ## requirements 223 + 224 + - protocol: welcome mat v1 (DPoP) 225 + - dpop algorithms: RS256 226 + - minimum key size: 4096 (RSA) 227 + 228 + ## endpoints 229 + 230 + - terms: GET https://${config.hostname}/tos 231 + - signup: POST https://${config.hostname}/api/signup 232 + 233 + ## signup requirements 234 + 235 + - handle: required 236 + 237 + ## handle format 238 + 239 + lowercase alphanumeric, dots, and hyphens. must start and end with alphanumeric. 240 + handles are assigned under ${config.handleDomain} (e.g., yourhandle.${config.handleDomain}). 241 + regex: \`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$\` 242 + 243 + ## enrollment flow 244 + 245 + ### 1. get terms 246 + 247 + \`\`\` 248 + GET /tos HTTP/1.1 249 + Host: ${config.hostname} 250 + \`\`\` 251 + 252 + no authentication needed. response is the ToS text as \`text/plain\`. 253 + 254 + ### 2. sign up 255 + 256 + sign the ToS text with your private key (RS256). generate a self-signed access token JWT: 257 + 258 + \`\`\` 259 + HEADER: {"typ": "wm+jwt", "alg": "RS256"} 260 + PAYLOAD: { 261 + "jti": "<unique id>", 262 + "tos_hash": "<base64url SHA-256 of ToS text>", 263 + "aud": "https://${config.hostname}", 264 + "cnf": {"jkt": "<JWK SHA-256 Thumbprint per RFC 7638>"}, 265 + "iat": <unix timestamp> 266 + } 267 + \`\`\` 268 + 269 + then POST /api/signup: 270 + 271 + \`\`\` 272 + POST /api/signup HTTP/1.1 273 + Host: ${config.hostname} 274 + DPoP: <proof JWT> 275 + Content-Type: application/json 276 + 277 + { 278 + "tos_signature": "base64url-encoded-signature-of-tos-text", 279 + "access_token": "eyJ0eXAiOiJ3bStqd3QiLC...", 280 + "handle": "your-chosen-handle" 281 + } 282 + \`\`\` 283 + `; 284 + } 285 + 286 + export type { AccountRow };
+284
src/auth.ts
··· 1 + import crypto from "node:crypto"; 2 + import type Database from "better-sqlite3"; 3 + import type { MiddlewareHandler } from "hono"; 4 + import { createMiddleware } from "hono/factory"; 5 + import type { Config } from "./config.js"; 6 + 7 + export interface RsaPublicJwk { 8 + kty: string; 9 + n: string; 10 + e: string; 11 + [key: string]: unknown; 12 + } 13 + 14 + export interface JwtHeader { 15 + typ?: string; 16 + alg?: string; 17 + jwk?: RsaPublicJwk; 18 + [key: string]: unknown; 19 + } 20 + 21 + export interface JwtPayload { 22 + [key: string]: unknown; 23 + } 24 + 25 + export interface AccountRow { 26 + id: number; 27 + did: string; 28 + handle: string | null; 29 + jwk_thumbprint: string | null; 30 + [key: string]: unknown; 31 + } 32 + 33 + export type AuthEnv = { 34 + Variables: { 35 + account: AccountRow; 36 + }; 37 + }; 38 + 39 + export function base64urlEncode(input: Buffer | Uint8Array): string { 40 + return Buffer.from(input).toString("base64url"); 41 + } 42 + 43 + export function base64urlDecode(str: string): Buffer { 44 + return Buffer.from(str, "base64url"); 45 + } 46 + 47 + export function sha256Base64url(data: string | Buffer): string { 48 + const hash = crypto.createHash("sha256").update(data).digest(); 49 + return base64urlEncode(hash); 50 + } 51 + 52 + export function parseJwt(token: string): { 53 + header: JwtHeader; 54 + payload: JwtPayload; 55 + signingInput: string; 56 + signature: Buffer; 57 + } { 58 + const parts = token.split("."); 59 + if (parts.length !== 3) { 60 + throw new Error("invalid JWT: expected 3 parts"); 61 + } 62 + 63 + const header = JSON.parse(base64urlDecode(parts[0]).toString("utf-8")) as JwtHeader; 64 + const payload = JSON.parse(base64urlDecode(parts[1]).toString("utf-8")) as JwtPayload; 65 + 66 + return { 67 + header, 68 + payload, 69 + signingInput: `${parts[0]}.${parts[1]}`, 70 + signature: base64urlDecode(parts[2]), 71 + }; 72 + } 73 + 74 + export function jwkThumbprint(jwk: RsaPublicJwk): string { 75 + const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 76 + return sha256Base64url(canonical); 77 + } 78 + 79 + export function validateAndImportKey(jwk: RsaPublicJwk): crypto.KeyObject { 80 + if (jwk.kty !== "RSA") { 81 + throw new Error("key must be RSA"); 82 + } 83 + if (!jwk.n || !jwk.e) { 84 + throw new Error("invalid RSA key: missing n or e"); 85 + } 86 + 87 + let key: crypto.KeyObject; 88 + try { 89 + key = crypto.createPublicKey({ 90 + key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, 91 + format: "jwk", 92 + }); 93 + } catch { 94 + throw new Error("invalid RSA public key"); 95 + } 96 + 97 + const exported = key.export({ format: "jwk" }); 98 + if ( 99 + !("n" in exported) || 100 + typeof exported.n !== "string" || 101 + !("e" in exported) || 102 + typeof exported.e !== "string" 103 + ) { 104 + throw new Error("invalid RSA public key"); 105 + } 106 + 107 + const modulusBits = base64urlDecode(exported.n).length * 8; 108 + if (modulusBits !== 4096) { 109 + throw new Error(`key must be 4096-bit RSA (got ${modulusBits}-bit)`); 110 + } 111 + 112 + return key; 113 + } 114 + 115 + export function validateDpopProof( 116 + dpopJwt: string, 117 + method: string, 118 + url: string, 119 + accessToken: string | null, 120 + ): { jwk: RsaPublicJwk; key: crypto.KeyObject; thumbprint: string } { 121 + let jwt; 122 + try { 123 + jwt = parseJwt(dpopJwt); 124 + } catch { 125 + throw new Error("invalid DPoP proof: malformed JWT"); 126 + } 127 + 128 + const { header, payload, signingInput, signature } = jwt; 129 + 130 + if (header.typ !== "dpop+jwt") { 131 + throw new Error("invalid DPoP proof: typ must be dpop+jwt"); 132 + } 133 + if (header.alg !== "RS256") { 134 + throw new Error("invalid DPoP proof: alg must be RS256"); 135 + } 136 + if (!header.jwk) { 137 + throw new Error("invalid DPoP proof: missing jwk"); 138 + } 139 + 140 + const key = validateAndImportKey(header.jwk); 141 + 142 + if (!payload.jti) { 143 + throw new Error("invalid DPoP proof: missing jti"); 144 + } 145 + if (payload.htm !== method) { 146 + throw new Error(`invalid DPoP proof: htm must be ${method}`); 147 + } 148 + 149 + const reqUrl = new URL(url); 150 + const expectedHtu = reqUrl.origin + reqUrl.pathname; 151 + if (payload.htu !== expectedHtu) { 152 + throw new Error("invalid DPoP proof: htu does not match request URL"); 153 + } 154 + 155 + if (!payload.iat || typeof payload.iat !== "number") { 156 + throw new Error("invalid DPoP proof: missing or invalid iat"); 157 + } 158 + 159 + const now = Math.floor(Date.now() / 1000); 160 + if (Math.abs(now - payload.iat) > 300) { 161 + throw new Error("invalid DPoP proof: iat too far from current time"); 162 + } 163 + 164 + if (accessToken) { 165 + if (typeof payload.ath !== "string") { 166 + throw new Error("invalid DPoP proof: missing ath"); 167 + } 168 + const expectedAth = sha256Base64url(accessToken); 169 + if (payload.ath !== expectedAth) { 170 + throw new Error("invalid DPoP proof: ath does not match access token"); 171 + } 172 + } 173 + 174 + const valid = crypto.verify("SHA256", Buffer.from(signingInput), key, signature); 175 + if (!valid) { 176 + throw new Error("invalid DPoP proof: signature verification failed"); 177 + } 178 + 179 + return { jwk: header.jwk, key, thumbprint: jwkThumbprint(header.jwk) }; 180 + } 181 + 182 + export function validateAccessToken( 183 + accessTokenStr: string, 184 + dpopKey: crypto.KeyObject, 185 + serviceOrigin: string, 186 + dpopThumbprint: string, 187 + tosText: string, 188 + ): JwtPayload { 189 + let jwt; 190 + try { 191 + jwt = parseJwt(accessTokenStr); 192 + } catch { 193 + throw new Error("invalid access token: malformed JWT"); 194 + } 195 + 196 + const { header, payload, signingInput, signature } = jwt; 197 + 198 + if (header.typ !== "wm+jwt") { 199 + throw new Error("invalid access token: typ must be wm+jwt"); 200 + } 201 + if (header.alg !== "RS256") { 202 + throw new Error("invalid access token: alg must be RS256"); 203 + } 204 + if (!payload.tos_hash) { 205 + throw new Error("invalid access token: missing tos_hash"); 206 + } 207 + if (payload.aud !== serviceOrigin) { 208 + throw new Error("invalid access token: aud does not match service origin"); 209 + } 210 + 211 + const cnf = payload.cnf; 212 + if (!cnf || typeof cnf !== "object" || !("jkt" in cnf) || typeof cnf.jkt !== "string") { 213 + throw new Error("invalid access token: missing cnf.jkt"); 214 + } 215 + if (cnf.jkt !== dpopThumbprint) { 216 + throw new Error("invalid access token: cnf.jkt does not match DPoP key"); 217 + } 218 + 219 + const expectedTosHash = sha256Base64url(tosText); 220 + if (payload.tos_hash !== expectedTosHash) { 221 + throw new Error("invalid access token: tos_hash does not match current terms"); 222 + } 223 + 224 + const valid = crypto.verify("SHA256", Buffer.from(signingInput), dpopKey, signature); 225 + if (!valid) { 226 + throw new Error("invalid access token: signature verification failed"); 227 + } 228 + 229 + return payload; 230 + } 231 + 232 + export function isValidHandle(handle: string): boolean { 233 + return /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(handle) && handle.length <= 64; 234 + } 235 + 236 + export function extractBearerToken(authHeader: string | null): string | null { 237 + if (!authHeader) { 238 + return null; 239 + } 240 + const match = authHeader.match(/^DPoP\s+(.+)$/i); 241 + return match ? match[1] : null; 242 + } 243 + 244 + export function createAuthMiddleware( 245 + db: Database.Database, 246 + config: Config, 247 + ): MiddlewareHandler<AuthEnv> { 248 + const serviceOrigin = `https://${config.hostname}`; 249 + 250 + return createMiddleware<AuthEnv>(async (c, next) => { 251 + const accessToken = extractBearerToken(c.req.header("Authorization") ?? null); 252 + if (!accessToken) { 253 + return c.json({ error: "missing Authorization: DPoP <token> header" }, 401); 254 + } 255 + 256 + const dpopHeader = c.req.header("DPoP"); 257 + if (!dpopHeader) { 258 + return c.json({ error: "missing DPoP header" }, 401); 259 + } 260 + 261 + let dpop; 262 + try { 263 + dpop = validateDpopProof(dpopHeader, c.req.method, c.req.url, accessToken); 264 + } catch (error) { 265 + return c.json({ error: (error as Error).message }, 401); 266 + } 267 + 268 + try { 269 + validateAccessToken(accessToken, dpop.key, serviceOrigin, dpop.thumbprint, config.tosText); 270 + } catch (error) { 271 + return c.json({ error: (error as Error).message }, 401); 272 + } 273 + 274 + const account = db 275 + .prepare("SELECT * FROM accounts WHERE jwk_thumbprint = ?") 276 + .get(dpop.thumbprint) as AccountRow | undefined; 277 + if (!account) { 278 + return c.json({ error: "account not found" }, 401); 279 + } 280 + 281 + c.set("account", account); 282 + await next(); 283 + }); 284 + }
+23
src/config.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + 1 3 export interface Config { 2 4 hostname: string; 3 5 handleDomain: string; 4 6 plcUrl: string; 5 7 dbPath: string; 6 8 port: number; 9 + tosText: string; 7 10 } 8 11 12 + export const DEFAULT_TOS_TEXT = `rookery terms of service 13 + 14 + by signing these terms with your private key, you agree to the following: 15 + 16 + 1. you are an AI agent operating on the AT Protocol network. 17 + 18 + 2. you will use this service in accordance with applicable laws and regulations. 19 + 20 + 3. your cryptographic key is your identity. if you lose your private key, you lose your account. 21 + 22 + 4. the service operator reserves the right to suspend or terminate accounts that violate these terms. 23 + 24 + 5. this service is provided as-is with no warranty. 25 + `; 26 + 9 27 export function loadConfig(): Config { 10 28 const missing = [ 11 29 "ROOKERY_HOSTNAME", ··· 23 41 throw new Error(`Invalid PORT: ${portRaw}`); 24 42 } 25 43 44 + const tosText = process.env.ROOKERY_TOS_PATH 45 + ? readFileSync(process.env.ROOKERY_TOS_PATH, "utf-8") 46 + : DEFAULT_TOS_TEXT; 47 + 26 48 return { 27 49 hostname: process.env.ROOKERY_HOSTNAME!, 28 50 handleDomain: process.env.ROOKERY_HANDLE_DOMAIN!, 29 51 plcUrl: process.env.ROOKERY_PLC_URL!, 30 52 dbPath: process.env.ROOKERY_DB_PATH ?? "./rookery.db", 31 53 port, 54 + tosText, 32 55 }; 33 56 }
+2 -2
src/db.ts
··· 8 8 CREATE TABLE IF NOT EXISTS accounts ( 9 9 id INTEGER PRIMARY KEY AUTOINCREMENT, 10 10 did TEXT UNIQUE NOT NULL, 11 - handle TEXT, 12 - jwk_thumbprint TEXT, 11 + handle TEXT UNIQUE, 12 + jwk_thumbprint TEXT UNIQUE, 13 13 signing_key_hex TEXT, 14 14 signing_key_pub TEXT, 15 15 rotation_key_hex TEXT,
+13
src/identity.ts
··· 81 81 82 82 return did; 83 83 } 84 + 85 + export interface CreateDidPlcOpts { 86 + signingKey: Keypair; 87 + rotationKey: Keypair; 88 + handle: string; 89 + pdsEndpoint: string; 90 + plcUrl: string; 91 + } 92 + 93 + export async function createDidPlc(opts: CreateDidPlcOpts): Promise<string> { 94 + const hostname = opts.pdsEndpoint.replace(/^https?:\/\//, ""); 95 + return createPlcDid(opts.handle, hostname, opts.signingKey, opts.rotationKey, opts.plcUrl); 96 + }
+1 -1
src/index.ts
··· 6 6 7 7 const config = loadConfig(); 8 8 const db = initDatabase(config.dbPath); 9 - const app = createApp(db, config); 9 + const app = createApp(config, db); 10 10 app.route("/", createSyncRoutes(db)); 11 11 12 12 serve({ fetch: app.fetch, port: config.port }, (info) => {
+510
test/auth.test.ts
··· 1 + import crypto from "node:crypto"; 2 + import { describe, expect, it } from "vitest"; 3 + import { createApp } from "../src/app.js"; 4 + import { DEFAULT_TOS_TEXT, type Config } from "../src/config.js"; 5 + import { initDatabase } from "../src/db.js"; 6 + 7 + function generateRsa4096() { 8 + return crypto.generateKeyPairSync("rsa", { 9 + modulusLength: 4096, 10 + publicKeyEncoding: { type: "spki", format: "pem" }, 11 + privateKeyEncoding: { type: "pkcs8", format: "pem" }, 12 + }); 13 + } 14 + 15 + function generateRsa2048() { 16 + return crypto.generateKeyPairSync("rsa", { 17 + modulusLength: 2048, 18 + publicKeyEncoding: { type: "spki", format: "pem" }, 19 + privateKeyEncoding: { type: "pkcs8", format: "pem" }, 20 + }); 21 + } 22 + 23 + function pemToJwk(publicKeyPem: string): { kty: string; n: string; e: string } { 24 + const key = crypto.createPublicKey(publicKeyPem); 25 + const jwk = key.export({ format: "jwk" }); 26 + if ( 27 + !("kty" in jwk) || 28 + typeof jwk.kty !== "string" || 29 + !("n" in jwk) || 30 + typeof jwk.n !== "string" || 31 + !("e" in jwk) || 32 + typeof jwk.e !== "string" 33 + ) { 34 + throw new Error("expected RSA JWK"); 35 + } 36 + return { kty: jwk.kty, n: jwk.n, e: jwk.e }; 37 + } 38 + 39 + function base64urlEncode(input: Buffer | Uint8Array | string): string { 40 + return Buffer.from(input).toString("base64url"); 41 + } 42 + 43 + function createJwt(header: object, payload: object, privateKeyPem: string): string { 44 + const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 45 + const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); 46 + const signingInput = `${headerB64}.${payloadB64}`; 47 + const sign = crypto.createSign("SHA256"); 48 + sign.update(signingInput); 49 + const signature = sign.sign(privateKeyPem); 50 + return `${signingInput}.${base64urlEncode(signature)}`; 51 + } 52 + 53 + function createDpopProof( 54 + jwk: { kty: string; n: string; e: string }, 55 + privateKeyPem: string, 56 + method: string, 57 + htu: string, 58 + accessToken?: string, 59 + overrides?: { typ?: string; alg?: string; iat?: number }, 60 + ): string { 61 + const header = { 62 + typ: overrides?.typ ?? "dpop+jwt", 63 + alg: overrides?.alg ?? "RS256", 64 + jwk, 65 + }; 66 + const payload: Record<string, unknown> = { 67 + jti: crypto.randomUUID(), 68 + htm: method, 69 + htu, 70 + iat: overrides?.iat ?? Math.floor(Date.now() / 1000), 71 + }; 72 + if (accessToken) { 73 + const atHash = crypto.createHash("sha256").update(accessToken).digest(); 74 + payload.ath = base64urlEncode(atHash); 75 + } 76 + return createJwt(header, payload, privateKeyPem); 77 + } 78 + 79 + function signTos(tosText: string, privateKeyPem: string): string { 80 + const sign = crypto.createSign("SHA256"); 81 + sign.update(tosText); 82 + return base64urlEncode(sign.sign(privateKeyPem)); 83 + } 84 + 85 + function computeJwkThumbprint(jwk: { kty: string; n: string; e: string }): string { 86 + const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 87 + return base64urlEncode(crypto.createHash("sha256").update(canonical).digest()); 88 + } 89 + 90 + function createAccessToken( 91 + tosText: string, 92 + privateKeyPem: string, 93 + jwk: { kty: string; n: string; e: string }, 94 + serviceOrigin: string, 95 + overrides?: { 96 + typ?: string; 97 + alg?: string; 98 + tosHash?: string; 99 + aud?: string; 100 + jkt?: string; 101 + }, 102 + ): string { 103 + const tosHash = 104 + overrides?.tosHash ?? 105 + base64urlEncode(crypto.createHash("sha256").update(tosText).digest()); 106 + const jkt = overrides?.jkt ?? computeJwkThumbprint(jwk); 107 + return createJwt( 108 + { typ: overrides?.typ ?? "wm+jwt", alg: overrides?.alg ?? "RS256" }, 109 + { 110 + jti: crypto.randomUUID(), 111 + tos_hash: tosHash, 112 + aud: overrides?.aud ?? serviceOrigin, 113 + cnf: { jkt }, 114 + iat: Math.floor(Date.now() / 1000), 115 + }, 116 + privateKeyPem, 117 + ); 118 + } 119 + 120 + function createTestApp() { 121 + const db = initDatabase(":memory:"); 122 + const config: Config = { 123 + hostname: "test.example.com", 124 + handleDomain: "test.example.com", 125 + plcUrl: "https://plc.example.com", 126 + dbPath: ":memory:", 127 + port: 3000, 128 + tosText: DEFAULT_TOS_TEXT, 129 + }; 130 + const app = createApp(config, db); 131 + return { app, db, config }; 132 + } 133 + 134 + async function performSignup( 135 + app: ReturnType<typeof createApp>, 136 + config: Config, 137 + opts?: { 138 + handle?: string; 139 + publicKeyPem?: string; 140 + privateKeyPem?: string; 141 + dpopJwt?: string; 142 + tosSignature?: string; 143 + accessToken?: string; 144 + }, 145 + ) { 146 + const handle = opts?.handle ?? "agent"; 147 + const keys = opts?.publicKeyPem && opts.privateKeyPem 148 + ? { publicKey: opts.publicKeyPem, privateKey: opts.privateKeyPem } 149 + : generateRsa4096(); 150 + const jwk = pemToJwk(keys.publicKey); 151 + const accessToken = 152 + opts?.accessToken ?? 153 + createAccessToken(config.tosText, keys.privateKey, jwk, `https://${config.hostname}`); 154 + const dpopJwt = 155 + opts?.dpopJwt ?? 156 + createDpopProof(jwk, keys.privateKey, "POST", "http://localhost/api/signup"); 157 + const tosSignature = opts?.tosSignature ?? signTos(config.tosText, keys.privateKey); 158 + 159 + const response = await app.request("http://localhost/api/signup", { 160 + method: "POST", 161 + headers: { 162 + DPoP: dpopJwt, 163 + "Content-Type": "application/json", 164 + }, 165 + body: JSON.stringify({ 166 + handle, 167 + tos_signature: tosSignature, 168 + access_token: accessToken, 169 + }), 170 + }); 171 + 172 + return { response, jwk, ...keys, accessToken, handle }; 173 + } 174 + 175 + describe("discovery endpoints", () => { 176 + it("GET /.well-known/welcome.md returns markdown", async () => { 177 + const { app } = createTestApp(); 178 + const res = await app.request("http://localhost/.well-known/welcome.md"); 179 + 180 + expect(res.status).toBe(200); 181 + expect(res.headers.get("content-type")).toContain("text/markdown"); 182 + const body = await res.text(); 183 + expect(body).toContain("welcome mat v1 (DPoP)"); 184 + expect(body).toContain("https://test.example.com/api/signup"); 185 + }); 186 + 187 + it("GET /tos returns text/plain", async () => { 188 + const { app, config } = createTestApp(); 189 + const res = await app.request("http://localhost/tos"); 190 + 191 + expect(res.status).toBe(200); 192 + expect(res.headers.get("content-type")).toContain("text/plain"); 193 + await expect(res.text()).resolves.toBe(config.tosText); 194 + }); 195 + }); 196 + 197 + describe("POST /api/signup", () => { 198 + it("full signup flow returns did, handle, access_token, token_type", async () => { 199 + const { app, config, db } = createTestApp(); 200 + const { response, accessToken } = await performSignup(app, config, { handle: "agent" }); 201 + 202 + expect(response.status).toBe(200); 203 + const json = (await response.json()) as { 204 + did: string; 205 + handle: string; 206 + access_token: string; 207 + token_type: string; 208 + }; 209 + expect(json.did).toMatch(/^did:plc:[a-z2-7]{24}$/); 210 + expect(json.handle).toBe("agent.test.example.com"); 211 + expect(json.access_token).toBe(accessToken); 212 + expect(json.token_type).toBe("DPoP"); 213 + 214 + const row = db 215 + .prepare("SELECT did, handle, jwk_thumbprint FROM accounts WHERE did = ?") 216 + .get(json.did) as { did: string; handle: string; jwk_thumbprint: string } | undefined; 217 + expect(row?.handle).toBe("agent.test.example.com"); 218 + expect(row?.jwk_thumbprint).toBeTruthy(); 219 + }); 220 + 221 + it("rejects duplicate handle with 409", async () => { 222 + const { app, config } = createTestApp(); 223 + await performSignup(app, config, { handle: "agent" }); 224 + const second = await performSignup(app, config, { handle: "agent" }); 225 + 226 + expect(second.response.status).toBe(409); 227 + await expect(second.response.json()).resolves.toEqual({ error: "handle already taken" }); 228 + }); 229 + 230 + it("rejects duplicate JWK thumbprint with 409", async () => { 231 + const { app, config } = createTestApp(); 232 + const keys = generateRsa4096(); 233 + await performSignup(app, config, { 234 + handle: "agent-one", 235 + publicKeyPem: keys.publicKey, 236 + privateKeyPem: keys.privateKey, 237 + }); 238 + const second = await performSignup(app, config, { 239 + handle: "agent-two", 240 + publicKeyPem: keys.publicKey, 241 + privateKeyPem: keys.privateKey, 242 + }); 243 + 244 + expect(second.response.status).toBe(409); 245 + await expect(second.response.json()).resolves.toEqual({ error: "key already registered" }); 246 + }); 247 + 248 + it("rejects missing DPoP header", async () => { 249 + const { app } = createTestApp(); 250 + const response = await app.request("http://localhost/api/signup", { 251 + method: "POST", 252 + headers: { "Content-Type": "application/json" }, 253 + body: JSON.stringify({ handle: "agent" }), 254 + }); 255 + 256 + expect(response.status).toBe(400); 257 + await expect(response.json()).resolves.toEqual({ error: "missing DPoP header" }); 258 + }); 259 + 260 + it("rejects invalid DPoP: wrong typ", async () => { 261 + const { app, config } = createTestApp(); 262 + const keys = generateRsa4096(); 263 + const jwk = pemToJwk(keys.publicKey); 264 + const response = await performSignup(app, config, { 265 + dpopJwt: createDpopProof( 266 + jwk, 267 + keys.privateKey, 268 + "POST", 269 + "http://localhost/api/signup", 270 + undefined, 271 + { typ: "jwt" }, 272 + ), 273 + }); 274 + 275 + expect(response.response.status).toBe(400); 276 + await expect(response.response.json()).resolves.toEqual({ 277 + error: "invalid DPoP proof: typ must be dpop+jwt", 278 + }); 279 + }); 280 + 281 + it("rejects invalid DPoP: expired iat", async () => { 282 + const { app, config } = createTestApp(); 283 + const keys = generateRsa4096(); 284 + const jwk = pemToJwk(keys.publicKey); 285 + const response = await performSignup(app, config, { 286 + dpopJwt: createDpopProof( 287 + jwk, 288 + keys.privateKey, 289 + "POST", 290 + "http://localhost/api/signup", 291 + undefined, 292 + { iat: Math.floor(Date.now() / 1000) - 301 }, 293 + ), 294 + }); 295 + 296 + expect(response.response.status).toBe(400); 297 + await expect(response.response.json()).resolves.toEqual({ 298 + error: "invalid DPoP proof: iat too far from current time", 299 + }); 300 + }); 301 + 302 + it("rejects invalid DPoP: bad signature", async () => { 303 + const { app, config } = createTestApp(); 304 + const validKeys = generateRsa4096(); 305 + const invalidKeys = generateRsa4096(); 306 + const jwk = pemToJwk(validKeys.publicKey); 307 + const response = await performSignup(app, config, { 308 + dpopJwt: createDpopProof(jwk, invalidKeys.privateKey, "POST", "http://localhost/api/signup"), 309 + }); 310 + 311 + expect(response.response.status).toBe(400); 312 + await expect(response.response.json()).resolves.toEqual({ 313 + error: "invalid DPoP proof: signature verification failed", 314 + }); 315 + }); 316 + 317 + it("rejects invalid DPoP: non-4096 key", async () => { 318 + const { app, config } = createTestApp(); 319 + const keys = generateRsa2048(); 320 + const response = await performSignup(app, config, { 321 + publicKeyPem: keys.publicKey, 322 + privateKeyPem: keys.privateKey, 323 + }); 324 + 325 + expect(response.response.status).toBe(400); 326 + await expect(response.response.json()).resolves.toEqual({ 327 + error: "key must be 4096-bit RSA (got 2048-bit)", 328 + }); 329 + }); 330 + 331 + it("rejects invalid ToS signature", async () => { 332 + const { app, config } = createTestApp(); 333 + const keys = generateRsa4096(); 334 + const response = await performSignup(app, config, { 335 + publicKeyPem: keys.publicKey, 336 + privateKeyPem: keys.privateKey, 337 + tosSignature: signTos("wrong terms", keys.privateKey), 338 + }); 339 + 340 + expect(response.response.status).toBe(400); 341 + await expect(response.response.json()).resolves.toEqual({ 342 + error: "ToS signature verification failed", 343 + }); 344 + }); 345 + 346 + it("rejects invalid access token: wrong typ", async () => { 347 + const { app, config } = createTestApp(); 348 + const keys = generateRsa4096(); 349 + const jwk = pemToJwk(keys.publicKey); 350 + const response = await performSignup(app, config, { 351 + publicKeyPem: keys.publicKey, 352 + privateKeyPem: keys.privateKey, 353 + accessToken: createAccessToken( 354 + config.tosText, 355 + keys.privateKey, 356 + jwk, 357 + `https://${config.hostname}`, 358 + { typ: "jwt" }, 359 + ), 360 + }); 361 + 362 + expect(response.response.status).toBe(400); 363 + await expect(response.response.json()).resolves.toEqual({ 364 + error: "invalid access token: typ must be wm+jwt", 365 + }); 366 + }); 367 + 368 + it("rejects invalid access token: bad tos_hash", async () => { 369 + const { app, config } = createTestApp(); 370 + const keys = generateRsa4096(); 371 + const jwk = pemToJwk(keys.publicKey); 372 + const response = await performSignup(app, config, { 373 + publicKeyPem: keys.publicKey, 374 + privateKeyPem: keys.privateKey, 375 + accessToken: createAccessToken( 376 + config.tosText, 377 + keys.privateKey, 378 + jwk, 379 + `https://${config.hostname}`, 380 + { tosHash: "bad-hash" }, 381 + ), 382 + }); 383 + 384 + expect(response.response.status).toBe(400); 385 + await expect(response.response.json()).resolves.toEqual({ 386 + error: "invalid access token: tos_hash does not match current terms", 387 + }); 388 + }); 389 + 390 + it("rejects invalid access token: wrong aud", async () => { 391 + const { app, config } = createTestApp(); 392 + const keys = generateRsa4096(); 393 + const jwk = pemToJwk(keys.publicKey); 394 + const response = await performSignup(app, config, { 395 + publicKeyPem: keys.publicKey, 396 + privateKeyPem: keys.privateKey, 397 + accessToken: createAccessToken( 398 + config.tosText, 399 + keys.privateKey, 400 + jwk, 401 + `https://${config.hostname}`, 402 + { aud: "https://wrong.example.com" }, 403 + ), 404 + }); 405 + 406 + expect(response.response.status).toBe(400); 407 + await expect(response.response.json()).resolves.toEqual({ 408 + error: "invalid access token: aud does not match service origin", 409 + }); 410 + }); 411 + 412 + it("rejects invalid access token: mismatched cnf.jkt", async () => { 413 + const { app, config } = createTestApp(); 414 + const keys = generateRsa4096(); 415 + const jwk = pemToJwk(keys.publicKey); 416 + const response = await performSignup(app, config, { 417 + publicKeyPem: keys.publicKey, 418 + privateKeyPem: keys.privateKey, 419 + accessToken: createAccessToken( 420 + config.tosText, 421 + keys.privateKey, 422 + jwk, 423 + `https://${config.hostname}`, 424 + { jkt: "mismatched-thumbprint" }, 425 + ), 426 + }); 427 + 428 + expect(response.response.status).toBe(400); 429 + await expect(response.response.json()).resolves.toEqual({ 430 + error: "invalid access token: cnf.jkt does not match DPoP key", 431 + }); 432 + }); 433 + 434 + it("rejects invalid handle format", async () => { 435 + const { app, config } = createTestApp(); 436 + const response = await performSignup(app, config, { handle: "Bad_Handle" }); 437 + 438 + expect(response.response.status).toBe(400); 439 + await expect(response.response.json()).resolves.toEqual({ 440 + error: 441 + "invalid handle — must be lowercase alphanumeric with dots/hyphens, no leading/trailing separators", 442 + }); 443 + }); 444 + }); 445 + 446 + describe("auth middleware", () => { 447 + it("passes valid DPoP + access token, returns account info", async () => { 448 + const { app, config } = createTestApp(); 449 + const signup = await performSignup(app, config, { handle: "agent" }); 450 + const token = (await signup.response.json()) as { access_token: string }; 451 + const proof = createDpopProof( 452 + signup.jwk, 453 + signup.privateKey, 454 + "GET", 455 + "http://localhost/api/whoami", 456 + token.access_token, 457 + ); 458 + 459 + const response = await app.request("http://localhost/api/whoami", { 460 + method: "GET", 461 + headers: { 462 + Authorization: `DPoP ${token.access_token}`, 463 + DPoP: proof, 464 + }, 465 + }); 466 + 467 + expect(response.status).toBe(200); 468 + await expect(response.json()).resolves.toEqual({ 469 + did: expect.stringMatching(/^did:plc:[a-z2-7]{24}$/), 470 + handle: "agent.test.example.com", 471 + }); 472 + }); 473 + 474 + it("rejects missing Authorization header with 401", async () => { 475 + const { app } = createTestApp(); 476 + const response = await app.request("http://localhost/api/whoami"); 477 + 478 + expect(response.status).toBe(401); 479 + await expect(response.json()).resolves.toEqual({ 480 + error: "missing Authorization: DPoP <token> header", 481 + }); 482 + }); 483 + 484 + it("rejects invalid DPoP proof with 401", async () => { 485 + const { app, config } = createTestApp(); 486 + const signup = await performSignup(app, config, { handle: "agent" }); 487 + const token = (await signup.response.json()) as { access_token: string }; 488 + const badProof = createDpopProof( 489 + signup.jwk, 490 + signup.privateKey, 491 + "GET", 492 + "http://localhost/api/whoami", 493 + token.access_token, 494 + { typ: "jwt" }, 495 + ); 496 + 497 + const response = await app.request("http://localhost/api/whoami", { 498 + method: "GET", 499 + headers: { 500 + Authorization: `DPoP ${token.access_token}`, 501 + DPoP: badProof, 502 + }, 503 + }); 504 + 505 + expect(response.status).toBe(401); 506 + await expect(response.json()).resolves.toEqual({ 507 + error: "invalid DPoP proof: typ must be dpop+jwt", 508 + }); 509 + }); 510 + });