bugfix(#24 #25):PBKDF2 600k→100k(CF runtime 上限,真雲 bootstrap 500)+http_request 補 1042 flag
- portal-auth.ts PBKDF2_ITERATIONS 600_000→100_000:CF Workers 正式 runtime 的 PBKDF2 上限 100k,crypto.subtle.deriveBits 超過直接拒絕 → /portal/admin/bootstrap 真雲 500(2026-07-14 uncle6 T6-cloud 實撞,證據 arcrun-rag docs/manual/ uncle6-deploy-record.md);miniflare 無此限制=本機全綠假象。儲存格式自帶迭代數, 平台放寬可無痛升。 - 補測試「驗 600k 舊 hash 仍通」(verify 從儲存值解析 iterations;誠實邊界:真雲 deriveBits 同樣封頂,但真雲 bootstrap 從未成功→零存量 hash 無遷移面)。 - .component-builds/http_request/wrangler.toml 補 global_fetch_strictly_public (self-hosted 同帳號 fetch 1042 正解,Arcrun#33 同族;官方跨 zone 行為不變, 原理同 cypher 前例 rules/03 1042 段)。 - SDD:design D-5 修訂註+tasks Bugfix 記錄;wiki status 更新。 - 驗證:cypher tsc 0;169/170(唯一失敗 pre-existing);portal 三套 56/56; http_request wrangler --dry-run 過。不 merge 待總管審。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
* credential 原語(那些屬 WASM auth primitive,本檔不碰 crypto.subtle.decrypt /
|
||||
* RSASSA / template 展開)。只用 WebCrypto 原生 PBKDF2(crypto.subtle.deriveBits)。
|
||||
*
|
||||
* 規格(OWASP 現行建議值):
|
||||
* - PBKDF2-SHA256、600,000 iterations、salt 16 bytes、輸出 256-bit
|
||||
* 規格:
|
||||
* - 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 慣例)
|
||||
@@ -15,7 +15,13 @@
|
||||
*/
|
||||
|
||||
export const PBKDF2_ALGO_PREFIX = 'pbkdf2-sha256';
|
||||
export const PBKDF2_ITERATIONS = 600_000;
|
||||
/**
|
||||
* 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 = '';
|
||||
@@ -61,7 +67,7 @@ export function constantTimeEqual(a: string, b: string): boolean {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** 雜湊一組密碼 → `pbkdf2-sha256$600000$<salt_b64>$<hash_b64>`。 */
|
||||
/** 雜湊一組密碼 → `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);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* portal-auth P2 測試(design §2/§4/§5,Gitea #24/#25)
|
||||
*
|
||||
* 覆蓋(=tasks.md P2 測試項):
|
||||
* 1. KDF:pbkdf2-sha256$600000$… 格式、驗證對錯、壞格式誠實 false
|
||||
* 1. KDF:pbkdf2-sha256$100000$… 格式(CF Workers runtime 上限 100k,2026-07-14 真雲實撞)、
|
||||
* 驗證對錯、壞格式誠實 false、600k 舊 hash 相容(迭代數從儲存值解析)
|
||||
* 2. bootstrap 閘:無 console session → 401;建 admin 寫 {tenant}::portal 子 namespace;
|
||||
* 已有 admin → 409
|
||||
* 3. 登入對錯:成功發 token(回應**無租戶字串**)、密碼錯 401、停用 403、未知 email 401
|
||||
* 4. 節流:5 次失敗 → 429(KV TTL 計數)
|
||||
* 5. session:每請求回讀 record;停用即拒(既有 session 立即失效)
|
||||
* 6. 改密碼:驗舊密;新 hash 以 600k 格式落 slot
|
||||
* 6. 改密碼:驗舊密;新 hash 以 100k 格式落 slot
|
||||
* 7. role 閘:非 admin 打 admin 端點 → 403;admin 列表**剝除 password_hash**
|
||||
*
|
||||
* KBDB 打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
|
||||
@@ -101,17 +102,28 @@ async function seedPortalSession(token: string, recordId: string) {
|
||||
// ═══════════════ 1. KDF 單元 ═══════════════
|
||||
|
||||
describe('PBKDF2 模組(lib/portal-auth)', () => {
|
||||
it('hashPassword 預設格式 = pbkdf2-sha256$600000$salt$hash,且驗證通過', async () => {
|
||||
it('hashPassword 預設格式 = pbkdf2-sha256$100000$salt$hash(CF runtime 上限),且驗證通過', async () => {
|
||||
const h = await hashPassword('some-password-123');
|
||||
const parts = h.split('$');
|
||||
expect(parts.length).toBe(4);
|
||||
expect(parts[0]).toBe('pbkdf2-sha256');
|
||||
expect(parts[1]).toBe(String(PBKDF2_ITERATIONS));
|
||||
expect(PBKDF2_ITERATIONS).toBe(600_000);
|
||||
// CF Workers 正式 runtime PBKDF2 封頂 100k(600k 在真雲 deriveBits 直接拒絕 → bootstrap 500)
|
||||
expect(PBKDF2_ITERATIONS).toBe(100_000);
|
||||
expect(PBKDF2_ITERATIONS).toBeLessThanOrEqual(100_000);
|
||||
expect(await verifyPassword('some-password-123', h)).toBe(true);
|
||||
expect(await verifyPassword('wrong-password-x', h)).toBe(false);
|
||||
});
|
||||
|
||||
it('600k 舊 hash 相容:verify 從儲存值解析迭代數,仍驗得動(miniflare 無平台上限可造)', async () => {
|
||||
// 誠實註:此相容性在 miniflare / 未來平台放寬時成立;真 CF runtime 對 deriveBits 一律封頂
|
||||
// 100k,600k hash 在真雲仍會被拒——但真雲 bootstrap 本身就 500,雲端不存在 600k hash,無遷移面。
|
||||
const legacy = await hashPassword(PASSWORD, 600_000);
|
||||
expect(legacy.startsWith('pbkdf2-sha256$600000$')).toBe(true);
|
||||
expect(await verifyPassword(PASSWORD, legacy)).toBe(true);
|
||||
expect(await verifyPassword('wrong-password-x', legacy)).toBe(false);
|
||||
});
|
||||
|
||||
it('壞格式 / 前綴不認得 / 被竄改 → false(誠實拒絕不拋錯)', async () => {
|
||||
expect(await verifyPassword('x', '')).toBe(false);
|
||||
expect(await verifyPassword('x', 'bcrypt$10$abc$def')).toBe(false);
|
||||
@@ -306,7 +318,7 @@ describe('POST /portal/me/password', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('舊密碼對 → PATCH 新 hash(600k 格式、非明碼、與舊 hash 不同)', async () => {
|
||||
it('舊密碼對 → PATCH 新 hash(現行 100k 格式、非明碼、與舊 hash 不同)', async () => {
|
||||
await seedPortalSession('tok-4', 'rec_1');
|
||||
mockGetRecord('rec_1', activeUserValues());
|
||||
let patched = '';
|
||||
|
||||
Reference in New Issue
Block a user