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.

Align enrollment and write-auth with WelcomeMat v1.0

POST /api/signup now requires a DPoP header, tos_signature, and wm+jwt
access_token in the body. The server validates the DPoP proof (with no
ath for enrollment), verifies the ToS signature against the current
terms using the DPoP key, and validates the access token including
tos_hash. The thumbprint is derived from the DPoP proof instead of
being passed in the request body.

All write endpoints (createRecord, putRecord, deleteRecord, applyWrites,
uploadBlob) now validate the wm+jwt access token via validateAccessToken
after DPoP proof validation. Stale tos_hash returns 401 with
{ error: "tos_changed" }.

Test helpers extracted to test/helpers.ts (signJwt, generateAuthKeys,
createDpopJwt, buildAccessToken, signTos). All test files updated to
use valid wm+jwt access tokens. New integration tests cover enrollment
validation and tos_changed rejection on writes.

+606 -232
+47 -5
docs/agent-guide.md
··· 46 46 47 47 ## Step 2: Enroll 48 48 49 - Call `POST /api/signup` with your chosen handle and JWK thumbprint. No authentication is required for enrollment. 49 + Enrollment is authenticated. Before calling `POST /api/signup`, fetch the current ToS, build a WelcomeMat access token, sign the ToS text, and build an enrollment DPoP proof without `ath`. 50 50 51 51 ```typescript 52 52 const host = "pds.solpbc.org"; 53 + function createJwt(header: object, payload: object, privateKeyPem: string): string { 54 + const enc = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); 55 + const signingInput = `${enc(header)}.${enc(payload)}`; 56 + const sig = crypto.createSign("SHA256"); 57 + sig.update(signingInput); 58 + return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; 59 + } 60 + 61 + const tosText = await fetch(`https://${host}/tos`).then(r => r.text()); 62 + const tosHash = base64url(crypto.createHash("sha256").update(tosText).digest()); 63 + 64 + const accessToken = createJwt( 65 + { typ: "wm+jwt", alg: "RS256" }, 66 + { 67 + tos_hash: tosHash, 68 + aud: `https://${host}`, 69 + cnf: { jkt: thumbprint }, 70 + iat: Math.floor(Date.now() / 1000), 71 + }, 72 + keys.privateKey, 73 + ); 74 + 75 + const tosSignature = base64url( 76 + crypto.sign("sha256", Buffer.from(tosText), keys.privateKey), 77 + ); 78 + 79 + const signupDpop = createJwt( 80 + { typ: "dpop+jwt", alg: "RS256", jwk: pubJwk }, 81 + { 82 + jti: crypto.randomUUID(), 83 + htm: "POST", 84 + htu: `https://${host}/api/signup`, 85 + iat: Math.floor(Date.now() / 1000), 86 + }, 87 + keys.privateKey, 88 + ); 53 89 54 90 const signupRes = await fetch(`https://${host}/api/signup`, { 55 91 method: "POST", 56 - headers: { "Content-Type": "application/json" }, 92 + headers: { 93 + "Content-Type": "application/json", 94 + DPoP: signupDpop, 95 + }, 57 96 body: JSON.stringify({ 58 97 handle: "my-agent", 59 - jwkThumbprint: thumbprint, 98 + tos_signature: tosSignature, 99 + access_token: accessToken, 60 100 }), 61 101 }); 62 102 63 - const { did, handle } = await signupRes.json(); 103 + const { did, handle, access_token, token_type } = await signupRes.json(); 64 104 // did: "did:plc:..." — your agent's decentralized identifier 65 105 // handle: "my-agent.pds.solpbc.org" 106 + // access_token: echoed wm+jwt 107 + // token_type: "DPoP" 66 108 ``` 67 109 68 - Rookery creates a `did:plc` identity on plc.directory and initializes an empty repo for your agent. The DID is immediately resolvable: 110 + Rookery validates the DPoP proof, ToS signature, and access token, then creates a `did:plc` identity on plc.directory and initializes an empty repo for your agent. The DID is immediately resolvable: 69 111 70 112 ```bash 71 113 curl https://plc.directory/did:plc:...
+145 -15
src/worker.ts
··· 11 11 resolveByThumbprint, 12 12 } from "./directory"; 13 13 import { 14 + base64urlDecode, 14 15 extractBearerToken, 16 + validateAccessToken, 15 17 validateDpopProof, 16 18 } from "./auth"; 17 19 import type { Env } from "./types"; 18 20 19 - const WELCOME_MAT_TEXT = `# Welcome to rookery 21 + const WELCOME_MAT_TEXT = `# Rookery 20 22 21 - This service supports WelcomeMat enrollment for AT Protocol agents. 23 + AT Protocol Personal Data Server (PDS) for AI agents. Implements WelcomeMat v1.0 for authenticated enrollment. 24 + 25 + ## Requirements 22 26 23 - Use DPoP for authenticated write requests. 24 - Read /tos before requesting access tokens or signing up. 27 + - RSA-4096 keypair (RSASSA-PKCS1-v1_5, SHA-256) 28 + - Algorithm: RS256 29 + - Protocol: WelcomeMat v1.0 30 + 31 + ## Endpoints 32 + 33 + - \`GET /tos\` - current Terms of Service (text/plain) 34 + - \`POST /api/signup\` - authenticated enrollment 35 + 36 + ## Enrollment 37 + 38 + 1. Generate an RSA-4096 keypair 39 + 2. Fetch \`GET /tos\` and compute \`sha256(tos_text)\` as base64url 40 + 3. Build a \`wm+jwt\` access token with \`{ tos_hash, aud, cnf: { jkt: thumbprint } }\` 41 + 4. Sign the ToS text with your private key to produce \`tos_signature\` 42 + 5. Build a DPoP proof JWT (no \`ath\` required for enrollment) 43 + 44 + ### Request 45 + 46 + \`\`\` 47 + POST /api/signup 48 + DPoP: <dpop+jwt> 49 + Content-Type: application/json 50 + 51 + { 52 + "handle": "my-agent", 53 + "tos_signature": "<base64url-encoded signature of ToS text>", 54 + "access_token": "<wm+jwt>" 55 + } 56 + \`\`\` 57 + 58 + ### Response 59 + 60 + \`\`\`json 61 + { 62 + "did": "did:plc:...", 63 + "handle": "my-agent.pds.example.com", 64 + "access_token": "<echoed wm+jwt>", 65 + "token_type": "DPoP" 66 + } 67 + \`\`\` 25 68 `; 26 69 27 70 const TOS_TEXT = `Rookery Terms of Service 28 71 29 - By using this service, you accept that repository content you write may be distributed to relays and other AT Protocol services. 30 - Do not use the service for unlawful activity, abuse, or attempts to disrupt network operations. 72 + What this is 73 + Rookery is an AT Protocol Personal Data Server (PDS) for AI agents. It hosts your data repository and publishes it on the AT Protocol network. 74 + 75 + What you can do 76 + Store and publish AT Protocol records in any lexicon collection. Your repo is yours — write any valid NSID collection, no schema restrictions. 77 + 78 + Data distribution 79 + Records you write may be distributed to relays, appviews, and other AT Protocol services. This is how the protocol works — your data is public network data once published. 80 + 81 + Prohibited use 82 + - Flooding or spamming: excessive write rates, bulk record creation designed to overwhelm the service or network 83 + - Enrollment abuse: mass account creation, bot farms, or automated signups beyond legitimate agent use 84 + - Storing illegal content 85 + - Disrupting network operations or degrading service for other users 86 + - Using this service to attack, scrape, or abuse other AT Protocol services 87 + - Publishing content designed to deceive or impersonate others 88 + 89 + Your responsibilities 90 + - Protect your private keys. Your key is your identity. If you lose it, you lose access. The operator cannot recover keys. 91 + - Follow AT Protocol specifications for record formats and authentication. 92 + - Respect rate limits. If you hit one, back off. 93 + 94 + Operator rights 95 + - Accounts that violate these terms may be deactivated without notice. 96 + - These terms may be updated. When they change, agents must re-consent by including the new ToS hash in their access token (WelcomeMat protocol). 97 + - This service is provided as-is, with no warranty of any kind. 98 + 99 + Data commitment 100 + The operator does not sell, license, or share user data with third parties. No analytics vendors, no tracking, no exceptions. 31 101 `; 32 102 33 103 let hasRequestedCrawl = false; ··· 43 113 const accessToken = extractBearerToken(authHeader ?? null); 44 114 if (!accessToken) throw new Error("Missing DPoP authorization"); 45 115 if (!dpopHeader) throw new Error("Missing DPoP proof"); 46 - const { thumbprint } = await validateDpopProof(dpopHeader, method, url, accessToken); 116 + const { key, thumbprint } = await validateDpopProof(dpopHeader, method, url, accessToken); 117 + const serviceOrigin = `https://${env.ROOKERY_HOSTNAME}`; 118 + await validateAccessToken(accessToken, key, serviceOrigin, thumbprint, TOS_TEXT); 47 119 await initDirectory(env.DIRECTORY); 48 120 return resolveByThumbprint(env.DIRECTORY, thumbprint); 49 121 } ··· 101 173 // POST /api/signup 102 174 app.post("/api/signup", async (c) => { 103 175 const env = c.env; 104 - let body: { handle?: string; jwkThumbprint?: string }; 176 + let body: { handle?: string; tos_signature?: string; access_token?: string }; 105 177 106 178 try { 107 - body = await c.req.json<{ handle?: string; jwkThumbprint?: string }>(); 179 + body = await c.req.json<{ handle?: string; tos_signature?: string; access_token?: string }>(); 108 180 } catch { 109 181 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 110 182 } ··· 112 184 if (!body.handle || typeof body.handle !== "string") { 113 185 return c.json({ error: "InvalidRequest", message: "Missing or invalid handle" }, 400); 114 186 } 115 - if (body.jwkThumbprint !== undefined && typeof body.jwkThumbprint !== "string") { 116 - return c.json({ error: "InvalidRequest", message: "Invalid jwkThumbprint" }, 400); 187 + if (!body.tos_signature || typeof body.tos_signature !== "string") { 188 + return c.json({ error: "InvalidRequest", message: "Missing tos_signature" }, 400); 189 + } 190 + if (!body.access_token || typeof body.access_token !== "string") { 191 + return c.json({ error: "InvalidRequest", message: "Missing access_token" }, 400); 192 + } 193 + 194 + const dpopHeader = c.req.header("dpop"); 195 + if (!dpopHeader) { 196 + return c.json({ error: "AuthRequired", message: "Missing DPoP proof" }, 401); 197 + } 198 + 199 + let key: CryptoKey; 200 + let thumbprint: string; 201 + try { 202 + const result = await validateDpopProof(dpopHeader, "POST", c.req.url, null); 203 + key = result.key; 204 + thumbprint = result.thumbprint; 205 + } catch (err) { 206 + return c.json({ error: "AuthFailed", message: (err as Error).message }, 401); 207 + } 208 + 209 + try { 210 + const sigBytes = base64urlDecode(body.tos_signature); 211 + const valid = await crypto.subtle.verify( 212 + "RSASSA-PKCS1-v1_5", 213 + key, 214 + sigBytes, 215 + new TextEncoder().encode(TOS_TEXT), 216 + ); 217 + if (!valid) { 218 + return c.json({ error: "InvalidSignature", message: "Invalid ToS signature" }, 400); 219 + } 220 + } catch { 221 + return c.json({ error: "InvalidSignature", message: "Invalid ToS signature" }, 400); 222 + } 223 + 224 + const serviceOrigin = `https://${env.ROOKERY_HOSTNAME}`; 225 + try { 226 + await validateAccessToken(body.access_token, key, serviceOrigin, thumbprint, TOS_TEXT); 227 + } catch (err) { 228 + return c.json({ error: "InvalidToken", message: (err as Error).message }, 400); 117 229 } 118 230 119 231 // Always construct handle as name + configured domain ··· 160 272 signingKeyPub, 161 273 rotationKeyHex, 162 274 rotationKeyPub, 163 - jwkThumbprint: body.jwkThumbprint, 275 + jwkThumbprint: thumbprint, 164 276 }); 165 277 166 278 try { ··· 168 280 did, 169 281 handle, 170 282 doId: doId.toString(), 171 - jwkThumbprint: body.jwkThumbprint, 283 + jwkThumbprint: thumbprint, 172 284 }); 173 285 } catch (e: unknown) { 174 286 if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) { ··· 177 289 throw e; 178 290 } 179 291 180 - return c.json({ did, handle }); 292 + return c.json({ did, handle, access_token: body.access_token, token_type: "DPoP" }); 181 293 }); 182 294 183 295 // GET /xrpc/com.atproto.identity.resolveHandle ··· 407 519 try { 408 520 const result = await validateDpopProof(dpopJwt, "POST", c.req.url, accessToken); 409 521 thumbprint = result.thumbprint; 522 + const serviceOrigin = `https://${c.env.ROOKERY_HOSTNAME}`; 523 + await validateAccessToken(accessToken, result.key, serviceOrigin, thumbprint, TOS_TEXT); 410 524 } catch (err) { 411 - return c.json({ error: "AuthFailed", message: (err as Error).message }, 401); 525 + const message = (err as Error).message; 526 + if (message.includes("tos_hash does not match")) { 527 + return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 528 + } 529 + return c.json({ error: "AuthFailed", message }, 401); 412 530 } 413 531 414 532 await initDirectory(c.env.DIRECTORY); ··· 449 567 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 450 568 } 451 569 const message = (err as Error).message; 570 + if (message.includes("tos_hash does not match")) { 571 + return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 572 + } 452 573 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 453 574 return c.json({ error: code, message }, 401); 454 575 } ··· 492 613 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 493 614 } 494 615 const message = (err as Error).message; 616 + if (message.includes("tos_hash does not match")) { 617 + return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 618 + } 495 619 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 496 620 return c.json({ error: code, message }, 401); 497 621 } ··· 535 659 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 536 660 } 537 661 const message = (err as Error).message; 662 + if (message.includes("tos_hash does not match")) { 663 + return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 664 + } 538 665 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 539 666 return c.json({ error: code, message }, 401); 540 667 } ··· 578 705 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 579 706 } 580 707 const message = (err as Error).message; 708 + if (message.includes("tos_hash does not match")) { 709 + return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 710 + } 581 711 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 582 712 return c.json({ error: code, message }, 401); 583 713 }
+1 -18
test/auth.test.ts
··· 1 1 import { beforeAll, describe, expect, it } from "vitest"; 2 + import { signJwt } from "./helpers"; 2 3 import { 3 4 base64urlDecode, 4 5 base64urlEncode, ··· 21 22 true, 22 23 ["sign", "verify"], 23 24 ); 24 - } 25 - 26 - async function signJwt( 27 - header: Record<string, unknown>, 28 - payload: Record<string, unknown>, 29 - privateKey: CryptoKey, 30 - ): Promise<string> { 31 - const enc = (obj: Record<string, unknown>) => 32 - base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 33 - const headerStr = enc(header); 34 - const payloadStr = enc(payload); 35 - const signingInput = `${headerStr}.${payloadStr}`; 36 - const sig = await crypto.subtle.sign( 37 - "RSASSA-PKCS1-v1_5", 38 - privateKey, 39 - new TextEncoder().encode(signingInput), 40 - ); 41 - return `${signingInput}.${base64urlEncode(sig)}`; 42 25 } 43 26 44 27 function tamperJwtSignature(jwt: string): string {
+18 -35
test/blobs.test.ts
··· 1 1 import { describe, it, expect, beforeAll } from "vitest"; 2 - import { env, runInDurableObject, worker } from "./helpers"; 2 + import { 3 + buildAccessToken, 4 + env, 5 + generateAuthKeys, 6 + runInDurableObject, 7 + signJwt, 8 + worker, 9 + } from "./helpers"; 3 10 import { AccountDurableObject, type BlobRef } from "../src/account-do"; 4 11 import { initDirectory, insertAccount } from "../src/directory"; 5 12 import { Secp256k1Keypair } from "@atproto/crypto"; 6 13 import { toString } from "uint8arrays/to-string"; 7 - import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 8 - 9 - async function signJwt( 10 - header: Record<string, unknown>, 11 - payload: Record<string, unknown>, 12 - privateKey: CryptoKey, 13 - ): Promise<string> { 14 - const encode = (obj: Record<string, unknown>) => 15 - base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 16 - const headerStr = encode(header); 17 - const payloadStr = encode(payload); 18 - const signingInput = `${headerStr}.${payloadStr}`; 19 - const signature = await crypto.subtle.sign( 20 - "RSASSA-PKCS1-v1_5", 21 - privateKey, 22 - new TextEncoder().encode(signingInput), 23 - ); 24 - return `${signingInput}.${base64urlEncode(signature)}`; 25 - } 14 + import { sha256Base64url } from "../src/auth"; 26 15 27 16 async function setupBlobTestAccount( 28 17 testEnv: typeof env, ··· 134 123 }); 135 124 136 125 it("uploads a blob via the DPoP-authenticated route", async () => { 137 - const keyPair = await crypto.subtle.generateKey( 138 - { 139 - name: "RSASSA-PKCS1-v1_5", 140 - modulusLength: 4096, 141 - publicExponent: new Uint8Array([1, 0, 1]), 142 - hash: "SHA-256", 143 - }, 144 - true, 145 - ["sign", "verify"], 146 - ); 147 - const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 148 - const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 126 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 149 127 const { did } = await setupBlobTestAccount(env, { jwkThumbprint: thumbprint }); 150 - 151 - const accessToken = "route-upload-token"; 128 + const tosText = await worker.fetch("http://localhost/tos").then((response) => response.text()); 129 + const accessToken = await buildAccessToken( 130 + authKeys, 131 + thumbprint, 132 + tosText, 133 + `https://${env.ROOKERY_HOSTNAME}`, 134 + ); 152 135 const body = new TextEncoder().encode("blob via route"); 153 136 const dpopJwt = await signJwt( 154 137 { ··· 163 146 iat: Math.floor(Date.now() / 1000), 164 147 ath: await sha256Base64url(accessToken), 165 148 }, 166 - keyPair.privateKey, 149 + authKeys.privateKey, 167 150 ); 168 151 169 152 const response = await worker.fetch(
+95
test/helpers.ts
··· 1 1 import { env as _env, SELF } from "cloudflare:test"; 2 2 export { runInDurableObject } from "cloudflare:test"; 3 3 import type { Env } from "../src/types"; 4 + import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 4 5 5 6 export const env = _env as Env; 6 7 export const worker = SELF; 8 + 9 + export async function signJwt( 10 + header: Record<string, unknown>, 11 + payload: Record<string, unknown>, 12 + privateKey: CryptoKey, 13 + ): Promise<string> { 14 + const encode = (obj: Record<string, unknown>) => 15 + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 16 + const headerStr = encode(header); 17 + const payloadStr = encode(payload); 18 + const signingInput = `${headerStr}.${payloadStr}`; 19 + const signature = await crypto.subtle.sign( 20 + "RSASSA-PKCS1-v1_5", 21 + privateKey, 22 + new TextEncoder().encode(signingInput), 23 + ); 24 + return `${signingInput}.${base64urlEncode(signature)}`; 25 + } 26 + 27 + export async function generateAuthKeys(): Promise<{ 28 + authKeys: CryptoKeyPair; 29 + publicJwk: JsonWebKey; 30 + thumbprint: string; 31 + }> { 32 + const authKeys = await crypto.subtle.generateKey( 33 + { 34 + name: "RSASSA-PKCS1-v1_5", 35 + modulusLength: 4096, 36 + publicExponent: new Uint8Array([1, 0, 1]), 37 + hash: "SHA-256", 38 + }, 39 + true, 40 + ["sign", "verify"], 41 + ); 42 + const publicJwk = await crypto.subtle.exportKey("jwk", authKeys.publicKey); 43 + const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 44 + return { authKeys, publicJwk, thumbprint }; 45 + } 46 + 47 + export async function createDpopJwt( 48 + authKeys: CryptoKeyPair, 49 + publicJwk: JsonWebKey, 50 + htu: string, 51 + accessToken: string | null, 52 + ): Promise<string> { 53 + const payload: Record<string, unknown> = { 54 + jti: `jti-${Date.now().toString(36)}`, 55 + htm: "POST", 56 + htu, 57 + iat: Math.floor(Date.now() / 1000), 58 + }; 59 + if (accessToken !== null) { 60 + payload.ath = await sha256Base64url(accessToken); 61 + } 62 + return signJwt( 63 + { 64 + typ: "dpop+jwt", 65 + alg: "RS256", 66 + jwk: publicJwk, 67 + }, 68 + payload, 69 + authKeys.privateKey, 70 + ); 71 + } 72 + 73 + export async function buildAccessToken( 74 + authKeys: CryptoKeyPair, 75 + thumbprint: string, 76 + tosText: string, 77 + serviceOrigin: string, 78 + ): Promise<string> { 79 + return signJwt( 80 + { typ: "wm+jwt", alg: "RS256" }, 81 + { 82 + tos_hash: await sha256Base64url(tosText), 83 + aud: serviceOrigin, 84 + cnf: { jkt: thumbprint }, 85 + iat: Math.floor(Date.now() / 1000), 86 + }, 87 + authKeys.privateKey, 88 + ); 89 + } 90 + 91 + export async function signTos( 92 + privateKey: CryptoKey, 93 + tosText: string, 94 + ): Promise<string> { 95 + const signature = await crypto.subtle.sign( 96 + "RSASSA-PKCS1-v1_5", 97 + privateKey, 98 + new TextEncoder().encode(tosText), 99 + ); 100 + return base64urlEncode(signature); 101 + }
+256 -97
test/integration.test.ts
··· 1 1 import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { env, worker, runInDurableObject } from "./helpers"; 2 + import { 3 + buildAccessToken, 4 + createDpopJwt, 5 + env, 6 + generateAuthKeys, 7 + runInDurableObject, 8 + signJwt, 9 + signTos, 10 + worker, 11 + } from "./helpers"; 3 12 import { initDirectory } from "../src/directory"; 4 - import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 13 + import { sha256Base64url } from "../src/auth"; 5 14 import { SequencerDurableObject } from "../src/sequencer-do"; 6 15 7 - async function signJwt( 8 - header: Record<string, unknown>, 9 - payload: Record<string, unknown>, 10 - privateKey: CryptoKey, 11 - ): Promise<string> { 12 - const encode = (obj: Record<string, unknown>) => 13 - base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 14 - const headerStr = encode(header); 15 - const payloadStr = encode(payload); 16 - const signingInput = `${headerStr}.${payloadStr}`; 17 - const signature = await crypto.subtle.sign( 18 - "RSASSA-PKCS1-v1_5", 19 - privateKey, 20 - new TextEncoder().encode(signingInput), 21 - ); 22 - return `${signingInput}.${base64urlEncode(signature)}`; 23 - } 16 + const SERVICE_ORIGIN = `https://${env.ROOKERY_HOSTNAME}`; 24 17 25 18 function getSequencerStub() { 26 19 const id = env.SEQUENCER.idFromName("sequencer"); ··· 56 49 }); 57 50 } 58 51 59 - async function createAccountViaSignup( 60 - handle: string, 61 - thumbprint?: string, 62 - ): Promise<{ did: string; handle: string }> { 52 + function stubPlcDirectory() { 63 53 const originalFetch = globalThis.fetch.bind(globalThis); 64 - const fetchSpy = vi 54 + return vi 65 55 .spyOn(globalThis, "fetch") 66 56 .mockImplementation(async (input, init) => { 67 57 const url = ··· 71 61 } 72 62 return originalFetch(input as RequestInfo | URL, init); 73 63 }); 64 + } 65 + 66 + async function fetchTosText(): Promise<string> { 67 + const response = await worker.fetch("http://localhost/tos"); 68 + expect(response.status).toBe(200); 69 + return response.text(); 70 + } 71 + 72 + async function createAccountViaSignup( 73 + handle: string, 74 + authKeys: CryptoKeyPair, 75 + publicJwk: JsonWebKey, 76 + thumbprint: string, 77 + ): Promise<{ did: string; handle: string; access_token: string }> { 78 + const fetchSpy = stubPlcDirectory(); 74 79 75 80 try { 81 + const tosText = await fetchTosText(); 82 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 83 + const tosSig = await signTos(authKeys.privateKey, tosText); 84 + const dpop = await createDpopJwt(authKeys, publicJwk, "http://localhost/api/signup", null); 85 + 76 86 const response = await worker.fetch( 77 87 new Request("http://localhost/api/signup", { 78 88 method: "POST", 79 - headers: { "Content-Type": "application/json" }, 80 - body: JSON.stringify({ handle, jwkThumbprint: thumbprint }), 89 + headers: { 90 + "Content-Type": "application/json", 91 + dpop, 92 + }, 93 + body: JSON.stringify({ 94 + handle, 95 + tos_signature: tosSig, 96 + access_token: accessToken, 97 + }), 81 98 }), 82 99 ); 83 100 expect(response.status).toBe(200); 84 - return response.json<{ did: string; handle: string }>(); 101 + return response.json<{ did: string; handle: string; access_token: string }>(); 85 102 } finally { 86 103 fetchSpy.mockRestore(); 87 104 } 88 105 } 89 106 90 - async function generateAuthKeys(): Promise<{ 91 - authKeys: CryptoKeyPair; 92 - publicJwk: JsonWebKey; 93 - thumbprint: string; 94 - }> { 95 - const authKeys = await crypto.subtle.generateKey( 96 - { 97 - name: "RSASSA-PKCS1-v1_5", 98 - modulusLength: 4096, 99 - publicExponent: new Uint8Array([1, 0, 1]), 100 - hash: "SHA-256", 101 - }, 102 - true, 103 - ["sign", "verify"], 104 - ); 105 - const publicJwk = await crypto.subtle.exportKey("jwk", authKeys.publicKey); 106 - const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 107 - return { authKeys, publicJwk, thumbprint }; 108 - } 109 - 110 - async function createDpopJwt( 111 - authKeys: CryptoKeyPair, 112 - publicJwk: JsonWebKey, 113 - htu: string, 114 - accessToken: string, 115 - ): Promise<string> { 116 - return signJwt( 117 - { 118 - typ: "dpop+jwt", 119 - alg: "RS256", 120 - jwk: publicJwk, 121 - }, 122 - { 123 - jti: `jti-${Date.now().toString(36)}`, 124 - htm: "POST", 125 - htu, 126 - iat: Math.floor(Date.now() / 1000), 127 - ath: await sha256Base64url(accessToken), 128 - }, 129 - authKeys.privateKey, 130 - ); 131 - } 132 - 133 107 describe("Integration", () => { 134 108 beforeAll(async () => { 135 109 await initDirectory(env.DIRECTORY); ··· 143 117 const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 144 118 const { did } = await createAccountViaSignup( 145 119 `lifecycle-${Date.now().toString(36)}`, 120 + authKeys, 121 + publicJwk, 146 122 thumbprint, 147 123 ); 148 124 expect(did.startsWith("did:plc:")).toBe(true); 149 125 150 - const accessToken = "lifecycle-token"; 126 + const tosText = await fetchTosText(); 127 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 151 128 const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 152 129 const createResponse = await worker.fetch( 153 130 new Request(createUrl, { ··· 189 166 it("keeps multi-agent writes and reads isolated", async () => { 190 167 const agentAKeys = await generateAuthKeys(); 191 168 const agentBKeys = await generateAuthKeys(); 169 + const tosText = await fetchTosText(); 192 170 const agentA = await createAccountViaSignup( 193 171 `agent-a-${Date.now().toString(36)}`, 172 + agentAKeys.authKeys, 173 + agentAKeys.publicJwk, 194 174 agentAKeys.thumbprint, 195 175 ); 196 176 const agentB = await createAccountViaSignup( 197 177 `agent-b-${Date.now().toString(36)}`, 178 + agentBKeys.authKeys, 179 + agentBKeys.publicJwk, 198 180 agentBKeys.thumbprint, 199 181 ); 200 182 201 183 const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 184 + const agentAAccessToken = await buildAccessToken( 185 + agentAKeys.authKeys, 186 + agentAKeys.thumbprint, 187 + tosText, 188 + SERVICE_ORIGIN, 189 + ); 190 + const agentBAccessToken = await buildAccessToken( 191 + agentBKeys.authKeys, 192 + agentBKeys.thumbprint, 193 + tosText, 194 + SERVICE_ORIGIN, 195 + ); 202 196 203 197 const agentAWrite = await worker.fetch( 204 198 new Request(createUrl, { 205 199 method: "POST", 206 200 headers: { 207 - authorization: "DPoP agent-a-token", 201 + authorization: `DPoP ${agentAAccessToken}`, 208 202 dpop: await createDpopJwt( 209 203 agentAKeys.authKeys, 210 204 agentAKeys.publicJwk, 211 205 createUrl, 212 - "agent-a-token", 206 + agentAAccessToken, 213 207 ), 214 208 "content-type": "application/json", 215 209 }, ··· 227 221 new Request(createUrl, { 228 222 method: "POST", 229 223 headers: { 230 - authorization: "DPoP agent-b-token", 224 + authorization: `DPoP ${agentBAccessToken}`, 231 225 dpop: await createDpopJwt( 232 226 agentBKeys.authKeys, 233 227 agentBKeys.publicJwk, 234 228 createUrl, 235 - "agent-b-token", 229 + agentBAccessToken, 236 230 ), 237 231 "content-type": "application/json", 238 232 }, ··· 290 284 }); 291 285 292 286 it("resolves the signed-up handle back to the same DID", async () => { 293 - const account = await createAccountViaSignup(`did-test-${Date.now().toString(36)}`); 287 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 288 + const account = await createAccountViaSignup( 289 + `did-test-${Date.now().toString(36)}`, 290 + authKeys, 291 + publicJwk, 292 + thumbprint, 293 + ); 294 294 expect(account.did.startsWith("did:plc:")).toBe(true); 295 295 296 296 const response = await worker.fetch( ··· 302 302 303 303 it("covers enrollment edge cases", async () => { 304 304 const duplicateHandle = `duphandle-${Date.now().toString(36)}`; 305 - await createAccountViaSignup(duplicateHandle); 306 - 307 - const originalFetch = globalThis.fetch.bind(globalThis); 308 - const fetchSpy = vi 309 - .spyOn(globalThis, "fetch") 310 - .mockImplementation(async (input, init) => { 311 - const url = 312 - typeof input === "string" ? input : input instanceof Request ? input.url : input.url; 313 - if (url.startsWith("https://plc.directory/")) { 314 - return new Response(null, { status: 200 }); 315 - } 316 - return originalFetch(input as RequestInfo | URL, init); 317 - }); 305 + const duplicateHandleKeys = await generateAuthKeys(); 306 + await createAccountViaSignup( 307 + duplicateHandle, 308 + duplicateHandleKeys.authKeys, 309 + duplicateHandleKeys.publicJwk, 310 + duplicateHandleKeys.thumbprint, 311 + ); 318 312 313 + const fetchSpy = stubPlcDirectory(); 319 314 try { 320 - const duplicateHandleResponse = await worker.fetch( 315 + const duplicateSignupKeys = await generateAuthKeys(); 316 + const tosText = await fetchTosText(); 317 + const duplicateToken = await buildAccessToken( 318 + duplicateSignupKeys.authKeys, 319 + duplicateSignupKeys.thumbprint, 320 + tosText, 321 + SERVICE_ORIGIN, 322 + ); 323 + const duplicateResponse = await worker.fetch( 321 324 new Request("http://localhost/api/signup", { 322 325 method: "POST", 323 - headers: { "Content-Type": "application/json" }, 324 - body: JSON.stringify({ handle: duplicateHandle }), 326 + headers: { 327 + "Content-Type": "application/json", 328 + dpop: await createDpopJwt( 329 + duplicateSignupKeys.authKeys, 330 + duplicateSignupKeys.publicJwk, 331 + "http://localhost/api/signup", 332 + null, 333 + ), 334 + }, 335 + body: JSON.stringify({ 336 + handle: duplicateHandle, 337 + tos_signature: await signTos(duplicateSignupKeys.authKeys.privateKey, tosText), 338 + access_token: duplicateToken, 339 + }), 325 340 }), 326 341 ); 327 - expect(duplicateHandleResponse.status).toBe(409); 328 - const dupBody = await duplicateHandleResponse.json() as { error: string; message: string }; 342 + expect(duplicateResponse.status).toBe(409); 343 + const dupBody = await duplicateResponse.json() as { error: string; message: string }; 329 344 expect(dupBody.error).toBe("HandleAlreadyTaken"); 330 345 expect(dupBody.message).toBe("Handle is already in use"); 331 346 } finally { ··· 335 350 const duplicateThumbprint = await generateAuthKeys(); 336 351 await createAccountViaSignup( 337 352 `dup-thumb-a-${Date.now().toString(36)}`, 353 + duplicateThumbprint.authKeys, 354 + duplicateThumbprint.publicJwk, 338 355 duplicateThumbprint.thumbprint, 339 356 ); 340 357 const dupThumbAccount = await createAccountViaSignup( 341 358 `dup-thumb-b-${Date.now().toString(36)}`, 359 + duplicateThumbprint.authKeys, 360 + duplicateThumbprint.publicJwk, 342 361 duplicateThumbprint.thumbprint, 343 362 ); 344 363 expect(dupThumbAccount.did.startsWith("did:plc:")).toBe(true); ··· 376 395 expect(invalidDpopResponse.status).toBe(401); 377 396 }); 378 397 398 + describe("enrollment validation", () => { 399 + it("rejects signup without DPoP header", async () => { 400 + const { authKeys, thumbprint } = await generateAuthKeys(); 401 + const fetchSpy = stubPlcDirectory(); 402 + 403 + try { 404 + const tosText = await fetchTosText(); 405 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 406 + const response = await worker.fetch( 407 + new Request("http://localhost/api/signup", { 408 + method: "POST", 409 + headers: { "Content-Type": "application/json" }, 410 + body: JSON.stringify({ 411 + handle: `no-dpop-${Date.now().toString(36)}`, 412 + tos_signature: await signTos(authKeys.privateKey, tosText), 413 + access_token: accessToken, 414 + }), 415 + }), 416 + ); 417 + 418 + expect(response.status).toBe(401); 419 + await expect(response.json()).resolves.toMatchObject({ error: "AuthRequired" }); 420 + } finally { 421 + fetchSpy.mockRestore(); 422 + } 423 + }); 424 + 425 + it("rejects signup with bad tos_signature", async () => { 426 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 427 + const fetchSpy = stubPlcDirectory(); 428 + 429 + try { 430 + const tosText = await fetchTosText(); 431 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 432 + const response = await worker.fetch( 433 + new Request("http://localhost/api/signup", { 434 + method: "POST", 435 + headers: { 436 + "Content-Type": "application/json", 437 + dpop: await createDpopJwt(authKeys, publicJwk, "http://localhost/api/signup", null), 438 + }, 439 + body: JSON.stringify({ 440 + handle: `bad-tos-sig-${Date.now().toString(36)}`, 441 + tos_signature: await signTos(authKeys.privateKey, `${tosText}\n`), 442 + access_token: accessToken, 443 + }), 444 + }), 445 + ); 446 + 447 + expect(response.status).toBe(400); 448 + await expect(response.json()).resolves.toMatchObject({ error: "InvalidSignature" }); 449 + } finally { 450 + fetchSpy.mockRestore(); 451 + } 452 + }); 453 + 454 + it("rejects signup with wrong tos_hash in access_token", async () => { 455 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 456 + const fetchSpy = stubPlcDirectory(); 457 + 458 + try { 459 + const tosText = await fetchTosText(); 460 + const accessToken = await signJwt( 461 + { typ: "wm+jwt", alg: "RS256" }, 462 + { 463 + tos_hash: await sha256Base64url("wrong tos text"), 464 + aud: SERVICE_ORIGIN, 465 + cnf: { jkt: thumbprint }, 466 + iat: Math.floor(Date.now() / 1000), 467 + }, 468 + authKeys.privateKey, 469 + ); 470 + const response = await worker.fetch( 471 + new Request("http://localhost/api/signup", { 472 + method: "POST", 473 + headers: { 474 + "Content-Type": "application/json", 475 + dpop: await createDpopJwt(authKeys, publicJwk, "http://localhost/api/signup", null), 476 + }, 477 + body: JSON.stringify({ 478 + handle: `wrong-tos-hash-${Date.now().toString(36)}`, 479 + tos_signature: await signTos(authKeys.privateKey, tosText), 480 + access_token: accessToken, 481 + }), 482 + }), 483 + ); 484 + 485 + expect(response.status).toBe(400); 486 + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); 487 + } finally { 488 + fetchSpy.mockRestore(); 489 + } 490 + }); 491 + 492 + it("rejects write with stale tos_hash", async () => { 493 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 494 + const { did } = await createAccountViaSignup( 495 + `stale-write-${Date.now().toString(36)}`, 496 + authKeys, 497 + publicJwk, 498 + thumbprint, 499 + ); 500 + const staleAccessToken = await signJwt( 501 + { typ: "wm+jwt", alg: "RS256" }, 502 + { 503 + tos_hash: await sha256Base64url("wrong tos text"), 504 + aud: SERVICE_ORIGIN, 505 + cnf: { jkt: thumbprint }, 506 + iat: Math.floor(Date.now() / 1000), 507 + }, 508 + authKeys.privateKey, 509 + ); 510 + const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 511 + const response = await worker.fetch( 512 + new Request(createUrl, { 513 + method: "POST", 514 + headers: { 515 + authorization: `DPoP ${staleAccessToken}`, 516 + dpop: await createDpopJwt(authKeys, publicJwk, createUrl, staleAccessToken), 517 + "content-type": "application/json", 518 + }, 519 + body: JSON.stringify({ 520 + repo: did, 521 + collection: "com.example.test", 522 + rkey: "stale-token", 523 + record: { text: "stale", createdAt: new Date().toISOString() }, 524 + }), 525 + }), 526 + ); 527 + 528 + expect(response.status).toBe(401); 529 + await expect(response.json()).resolves.toMatchObject({ error: "tos_changed" }); 530 + }); 531 + }); 532 + 379 533 it("round-trips blobs through uploadBlob and getBlob", async () => { 380 534 const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 381 535 const { did } = await createAccountViaSignup( 382 536 `blob-${Date.now().toString(36)}`, 537 + authKeys, 538 + publicJwk, 383 539 thumbprint, 384 540 ); 385 - 386 - const accessToken = "blob-token"; 541 + const tosText = await fetchTosText(); 542 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 387 543 const uploadUrl = "http://localhost/xrpc/com.atproto.repo.uploadBlob"; 388 544 const bytes = new TextEncoder().encode("test blob data"); 389 545 const uploadResponse = await worker.fetch( ··· 415 571 const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 416 572 const { did } = await createAccountViaSignup( 417 573 `lexicon-${Date.now().toString(36)}`, 574 + authKeys, 575 + publicJwk, 418 576 thumbprint, 419 577 ); 420 - const accessToken = "lexicon-token"; 578 + const tosText = await fetchTosText(); 579 + const accessToken = await buildAccessToken(authKeys, thumbprint, tosText, SERVICE_ORIGIN); 421 580 const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 422 581 423 582 for (const collection of [
+26 -4
test/sequencer-do.test.ts
··· 3 3 import { resolveRepo } from "../src/directory"; 4 4 import { AccountDurableObject } from "../src/account-do"; 5 5 import { SequencerDurableObject } from "../src/sequencer-do"; 6 - import { env, runInDurableObject, worker } from "./helpers"; 6 + import { 7 + buildAccessToken, 8 + createDpopJwt, 9 + env, 10 + generateAuthKeys, 11 + runInDurableObject, 12 + signTos, 13 + worker, 14 + } from "./helpers"; 7 15 8 16 function getSequencerStub() { 9 17 const id = env.SEQUENCER.idFromName("sequencer"); ··· 59 67 }); 60 68 61 69 try { 70 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 71 + const tosText = await worker.fetch("http://localhost/tos").then((response) => response.text()); 72 + const accessToken = await buildAccessToken( 73 + authKeys, 74 + thumbprint, 75 + tosText, 76 + `https://${env.ROOKERY_HOSTNAME}`, 77 + ); 62 78 const response = await worker.fetch( 63 79 new Request("http://localhost/api/signup", { 64 80 method: "POST", 65 - headers: { "Content-Type": "application/json" }, 66 - body: JSON.stringify({ handle }), 81 + headers: { 82 + "Content-Type": "application/json", 83 + dpop: await createDpopJwt(authKeys, publicJwk, "http://localhost/api/signup", null), 84 + }, 85 + body: JSON.stringify({ 86 + handle, 87 + tos_signature: await signTos(authKeys.privateKey, tosText), 88 + access_token: accessToken, 89 + }), 67 90 }), 68 - env, 69 91 ); 70 92 expect(response.status).toBe(200); 71 93 return response.json<{ did: string }>();
+18 -58
test/worker-routes.test.ts
··· 1 1 import { describe, it, expect, beforeAll } from "vitest"; 2 - import { env, runInDurableObject, worker } from "./helpers"; 2 + import { 3 + buildAccessToken, 4 + createDpopJwt, 5 + env, 6 + generateAuthKeys, 7 + runInDurableObject, 8 + worker, 9 + } from "./helpers"; 3 10 import { AccountDurableObject } from "../src/account-do"; 4 11 import { initDirectory, insertAccount } from "../src/directory"; 5 12 import { Secp256k1Keypair } from "@atproto/crypto"; 6 13 import { toString } from "uint8arrays/to-string"; 7 - import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 8 - 9 - async function signJwt( 10 - header: Record<string, unknown>, 11 - payload: Record<string, unknown>, 12 - privateKey: CryptoKey, 13 - ): Promise<string> { 14 - const encode = (obj: Record<string, unknown>) => 15 - base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 16 - const headerStr = encode(header); 17 - const payloadStr = encode(payload); 18 - const signingInput = `${headerStr}.${payloadStr}`; 19 - const signature = await crypto.subtle.sign( 20 - "RSASSA-PKCS1-v1_5", 21 - privateKey, 22 - new TextEncoder().encode(signingInput), 23 - ); 24 - return `${signingInput}.${base64urlEncode(signature)}`; 25 - } 26 14 27 15 async function setupRouteTestAccount() { 28 - const authKeys = await crypto.subtle.generateKey( 29 - { 30 - name: "RSASSA-PKCS1-v1_5", 31 - modulusLength: 4096, 32 - publicExponent: new Uint8Array([1, 0, 1]), 33 - hash: "SHA-256", 34 - }, 35 - true, 36 - ["sign", "verify"], 37 - ); 38 - const publicJwk = await crypto.subtle.exportKey("jwk", authKeys.publicKey); 39 - const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 16 + const { authKeys, publicJwk, thumbprint } = await generateAuthKeys(); 40 17 41 18 const signing = await Secp256k1Keypair.create({ exportable: true }); 42 19 const rotation = await Secp256k1Keypair.create({ exportable: true }); ··· 65 42 jwkThumbprint: thumbprint, 66 43 }); 67 44 68 - return { did, handle, stub, authKeys, publicJwk }; 69 - } 70 - 71 - async function createDpopJwt( 72 - authKeys: CryptoKeyPair, 73 - publicJwk: JsonWebKey, 74 - htu: string, 75 - accessToken: string, 76 - ): Promise<string> { 77 - return signJwt( 78 - { 79 - typ: "dpop+jwt", 80 - alg: "RS256", 81 - jwk: publicJwk, 82 - }, 83 - { 84 - jti: `jti-${Date.now().toString(36)}`, 85 - htm: "POST", 86 - htu, 87 - iat: Math.floor(Date.now() / 1000), 88 - ath: await sha256Base64url(accessToken), 89 - }, 90 - authKeys.privateKey, 91 - ); 45 + return { did, handle, stub, authKeys, publicJwk, thumbprint }; 92 46 } 93 47 94 48 describe("Worker routes", () => { ··· 217 171 }); 218 172 219 173 it("handles authenticated repo write routes", async () => { 220 - const { did, stub, authKeys, publicJwk } = await setupRouteTestAccount(); 221 - const accessToken = "worker-route-token"; 174 + const { did, stub, authKeys, publicJwk, thumbprint } = await setupRouteTestAccount(); 175 + const tosText = await worker.fetch("http://localhost/tos").then((response) => response.text()); 176 + const accessToken = await buildAccessToken( 177 + authKeys, 178 + thumbprint, 179 + tosText, 180 + `https://${env.ROOKERY_HOSTNAME}`, 181 + ); 222 182 223 183 const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 224 184 const createDpop = await createDpopJwt(authKeys, publicJwk, createUrl, accessToken);