113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
/**
|
||
* 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$<iterations>$<salt_b64>$<hash_b64>`(自帶演算法前綴,
|
||
* 未來換 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<Uint8Array> {
|
||
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$<salt_b64>$<hash_b64>`。 */
|
||
export async function hashPassword(password: string, iterations: number = PBKDF2_ITERATIONS): Promise<string> {
|
||
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<boolean> {
|
||
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('');
|
||
}
|
||
|
||
/**
|
||
* 產生一次性隨機密碼(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;
|
||
}
|