this repo has no description
1/**
2 * Cryptographic utilities for ATProtocol OAuth
3 * Implements PKCE (S256) and DPoP (ES256) generation
4 */
5
6/**
7 * Generate a PKCE code verifier
8 * Must be 43-128 characters from [A-Za-z0-9-._~]
9 * @returns Base64url encoded verifier
10 */
11export function generateCodeVerifier(): string {
12 const array = new Uint8Array(32);
13 crypto.getRandomValues(array);
14 return base64urlEncode(array);
15}
16
17/**
18 * Generate PKCE code challenge from verifier
19 * Uses S256 method (SHA-256 hash)
20 * @param verifier - The code verifier
21 * @returns Base64url encoded challenge
22 */
23export async function generateCodeChallenge(verifier: string): Promise<string> {
24 const encoder = new TextEncoder();
25 const data = encoder.encode(verifier);
26 const hash = await crypto.subtle.digest('SHA-256', data);
27 return base64urlEncode(new Uint8Array(hash));
28}
29
30/**
31 * Generate ES256 DPoP keypair for token binding
32 * @returns CryptoKeyPair for DPoP proofs
33 */
34export async function generateDpopKeyPair(): Promise<CryptoKeyPair> {
35 return await crypto.subtle.generateKey(
36 {
37 name: 'ECDSA',
38 namedCurve: 'P-256',
39 },
40 false, // non-exportable for security
41 ['sign', 'verify']
42 );
43}
44
45/**
46 * Export public key as JWK for DPoP header
47 * @param publicKey - The public key to export
48 * @returns JWK representation
49 */
50export async function exportPublicKeyAsJwk(publicKey: CryptoKey): Promise<JsonWebKey> {
51 const jwk = await crypto.subtle.exportKey('jwk', publicKey);
52 // Remove private key material if present
53 delete jwk.d;
54 delete jwk.dp;
55 delete jwk.dq;
56 delete jwk.p;
57 delete jwk.q;
58 delete jwk.qi;
59 jwk.use = 'sig';
60 jwk.alg = 'ES256';
61 return jwk;
62}
63
64/**
65 * Generate a DPoP proof JWT
66 * @param keypair - The DPoP keypair
67 * @param method - HTTP method (GET, POST, etc.)
68 * @param url - Full URL of the request
69 * @param accessToken - Optional access token for binding
70 * @param nonce - Optional server-provided nonce
71 * @returns Signed DPoP JWT
72 */
73export async function generateDpopProof(
74 keypair: CryptoKeyPair,
75 method: string,
76 url: string,
77 accessToken?: string,
78 nonce?: string
79): Promise<string> {
80 const jwk = await exportPublicKeyAsJwk(keypair.publicKey);
81
82 const header = {
83 typ: 'dpop+jwt',
84 alg: 'ES256',
85 jwk,
86 };
87
88 const payload: any = {
89 jti: generateRandomString(16),
90 htm: method.toUpperCase(),
91 htu: url,
92 iat: Math.floor(Date.now() / 1000),
93 };
94
95 if (nonce) {
96 payload.nonce = nonce;
97 }
98
99 if (accessToken) {
100 // Include access token hash for token binding
101 payload.ath = await sha256Hash(accessToken);
102 }
103
104 return await signJwt(header, payload, keypair.privateKey);
105}
106
107/**
108 * Sign a JWT with ES256
109 * @param header - JWT header
110 * @param payload - JWT payload
111 * @param privateKey - Private key for signing
112 * @returns Signed JWT
113 */
114async function signJwt(
115 header: any,
116 payload: any,
117 privateKey: CryptoKey
118): Promise<string> {
119 const encodedHeader = base64urlEncode(
120 new TextEncoder().encode(JSON.stringify(header))
121 );
122 const encodedPayload = base64urlEncode(
123 new TextEncoder().encode(JSON.stringify(payload))
124 );
125
126 const message = `${encodedHeader}.${encodedPayload}`;
127 const signature = await crypto.subtle.sign(
128 {
129 name: 'ECDSA',
130 hash: 'SHA-256',
131 },
132 privateKey,
133 new TextEncoder().encode(message)
134 );
135
136 const encodedSignature = base64urlEncode(new Uint8Array(signature));
137 return `${message}.${encodedSignature}`;
138}
139
140/**
141 * Calculate SHA-256 hash and encode as base64url
142 * @param data - Data to hash
143 * @returns Base64url encoded hash
144 */
145export async function sha256Hash(data: string): Promise<string> {
146 const encoder = new TextEncoder();
147 const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data));
148 return base64urlEncode(new Uint8Array(hash));
149}
150
151/**
152 * Generate a random string for state tokens and JTI
153 * @param length - Length of the string
154 * @returns Random string
155 */
156export function generateRandomString(length: number): string {
157 const array = new Uint8Array(length);
158 crypto.getRandomValues(array);
159 return base64urlEncode(array);
160}
161
162/**
163 * Base64url encode (no padding, URL-safe)
164 * @param data - Data to encode
165 * @returns Base64url encoded string
166 */
167export function base64urlEncode(data: Uint8Array): string {
168 const base64 = btoa(String.fromCharCode(...data));
169 return base64
170 .replace(/\+/g, '-')
171 .replace(/\//g, '_')
172 .replace(/=/g, '');
173}
174
175/**
176 * Base64url decode
177 * @param str - Base64url string
178 * @returns Decoded bytes
179 */
180export function base64urlDecode(str: string): Uint8Array {
181 const base64 = str
182 .replace(/-/g, '+')
183 .replace(/_/g, '/')
184 .padEnd(str.length + ((4 - (str.length % 4)) % 4), '=');
185
186 const binary = atob(base64);
187 const bytes = new Uint8Array(binary.length);
188 for (let i = 0; i < binary.length; i++) {
189 bytes[i] = binary.charCodeAt(i);
190 }
191 return bytes;
192}
193
194/**
195 * Store DPoP keypair in IndexedDB
196 * @param keypair - The keypair to store
197 * @param identifier - Unique identifier for the keypair
198 */
199export async function storeDpopKeypair(
200 keypair: CryptoKeyPair,
201 identifier: string
202): Promise<void> {
203 return new Promise((resolve, reject) => {
204 const request = indexedDB.open('atproto-oauth', 1);
205
206 request.onupgradeneeded = (event) => {
207 const db = (event.target as IDBOpenDBRequest).result;
208 if (!db.objectStoreNames.contains('dpop-keys')) {
209 db.createObjectStore('dpop-keys');
210 }
211 };
212
213 request.onsuccess = (event) => {
214 const db = (event.target as IDBOpenDBRequest).result;
215 const transaction = db.transaction(['dpop-keys'], 'readwrite');
216 const store = transaction.objectStore('dpop-keys');
217
218 store.put(keypair, identifier);
219
220 transaction.oncomplete = () => {
221 db.close();
222 resolve();
223 };
224
225 transaction.onerror = () => {
226 db.close();
227 reject(new Error('Failed to store DPoP keypair'));
228 };
229 };
230
231 request.onerror = () => {
232 reject(new Error('Failed to open IndexedDB'));
233 };
234 });
235}
236
237/**
238 * Retrieve DPoP keypair from IndexedDB
239 * @param identifier - Unique identifier for the keypair
240 * @returns The stored keypair or null
241 */
242export async function retrieveDpopKeypair(
243 identifier: string
244): Promise<CryptoKeyPair | null> {
245 return new Promise((resolve, reject) => {
246 const request = indexedDB.open('atproto-oauth', 1);
247
248 request.onupgradeneeded = (event) => {
249 const db = (event.target as IDBOpenDBRequest).result;
250 if (!db.objectStoreNames.contains('dpop-keys')) {
251 db.createObjectStore('dpop-keys');
252 }
253 };
254
255 request.onsuccess = (event) => {
256 const db = (event.target as IDBOpenDBRequest).result;
257 const transaction = db.transaction(['dpop-keys'], 'readonly');
258 const store = transaction.objectStore('dpop-keys');
259
260 const getRequest = store.get(identifier);
261
262 getRequest.onsuccess = () => {
263 db.close();
264 resolve(getRequest.result || null);
265 };
266
267 getRequest.onerror = () => {
268 db.close();
269 reject(new Error('Failed to retrieve DPoP keypair'));
270 };
271 };
272
273 request.onerror = () => {
274 reject(new Error('Failed to open IndexedDB'));
275 };
276 });
277}
278
279/**
280 * Clear all stored DPoP keypairs
281 */
282export async function clearDpopKeypairs(): Promise<void> {
283 return new Promise((resolve, reject) => {
284 const request = indexedDB.open('atproto-oauth', 1);
285
286 request.onupgradeneeded = (event) => {
287 const db = (event.target as IDBOpenDBRequest).result;
288 if (!db.objectStoreNames.contains('dpop-keys')) {
289 db.createObjectStore('dpop-keys');
290 }
291 };
292
293 request.onsuccess = (event) => {
294 const db = (event.target as IDBOpenDBRequest).result;
295 const transaction = db.transaction(['dpop-keys'], 'readwrite');
296 const store = transaction.objectStore('dpop-keys');
297
298 store.clear();
299
300 transaction.oncomplete = () => {
301 db.close();
302 resolve();
303 };
304
305 transaction.onerror = () => {
306 db.close();
307 reject(new Error('Failed to clear DPoP keypairs'));
308 };
309 };
310
311 request.onerror = () => {
312 reject(new Error('Failed to open IndexedDB'));
313 };
314 });
315}