/** * Cryptographic utilities for ATProtocol OAuth * Implements PKCE (S256) and DPoP (ES256) generation */ /** * Generate a PKCE code verifier * Must be 43-128 characters from [A-Za-z0-9-._~] * @returns Base64url encoded verifier */ export function generateCodeVerifier(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64urlEncode(array); } /** * Generate PKCE code challenge from verifier * Uses S256 method (SHA-256 hash) * @param verifier - The code verifier * @returns Base64url encoded challenge */ export async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return base64urlEncode(new Uint8Array(hash)); } /** * Generate ES256 DPoP keypair for token binding * @returns CryptoKeyPair for DPoP proofs */ export async function generateDpopKeyPair(): Promise { return await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256', }, false, // non-exportable for security ['sign', 'verify'] ); } /** * Export public key as JWK for DPoP header * @param publicKey - The public key to export * @returns JWK representation */ export async function exportPublicKeyAsJwk(publicKey: CryptoKey): Promise { const jwk = await crypto.subtle.exportKey('jwk', publicKey); // Remove private key material if present delete jwk.d; delete jwk.dp; delete jwk.dq; delete jwk.p; delete jwk.q; delete jwk.qi; jwk.use = 'sig'; jwk.alg = 'ES256'; return jwk; } /** * Generate a DPoP proof JWT * @param keypair - The DPoP keypair * @param method - HTTP method (GET, POST, etc.) * @param url - Full URL of the request * @param accessToken - Optional access token for binding * @param nonce - Optional server-provided nonce * @returns Signed DPoP JWT */ export async function generateDpopProof( keypair: CryptoKeyPair, method: string, url: string, accessToken?: string, nonce?: string ): Promise { const jwk = await exportPublicKeyAsJwk(keypair.publicKey); const header = { typ: 'dpop+jwt', alg: 'ES256', jwk, }; const payload: any = { jti: generateRandomString(16), htm: method.toUpperCase(), htu: url, iat: Math.floor(Date.now() / 1000), }; if (nonce) { payload.nonce = nonce; } if (accessToken) { // Include access token hash for token binding payload.ath = await sha256Hash(accessToken); } return await signJwt(header, payload, keypair.privateKey); } /** * Sign a JWT with ES256 * @param header - JWT header * @param payload - JWT payload * @param privateKey - Private key for signing * @returns Signed JWT */ async function signJwt( header: any, payload: any, privateKey: CryptoKey ): Promise { const encodedHeader = base64urlEncode( new TextEncoder().encode(JSON.stringify(header)) ); const encodedPayload = base64urlEncode( new TextEncoder().encode(JSON.stringify(payload)) ); const message = `${encodedHeader}.${encodedPayload}`; const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256', }, privateKey, new TextEncoder().encode(message) ); const encodedSignature = base64urlEncode(new Uint8Array(signature)); return `${message}.${encodedSignature}`; } /** * Calculate SHA-256 hash and encode as base64url * @param data - Data to hash * @returns Base64url encoded hash */ export async function sha256Hash(data: string): Promise { const encoder = new TextEncoder(); const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data)); return base64urlEncode(new Uint8Array(hash)); } /** * Generate a random string for state tokens and JTI * @param length - Length of the string * @returns Random string */ export function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return base64urlEncode(array); } /** * Base64url encode (no padding, URL-safe) * @param data - Data to encode * @returns Base64url encoded string */ export function base64urlEncode(data: Uint8Array): string { const base64 = btoa(String.fromCharCode(...data)); return base64 .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Base64url decode * @param str - Base64url string * @returns Decoded bytes */ export function base64urlDecode(str: string): Uint8Array { const base64 = str .replace(/-/g, '+') .replace(/_/g, '/') .padEnd(str.length + ((4 - (str.length % 4)) % 4), '='); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } /** * Store DPoP keypair in IndexedDB * @param keypair - The keypair to store * @param identifier - Unique identifier for the keypair */ export async function storeDpopKeypair( keypair: CryptoKeyPair, identifier: string ): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open('atproto-oauth', 1); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('dpop-keys')) { db.createObjectStore('dpop-keys'); } }; request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const transaction = db.transaction(['dpop-keys'], 'readwrite'); const store = transaction.objectStore('dpop-keys'); store.put(keypair, identifier); transaction.oncomplete = () => { db.close(); resolve(); }; transaction.onerror = () => { db.close(); reject(new Error('Failed to store DPoP keypair')); }; }; request.onerror = () => { reject(new Error('Failed to open IndexedDB')); }; }); } /** * Retrieve DPoP keypair from IndexedDB * @param identifier - Unique identifier for the keypair * @returns The stored keypair or null */ export async function retrieveDpopKeypair( identifier: string ): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open('atproto-oauth', 1); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('dpop-keys')) { db.createObjectStore('dpop-keys'); } }; request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const transaction = db.transaction(['dpop-keys'], 'readonly'); const store = transaction.objectStore('dpop-keys'); const getRequest = store.get(identifier); getRequest.onsuccess = () => { db.close(); resolve(getRequest.result || null); }; getRequest.onerror = () => { db.close(); reject(new Error('Failed to retrieve DPoP keypair')); }; }; request.onerror = () => { reject(new Error('Failed to open IndexedDB')); }; }); } /** * Clear all stored DPoP keypairs */ export async function clearDpopKeypairs(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open('atproto-oauth', 1); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains('dpop-keys')) { db.createObjectStore('dpop-keys'); } }; request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; const transaction = db.transaction(['dpop-keys'], 'readwrite'); const store = transaction.objectStore('dpop-keys'); store.clear(); transaction.oncomplete = () => { db.close(); resolve(); }; transaction.onerror = () => { db.close(); reject(new Error('Failed to clear DPoP keypairs')); }; }; request.onerror = () => { reject(new Error('Failed to open IndexedDB')); }; }); }