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.

Port auth.ts from node:crypto to Web Crypto API (L11)

Replace all node:crypto and Buffer usage with crypto.subtle and
Uint8Array/TextEncoder. Remove stale v0.1 imports (better-sqlite3,
config.js) and the broken createAuthMiddleware function. Add 16 auth
tests covering DPoP validation, access token validation, JWK thumbprint,
and key size enforcement, all running in the CF Workers test pool.

+323 -78
+62 -78
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 1 export interface RsaPublicJwk { 8 2 kty: string; 9 3 n: string; ··· 36 30 }; 37 31 }; 38 32 39 - export function base64urlEncode(input: Buffer | Uint8Array): string { 40 - return Buffer.from(input).toString("base64url"); 33 + export 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(/=+$/, ""); 41 38 } 42 39 43 - export function base64urlDecode(str: string): Buffer { 44 - return Buffer.from(str, "base64url"); 40 + export 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; 45 47 } 46 48 47 - export function sha256Base64url(data: string | Buffer): string { 48 - const hash = crypto.createHash("sha256").update(data).digest(); 49 + export 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 + ); 49 54 return base64urlEncode(hash); 50 55 } 51 56 ··· 53 58 header: JwtHeader; 54 59 payload: JwtPayload; 55 60 signingInput: string; 56 - signature: Buffer; 61 + signature: string; 57 62 } { 58 63 const parts = token.split("."); 59 64 if (parts.length !== 3) { 60 65 throw new Error("invalid JWT: expected 3 parts"); 61 66 } 62 67 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; 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; 65 74 66 75 return { 67 76 header, 68 77 payload, 69 78 signingInput: `${parts[0]}.${parts[1]}`, 70 - signature: base64urlDecode(parts[2]), 79 + signature: parts[2], 71 80 }; 72 81 } 73 82 74 - export function jwkThumbprint(jwk: RsaPublicJwk): string { 83 + export async function jwkThumbprint(jwk: RsaPublicJwk): Promise<string> { 75 84 const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }); 76 85 return sha256Base64url(canonical); 77 86 } 78 87 79 - export function validateAndImportKey(jwk: RsaPublicJwk): crypto.KeyObject { 88 + export async function validateAndImportKey(jwk: RsaPublicJwk): Promise<CryptoKey> { 80 89 if (jwk.kty !== "RSA") { 81 90 throw new Error("key must be RSA"); 82 91 } ··· 84 93 throw new Error("invalid RSA key: missing n or e"); 85 94 } 86 95 87 - let key: crypto.KeyObject; 96 + let key: CryptoKey; 88 97 try { 89 - key = crypto.createPublicKey({ 90 - key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, 91 - format: "jwk", 92 - }); 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 + ); 93 105 } catch { 94 106 throw new Error("invalid RSA public key"); 95 107 } 96 108 97 - const exported = key.export({ format: "jwk" }); 109 + const exported = await crypto.subtle.exportKey("jwk", key); 98 110 if ( 99 111 !("n" in exported) || 100 112 typeof exported.n !== "string" || ··· 104 116 throw new Error("invalid RSA public key"); 105 117 } 106 118 107 - const modulusBits = base64urlDecode(exported.n).length * 8; 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; 108 122 if (modulusBits !== 4096) { 109 123 throw new Error(`key must be 4096-bit RSA (got ${modulusBits}-bit)`); 110 124 } ··· 112 126 return key; 113 127 } 114 128 115 - export function validateDpopProof( 129 + export async function validateDpopProof( 116 130 dpopJwt: string, 117 131 method: string, 118 132 url: string, 119 133 accessToken: string | null, 120 - ): { jwk: RsaPublicJwk; key: crypto.KeyObject; thumbprint: string } { 134 + ): Promise<{ jwk: RsaPublicJwk; key: CryptoKey; thumbprint: string }> { 121 135 let jwt; 122 136 try { 123 137 jwt = parseJwt(dpopJwt); ··· 137 151 throw new Error("invalid DPoP proof: missing jwk"); 138 152 } 139 153 140 - const key = validateAndImportKey(header.jwk); 154 + const key = await validateAndImportKey(header.jwk); 141 155 142 156 if (!payload.jti) { 143 157 throw new Error("invalid DPoP proof: missing jti"); ··· 165 179 if (typeof payload.ath !== "string") { 166 180 throw new Error("invalid DPoP proof: missing ath"); 167 181 } 168 - const expectedAth = sha256Base64url(accessToken); 182 + const expectedAth = await sha256Base64url(accessToken); 169 183 if (payload.ath !== expectedAth) { 170 184 throw new Error("invalid DPoP proof: ath does not match access token"); 171 185 } 172 186 } 173 187 174 - const valid = crypto.verify("SHA256", Buffer.from(signingInput), key, signature); 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 + ); 175 195 if (!valid) { 176 196 throw new Error("invalid DPoP proof: signature verification failed"); 177 197 } 178 198 179 - return { jwk: header.jwk, key, thumbprint: jwkThumbprint(header.jwk) }; 199 + return { jwk: header.jwk, key, thumbprint: await jwkThumbprint(header.jwk) }; 180 200 } 181 201 182 - export function validateAccessToken( 202 + export async function validateAccessToken( 183 203 accessTokenStr: string, 184 - dpopKey: crypto.KeyObject, 204 + dpopKey: CryptoKey, 185 205 serviceOrigin: string, 186 206 dpopThumbprint: string, 187 207 tosText: string, 188 - ): JwtPayload { 208 + ): Promise<JwtPayload> { 189 209 let jwt; 190 210 try { 191 211 jwt = parseJwt(accessTokenStr); ··· 216 236 throw new Error("invalid access token: cnf.jkt does not match DPoP key"); 217 237 } 218 238 219 - const expectedTosHash = sha256Base64url(tosText); 239 + const expectedTosHash = await sha256Base64url(tosText); 220 240 if (payload.tos_hash !== expectedTosHash) { 221 241 throw new Error("invalid access token: tos_hash does not match current terms"); 222 242 } 223 243 224 - const valid = crypto.verify("SHA256", Buffer.from(signingInput), dpopKey, signature); 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 + ); 225 251 if (!valid) { 226 252 throw new Error("invalid access token: signature verification failed"); 227 253 } ··· 240 266 const match = authHeader.match(/^DPoP\s+(.+)$/i); 241 267 return match ? match[1] : null; 242 268 } 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 - }
+261
test/auth.test.ts
··· 1 + import { beforeAll, describe, expect, it } from "vitest"; 2 + import { 3 + base64urlDecode, 4 + base64urlEncode, 5 + jwkThumbprint, 6 + parseJwt, 7 + sha256Base64url, 8 + validateAccessToken, 9 + validateAndImportKey, 10 + validateDpopProof, 11 + } from "../src/auth"; 12 + 13 + async function generateRsaKeyPair(modulusLength: number) { 14 + return crypto.subtle.generateKey( 15 + { 16 + name: "RSASSA-PKCS1-v1_5", 17 + modulusLength, 18 + publicExponent: new Uint8Array([1, 0, 1]), 19 + hash: "SHA-256", 20 + }, 21 + true, 22 + ["sign", "verify"], 23 + ); 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 + } 43 + 44 + function tamperJwtSignature(jwt: string): string { 45 + const parts = jwt.split("."); 46 + const firstChar = parts[2][0] === "A" ? "B" : "A"; 47 + parts[2] = `${firstChar}${parts[2].slice(1)}`; 48 + return parts.join("."); 49 + } 50 + 51 + describe("base64url", () => { 52 + it("round-trips binary data", () => { 53 + const bytes = crypto.getRandomValues(new Uint8Array(32)); 54 + const encoded = base64urlEncode(bytes); 55 + const decoded = base64urlDecode(encoded); 56 + 57 + expect(Array.from(decoded)).toEqual(Array.from(bytes)); 58 + }); 59 + 60 + it("round-trips a string via TextEncoder", () => { 61 + const text = "rookery auth"; 62 + const encoded = base64urlEncode(new TextEncoder().encode(text)); 63 + const decoded = new TextDecoder().decode(base64urlDecode(encoded)); 64 + 65 + expect(decoded).toBe(text); 66 + }); 67 + }); 68 + 69 + describe("sha256Base64url", () => { 70 + it("hashes a known string correctly", async () => { 71 + await expect(sha256Base64url("")).resolves.toBe( 72 + "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", 73 + ); 74 + }); 75 + }); 76 + 77 + describe("parseJwt", () => { 78 + it("parses a valid 3-part JWT", () => { 79 + const header = { typ: "wm+jwt", alg: "RS256" }; 80 + const payload = { sub: "did:plc:test" }; 81 + const headerStr = base64urlEncode(new TextEncoder().encode(JSON.stringify(header))); 82 + const payloadStr = base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))); 83 + const signature = "test-signature"; 84 + const jwt = `${headerStr}.${payloadStr}.${signature}`; 85 + 86 + expect(parseJwt(jwt)).toEqual({ 87 + header, 88 + payload, 89 + signingInput: `${headerStr}.${payloadStr}`, 90 + signature, 91 + }); 92 + }); 93 + 94 + it("rejects a 2-part string", () => { 95 + expect(() => parseJwt("one.two")).toThrow("expected 3 parts"); 96 + }); 97 + }); 98 + 99 + describe("jwkThumbprint", () => { 100 + let publicJwk: JsonWebKey; 101 + 102 + beforeAll(async () => { 103 + const keyPair = await generateRsaKeyPair(4096); 104 + publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 105 + }); 106 + 107 + it("computes RFC 7638 thumbprint", async () => { 108 + const thumbprint1 = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 109 + const thumbprint2 = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 110 + 111 + expect(thumbprint1).toMatch(/^[A-Za-z0-9_-]+$/); 112 + expect(thumbprint1.length).toBeGreaterThan(0); 113 + expect(thumbprint2).toBe(thumbprint1); 114 + }); 115 + }); 116 + 117 + describe("validateAndImportKey", () => { 118 + let public4096: JsonWebKey; 119 + let public2048: JsonWebKey; 120 + 121 + beforeAll(async () => { 122 + const key4096 = await generateRsaKeyPair(4096); 123 + const key2048 = await generateRsaKeyPair(2048); 124 + public4096 = await crypto.subtle.exportKey("jwk", key4096.publicKey); 125 + public2048 = await crypto.subtle.exportKey("jwk", key2048.publicKey); 126 + }); 127 + 128 + it("accepts RSA-4096", async () => { 129 + const key = await validateAndImportKey(public4096 as { kty: string; n: string; e: string }); 130 + 131 + expect(key).toBeInstanceOf(CryptoKey); 132 + expect(key.type).toBe("public"); 133 + }); 134 + 135 + it("rejects RSA-2048", async () => { 136 + await expect( 137 + validateAndImportKey(public2048 as { kty: string; n: string; e: string }), 138 + ).rejects.toThrow("4096-bit"); 139 + }); 140 + 141 + it("rejects missing n", async () => { 142 + const { n: _n, ...missingN } = public4096; 143 + await expect( 144 + validateAndImportKey(missingN as { kty: string; n: string; e: string }), 145 + ).rejects.toThrow("missing n or e"); 146 + }); 147 + }); 148 + 149 + describe("validateDpopProof", () => { 150 + let privateKey: CryptoKey; 151 + let publicJwk: { kty: string; n: string; e: string }; 152 + 153 + beforeAll(async () => { 154 + const keyPair = await generateRsaKeyPair(4096); 155 + privateKey = keyPair.privateKey; 156 + publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 157 + }); 158 + 159 + async function buildDpopJwt(payloadOverrides: Record<string, unknown> = {}) { 160 + return signJwt( 161 + { typ: "dpop+jwt", alg: "RS256", jwk: publicJwk }, 162 + { 163 + jti: "test-jti-123", 164 + htm: "POST", 165 + htu: "https://example.com/path", 166 + iat: Math.floor(Date.now() / 1000), 167 + ...payloadOverrides, 168 + }, 169 + privateKey, 170 + ); 171 + } 172 + 173 + it("validates a correct DPoP proof", async () => { 174 + const jwt = await buildDpopJwt(); 175 + const result = await validateDpopProof(jwt, "POST", "https://example.com/path", null); 176 + 177 + expect(result.jwk).toEqual(publicJwk); 178 + expect(result.key).toBeInstanceOf(CryptoKey); 179 + expect(result.thumbprint).toBe(await jwkThumbprint(publicJwk)); 180 + }); 181 + 182 + it("validates DPoP with access token hash", async () => { 183 + const accessToken = "access-token"; 184 + const jwt = await buildDpopJwt({ ath: await sha256Base64url(accessToken) }); 185 + 186 + await expect( 187 + validateDpopProof(jwt, "POST", "https://example.com/path", accessToken), 188 + ).resolves.toMatchObject({ jwk: publicJwk }); 189 + }); 190 + 191 + it("rejects invalid signature", async () => { 192 + const jwt = tamperJwtSignature(await buildDpopJwt()); 193 + 194 + await expect( 195 + validateDpopProof(jwt, "POST", "https://example.com/path", null), 196 + ).rejects.toThrow("signature verification failed"); 197 + }); 198 + 199 + it("rejects expired iat", async () => { 200 + const jwt = await buildDpopJwt({ iat: Math.floor(Date.now() / 1000) - 600 }); 201 + 202 + await expect( 203 + validateDpopProof(jwt, "POST", "https://example.com/path", null), 204 + ).rejects.toThrow("iat too far"); 205 + }); 206 + 207 + it("rejects wrong ath", async () => { 208 + const jwt = await buildDpopJwt({ ath: "wrong" }); 209 + 210 + await expect( 211 + validateDpopProof(jwt, "POST", "https://example.com/path", "access-token"), 212 + ).rejects.toThrow("ath does not match"); 213 + }); 214 + }); 215 + 216 + describe("validateAccessToken", () => { 217 + let privateKey: CryptoKey; 218 + let dpopKey: CryptoKey; 219 + let publicJwk: { kty: string; n: string; e: string }; 220 + let thumbprint: string; 221 + 222 + beforeAll(async () => { 223 + const keyPair = await generateRsaKeyPair(4096); 224 + privateKey = keyPair.privateKey; 225 + publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 226 + dpopKey = await validateAndImportKey(publicJwk); 227 + thumbprint = await jwkThumbprint(publicJwk); 228 + }); 229 + 230 + async function buildAccessToken(payloadOverrides: Record<string, unknown> = {}) { 231 + return signJwt( 232 + { typ: "wm+jwt", alg: "RS256" }, 233 + { 234 + tos_hash: await sha256Base64url("test-tos"), 235 + aud: "https://example.com", 236 + cnf: { jkt: thumbprint }, 237 + ...payloadOverrides, 238 + }, 239 + privateKey, 240 + ); 241 + } 242 + 243 + it("validates a correct access token", async () => { 244 + const jwt = await buildAccessToken(); 245 + 246 + await expect( 247 + validateAccessToken(jwt, dpopKey, "https://example.com", thumbprint, "test-tos"), 248 + ).resolves.toMatchObject({ 249 + aud: "https://example.com", 250 + cnf: { jkt: thumbprint }, 251 + }); 252 + }); 253 + 254 + it("rejects invalid signature", async () => { 255 + const jwt = tamperJwtSignature(await buildAccessToken()); 256 + 257 + await expect( 258 + validateAccessToken(jwt, dpopKey, "https://example.com", thumbprint, "test-tos"), 259 + ).rejects.toThrow("signature verification failed"); 260 + }); 261 + });