Files
Arcrun/cypher-executor/src/lib/portal-auth.ts
T

113 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Portal 密碼 KDF 模組(portal-auth design §4.1 / D-5、D-6Gitea #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 原生 PBKDF2crypto.subtle.deriveBits)。
*
* 規格:
* - PBKDF2-SHA256、100,000 iterationsCF 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 tokensession 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;
}