// OAuth 加密工具:PKCE S256 驗證、SHA-256 hash、隨機不可猜 token、常數時間比對。 // 全走 Web Crypto(Workers / Node 18+ 全域 crypto.subtle),無外部依賴。 /** base64url 編碼(無 padding,RFC 7636 §A)。 */ function base64UrlEncode(bytes: Uint8Array): string { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** 產生密碼學等級隨機 token(bytes 位元組 → base64url 字串,預設 32 bytes = 256-bit)。 */ export function randomToken(bytes = 32): string { const buf = new Uint8Array(bytes); crypto.getRandomValues(buf); return base64UrlEncode(buf); } /** SHA-256 → base64url(PKCE code_challenge 用;RFC 7636 §4.2 BASE64URL(SHA256(verifier)))。 */ export async function sha256Base64Url(input: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); return base64UrlEncode(new Uint8Array(digest)); } /** SHA-256 → hex(拿來當 KV key:不把可用的 raw token 直接當 key,KV list 也看不到明碼)。 */ export async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); } /** * 常數時間字串比對(防 timing attack;比 owner secret / static token 用)。 * 長度不同直接 false,但仍走完固定迴圈避免長度洩漏。 */ export function constantTimeEqual(a: string, b: string): boolean { const ab = new TextEncoder().encode(a); const bb = new TextEncoder().encode(b); let diff = ab.length ^ bb.length; const len = Math.max(ab.length, bb.length); for (let i = 0; i < len; i++) { diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); } return diff === 0; } /** * 驗證 PKCE:BASE64URL(SHA256(code_verifier)) === code_challenge(僅支援 S256)。 * method 非 "S256"(含缺省的 "plain")一律拒絕——OAuth 2.1 + 本 server 只宣告 S256。 */ export async function verifyPkceS256( codeVerifier: string, codeChallenge: string, method: string | undefined, ): Promise { if (method !== "S256") return false; if (!codeVerifier || !codeChallenge) return false; // RFC 7636 §4.1:verifier 43–128 字元、[A-Za-z0-9-._~]。 if (codeVerifier.length < 43 || codeVerifier.length > 128) return false; if (!/^[A-Za-z0-9\-._~]+$/.test(codeVerifier)) return false; const computed = await sha256Base64Url(codeVerifier); return constantTimeEqual(computed, codeChallenge); }