this repo has no description
at main 315 lines 8.2 kB view raw
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}