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.

at main 268 lines 7.3 kB view raw
1export interface RsaPublicJwk { 2 kty: string; 3 n: string; 4 e: string; 5 [key: string]: unknown; 6} 7 8export interface JwtHeader { 9 typ?: string; 10 alg?: string; 11 jwk?: RsaPublicJwk; 12 [key: string]: unknown; 13} 14 15export interface JwtPayload { 16 [key: string]: unknown; 17} 18 19export interface AccountRow { 20 id: number; 21 did: string; 22 handle: string | null; 23 jwk_thumbprint: string | null; 24 [key: string]: unknown; 25} 26 27export type AuthEnv = { 28 Variables: { 29 account: AccountRow; 30 }; 31}; 32 33export function base64urlEncode(buffer: ArrayBuffer | Uint8Array): string { 34 const bytes = new Uint8Array(buffer); 35 let binary = ""; 36 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); 37 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 38} 39 40export function base64urlDecode(str: string): Uint8Array { 41 const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); 42 const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); 43 const binary = atob(padded); 44 const bytes = new Uint8Array(binary.length); 45 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); 46 return bytes; 47} 48 49export async function sha256Base64url(data: string | Uint8Array): Promise<string> { 50 const hash = await crypto.subtle.digest( 51 "SHA-256", 52 typeof data === "string" ? new TextEncoder().encode(data) : data, 53 ); 54 return base64urlEncode(hash); 55} 56 57export function parseJwt(token: string): { 58 header: JwtHeader; 59 payload: JwtPayload; 60 signingInput: string; 61 signature: string; 62} { 63 const parts = token.split("."); 64 if (parts.length !== 3) { 65 throw new Error("invalid JWT: expected 3 parts"); 66 } 67 68 const header = JSON.parse( 69 new TextDecoder().decode(base64urlDecode(parts[0])), 70 ) as JwtHeader; 71 const payload = JSON.parse( 72 new TextDecoder().decode(base64urlDecode(parts[1])), 73 ) as JwtPayload; 74 75 return { 76 header, 77 payload, 78 signingInput: `${parts[0]}.${parts[1]}`, 79 signature: parts[2], 80 }; 81} 82 83export async function jwkThumbprint(jwk: RsaPublicJwk): Promise<string> { 84 const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 85 return sha256Base64url(canonical); 86} 87 88export async function validateAndImportKey(jwk: RsaPublicJwk): Promise<CryptoKey> { 89 if (jwk.kty !== "RSA") { 90 throw new Error("key must be RSA"); 91 } 92 if (!jwk.n || !jwk.e) { 93 throw new Error("invalid RSA key: missing n or e"); 94 } 95 96 let key: CryptoKey; 97 try { 98 key = await crypto.subtle.importKey( 99 "jwk", 100 { kty: jwk.kty, n: jwk.n, e: jwk.e }, 101 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, 102 true, 103 ["verify"], 104 ); 105 } catch { 106 throw new Error("invalid RSA public key"); 107 } 108 109 const exported = await crypto.subtle.exportKey("jwk", key); 110 if ( 111 !("n" in exported) || 112 typeof exported.n !== "string" || 113 !("e" in exported) || 114 typeof exported.e !== "string" 115 ) { 116 throw new Error("invalid RSA public key"); 117 } 118 119 const nBase64 = exported.n.replace(/-/g, "+").replace(/_/g, "/"); 120 const padded = nBase64 + "=".repeat((4 - (nBase64.length % 4)) % 4); 121 const modulusBits = atob(padded).length * 8; 122 if (modulusBits !== 4096) { 123 throw new Error(`key must be 4096-bit RSA (got ${modulusBits}-bit)`); 124 } 125 126 return key; 127} 128 129export async function validateDpopProof( 130 dpopJwt: string, 131 method: string, 132 url: string, 133 accessToken: string | null, 134): Promise<{ jwk: RsaPublicJwk; key: CryptoKey; thumbprint: string }> { 135 let jwt; 136 try { 137 jwt = parseJwt(dpopJwt); 138 } catch { 139 throw new Error("invalid DPoP proof: malformed JWT"); 140 } 141 142 const { header, payload, signingInput, signature } = jwt; 143 144 if (header.typ !== "dpop+jwt") { 145 throw new Error("invalid DPoP proof: typ must be dpop+jwt"); 146 } 147 if (header.alg !== "RS256") { 148 throw new Error("invalid DPoP proof: alg must be RS256"); 149 } 150 if (!header.jwk) { 151 throw new Error("invalid DPoP proof: missing jwk"); 152 } 153 154 const key = await validateAndImportKey(header.jwk); 155 156 if (!payload.jti) { 157 throw new Error("invalid DPoP proof: missing jti"); 158 } 159 if (payload.htm !== method) { 160 throw new Error(`invalid DPoP proof: htm must be ${method}`); 161 } 162 163 const reqUrl = new URL(url); 164 const expectedHtu = reqUrl.origin + reqUrl.pathname; 165 if (payload.htu !== expectedHtu) { 166 throw new Error("invalid DPoP proof: htu does not match request URL"); 167 } 168 169 if (!payload.iat || typeof payload.iat !== "number") { 170 throw new Error("invalid DPoP proof: missing or invalid iat"); 171 } 172 173 const now = Math.floor(Date.now() / 1000); 174 if (Math.abs(now - payload.iat) > 300) { 175 throw new Error("invalid DPoP proof: iat too far from current time"); 176 } 177 178 if (accessToken) { 179 if (typeof payload.ath !== "string") { 180 throw new Error("invalid DPoP proof: missing ath"); 181 } 182 const expectedAth = await sha256Base64url(accessToken); 183 if (payload.ath !== expectedAth) { 184 throw new Error("invalid DPoP proof: ath does not match access token"); 185 } 186 } 187 188 const sigBytes = base64urlDecode(signature); 189 const valid = await crypto.subtle.verify( 190 "RSASSA-PKCS1-v1_5", 191 key, 192 sigBytes, 193 new TextEncoder().encode(signingInput), 194 ); 195 if (!valid) { 196 throw new Error("invalid DPoP proof: signature verification failed"); 197 } 198 199 return { jwk: header.jwk, key, thumbprint: await jwkThumbprint(header.jwk) }; 200} 201 202export async function validateAccessToken( 203 accessTokenStr: string, 204 dpopKey: CryptoKey, 205 serviceOrigin: string, 206 dpopThumbprint: string, 207 tosText: string, 208): Promise<JwtPayload> { 209 let jwt; 210 try { 211 jwt = parseJwt(accessTokenStr); 212 } catch { 213 throw new Error("invalid access token: malformed JWT"); 214 } 215 216 const { header, payload, signingInput, signature } = jwt; 217 218 if (header.typ !== "wm+jwt") { 219 throw new Error("invalid access token: typ must be wm+jwt"); 220 } 221 if (header.alg !== "RS256") { 222 throw new Error("invalid access token: alg must be RS256"); 223 } 224 if (!payload.tos_hash) { 225 throw new Error("invalid access token: missing tos_hash"); 226 } 227 if (payload.aud !== serviceOrigin) { 228 throw new Error("invalid access token: aud does not match service origin"); 229 } 230 231 const cnf = payload.cnf; 232 if (!cnf || typeof cnf !== "object" || !("jkt" in cnf) || typeof cnf.jkt !== "string") { 233 throw new Error("invalid access token: missing cnf.jkt"); 234 } 235 if (cnf.jkt !== dpopThumbprint) { 236 throw new Error("invalid access token: cnf.jkt does not match DPoP key"); 237 } 238 239 const expectedTosHash = await sha256Base64url(tosText); 240 if (payload.tos_hash !== expectedTosHash) { 241 throw new Error("invalid access token: tos_hash does not match current terms"); 242 } 243 244 const sigBytes = base64urlDecode(signature); 245 const valid = await crypto.subtle.verify( 246 "RSASSA-PKCS1-v1_5", 247 dpopKey, 248 sigBytes, 249 new TextEncoder().encode(signingInput), 250 ); 251 if (!valid) { 252 throw new Error("invalid access token: signature verification failed"); 253 } 254 255 return payload; 256} 257 258export function isValidHandle(handle: string): boolean { 259 return /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(handle) && handle.length <= 64; 260} 261 262export function extractBearerToken(authHeader: string | null): string | null { 263 if (!authHeader) { 264 return null; 265 } 266 const match = authHeader.match(/^DPoP\s+(.+)$/i); 267 return match ? match[1] : null; 268}