/** * Portal 密碼 KDF 模組(portal-auth design §4.1 / D-5、D-6,Gitea #24/#25 P2) * * 職責界線(rule 2.1/2.2 對照,design D-5 已釐清): * 這是「UI session 登入」的密碼雜湊——console-auth.ts 同類先例,**不是** workflow * credential 原語(那些屬 WASM auth primitive,本檔不碰 crypto.subtle.decrypt / * RSASSA / template 展開)。只用 WebCrypto 原生 PBKDF2(crypto.subtle.deriveBits)。 * * 規格: * - PBKDF2-SHA256、100,000 iterations(CF Workers runtime 上限,見下)、salt 16 bytes、輸出 256-bit * - 儲存格式 `pbkdf2-sha256$$$`(自帶演算法前綴, * 未來換 KDF 可共存漸進遷移——verify 按前綴解析,不寫死參數) * - 驗證用常數時間比對(同 mcp/src/oauth/crypto.ts PR#15 慣例) * - 密碼永不明碼儲存、永不進 log(本模組不 console.log 任何輸入) */ export const PBKDF2_ALGO_PREFIX = 'pbkdf2-sha256'; /** * CF Workers **正式 runtime** 的 PBKDF2 上限是 100,000 iterations——超過時 * `crypto.subtle.deriveBits` 直接拒絕(2026-07-14 uncle6 真雲實撞:/portal/admin/bootstrap 500; * miniflare 無此限制 → 本機測試全綠的假象)。OWASP 建議 600k 但平台封頂只能 100k。 * 儲存格式自帶迭代數(verify 從儲存值解析)→ 未來平台放寬可無痛升,舊 hash 照驗。 */ export const PBKDF2_ITERATIONS = 100_000; function b64encode(bytes: Uint8Array): string { let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin); } function b64decode(s: string): Uint8Array | null { try { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } catch { return null; } } async function deriveBits(password: string, salt: Uint8Array, iterations: number): Promise { const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [ 'deriveBits', ]); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations }, key, 256, ); return new Uint8Array(bits); } /** * 常數時間字串比對(防 timing attack)。與 mcp/src/oauth/crypto.ts 同實作 * (cypher-executor 與 mcp 是不同 package,無共用 lib 路徑,故各持一份同款)。 */ 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; } /** 雜湊一組密碼 → `pbkdf2-sha256$100000$$`。 */ export async function hashPassword(password: string, iterations: number = PBKDF2_ITERATIONS): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); const hash = await deriveBits(password, salt, iterations); return `${PBKDF2_ALGO_PREFIX}$${iterations}$${b64encode(salt)}$${b64encode(hash)}`; } /** * 驗證密碼 vs 儲存格式。格式壞掉 / 前綴不認得 → false(誠實拒絕,不拋錯洩漏細節)。 * iterations 從儲存值解析(漸進遷移:舊 hash 用舊參數驗,新寫入用現行常數)。 */ export async function verifyPassword(password: string, stored: string): Promise { const parts = (stored ?? '').split('$'); if (parts.length !== 4 || parts[0] !== PBKDF2_ALGO_PREFIX) return false; const iterations = Number.parseInt(parts[1], 10); if (!Number.isFinite(iterations) || iterations < 1 || iterations > 10_000_000) return false; const salt = b64decode(parts[2]); if (!salt || salt.length === 0) return false; const derived = await deriveBits(password, salt, iterations); return constantTimeEqual(b64encode(derived), parts[3]); } /** 密碼學等級隨機 hex token(session token 用;與 console-auth randomHex 同款)。 */ export function randomHex(bytes: number): string { const arr = new Uint8Array(bytes); crypto.getRandomValues(arr); return Array.from(arr) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV key(D62)。 * * 為什麼不直接拿 token 當 key:連結裡的 token 是**能改密碼的憑據**,直接當 key 等於 * 把它明碼存在 KV 裡;改存雜湊後,看得到 KV 的人也拿不到可用的連結。 * (這裡只做 digest——不是 rule 2.2 禁的 `crypto.subtle.decrypt` / RSASSA 簽章。) */ export async function sha256Hex(input: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * 產生一次性隨機密碼(admin reset-password / 建帳號未給密碼時用)。 * 16 字元、大小寫+數字(去掉易混淆字元),熵約 93 bits。 */ export function generatePassword(length = 16): string { const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; const arr = new Uint8Array(length); crypto.getRandomValues(arr); let out = ''; for (const b of arr) out += charset[b % charset.length]; return out; }