This repository has no description
1/**
2 * Rookery DPoP auth (Welcome Mat v1.0) for researcher.pds.latha.org
3 *
4 * Flow:
5 * 1. Load RSA private key from vault
6 * 2. Derive public JWK and thumbprint
7 * 3. Fetch ToS, build wm+jwt access token
8 * 4. Create session via com.atproto.server.createSession with DPoP
9 * 5. Use session JWT + DPoP for subsequent PDS writes
10 */
11
12import crypto from "node:crypto";
13import { readFile } from "node:fs/promises";
14
15const PDS_ORIGIN = "https://pds.latha.org";
16const RESEARCHER_DID = "did:plc:3kkhul7jznlb6ba7rprzawnj";
17const RESEARCHER_HANDLE = "researcher.pds.latha.org";
18
19function base64url(input: Buffer | Uint8Array): string {
20 return Buffer.from(input).toString("base64url");
21}
22
23function pemToJwk(pem: string): { kty: string; n: string; e: string } {
24 const key = crypto.createPublicKey(pem);
25 const jwk = key.export({ format: "jwk" });
26 return { kty: jwk.kty as string, n: jwk.n as string, e: jwk.e as string };
27}
28
29function computeThumbprint(jwk: { kty: string; n: string; e: string }): string {
30 return base64url(
31 crypto.createHash("sha256")
32 .update(JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n }))
33 .digest(),
34 );
35}
36
37function createJwt(header: object, payload: object, privateKeyPem: string): string {
38 const encode = (obj: object) => base64url(Buffer.from(JSON.stringify(obj)));
39 const signingInput = `${encode(header)}.${encode(payload)}`;
40 const sig = crypto.createSign("SHA256");
41 sig.update(signingInput);
42 return `${signingInput}.${base64url(sig.sign(privateKeyPem))}`;
43}
44
45function createDpopProof(
46 method: string,
47 url: string,
48 accessToken: string,
49 publicJwk: { kty: string; n: string; e: string },
50 privateKeyPem: string,
51): string {
52 const ath = base64url(crypto.createHash("sha256").update(accessToken).digest());
53 return createJwt(
54 { typ: "dpop+jwt", alg: "RS256", jwk: publicJwk },
55 {
56 jti: crypto.randomUUID(),
57 htm: method,
58 htu: url,
59 iat: Math.floor(Date.now() / 1000),
60 ath,
61 },
62 privateKeyPem,
63 );
64}
65
66export interface PdsSession {
67 did: string;
68 handle: string;
69 accessJwt: string;
70 wmJwt: string;
71 privateKeyPem: string;
72 publicJwk: { kty: string; n: string; e: string };
73}
74
75export async function createPdsSession(privateKeyPem: string): Promise<PdsSession> {
76 const publicJwk = pemToJwk(privateKeyPem);
77 const thumbprint = computeThumbprint(publicJwk);
78
79 // Fetch ToS and build wm+jwt access token
80 const tosRes = await fetch(`${PDS_ORIGIN}/tos`);
81 if (!tosRes.ok) throw new Error(`Failed to fetch ToS: ${tosRes.status}`);
82 const tosText = await tosRes.text();
83 const tosHash = base64url(crypto.createHash("sha256").update(tosText).digest());
84
85 const wmJwt = createJwt(
86 { typ: "wm+jwt", alg: "RS256" },
87 {
88 tos_hash: tosHash,
89 aud: PDS_ORIGIN,
90 cnf: { jkt: thumbprint },
91 iat: Math.floor(Date.now() / 1000),
92 },
93 privateKeyPem,
94 );
95
96 // Create session
97 const sessionUrl = `${PDS_ORIGIN}/xrpc/com.atproto.server.createSession`;
98 const dpop = createDpopProof("POST", sessionUrl, wmJwt, publicJwk, privateKeyPem);
99
100 const res = await fetch(sessionUrl, {
101 method: "POST",
102 headers: {
103 "Content-Type": "application/json",
104 Authorization: `DPoP ${wmJwt}`,
105 DPoP: dpop,
106 },
107 body: JSON.stringify({}),
108 });
109
110 if (!res.ok) {
111 const body = await res.text();
112 throw new Error(`createSession failed (${res.status}): ${body}`);
113 }
114
115 const data = await res.json() as { did: string; handle: string; accessJwt: string };
116 return {
117 did: data.did || RESEARCHER_DID,
118 handle: data.handle || RESEARCHER_HANDLE,
119 accessJwt: data.accessJwt || wmJwt,
120 wmJwt,
121 privateKeyPem,
122 publicJwk,
123 };
124}
125
126export async function dpopFetch(
127 url: string,
128 init: RequestInit,
129 session: PdsSession,
130): Promise<Response> {
131 const method = init.method || "GET";
132 const accessToken = session.wmJwt;
133 const dpop = createDpopProof(method, url, accessToken, session.publicJwk, session.privateKeyPem);
134
135 const headers = new Headers(init.headers);
136 headers.set("Authorization", `DPoP ${accessToken}`);
137 headers.set("DPoP", dpop);
138 if (!headers.has("Content-Type")) {
139 headers.set("Content-Type", "application/json");
140 }
141
142 return fetch(url, { ...init, headers });
143}
144
145export async function loadPrivateKey(): Promise<string> {
146 // In Cloudflare Workers, the key is passed directly via RESEARCHER_KEY_PEM secret
147 if (process.env.RESEARCHER_KEY_PEM) {
148 return process.env.RESEARCHER_KEY_PEM;
149 }
150 // Fallback: read from filesystem (CLI/local use)
151 const keyPath = process.env.RESEARCHER_KEY_PATH || "/home/nandi/vault/.credentials/researcher-pds-latha-org.pem";
152 return readFile(keyPath, "utf-8");
153}