Files
Arcrun/cypher-executor/src/lib/portal-auth.ts
T
Claude 8d49d883c0 feat(portal): 改密碼與忘記密碼合成同一個機制,用連結不用一次性密碼(D62)
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
2026-08-10 13:23:44 +00:00

127 lines
5.4 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('');
}
/**
* SHA-256 → hex。**用途只有一個**:把「修改密碼連結」的 token 換成查詢用的 KV keyD62)。
*
* 為什麼不直接拿 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;
}