@tacet-ink/journal-core 0.2.4 → 0.3.0
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 +37 -15
- package/dist/client/argon2.d.ts +166 -0
- package/dist/client/argon2.js +406 -0
- package/dist/client/bip39.d.ts +11 -0
- package/dist/client/bip39.js +139 -0
- package/dist/client/keys.d.ts +15 -0
- package/dist/client/keys.js +30 -0
- package/dist/client/note-crypto.d.ts +203 -0
- package/dist/client/note-crypto.js +600 -0
- package/dist/client/pinlock.d.ts +38 -0
- package/dist/client/pinlock.js +84 -0
- package/dist/client/wordlist.d.ts +7 -0
- package/dist/client/wordlist.js +264 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/server/auth.d.ts +99 -0
- package/dist/server/auth.js +158 -0
- package/dist/server/cors.d.ts +10 -0
- package/dist/server/cors.js +21 -0
- package/dist/server/env.d.ts +6 -0
- package/dist/server/env.js +1 -0
- package/dist/server/hash.d.ts +11 -0
- package/dist/server/hash.js +26 -0
- package/dist/server/ladder.d.ts +49 -0
- package/dist/server/ladder.js +50 -0
- package/dist/server/ratelimit.d.ts +32 -0
- package/dist/server/ratelimit.js +63 -0
- package/docs/format-spec.md +369 -0
- package/docs/vectors/bip39.json +1063 -0
- package/docs/vectors/families.json +594 -0
- package/docs/vectors/kat.json +145 -0
- package/package.json +60 -16
- package/scripts/verify-core-crypto.ts +303 -73
- package/src/server/auth.ts +3 -2
- package/src/server/ladder.ts +3 -2
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import { corsResponse } from './cors.js';
|
|
29
|
+
import { sha256Hex, generateSessionToken } from './hash.js';
|
|
30
|
+
import { checkRate } from './ratelimit.js';
|
|
31
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
32
|
+
const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
|
|
33
|
+
const HASH64_RE = /^[0-9a-f]{64}$/;
|
|
34
|
+
const SALT32_RE = /^[0-9a-f]{32}$/;
|
|
35
|
+
/** regex 用字面跳脫:全字元掃描(.replace('.',) 只跳第一個點——前綴含第二個 . 即壞)。 */
|
|
36
|
+
function escapeReLiteral(s) {
|
|
37
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
}
|
|
39
|
+
/** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
|
|
40
|
+
* never-match 守衛(空字首組態防線,opt-in 鐵律面):空字首組態會
|
|
41
|
+
* 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
|
|
42
|
+
* (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
|
|
43
|
+
* 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
|
|
44
|
+
* (probe 實證:`[]` 對純 b64 樣本 NON-NULL——
|
|
45
|
+
* 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
|
|
46
|
+
function cipherPrefixRe(cipherPrefixes) {
|
|
47
|
+
const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
|
|
48
|
+
if (usable.length === 0)
|
|
49
|
+
return /^(?=a)b/; // never-match(lookahead 錨死矛盾面)——空字串/任意串皆不匹配
|
|
50
|
+
const escaped = usable.map(p => escapeReLiteral(p));
|
|
51
|
+
return new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
|
|
55
|
+
* cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
|
|
56
|
+
* 截到 cipherMax 入庫(JSDoc 幽靈參數 plainMax 已摘除)。
|
|
57
|
+
*/
|
|
58
|
+
export function makeInboundCipher(c) {
|
|
59
|
+
const RE = cipherPrefixRe(c.cipherPrefixes);
|
|
60
|
+
return function inboundCipher(raw) {
|
|
61
|
+
if (typeof raw !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
const text = raw.trim();
|
|
64
|
+
if (!text)
|
|
65
|
+
return null;
|
|
66
|
+
if (text.length > c.cipherMax) {
|
|
67
|
+
return RE.test(text) || BASE64_RE.test(text)
|
|
68
|
+
? null // 密文/編碼超限:截斷必壞,直接丟棄
|
|
69
|
+
: text.slice(0, c.cipherMax); // 純明文(舊版客戶端):截到上限照收
|
|
70
|
+
}
|
|
71
|
+
if (RE.test(text))
|
|
72
|
+
return text; // 前綴密文:原樣入庫
|
|
73
|
+
if (BASE64_RE.test(text) && text.length > 40)
|
|
74
|
+
return text; // 舊客戶端 b64 慣例:照收
|
|
75
|
+
if (BINARY_RE.test(text))
|
|
76
|
+
return null; // 控制字元垃圾:丟棄
|
|
77
|
+
return text; // 真明文:照收(舊版相容)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function isCipherFor(text, c) {
|
|
81
|
+
if (typeof text !== 'string')
|
|
82
|
+
return false;
|
|
83
|
+
// 空字首組態同面守衛(範圍增補):'' 態 startsWith('') 恆真=
|
|
84
|
+
// 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
|
|
85
|
+
// 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
|
|
86
|
+
// opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
|
|
87
|
+
// 「空集 if 檢+保留 startsWith(tuple) 原式」兩行形對混合形不設防(空槽仍經 startsWith('') 放行)。
|
|
88
|
+
return c.cipherPrefixes.some(p => typeof p === 'string' && p.length > 0 && text.startsWith(p));
|
|
89
|
+
}
|
|
90
|
+
export function validWrappedKey(v, wrapPrefix) {
|
|
91
|
+
const prefixes = Array.isArray(wrapPrefix) ? wrapPrefix : [wrapPrefix];
|
|
92
|
+
const re = cipherPrefixRe(prefixes);
|
|
93
|
+
return typeof v === 'string' && v.length <= 200 && re.test(v) ? v : null;
|
|
94
|
+
}
|
|
95
|
+
export function validHash64(v) {
|
|
96
|
+
return typeof v === 'string' && HASH64_RE.test(v) ? v : null;
|
|
97
|
+
}
|
|
98
|
+
export function validSalt(v) {
|
|
99
|
+
return typeof v === 'string' && SALT32_RE.test(v) ? v : null;
|
|
100
|
+
}
|
|
101
|
+
/** 綁定/復原共用的金鑰包裹欄組:wrapped 與 salt 必須成對,缺一整組放棄。 */
|
|
102
|
+
export function pickKeyPackage(body, wrapPrefix) {
|
|
103
|
+
const wrapped = validWrappedKey(body?.note_wrapped ?? body?.wrapped, wrapPrefix);
|
|
104
|
+
const salt = validSalt(body?.note_salt ?? body?.salt);
|
|
105
|
+
return { wrapped: wrapped && salt ? wrapped : null, salt: wrapped && salt ? salt : null };
|
|
106
|
+
}
|
|
107
|
+
export const AUTH_RATE = { table: 'login_rate', windowMs: 60_000, max: 10 };
|
|
108
|
+
export function errResponse(code, status) {
|
|
109
|
+
return corsResponse(new Response(code, { status }));
|
|
110
|
+
}
|
|
111
|
+
// ── /auth/login:PH1 進站 → PH2 查詢(UNIQUE)→ session ─────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* loginRouteCore ladder 查表守衛(0.2.4 外審 #8 幽靈帳真洞收口——tacet fork 母型上位):
|
|
114
|
+
* 現值(PH2 UNIQUE 查表)miss 時,先查 ladder 表(ph2_ladder:遷移線落下的「舊 ph2 值」),
|
|
115
|
+
* 命中=回舊帳不建幽靈;ladder 也 miss(或未配置)=才 createUser。
|
|
116
|
+
* - ladder hit 語意照 ladder.ts 檔頭註解:表列只承載舊值,帳戶現行憑證面以
|
|
117
|
+
* `getByUserKey` 讀到的 users 現值為準(v3 帳的 salt/wrapped 已是 v3 形);
|
|
118
|
+
* ladder hit → getByUserKey 落查無列(帳戶已刪、行殘留)=誠實 miss 續走建幽靈。
|
|
119
|
+
* - lookup 未配置(optional undefined)=零呼叫,行為與 0.2.3 單查恆等(向後相容鐵律);
|
|
120
|
+
* insert 是遷移線(rekeyWithLadder)職責、delete 已綁 delete-account(fork 面)——
|
|
121
|
+
* 本守衛只動查表,零寫入面。
|
|
122
|
+
* - lookup 拋錯(表缺席/儲存故障)=fail-open 視同 miss,不炸登入(tacet store.ts 母型;
|
|
123
|
+
* 靜默面照 tacet 慣例只 console.error)。
|
|
124
|
+
* - 零知識照舊:ladder 只存 ph2 hash 與 account_id,本函式零新洩漏面。
|
|
125
|
+
*/
|
|
126
|
+
async function ladderGuard(store, ph2) {
|
|
127
|
+
if (!store.ladderLookup)
|
|
128
|
+
return null;
|
|
129
|
+
let ladderAcc = null;
|
|
130
|
+
try {
|
|
131
|
+
ladderAcc = await store.ladderLookup(ph2);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
console.error('[ph2-ladder] lookup failed (fail-open):', e);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!ladderAcc)
|
|
138
|
+
return null;
|
|
139
|
+
return store.getByUserKey(ladderAcc);
|
|
140
|
+
}
|
|
141
|
+
export async function loginRouteCore(env, store, ip, ph1In) {
|
|
142
|
+
if (!(await checkRate(env, AUTH_RATE, ip)))
|
|
143
|
+
return errResponse('ERR_RATE_LIMITED', 429);
|
|
144
|
+
const ph1 = validHash64(ph1In);
|
|
145
|
+
if (!ph1)
|
|
146
|
+
return errResponse('ERR_BAD_REQUEST', 400);
|
|
147
|
+
const ph2 = await sha256Hex(ph1);
|
|
148
|
+
const user = (await store.findByIdentityQuery(ph2)) ?? (await ladderGuard(store, ph2));
|
|
149
|
+
const userKey = user ? user.userKey : await store.createUser(ph2);
|
|
150
|
+
const token = generateSessionToken();
|
|
151
|
+
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // PWA:30 天
|
|
152
|
+
await store.insertSession(await sha256Hex(token), userKey, expiresAt);
|
|
153
|
+
return corsResponse(new Response(JSON.stringify({
|
|
154
|
+
sessionToken: token,
|
|
155
|
+
userKey,
|
|
156
|
+
status: user?.ph2 ? 'ready' : 'require_binding',
|
|
157
|
+
}), { headers: { 'Content-Type': 'application/json' } }));
|
|
158
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cors.ts — 共用 CORS 本體(多產品 diff 證實完全一致,直取)。
|
|
3
|
+
*/
|
|
4
|
+
export declare const CORS_HEADERS: {
|
|
5
|
+
'Access-Control-Allow-Origin': string;
|
|
6
|
+
'Access-Control-Allow-Methods': string;
|
|
7
|
+
'Access-Control-Allow-Headers': string;
|
|
8
|
+
'Access-Control-Max-Age': string;
|
|
9
|
+
};
|
|
10
|
+
export declare function corsResponse(res: Response): Response;
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export function corsResponse(res) {
|
|
14
|
+
const newHeaders = new Headers(res.headers);
|
|
15
|
+
Object.entries(CORS_HEADERS).forEach(([k, v]) => newHeaders.set(k, v));
|
|
16
|
+
return new Response(res.body, {
|
|
17
|
+
status: res.status,
|
|
18
|
+
statusText: res.statusText,
|
|
19
|
+
headers: newHeaders,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hash.ts — 品牌無關的雜湊/token 工具。
|
|
3
|
+
* 產品專屬 obfuscate/deobfuscate 不入 core(透明品牌裁定:明文 JSON + TLS)。
|
|
4
|
+
*/
|
|
5
|
+
export declare function sha256Hex(text: string): Promise<string>;
|
|
6
|
+
/** 相容別名:姊妹產品呼叫點用此名。 */
|
|
7
|
+
export declare const hashString: typeof sha256Hex;
|
|
8
|
+
/** 256-bit 隨機 session token(hex)。伺服器只存 SHA-256。 */
|
|
9
|
+
export declare function generateSessionToken(): string;
|
|
10
|
+
/** 常數時間字串比較(防時序側信道;多產品共用抽出)。 */
|
|
11
|
+
export declare function timingSafeEq(a: string, b: string): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hash.ts — 品牌無關的雜湊/token 工具。
|
|
3
|
+
* 產品專屬 obfuscate/deobfuscate 不入 core(透明品牌裁定:明文 JSON + TLS)。
|
|
4
|
+
*/
|
|
5
|
+
export async function sha256Hex(text) {
|
|
6
|
+
const data = new TextEncoder().encode(text);
|
|
7
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
8
|
+
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
9
|
+
}
|
|
10
|
+
/** 相容別名:姊妹產品呼叫點用此名。 */
|
|
11
|
+
export const hashString = sha256Hex;
|
|
12
|
+
/** 256-bit 隨機 session token(hex)。伺服器只存 SHA-256。 */
|
|
13
|
+
export function generateSessionToken() {
|
|
14
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
15
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
16
|
+
.join('');
|
|
17
|
+
}
|
|
18
|
+
/** 常數時間字串比較(防時序側信道;多產品共用抽出)。 */
|
|
19
|
+
export function timingSafeEq(a, b) {
|
|
20
|
+
if (a.length !== b.length)
|
|
21
|
+
return false;
|
|
22
|
+
let diff = 0;
|
|
23
|
+
for (let i = 0; i < a.length; i++)
|
|
24
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
25
|
+
return diff === 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
import type { Env } from './env.ts';
|
|
27
|
+
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
28
|
+
export declare const PH2_LADDER_TABLE = "ph2_ladder";
|
|
29
|
+
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,遷移的正常前身)。
|
|
30
|
+
* 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
|
|
31
|
+
* 純粹是「這個舊值當年是什麼形」的語意記錄。 */
|
|
32
|
+
export type Ph2LadderKind = 'legacy' | 'v2';
|
|
33
|
+
export declare const PH2_LADDER_KIND_LEGACY = "legacy";
|
|
34
|
+
export declare const PH2_LADDER_KIND_V2 = "v2";
|
|
35
|
+
/** ladder 行形(fork schema 對位面)。
|
|
36
|
+
* 欄名映射註解(讀帳面——schema 欄名 vs Row 形名差異;t_87ef62dd #13 只補註解不改名,
|
|
37
|
+
* 禁 consumer breaking):schema 欄是 `upgraded_at`(D1/SQLite 欄面——「舊形被覆蓋的時刻」),
|
|
38
|
+
* Row 形名 `createdAt`(fork 消費面語意——「該舊值歷列建立時刻」);同一值、兩層命名——
|
|
39
|
+
* findByOldPh2 讀取面做 upgraded_at→createdAt 的逐欄映射,改任一層名都是 breaking(禁動)。 */
|
|
40
|
+
export interface Ph2LadderRow {
|
|
41
|
+
accountId: string;
|
|
42
|
+
ph2Kind: string;
|
|
43
|
+
createdAt: number;
|
|
44
|
+
}
|
|
45
|
+
/** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
|
|
46
|
+
export declare function makePh2LadderStore(env: Env): {
|
|
47
|
+
findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null>;
|
|
48
|
+
insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void>;
|
|
49
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
27
|
+
export const PH2_LADDER_TABLE = 'ph2_ladder';
|
|
28
|
+
export const PH2_LADDER_KIND_LEGACY = 'legacy';
|
|
29
|
+
export const PH2_LADDER_KIND_V2 = 'v2';
|
|
30
|
+
/** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
|
|
31
|
+
export function makePh2LadderStore(env) {
|
|
32
|
+
return {
|
|
33
|
+
async findByOldPh2(oldPh2) {
|
|
34
|
+
const row = await env.DB.prepare(`SELECT account_id, ph2_kind, upgraded_at FROM ${PH2_LADDER_TABLE} WHERE ph2 = ?`).bind(oldPh2).first();
|
|
35
|
+
if (!row)
|
|
36
|
+
return null;
|
|
37
|
+
return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
|
|
38
|
+
},
|
|
39
|
+
async insert(accountId, oldPh2, oldKind) {
|
|
40
|
+
// upsert ON CONFLICT(ph2)(值主權威,PK 定形):同一舊值恰一列,最新持有者勝——
|
|
41
|
+
// A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
|
|
42
|
+
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 寫入面另備 ladderDelete 給
|
|
43
|
+
// delete-account 清場面。
|
|
44
|
+
// fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
|
|
45
|
+
//(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
|
|
46
|
+
await env.DB.prepare(`INSERT INTO ${PH2_LADDER_TABLE} (ph2, account_id, ph2_kind, upgraded_at) VALUES (?, ?, ?, ?)
|
|
47
|
+
ON CONFLICT(ph2) DO UPDATE SET account_id = excluded.account_id, ph2_kind = excluded.ph2_kind, upgraded_at = excluded.upgraded_at`).bind(oldPh2, accountId, oldKind, Date.now()).run();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
18
|
+
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
19
|
+
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
20
|
+
* 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
|
|
21
|
+
*/
|
|
22
|
+
import type { Env } from './env.ts';
|
|
23
|
+
export interface RateWindow {
|
|
24
|
+
table: string;
|
|
25
|
+
windowMs: number;
|
|
26
|
+
max: number;
|
|
27
|
+
}
|
|
28
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。UPSERT…RETURNING 單句({table} 佔位由呼叫端替換;D1 實測 RETURNING 列經 results 回帶)。 */
|
|
29
|
+
export declare const SQL_RATE_BUMP = "INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)\n ON CONFLICT(ip) DO UPDATE SET\n count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,\n window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END\n RETURNING count AS n";
|
|
30
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
|
|
31
|
+
export declare function isRateAllowed(count: number, max: number): boolean;
|
|
32
|
+
export declare function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean>;
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
18
|
+
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
19
|
+
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
20
|
+
* 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* 表名只允許 \w 字元(字母/數字/底線):表名直接拼進 SQL 字串,禁注入符號。
|
|
24
|
+
* 違例 throw——配置錯誤在開發期炸開,不靜默。
|
|
25
|
+
*/
|
|
26
|
+
function assertSafeTable(table) {
|
|
27
|
+
if (!/^[\w]+$/.test(table))
|
|
28
|
+
throw new Error(`ERR_RATE_TABLE_NAME: ${table}`);
|
|
29
|
+
}
|
|
30
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。UPSERT…RETURNING 單句({table} 佔位由呼叫端替換;D1 實測 RETURNING 列經 results 回帶)。 */
|
|
31
|
+
export const SQL_RATE_BUMP = `INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)
|
|
32
|
+
ON CONFLICT(ip) DO UPDATE SET
|
|
33
|
+
count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,
|
|
34
|
+
window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END
|
|
35
|
+
RETURNING count AS n`;
|
|
36
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
|
|
37
|
+
export function isRateAllowed(count, max) {
|
|
38
|
+
return count <= max;
|
|
39
|
+
}
|
|
40
|
+
async function bumpRate(env, w, ip) {
|
|
41
|
+
assertSafeTable(w.table);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
try {
|
|
44
|
+
const { results } = await env.DB.prepare(SQL_RATE_BUMP.replaceAll('{table}', w.table)).bind(ip, now, now - w.windowMs).run();
|
|
45
|
+
const first = results[0];
|
|
46
|
+
const n = first?.n;
|
|
47
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
48
|
+
// 異形回應(results 空/alias 斷裂)與儲存故障同向 fail-open(2026-09-06 裁定:
|
|
49
|
+
// 缺陷偏向放行,不反向成 deny);真 D1 不產生(探針實證)——守衛照實回報。
|
|
50
|
+
console.error(`[ratelimit] ${w.table} unexpected RETURNING shape:`, first);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return isRateAllowed(n, w.max);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
// fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額
|
|
57
|
+
console.error(`[ratelimit] ${w.table} update failed:`, e);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function checkRate(env, w, ip) {
|
|
62
|
+
return bumpRate(env, w, ip);
|
|
63
|
+
}
|