8d49d883c0
leo 2026-08-10 拍板:「這兩個機制其實是一個機制,可以簡化。」 修改密碼:輸入**現有的** → 輸入新的 → 覆蓋 忘記密碼:收到**「修改密碼」連結** → **不輸入現有密碼(忽略)** → 輸入新的 → 覆蓋 ⇒ 同一個畫面、同一條寫入路徑,差別只有「現有密碼」那一格。 後端(cypher-executor/src/routes/portal.ts) - POST /portal/password/change =**那一支**。帶 reset_token 就走連結那一格(忽略 current), 沒帶就要登入 + 正確的 current。兩條路在 writeNewPassword 之後完全相同。 /portal/me/password 保留成**別名轉呼同一支**(不留第二份實作,兩份必然漂移)。 - POST /portal/password/forgot(公開)/GET /portal/password/reset(看票,不消耗) - 連結的安全性(承 D50,不可退讓):**一次有效**(用掉即刪,先刪再回)、 **會過期**(KV TTL 30 分鐘)、**與註冊辨識碼不同源**(現場 crypto 亂數, 只活在本實例 SESSIONS_KV,與 landing SIGNUPS 那組安裝辨識碼毫無關係)。 KV 存的是 token 的 sha256,不是 token 本身。 - 🔴 不做一次性密碼(leo:「不要發一次性密碼太麻煩」)。 - 🔴 已否決不准寫回來的三條(D50):console 密碼救援/重裝重設密碼/直接用固定辨識碼。 中央代寄(arcrun-rag landing 那半在該 repo) leo 給的職責切法:實例產生連結、管一次性/過期;arcrun.dev 只是郵差。 必須這樣切的硬理由:用戶自己的實例**沒有 send_email binding**,根本寄不了信。 🔴 總管紅線:**絕不把整條 URL 交給郵差**——寄件網域帶 DKIM,肯收「任意 URL+任意 email」 就是一台開放的釣魚中繼,燒的是整個網域信譽、不可逆。 故只交出本實例 origin + 一張回呼票,並新增 POST /portal/password/relay-verify: 郵差**回頭打這個 origin** 問「這張票是你發的嗎」,冒用別人網域會被那台實例自己否認 ⇒ 主機屬於呼叫方這件事由郵差親自確認,不是相信宣稱。 信裡的連結落點 GET /portal/password/reset-link(主機刻意=被確認過的那個 origin)。 前端(console-ui/public/portal/index.html) - 登入頁「忘記密碼」入口,**在 portal 不在 console**(leo:「是對 portal 不是對 console, 這樣 youlin 雖然忘記,我還是可以去 portal 忘記密碼。」) - 拆掉登入頁原有的「用管理主控台密碼救援自己」連結與「忘記密碼請聯絡管理員」 ——兩條都是 D50 已否決的做法(console 與 portal 是同一組帳密的兩個鑰匙孔)。 - #v-reset 殼**沒有自己的密碼欄位**:真正的表單是設定頁那唯一一份 #pw-form, 進入連結模式時被原封不動搬過去,只切換「現有密碼」那一格顯不顯示。 同一個表單元素、同一支送出函式 —— D62「同一個畫面」的字面落地。 驗證:tsc 與 baseline 同為 23 個既有錯誤(零新增);27/27 既有測試綠;前端 JS node --check 過。 stage 實測見交付回報。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8vF2zS2XpaZjzkC75Fjss
127 lines
5.4 KiB
TypeScript
127 lines
5.4 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('');
|
||
}
|
||
|
||
/**
|
||
* 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<string> {
|
||
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;
|
||
}
|