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