@tacet-ink/journal-core 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -36
- package/dist/client/bip39.d.ts +33 -0
- package/dist/client/bip39.js +12 -0
- package/dist/client/vault-error.d.ts +4 -1
- package/dist/client/vault-error.js +9 -2
- package/dist/client/vault.d.ts +94 -15
- package/dist/client/vault.js +155 -27
- package/dist/server/ratelimit.d.ts +1 -0
- package/dist/server/ratelimit.js +1 -0
- package/package.json +1 -6
- package/scripts/prepare-core-pkg.cjs +0 -74
- package/scripts/verify-core-crypto.ts +0 -3377
- package/src/client/argon-auto.ts +0 -224
- package/src/client/argon-worker.ts +0 -147
- package/src/client/argon2.ts +0 -532
- package/src/client/bip39.ts +0 -145
- package/src/client/keys.ts +0 -27
- package/src/client/note-crypto.ts +0 -719
- package/src/client/pinlock.ts +0 -110
- package/src/client/vault-error.ts +0 -51
- package/src/client/vault.ts +0 -486
- package/src/client/vendor/hash-wasm-argon2.ts +0 -27
- package/src/client/wordlist.ts +0 -264
- package/src/index.ts +0 -104
- package/src/server/auth.ts +0 -225
- package/src/server/cors.ts +0 -22
- package/src/server/env.ts +0 -13
- package/src/server/hash.ts +0 -28
- package/src/server/ladder.ts +0 -76
- package/src/server/ratelimit.ts +0 -123
package/src/server/auth.ts
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* auth.ts — 零知識伺服器端 auth 核心邏輯(品牌/schema 參數化)。
|
|
3
|
-
*
|
|
4
|
-
* 本檔實作的契約:
|
|
5
|
-
* - pass 明文永不過線:線上憑證送 PH1(雜湊形),伺服器存 PH2 = SHA-256(PH1)。
|
|
6
|
-
* PH1 派生方式由各產品自定義(快雜湊或 Argon2id 派生皆可)——本層只驗
|
|
7
|
-
* hex64 形,不驗 PH1 派生方式(伺服器零知識,無從也無需區分形別)。
|
|
8
|
-
* - 入庫前密文/包裹格式驗證(inboundCipher/isCipherFor/validWrappedKey/
|
|
9
|
-
* pickKeyPackage 等,前綴由 config 注入)。
|
|
10
|
-
* - /auth/login 核心動線(loginRouteCore):rate-limit 閘 → PH1 驗形 →
|
|
11
|
-
* PH2 → store 查詢(現值 miss → ladder 查表守衛——0.2.4 內建,optional 未配置
|
|
12
|
-
* 即退場)/建號 → session token 簽發(30 天)。
|
|
13
|
-
*
|
|
14
|
-
* 呼叫端(fork)職責——本檔只定義介面,機制在呼叫端實作:
|
|
15
|
-
* - identity 生成:AuthStore.createUser 由各 fork 自決(隨機 account_id 或
|
|
16
|
-
* 客戶端帶入語意皆可);PH2 UNIQUE 衝突(兩個同時首登同密語)是 store
|
|
17
|
-
* 責任——tacet 參考實作:UNIQUE 失敗重查既有列回 userKey(非 5xx)。
|
|
18
|
-
* - hash-ladder 雙軌(舊式 PH1 直比 → 升級寫回 PH2)與密語重設後的 session
|
|
19
|
-
* 撤銷時機:fork 呼叫端接線(revokeAllSessions 介面已備);ladder 查表守衛已
|
|
20
|
-
* 內建 loginRouteCore(optional——fork 只需配 ladderLookup+0011 migration,接線零寫)。
|
|
21
|
-
* - 常數時間比較 timingSafeEq:./hash.ts 匯出(login 動線經 PH2 查詢不直比)。
|
|
22
|
-
*
|
|
23
|
-
* 不變量(勿破壞):
|
|
24
|
-
* - 金鑰包裹欄組(wrapped+salt)缺一整組放棄——pair 檢查本體在 pickKeyPackage。
|
|
25
|
-
* - recPkg 與 recHash 成對出現=呼叫端(fork store)職責:本層介面兩參各自可空、
|
|
26
|
-
* 不做 pair 檢查(裁定照此收口;pickKeyPackage 是同職責的成對檢查先例)。
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { corsResponse } from './cors.ts';
|
|
30
|
-
import { sha256Hex, generateSessionToken } from './hash.ts';
|
|
31
|
-
import { checkRate, type RateWindow } from './ratelimit.ts';
|
|
32
|
-
import type { Env } from './env.ts';
|
|
33
|
-
|
|
34
|
-
// ── 格式驗證(自 noteCrypt.ts 抽出;前綴由 config 注入) ─────────────────────
|
|
35
|
-
|
|
36
|
-
export interface CipherFormats {
|
|
37
|
-
/** 密文前綴家族字面(startsWith 語意,非 regex 來源),如 'jr1b.'。 */
|
|
38
|
-
cipherPrefixes: [string, string];
|
|
39
|
-
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
40
|
-
wrapPrefix: string;
|
|
41
|
-
/** 密文上限:UTF-16 code unit 計數(.length 語意;非 UTF-8 bytes)。 */
|
|
42
|
-
cipherMax: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
46
|
-
const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
|
|
47
|
-
const HASH64_RE = /^[0-9a-f]{64}$/;
|
|
48
|
-
const SALT32_RE = /^[0-9a-f]{32}$/;
|
|
49
|
-
|
|
50
|
-
/** regex 用字面跳脫:全字元掃描(.replace('.',) 只跳第一個點——前綴含第二個 . 即壞)。 */
|
|
51
|
-
function escapeReLiteral(s: string): string {
|
|
52
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
|
|
56
|
-
* never-match 守衛(空字首組態防線,opt-in 鐵律面):空字首組態會
|
|
57
|
-
* 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
|
|
58
|
-
* (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
|
|
59
|
-
* 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
|
|
60
|
-
* (probe 實證:`[]` 對純 b64 樣本 NON-NULL——
|
|
61
|
-
* 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
|
|
62
|
-
function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
|
|
63
|
-
const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
|
|
64
|
-
if (usable.length === 0) return /^(?=a)b/; // never-match(lookahead 錨死矛盾面)——空字串/任意串皆不匹配
|
|
65
|
-
const escaped = usable.map(p => escapeReLiteral(p));
|
|
66
|
-
return new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
|
|
71
|
-
* cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
|
|
72
|
-
* 截到 cipherMax 入庫(JSDoc 幽靈參數 plainMax 已摘除)。
|
|
73
|
-
*/
|
|
74
|
-
export function makeInboundCipher(c: CipherFormats) {
|
|
75
|
-
const RE = cipherPrefixRe(c.cipherPrefixes);
|
|
76
|
-
return function inboundCipher(raw: unknown): string | null {
|
|
77
|
-
if (typeof raw !== 'string') return null;
|
|
78
|
-
const text = raw.trim();
|
|
79
|
-
if (!text) return null;
|
|
80
|
-
if (text.length > c.cipherMax) {
|
|
81
|
-
return RE.test(text) || BASE64_RE.test(text)
|
|
82
|
-
? null // 密文/編碼超限:截斷必壞,直接丟棄
|
|
83
|
-
: text.slice(0, c.cipherMax); // 純明文(舊版客戶端):截到上限照收
|
|
84
|
-
}
|
|
85
|
-
if (RE.test(text)) return text; // 前綴密文:原樣入庫
|
|
86
|
-
if (BASE64_RE.test(text) && text.length > 40) return text; // 舊客戶端 b64 慣例:照收
|
|
87
|
-
if (BINARY_RE.test(text)) return null; // 控制字元垃圾:丟棄
|
|
88
|
-
return text; // 真明文:照收(舊版相容)
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function isCipherFor(text: string | null | undefined, c: CipherFormats): boolean {
|
|
93
|
-
if (typeof text !== 'string') return false;
|
|
94
|
-
// 空字首組態同面守衛(範圍增補):'' 態 startsWith('') 恆真=
|
|
95
|
-
// 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
|
|
96
|
-
// 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
|
|
97
|
-
// opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
|
|
98
|
-
// 「空集 if 檢+保留 startsWith(tuple) 原式」兩行形對混合形不設防(空槽仍經 startsWith('') 放行)。
|
|
99
|
-
return c.cipherPrefixes.some(p => typeof p === 'string' && p.length > 0 && text.startsWith(p));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function validWrappedKey(v: unknown, wrapPrefix: string | string[]): string | null {
|
|
103
|
-
const prefixes = Array.isArray(wrapPrefix) ? wrapPrefix : [wrapPrefix];
|
|
104
|
-
const re = cipherPrefixRe(prefixes);
|
|
105
|
-
return typeof v === 'string' && v.length <= 200 && re.test(v) ? v : null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function validHash64(v: unknown): string | null {
|
|
109
|
-
return typeof v === 'string' && HASH64_RE.test(v) ? v : null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function validSalt(v: unknown): string | null {
|
|
113
|
-
return typeof v === 'string' && SALT32_RE.test(v) ? v : null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** 綁定/復原共用的金鑰包裹欄組:wrapped 與 salt 必須成對,缺一整組放棄。 */
|
|
117
|
-
export function pickKeyPackage(body: any, wrapPrefix: string | string[]): { wrapped: string | null; salt: string | null } {
|
|
118
|
-
const wrapped = validWrappedKey(body?.note_wrapped ?? body?.wrapped, wrapPrefix);
|
|
119
|
-
const salt = validSalt(body?.note_salt ?? body?.salt);
|
|
120
|
-
return { wrapped: wrapped && salt ? wrapped : null, salt: wrapped && salt ? salt : null };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ── 介面:各 fork 接線自己的 schema 欄位 ─────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
export interface KeyPackage {
|
|
126
|
-
wrapped: string;
|
|
127
|
-
salt: string;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface AuthStore {
|
|
131
|
-
/** 以 PH2 查身份(PH2 UNIQUE;tacet:隨機 account_id 在此建立)。 */
|
|
132
|
-
findByIdentityQuery(ph2: string): Promise<AuthRow | null>;
|
|
133
|
-
/** 建立(identity 生成由各 fork 自決——見檔頭「呼叫端職責」;PH2 UNIQUE 衝突亦是 store 責任)。 */
|
|
134
|
-
createUser(ph2: string): Promise<string>;
|
|
135
|
-
getByUserKey(key: string): Promise<AuthRow | null>;
|
|
136
|
-
getByRecHash(recHash: string): Promise<AuthRow | null>;
|
|
137
|
-
setWrapped(userKey: string, ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
138
|
-
/** v3 重鑰:舊值入 ladder 表+ph2/wrapped/salt/wrappedRec/rec_hash
|
|
139
|
-
* 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork 實作三語句同批寫入)。 */
|
|
140
|
-
rekeyWithLadder?(userKey: string, oldPh2: string, oldKind: 'legacy' | 'v2', ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
141
|
-
/**
|
|
142
|
-
* ladder 查表:以「舊 ph2 值」(v2/legacy 形)查 ladder 表,命中回帳戶 id。
|
|
143
|
-
* loginRouteCore 內建接線(0.2.4 外審 #8 幽靈守衛上位):現值 miss → ladderLookup →
|
|
144
|
-
* 命中=回舊帳(不建幽靈;ph2Kind 語意照 ladder.ts 檔頭註解——core 端回帳序:
|
|
145
|
-
* ladder hit → getByUserKey → 可續 buildSession),兩面皆 miss 才 createUser。
|
|
146
|
-
* 未配置(optional undefined)=查表面退場(現行單查行為零變——向後相容:
|
|
147
|
-
* fork 未配 ladder(0011 未上)時行為與 0.2.3 恆等,sennight/vestige 未來接 core 也零炸)。
|
|
148
|
-
* 錯誤處理照 tacet store.ts 母型:lookup 拋任何錯(0011 未上的 ladder 表缺席、ladder store
|
|
149
|
-
* 停機故障)=fail-open 視同 miss,不炸登入(ladder store 停機中登入=建幽靈——fail-open
|
|
150
|
-
* 語意的已知代價;接線端 ladderGuard 統一 catch 只包 lookup)+非 lookup 錯誤照常外拋。
|
|
151
|
-
*/
|
|
152
|
-
ladderLookup?(oldPh2: string): Promise<string | null>;
|
|
153
|
-
/** ladder 入表(遷移線用;upsert冪等)。 */
|
|
154
|
-
ladderInsert?(accountId: string, oldPh2: string, oldKind: 'legacy' | 'v2'): Promise<void>;
|
|
155
|
-
/** ladder 刪列(直銷面;遷移批次同批或後續呼叫端承擔)。 */
|
|
156
|
-
ladderDelete?(accountId: string): Promise<void>;
|
|
157
|
-
revokeAllSessions(userKey: string): Promise<void>;
|
|
158
|
-
insertSession(tokenHash: string, userKey: string, expiresAt: number): Promise<void>;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export interface AuthRow {
|
|
162
|
-
userKey: string; // identity(account_id / user id)
|
|
163
|
-
ph2: string | null; // SHA-256(PH1);null = 未綁定 guest
|
|
164
|
-
salt: string | null;
|
|
165
|
-
wrapped: string | null;
|
|
166
|
-
wrappedRec: string | null;
|
|
167
|
-
recHash: string | null;
|
|
168
|
-
/** 訂閱方案(tacet:users.plan,'free'/'paid');兄弟 fork 無訂閱面時預設 'free'。 */
|
|
169
|
-
plan?: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export const AUTH_RATE: RateWindow = { table: 'login_rate', windowMs: 60_000, max: 10 };
|
|
173
|
-
|
|
174
|
-
export function errResponse(code: string, status: number): Response {
|
|
175
|
-
return corsResponse(new Response(code, { status }));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ── /auth/login:PH1 進站 → PH2 查詢(UNIQUE)→ session ─────────────────────
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* loginRouteCore ladder 查表守衛(0.2.4 外審 #8 幽靈帳真洞收口——tacet fork 母型上位):
|
|
182
|
-
* 現值(PH2 UNIQUE 查表)miss 時,先查 ladder 表(ph2_ladder:遷移線落下的「舊 ph2 值」),
|
|
183
|
-
* 命中=回舊帳不建幽靈;ladder 也 miss(或未配置)=才 createUser。
|
|
184
|
-
* - ladder hit 語意照 ladder.ts 檔頭註解:表列只承載舊值,帳戶現行憑證面以
|
|
185
|
-
* `getByUserKey` 讀到的 users 現值為準(v3 帳的 salt/wrapped 已是 v3 形);
|
|
186
|
-
* ladder hit → getByUserKey 落查無列(帳戶已刪、行殘留)=誠實 miss 續走建幽靈。
|
|
187
|
-
* - lookup 未配置(optional undefined)=零呼叫,行為與 0.2.3 單查恆等(向後相容鐵律);
|
|
188
|
-
* insert 是遷移線(rekeyWithLadder)職責、delete 已綁 delete-account(fork 面)——
|
|
189
|
-
* 本守衛只動查表,零寫入面。
|
|
190
|
-
* - lookup 拋錯(表缺席/儲存故障)=fail-open 視同 miss,不炸登入(tacet store.ts 母型;
|
|
191
|
-
* 靜默面照 tacet 慣例只 console.error)。
|
|
192
|
-
* - 零知識照舊:ladder 只存 ph2 hash 與 account_id,本函式零新洩漏面。
|
|
193
|
-
*/
|
|
194
|
-
async function ladderGuard(
|
|
195
|
-
store: AuthStore,
|
|
196
|
-
ph2: string,
|
|
197
|
-
): Promise<AuthRow | null> {
|
|
198
|
-
if (!store.ladderLookup) return null;
|
|
199
|
-
let ladderAcc: string | null = null;
|
|
200
|
-
try {
|
|
201
|
-
ladderAcc = await store.ladderLookup(ph2);
|
|
202
|
-
} catch (e) {
|
|
203
|
-
console.error('[ph2-ladder] lookup failed (fail-open):', e);
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
if (!ladderAcc) return null;
|
|
207
|
-
return store.getByUserKey(ladderAcc);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export async function loginRouteCore(env: Env & Record<string, unknown>, store: AuthStore, ip: string, ph1In: unknown): Promise<Response> {
|
|
211
|
-
if (!(await checkRate(env, AUTH_RATE, ip))) return errResponse('ERR_RATE_LIMITED', 429);
|
|
212
|
-
const ph1 = validHash64(ph1In);
|
|
213
|
-
if (!ph1) return errResponse('ERR_BAD_REQUEST', 400);
|
|
214
|
-
const ph2 = await sha256Hex(ph1);
|
|
215
|
-
const user = (await store.findByIdentityQuery(ph2)) ?? (await ladderGuard(store, ph2));
|
|
216
|
-
const userKey: string = user ? user.userKey : await store.createUser(ph2);
|
|
217
|
-
const token = generateSessionToken();
|
|
218
|
-
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // PWA:30 天
|
|
219
|
-
await store.insertSession(await sha256Hex(token), userKey, expiresAt);
|
|
220
|
-
return corsResponse(new Response(JSON.stringify({
|
|
221
|
-
sessionToken: token,
|
|
222
|
-
userKey,
|
|
223
|
-
status: user?.ph2 ? 'ready' : 'require_binding',
|
|
224
|
-
}), { headers: { 'Content-Type': 'application/json' } }));
|
|
225
|
-
}
|
package/src/server/cors.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cors.ts — 共用 CORS 本體(多產品 diff 證實完全一致,直取)。
|
|
3
|
-
*/
|
|
4
|
-
export const CORS_HEADERS = {
|
|
5
|
-
'Access-Control-Allow-Origin': '*',
|
|
6
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
7
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
8
|
-
// preflight 快取(缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
|
|
9
|
-
// 86400 落業界 600-86400 慣例上緣;瀏覽器端自行 clamp(Chromium 2h/WebKit 10m、Firefox
|
|
10
|
-
// 照走)——快取命中不影響伺服器語意;消費端 inline OPTIONS 分流照 corsResponse 放行。
|
|
11
|
-
'Access-Control-Max-Age': '86400',
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export function corsResponse(res: Response): Response {
|
|
15
|
-
const newHeaders = new Headers(res.headers);
|
|
16
|
-
Object.entries(CORS_HEADERS).forEach(([k, v]) => newHeaders.set(k, v));
|
|
17
|
-
return new Response(res.body, {
|
|
18
|
-
status: res.status,
|
|
19
|
-
statusText: res.statusText,
|
|
20
|
-
headers: newHeaders,
|
|
21
|
-
});
|
|
22
|
-
}
|
package/src/server/env.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* env.ts — core 對 Worker Env 的最小介面。各 fork 的 Env 以此為基底擴充。
|
|
3
|
-
*
|
|
4
|
-
* D1Database 型別以模組 import 承載(非全域 triple-slash reference):
|
|
5
|
-
* 消費者未帶 @cloudflare/workers-types 時,吃 client 面 .d.ts 不會被 env.d.ts 的
|
|
6
|
-
* 全域型別缺席炸掉(types= reference 在 types 套件缺席=TS2688 全檔紅;import type
|
|
7
|
-
* 缺席=該欄型別 any 化+TS2304 只在吃 env.d.ts 的檔案現形=影響面收斂)。
|
|
8
|
-
*/
|
|
9
|
-
import type { D1Database } from '@cloudflare/workers-types';
|
|
10
|
-
|
|
11
|
-
export interface Env {
|
|
12
|
-
DB: D1Database;
|
|
13
|
-
}
|
package/src/server/hash.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hash.ts — 品牌無關的雜湊/token 工具。
|
|
3
|
-
* 產品專屬 obfuscate/deobfuscate 不入 core(透明品牌裁定:明文 JSON + TLS)。
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export async function sha256Hex(text: string): Promise<string> {
|
|
7
|
-
const data = new TextEncoder().encode(text);
|
|
8
|
-
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
9
|
-
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** 相容別名:姊妹產品呼叫點用此名。 */
|
|
13
|
-
export const hashString = sha256Hex;
|
|
14
|
-
|
|
15
|
-
/** 256-bit 隨機 session token(hex)。伺服器只存 SHA-256。 */
|
|
16
|
-
export function generateSessionToken(): string {
|
|
17
|
-
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
18
|
-
.map(b => b.toString(16).padStart(2, '0'))
|
|
19
|
-
.join('');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 常數時間字串比較(防時序側信道;多產品共用抽出)。 */
|
|
23
|
-
export function timingSafeEq(a: string, b: string): boolean {
|
|
24
|
-
if (a.length !== b.length) return false;
|
|
25
|
-
let diff = 0;
|
|
26
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
27
|
-
return diff === 0;
|
|
28
|
-
}
|
package/src/server/ladder.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ladder.ts — 密語正規化 v3 世代 ladder 表(server 內部表)。
|
|
3
|
-
*
|
|
4
|
-
* (a+)方案(設計裁定):遷移時舊 ph2 值入 server 內部表(ph2_ladder),
|
|
5
|
-
* users 表零 schema 變更、ph2 欄 UNIQUE 恆動──client 舊 bundle 的單欄 v2-first 查表
|
|
6
|
-
* 在遷移後仍對既有帳戶命中(查 users.ph2 現值=v2 形),行為不變。
|
|
7
|
-
*
|
|
8
|
-
* 語意面:
|
|
9
|
-
* - ladder hit(現值未命中 → ladder 命中)=不建幽靈、回舊帳(fork 語意面 ph2Kind='legacy')。
|
|
10
|
-
* v3 帳戶:鹽與包裹已旋轉成 v3 形;fork 語意「本 login 的舊形憑證 unwrap 必失敗;
|
|
11
|
-
* client 以 ladder 缺鹽面誠實告知升級」對應 v0.2.0 前的單欄舊 bundle(帶 ph1_v3 的
|
|
12
|
-
* 新 client 恰命中 v3 現值、前綴自描述走 v3 腿)。
|
|
13
|
-
* - ladder miss(兩面皆查無)=才建幽靈(fork createUser,v2 先行)。
|
|
14
|
-
* - 幽靈帳戶走完遷移後以同舊值 upsert(ON CONFLICT(ph2)=值主權威覆蓋,最新持有者勝)。
|
|
15
|
-
* - 表列隨帳戶刪除清理(FK ON DELETE CASCADE,fork delete-account 面零新語句);
|
|
16
|
-
* users 列是真值面,ladder 表列可重建(v2 形重新遷移即重落)——表列毀損不傷帳戶面。
|
|
17
|
-
* - 幽靈 GC(wrapped_key IS NULL 判準)不收 ladder 命中面(帳戶列仍在=判準不變)。
|
|
18
|
-
*
|
|
19
|
-
* 零知識照舊:ladder 只存 ph2 hash 與 account_id,零 PII、零密語材料。
|
|
20
|
-
*
|
|
21
|
-
* 職責分工(fork 對位面):表名/migration 由 fork migrations 承載(0011-ph2-ladder.sql,
|
|
22
|
-
* PK=ph2 值主權威形);本層提供「以舊 ph2 查列」與「遷移時入表」原語。查表守衛自 0.2.4 起
|
|
23
|
-
* 內建 loginRouteCore(現值 miss → ladder → 兩面 miss 才建幽靈;lookup 未配置=零呼叫
|
|
24
|
-
* 行為零變)——自帶 login route 的 fork(tacet loginRoute,v2-first 裁定)續走自身接線。
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import type { Env } from './env.ts';
|
|
28
|
-
|
|
29
|
-
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
30
|
-
export const PH2_LADDER_TABLE = 'ph2_ladder';
|
|
31
|
-
|
|
32
|
-
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,遷移的正常前身)。
|
|
33
|
-
* 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
|
|
34
|
-
* 純粹是「這個舊值當年是什麼形」的語意記錄。 */
|
|
35
|
-
export type Ph2LadderKind = 'legacy' | 'v2';
|
|
36
|
-
export const PH2_LADDER_KIND_LEGACY = 'legacy';
|
|
37
|
-
export const PH2_LADDER_KIND_V2 = 'v2';
|
|
38
|
-
|
|
39
|
-
/** ladder 行形(fork schema 對位面)。
|
|
40
|
-
* 欄名映射註解(讀帳面——schema 欄名 vs Row 形名差異;t_87ef62dd #13 只補註解不改名,
|
|
41
|
-
* 禁 consumer breaking):schema 欄是 `upgraded_at`(D1/SQLite 欄面——「舊形被覆蓋的時刻」),
|
|
42
|
-
* Row 形名 `createdAt`(fork 消費面語意——「該舊值歷列建立時刻」);同一值、兩層命名——
|
|
43
|
-
* findByOldPh2 讀取面做 upgraded_at→createdAt 的逐欄映射,改任一層名都是 breaking(禁動)。 */
|
|
44
|
-
export interface Ph2LadderRow {
|
|
45
|
-
accountId: string;
|
|
46
|
-
ph2Kind: string;
|
|
47
|
-
createdAt: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
|
|
51
|
-
export function makePh2LadderStore(env: Env): {
|
|
52
|
-
findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null>;
|
|
53
|
-
insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void>;
|
|
54
|
-
} {
|
|
55
|
-
return {
|
|
56
|
-
async findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null> {
|
|
57
|
-
const row = await env.DB.prepare(
|
|
58
|
-
`SELECT account_id, ph2_kind, upgraded_at FROM ${PH2_LADDER_TABLE} WHERE ph2 = ?`,
|
|
59
|
-
).bind(oldPh2).first<{ account_id: string; ph2_kind: string; upgraded_at: number }>();
|
|
60
|
-
if (!row) return null;
|
|
61
|
-
return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
|
|
62
|
-
},
|
|
63
|
-
async insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void> {
|
|
64
|
-
// upsert ON CONFLICT(ph2)(值主權威,PK 定形):同一舊值恰一列,最新持有者勝——
|
|
65
|
-
// A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
|
|
66
|
-
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 寫入面另備 ladderDelete 給
|
|
67
|
-
// delete-account 清場面。
|
|
68
|
-
// fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
|
|
69
|
-
//(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
|
|
70
|
-
await env.DB.prepare(
|
|
71
|
-
`INSERT INTO ${PH2_LADDER_TABLE} (ph2, account_id, ph2_kind, upgraded_at) VALUES (?, ?, ?, ?)
|
|
72
|
-
ON CONFLICT(ph2) DO UPDATE SET account_id = excluded.account_id, ph2_kind = excluded.ph2_kind, upgraded_at = excluded.upgraded_at`,
|
|
73
|
-
).bind(oldPh2, accountId, oldKind, Date.now()).run();
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
}
|
package/src/server/ratelimit.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ratelimit.ts — per-IP fixed-window 限流(D1 計數,跨 isolate 有效;單句 UPSERT…RETURNING)。
|
|
3
|
-
*
|
|
4
|
-
* 設計(2026-10-04 定案:原 4 往返條件寫入鏈 1 往返收口):
|
|
5
|
-
* - 限流語意是 fixed window:以 window_start 為窗錨,窗過期整窗重置。
|
|
6
|
-
* (原檔頭「滑動視窗」措辭錯誤——c7c8701 只修 README 未修本檔;本檔一併收口。)
|
|
7
|
-
* - 單條 UPSERT…RETURNING 原子完成「計數+窗重置+讀回放行判準」:
|
|
8
|
-
* 同 IP 併發首發不再有 INSERT/輸家競態(同語句同鍵衝突時第二發原子吃到
|
|
9
|
-
* count+1;D1 實測 RETURNING 逐發回真值——同 IP 併發首發不 undercount)。
|
|
10
|
-
* - fail-open 裁定不變(2026-09-06):限流儲存故障時放行(保護面不可反噬主功能),
|
|
11
|
-
* 只擋明確超額。RETURNING 異形回應(results 空/alias 斷裂)同向 fail-open
|
|
12
|
-
* 並 console.error 照實回報(異形不反向成 deny)。
|
|
13
|
-
*
|
|
14
|
-
* 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
|
|
15
|
-
* 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。
|
|
16
|
-
*
|
|
17
|
-
* 限流鍵(2026-10-11 定案):checkRate 入口經 normalizeRateIp 正規化——IPv6 聚合至 /64
|
|
18
|
-
* (單一終端可自由輪換 /64 內尾段=完整地址當鍵即每發新鍵、限流失效);IPv4-mapped
|
|
19
|
-
* 位址回點分 IPv4(=原生 IPv4 同 /32 鍵,review r1 MAJOR-1 收口);IPv4 與非 IPv6 形原樣。
|
|
20
|
-
*
|
|
21
|
-
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
22
|
-
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
23
|
-
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
24
|
-
* 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import type { Env } from './env.ts';
|
|
28
|
-
|
|
29
|
-
export interface RateWindow {
|
|
30
|
-
table: string;
|
|
31
|
-
windowMs: number;
|
|
32
|
-
max: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 表名只允許 \w 字元(字母/數字/底線):表名直接拼進 SQL 字串,禁注入符號。
|
|
37
|
-
* 違例 throw——配置錯誤在開發期炸開,不靜默。
|
|
38
|
-
*/
|
|
39
|
-
function assertSafeTable(table: string): void {
|
|
40
|
-
if (!/^[\w]+$/.test(table)) throw new Error(`ERR_RATE_TABLE_NAME: ${table}`);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** @internal(0.1.6 公開面走 index.ts checkRate)。UPSERT…RETURNING 單句({table} 佔位由呼叫端替換;D1 實測 RETURNING 列經 results 回帶)。 */
|
|
44
|
-
export const SQL_RATE_BUMP = `INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)
|
|
45
|
-
ON CONFLICT(ip) DO UPDATE SET
|
|
46
|
-
count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,
|
|
47
|
-
window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END
|
|
48
|
-
RETURNING count AS n`;
|
|
49
|
-
|
|
50
|
-
/** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
|
|
51
|
-
export function isRateAllowed(count: number, max: number): boolean {
|
|
52
|
-
return count <= max;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function bumpRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
56
|
-
assertSafeTable(w.table);
|
|
57
|
-
const now = Date.now();
|
|
58
|
-
try {
|
|
59
|
-
const { results } = await env.DB.prepare(
|
|
60
|
-
SQL_RATE_BUMP.replaceAll('{table}', w.table)
|
|
61
|
-
).bind(ip, now, now - w.windowMs).run();
|
|
62
|
-
const first = results[0] as { n?: unknown } | undefined;
|
|
63
|
-
const n = first?.n;
|
|
64
|
-
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
65
|
-
// 異形回應(results 空/alias 斷裂)與儲存故障同向 fail-open(2026-09-06 裁定:
|
|
66
|
-
// 缺陷偏向放行,不反向成 deny);真 D1 不產生(探針實證)——守衛照實回報。
|
|
67
|
-
console.error(`[ratelimit] ${w.table} unexpected RETURNING shape:`, first);
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
return isRateAllowed(n, w.max);
|
|
71
|
-
} catch (e) {
|
|
72
|
-
// fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額
|
|
73
|
-
console.error(`[ratelimit] ${w.table} update failed:`, e);
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const HEXTET = /^[0-9a-f]{1,4}$/i;
|
|
79
|
-
const IPV4_OCTET = /^(0|[1-9]\d{0,2})$/;
|
|
80
|
-
|
|
81
|
-
/** IPv6 文字形 → 8 組 hextet 數值(RFC 4291 §2.2:`::` 至多一處、嵌入 IPv4 僅居末);非法形回 null。 */
|
|
82
|
-
function parseIpv6(s: string): number[] | null {
|
|
83
|
-
const halves = s.split('::');
|
|
84
|
-
if (halves.length > 2) return null;
|
|
85
|
-
const parts = halves.map((h) => (h === '' ? [] : h.split(':')));
|
|
86
|
-
const last = parts[parts.length - 1];
|
|
87
|
-
const v4: number[] = [];
|
|
88
|
-
if (last.length > 0 && last[last.length - 1].includes('.')) {
|
|
89
|
-
const q = last.pop()!.split('.');
|
|
90
|
-
if (q.length !== 4 || !q.every((o) => IPV4_OCTET.test(o) && Number(o) <= 255)) return null;
|
|
91
|
-
v4.push(Number(q[0]) * 256 + Number(q[1]), Number(q[2]) * 256 + Number(q[3]));
|
|
92
|
-
}
|
|
93
|
-
if (!parts.every((p) => p.every((g) => HEXTET.test(g)))) return null;
|
|
94
|
-
const n = parts.reduce((a, p) => a + p.length, 0) + v4.length;
|
|
95
|
-
const hex = (p: string[]): number[] => p.map((g) => parseInt(g, 16));
|
|
96
|
-
if (halves.length === 1) return n === 8 ? [...hex(parts[0]), ...v4] : null;
|
|
97
|
-
if (n > 7) return null;
|
|
98
|
-
return [...hex(parts[0]), ...new Array<number>(8 - n).fill(0), ...hex(parts[1]), ...v4];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* 限流鍵正規化(單一真相;純函式、零 throw):
|
|
103
|
-
* - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
|
|
104
|
-
* 例 `2001:0DB8:0000:000A::1` → `2001:db8:0:a::/64`、`::1` → `::/64`;zone id(`%…`)先剝除。
|
|
105
|
-
* 介面 ID 歸零後末 4 組恆零=最長零串恆為尾串(前綴內零串至多 3 組),故壓縮形=前綴摘尾零組+`::`。
|
|
106
|
-
* - IPv4-mapped(::/96 前綴+映射尾組,RFC 4291 §2.5.5.2)→ 點分 IPv4 原鍵——mapped 位址就是 IPv4
|
|
107
|
-
* 節點的表示法,回 /32 與原生同鍵(hex 尾組 twin `::ffff:c000:201` 同鍵 192.0.2.1;禁併入 ::/64 全域桶)。
|
|
108
|
-
* - IPv4/`'unknown'`/空串/無法解析形:原樣返回(鍵照樣進表=可見面,不靜默丟鍵)。
|
|
109
|
-
*/
|
|
110
|
-
export function normalizeRateIp(ip: string): string {
|
|
111
|
-
if (typeof ip !== 'string' || !ip.includes(':')) return ip;
|
|
112
|
-
const pct = ip.indexOf('%');
|
|
113
|
-
const g = parseIpv6(pct >= 0 ? ip.slice(0, pct) : ip);
|
|
114
|
-
if (!g) return ip;
|
|
115
|
-
if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) return [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join('.');
|
|
116
|
-
const net = g.slice(0, 4);
|
|
117
|
-
while (net.length > 0 && net[net.length - 1] === 0) net.pop();
|
|
118
|
-
return net.map((h) => h.toString(16)).join(':') + '::/64';
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
122
|
-
return bumpRate(env, w, normalizeRateIp(ip));
|
|
123
|
-
}
|