/** * Rookery DPoP auth (Welcome Mat v1.0) for researcher.pds.latha.org * * Flow: * 1. Load RSA private key from vault * 2. Derive public JWK and thumbprint * 3. Fetch ToS, build wm+jwt access token * 4. Create session via com.atproto.server.createSession with DPoP * 5. Use session JWT + DPoP for subsequent PDS writes */ import crypto from "node:crypto"; import { readFile } from "node:fs/promises"; const PDS_ORIGIN = "https://pds.latha.org"; const RESEARCHER_DID = "did:plc:3kkhul7jznlb6ba7rprzawnj"; const RESEARCHER_HANDLE = "researcher.pds.latha.org"; function base64url(input: Buffer | Uint8Array): string { return Buffer.from(input).toString("base64url"); } function pemToJwk(pem: string): { kty: string; n: string; e: string } { const key = crypto.createPublicKey(pem); const jwk = key.export({ format: "jwk" }); return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string }; } function computeThumbprint(jwk: { kty: string; n: string; e: string }): string { return base64url( crypto.createHash("sha256") .update(JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n })) .digest(), ); } function createJwt(header: object, payload: object, privateKeyPem: string): string { const encode = (obj: object) => base64url(Buffer.from(JSON.stringify(obj))); const signingInput = `${encode(header)}.${encode(payload)}`; const sig = crypto.createSign("SHA256"); sig.update(signingInput); return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`; } function createDpopProof( method: string, url: string, accessToken: string, publicJwk: { kty: string; n: string; e: string }, privateKeyPem: string, ): string { const ath = base64url(crypto.createHash("sha256").update(accessToken).digest()); return createJwt( { typ: "dpop+jwt", alg: "RS256", jwk: publicJwk }, { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), ath, }, privateKeyPem, ); } export interface PdsSession { did: string; handle: string; accessJwt: string; wmJwt: string; privateKeyPem: string; publicJwk: { kty: string; n: string; e: string }; } export async function createPdsSession(privateKeyPem: string): Promise { const publicJwk = pemToJwk(privateKeyPem); const thumbprint = computeThumbprint(publicJwk); // Fetch ToS and build wm+jwt access token const tosRes = await fetch(`${PDS_ORIGIN}/tos`); if (!tosRes.ok) throw new Error(`Failed to fetch ToS: ${tosRes.status}`); const tosText = await tosRes.text(); const tosHash = base64url(crypto.createHash("sha256").update(tosText).digest()); const wmJwt = createJwt( { typ: "wm+jwt", alg: "RS256" }, { tos_hash: tosHash, aud: PDS_ORIGIN, cnf: { jkt: thumbprint }, iat: Math.floor(Date.now() / 1000), }, privateKeyPem, ); // Create session const sessionUrl = `${PDS_ORIGIN}/xrpc/com.atproto.server.createSession`; const dpop = createDpopProof("POST", sessionUrl, wmJwt, publicJwk, privateKeyPem); const res = await fetch(sessionUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `DPoP ${wmJwt}`, DPoP: dpop, }, body: JSON.stringify({}), }); if (!res.ok) { const body = await res.text(); throw new Error(`createSession failed (${res.status}): ${body}`); } const data = await res.json() as { did: string; handle: string; accessJwt: string }; return { did: data.did || RESEARCHER_DID, handle: data.handle || RESEARCHER_HANDLE, accessJwt: data.accessJwt || wmJwt, wmJwt, privateKeyPem, publicJwk, }; } export async function dpopFetch( url: string, init: RequestInit, session: PdsSession, ): Promise { const method = init.method || "GET"; const accessToken = session.wmJwt; const dpop = createDpopProof(method, url, accessToken, session.publicJwk, session.privateKeyPem); const headers = new Headers(init.headers); headers.set("Authorization", `DPoP ${accessToken}`); headers.set("DPoP", dpop); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } return fetch(url, { ...init, headers }); } export async function loadPrivateKey(): Promise { // In Cloudflare Workers, the key is passed directly via RESEARCHER_KEY_PEM secret if (process.env.RESEARCHER_KEY_PEM) { return process.env.RESEARCHER_KEY_PEM; } // Fallback: read from filesystem (CLI/local use) const keyPath = process.env.RESEARCHER_KEY_PATH || "/home/nandi/vault/.credentials/researcher-pds-latha-org.pem"; return readFile(keyPath, "utf-8"); }