@tacet-ink/journal-core 0.1.6 → 0.2.1

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 血統:抽取自姊妹專案的日記加密模組(37 斷言驗證閘母型);
5
5
  * fork diff 已證實:除品牌前綴(guest KDF 前綴、密文前綴家族、KDF salt 前綴)
6
- * 與產品附加機制外,密碼學本體在多個產品間完全一致——本檔收編該本體。
6
+ * 與產品附加機制外,密碼學本體在多個產品間完全一致——本檔承載該本體。
7
7
  *
8
8
  * 兩時代金鑰模型(不變量,勿破壞):
9
9
  * 時代 1(未綁定 guest):K_u = SHA-256(guestKdfPrefix ‖ identity) — 純客戶端派生,混淆級
@@ -11,6 +11,11 @@
11
11
  * KEK = PBKDF2(pass, salt, 600000, SHA-256) 包裹成 wrapped 上傳;復原套件 wrappedRec
12
12
  * = 同款包裹在 KEK_rec = PBKDF2(recToken, salt = recSaltPrefix ‖ identity) 下。
13
13
  * passphrase 從此不過線:線上憑證送 PH1(雜湊形,派生方式由各產品自定義),伺服器存 PH2。
14
+ * 復原套件 v0.2.0 世代:前綴專用化(wrapRec=jr1r.)+KEK_rec 派生
15
+ * 世代化(recKekHkdf 配置=HKDF-SHA256,info='journal-kek-rec-v1:'+recKekHkdf)+
16
+ * AAD 帶內切換('notekey'→'notekey-rec');新世代讀舊寫新——unwrap 新面先行、回落
17
+ * 舊契約面(PBKDF2+AAD 'notekey')雙試(過渡保護承諾:舊 blob 永遠可解;摘雙試
18
+ * 時點=遷移率裁定非日曆)。
14
19
  *
15
20
  * 前綴全部可配置(帶內版本化:升級 KDF 參數 = 換新前綴)。AAD 由呼叫端傳入——
16
21
  * 例如綁 `noteId`(跨列搬移必解密失敗)、`lifeId:day`(逐日日記)或留言綁 `tst:<lifeId>`。
@@ -27,12 +32,38 @@ export interface NoteCryptoConfig {
27
32
  guestKdfPrefix: string;
28
33
  /** 復原包裹 KDF salt 前綴,如 'journal-note-rec1:'。 */
29
34
  recSaltPrefix: string;
30
- /** 密文前綴:guest 時代,如 'jr1g.'。未配置 = guest API 拒絕(未配置行為不變;2026-09-19 選配收編)。 */
35
+ /** 密文前綴:guest 時代,如 'jr1g.'。未配置 = guest API 拒絕(未配置行為不變;2026-09-19 轉選配)。 */
31
36
  cipherGuest?: string;
32
37
  /** 密文前綴:綁定時代,如 'jr1b.'。 */
33
38
  cipherBound: string;
34
39
  /** 金鑰包裹前綴,如 'jr1w.'。 */
35
40
  wrap: string;
41
+ /** 本機包裹前綴(jr1l.:本機包裹不再借用 cfg.wrap——「一個前綴一份契約」
42
+ * 的第二違例收口)。未配置 = storeLocalWrap/loadLocalWrap 退場(無寫入/讀取照走
43
+ * 舊形回落,零資料損失——cipherLocal opt-in 母型;寫面功能退場——v1 可用者升級後
44
+ * 需配置 wrapLocal 才保留本機包裹寫入)。 */
45
+ wrapLocal?: string;
46
+ /** 復原套件專用前綴(jr1r.:wrappedRec 不再借用 cfg.wrap——同前例收口)。
47
+ * AAD 亦帶內切換 'notekey'→'notekey-rec'(共用 AAD 收口)。與 recKekHkdf **兩欄一體**
48
+ * (同缺同在;部分配置=ERR_REC_CFG_PARTIAL 拒寫——世代配對門,fail-closed 禁寫出
49
+ * 「前綴面 jr1r.×派生面 PBKDF2」跨契約 blob)。兩欄皆缺席 = wrap/unwrap 復原套件面
50
+ * 照走舊契約(cfg.wrap+PBKDF2+AAD 'notekey')=行為不變態(配置形正確
51
+ * 時舊 wrappedRec blob 永遠可解——HKDF 腿誤配拋點整面 fail-closed 既有契約)。 */
52
+ wrapRec?: string;
53
+ /** 復原套件「KEK 派生」世代開關:KEK_rec = HKDF-SHA256(
54
+ * ikm = recToken(256-bit 實體因子), salt = recKekSalt?:16B hex(未配置=零鹽),
55
+ * info = 'journal-kek-rec-v1:' + recKekHkdf, L = 32 )——info 合成名沿 argon2 家族
56
+ * 慣例(呼叫端帶入;KEK 派生世代空間由前綴+info 承載)。HKDF 世代 identity 不入
57
+ * KEK(identity-free by design——256-bit 實體因子足綁;identity 綁域由
58
+ * 舊契約面 recSaltPrefix‖identity 專屬承載,兩世代綁域刻意分離)。與 wrapRec 兩欄
59
+ * 一體(同缺同在;部分配置=ERR_REC_CFG_PARTIAL 拒寫)。未配置 = PBKDF2 舊世代
60
+ * (裁定:HKDF 強化屬 v0.2.0 世代;強度差異對 256-bit recToken 無意義,
61
+ * 收的是「派生域+前綴契約面分離」的語意收口)。 */
62
+ recKekHkdf?: string;
63
+ /** HKDF salt:16B hex 專屬鹽域(與 PBKDF2 面 recSaltPrefix 前綴域分離
64
+ * ——KEK 派生世代鹽)。未配置 = HKDF 零鹽(HKDF salt 欄位本就 optional;語意不變,
65
+ * 域由 info 承載)。 */
66
+ recKekSalt?: string;
36
67
  /** 雙因子合鑰包裹前綴(jr2w.,PIN 第二因子)。未配置 = dual API 拒絕(未配置行為不變)。 */
37
68
  wrapDual?: string;
38
69
  /** 雙因子 pin 段 PBKDF2 salt 前綴,如 'journal-note-pin1:'(pinSalt 內嵌 payload 後拼接)。 */
@@ -53,7 +84,7 @@ export const IV_LEN = 12;
53
84
 
54
85
  const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
55
86
  /**
56
- * hex 合法性(fail-closed 目標 #2):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
87
+ * hex 合法性(fail-closed):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
57
88
  * 每雙位元組真 hex。
58
89
  */
59
90
  const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
@@ -61,11 +92,11 @@ const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
61
92
  // ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
62
93
 
63
94
  export function b64(bytes: Uint8Array): string {
64
- // 分塊 String.fromCharCode.apply(t_7361b68c 目標 2:review 實測瀏覽器 4MiB 222ms→27ms;
65
- // node 端分塊形 132-181ms→18ms,與閘執行帳同源——review 的 Node-only toBase64 1ms 帳是
66
- // 不同形,非本體帳:round 2 MINOR-2 對齊)。瀏覽器端恆走本形——core 是 isomorphic TS
95
+ // 分塊 String.fromCharCode.apply(瀏覽器實測 4MiB 222ms→27ms;
96
+ // node 端分塊形 132-181ms→18ms,與閘執行帳同源——Node-only toBase64 的 1ms 帳是
97
+ // 不同形,非本體帳)。瀏覽器端恆走本形——core 是 isomorphic TS
67
98
  // 源碼發行,不引入 Node-only
68
- // toBase64 分支(卡面否決案)。apply 參數上限安全窗 8192;輸出 byte 等價
99
+ // toBase64 分支(評估後否決的路線)。apply 參數上限安全窗 8192;輸出 byte 等價
69
100
  // (驗證閘 [14] 有 0/1/7/8191/8192/8193/65539 邊界逐位元組對照+unb64 roundtrip)。
70
101
  let binary = '';
71
102
  for (let i = 0; i < bytes.byteLength; i += 8192) {
@@ -86,15 +117,15 @@ export function toHex(bytes: Uint8Array): string {
86
117
  }
87
118
 
88
119
  export function hexToBytes(hex: string): Uint8Array {
89
- // fail-closed(目標 #2):非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
120
+ // fail-closed:非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
90
121
  // 拋 ERR_BAD_HEX(呼叫端 unwrap 全部 try/catch 回 null;非 catch 端僅 bip39
91
122
  // HEX64_RE 前置自守)。奇數長度、非 hex、空字串皆拒;大小寫/全形 NFKC 收容。
92
- // 測與 parse 同一(正規化後)真相——round 1 審查 MINOR-4:形檢吃正規化串、
123
+ // 測與 parse 同一(正規化後)真相——形檢吃正規化串、
93
124
  // parseInt 吃原始串=全形 hex digit NaN 歸零殘形(README 例外契約自穿透),收口殲滅。
94
- // NFKC 寬容=設計面刻意(round 2 NIT 記錄 t_580f9c54):'⑩'→'10' 等相容字元收容後是真 hex
125
+ // NFKC 寬容=設計面刻意:'⑩'→'10' 等相容字元收容後是真 hex
95
126
  // 值(⑩→[16] 實帳),非缺陷形(NaN 歸零已殲滅=輸出恆數學等值);rawHex 檢查同式(HEX_RE 同行後置
96
127
  // =rawHex 前置守衛);鹽欄同為同行後置(note-crypto/argon2 各 unwrap 面鹽欄形檢——行號帳不寫死,
97
- // 新增段推移行號=註解行號帳漂移,NIT-a 同病禁再犯)。
128
+ // 新增段推移行號=註解行號帳漂移,同病禁再犯)。
98
129
  // 行為恆 null(純註解);正式輸入面形檢在場,公開原語只保證「正規化後真 hex 恆等值、垃圾恆拒」。
99
130
  // 若要 ASCII-only 收緊=帶內版本化(換前綴),禁原地改語意。
100
131
  const norm = hex.normalize('NFKC').toLowerCase();
@@ -127,7 +158,7 @@ export async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): P
127
158
 
128
159
  async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
129
160
  // 這裡的 importKey 是 deriveKey 用途集——與 derivePbkdf2Bits 的 deriveBits 面不是同一形,
130
- // 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;t_7361b68c probe 實證)。
161
+ // 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;probe 實證)。
131
162
  const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveKey']);
132
163
  return crypto.subtle.deriveKey(
133
164
  { name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
@@ -157,6 +188,20 @@ export function normalizePin(pin: string): string {
157
188
  return pin.normalize('NFKC').trim().toLowerCase();
158
189
  }
159
190
 
191
+ /**
192
+ * 密語正規化契約 v3(NFKC-only 裁定):NFKC——**大小寫摺疊禁絕:區分大小寫
193
+ * (大小寫差=不同 KEK/ph2)+首尾空白保留(不 trim)**。
194
+ * 消費面只有 v3 世代:jr4w./jr4d. 包裹家族與 PH1 v3(derivePh1ArgonV3)——
195
+ * 舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw pass 派生,帶內版本化=
196
+ * KEK 輸入契約改變=換新前綴,禁原地改語意),禁把本函式接進舊家族入口。
197
+ * 與 normalizePin 契約並存不混(NFKC→trim→lowercase,pinlock/dual 面不動):
198
+ * 兩函式是正交契約,閘有「同輸入不同輸出」行為向量承載分離。
199
+ * 三面單一真相:本體(此處)+README 家族表+驗證閘 [15] 行為向量——改 NFKC 語意=帶內版本化(換世代),禁原地改。
200
+ */
201
+ export function normalizePassphrase(passphrase: string): string {
202
+ return passphrase.normalize('NFKC');
203
+ }
204
+
160
205
  async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
161
206
  const km = await importKeyRawForKdf(password);
162
207
  const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, km, 256);
@@ -164,12 +209,12 @@ async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations:
164
209
  }
165
210
 
166
211
  async function deriveKek2(cfg: NoteCryptoConfig, passphrase: string, pin: string, salt1: Uint8Array, pinSalt: Uint8Array): Promise<CryptoKey> {
167
- // pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行(t_7361b68c 目標 1)。
212
+ // pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行。
168
213
  // ⚠️ 瀏覽器 hash-wasm Argon2(jr3d. 對應形 deriveKek2Argon)禁並行——純 wasm Argon2id
169
- // 共享記憶體池、兩實例並行在部分引擎靜態直 throw,卡面 review 明示;並行只收 PBKDF2 家族。
170
- // 兩段各自 importKeyRawForKdf(round 2 NIT-2:keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
214
+ // 共享記憶體池、兩實例並行在部分引擎靜態直 throw(設計已明示);並行只收 PBKDF2 家族。
215
+ // 兩段各自 importKeyRawForKdf(keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
171
216
  // 輸出序=ikm 組裝序=passBits@0 ‖ pinBits@32(與原串行逐位一致);閘錨綁左手側 destructure,
172
- // 對調滑接=既有 jr2w. 帳戶全不可解(round 2 MAJOR-1 reviewer rev-harm 一手實證)。
217
+ // 對調滑接=既有 jr2w. 帳戶全不可解(一手實證)。
173
218
  const [passBits, pinBits] = await Promise.all([
174
219
  derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS),
175
220
  derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS),
@@ -194,7 +239,7 @@ const DUAL_IV_LEN = 12;
194
239
  export async function wrapNoteKeyDual(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
195
240
  if (!cfg.wrapDual || !cfg.pinSaltPrefix) throw new Error('ERR_DUAL_NOT_CONFIGURED');
196
241
  const pinNorm = normalizePin(pin);
197
- if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(t_7710c766 語意分離)
242
+ if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
198
243
  const salt1 = crypto.getRandomValues(new Uint8Array(16));
199
244
  const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
200
245
  const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
@@ -338,8 +383,11 @@ export function recTokenHash(recToken: string): Promise<string> {
338
383
  const NOTEKEY_HEX_LEN = 64;
339
384
 
340
385
  /**
341
- * 共用包裹核心(目標 #1 收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
342
- * 全家族單因子包裹(jr1w./jrsw./jr3w./jr3s.)與復原套件(wrappedRec,前綴共用 cfg.wrap)
386
+ * 共用包裹核心(收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
387
+ * 鹽外置家族字首家系清點(七呼叫端):jr1w.(wrapNoteKey)/jrsw.
388
+ *(wrapNoteKeyShare)/jr3w.(wrapNoteKey3)/jr4w.(wrapNoteKey4)/jr3s.
389
+ *(wrapNoteKeyShare3)/jr1l.(storeLocalWrap)/jr1r.+jr1w.(wrapNoteKeyWithRecToken——
390
+ * HKDF 世代專用前綴+AAD 'notekey-rec'、未配置世代 legacy 面=cfg.wrap+AAD 'notekey')
343
391
  * 同一本體;KDF 差異在呼叫端 KEK、payload 形狀(salt 外置 vs 內嵌)由各家族維持。
344
392
  * prefix null/undefined=未配置拒絕(呼叫端亦可先行拒絕,帶自家錯誤碼形;雙防線)。
345
393
  */
@@ -351,14 +399,17 @@ export async function sealNoteKey(prefix: string | null | undefined, noteKey: Cr
351
399
  }
352
400
 
353
401
  /**
354
- * 共用解包核心(目標 #1 收口):任何不符——前綴、base64 形、payload 長度、
402
+ * 共用解包核心(收口):任何不符——前綴、base64 形、payload 長度、
355
403
  * GCM(金鑰/AAD/密文損壞)、rawHex 長度/hex 形——恆回 null 不拋。
356
- * round 1 審查 MINOR-5:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
404
+ * 修正紀錄:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
357
405
  * 本體吞收 null(公開原語誠實契約;呼叫端 unwrap 家族 try/catch 是雙防線非依賴面)。
358
- * 家族嚴格度收口實況(round 1 審查 MINOR-2 校正+r2 MINOR-1 殘餘校正 t_580f9c54):
359
- * 鹽外置 6 點由本體嚴格 92B 終結——note-crypto 四點(unwrapNoteKey=jr1w/
360
- * unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken=jr1wRec 同本體/loadLocalWrap)
361
- * +argon2 兩點(unwrapNoteKey3=jr3w/unwrapNoteKeyShare3=jr3s;own 92B 檢已隨收口摘除);
406
+ * 家族嚴格度收口實況(校正+殘餘校正;
407
+ * 七呼叫端帳同步——鹽外置家族字首家系清點,6 點帳已過期):
408
+ * 本體嚴格 92B 終結七呼叫端=note-crypto 四點(unwrapNoteKey=jr1w/
409
+ * unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken(legacy 面 jr1w./HKDF 面 jr1r.)
410
+ * /loadLocalWrap=jr1l.)
411
+ * +argon2 三點(unwrapNoteKey3=jr3w/unwrapNoteKey4=jr4w/unwrapNoteKeyShare3=jr3s;
412
+ * own 92B 檢已隨收口摘除);
362
413
  * 鹽內嵌 3 點(jr2w/jr3d/jr1p)own 108B+rawHex 形檢——與本體同嚴格度、刻意不經本體
363
414
  * (pinSalt 前綴不在鹽外置形契約內)。維護本體時鹽內嵌三族不隨行。
364
415
  */
@@ -426,32 +477,83 @@ export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string,
426
477
  }
427
478
  }
428
479
 
429
- /** 復原套件包裹:KEK_rec = PBKDF2(recToken, salt = recSaltPrefix ‖ identity)。 */
430
- async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string, identity: string): Promise<CryptoKey> {
431
- return deriveKek(recToken, new TextEncoder().encode(cfg.recSaltPrefix + identity));
480
+ /** 復原套件 KEK 派生:HKDF 專責(本體只承載 recKekHkdf 配置世代——
481
+ * 呼叫端須先過世代配對門)。KEK_rec = HKDF-SHA256(
482
+ * ikm = recToken(TextEncoder UTF-8;recToken 恆 hex64 256-bit 實體因子),
483
+ * salt = recKekSalt?(16B hex → 16 raw bytes;未配置=零鹽),
484
+ * info = 'journal-kek-rec-v1:' + recKekHkdf, L = 32 )——輸出 AES-GCM 256 nonextractable。
485
+ * HKDF 世代 identity 不入 KEK(identity-free by design——256-bit 實體因子足綁;
486
+ * identity 綁域由舊契約面 recSaltPrefix‖identity 專屬承載,兩世代綁域刻意分離)。
487
+ * 強度語意:recToken 256-bit 實體因子,KDF 強度無意義——收的是
488
+ * 派生域(HKDF info)+前綴契約面(jr1r.)的世代分離;非防爆破強化帳。
489
+ * 鹽域二分(鹽家族):HKDF salt 屬「派生面內嵌 cfg、payload 零內嵌」——
490
+ * 92B 帳純粹來自 hex(noteKey) 64B+iv 12+tag 16(pinSalt 內嵌 payload 是 jr2w./jr3d.
491
+ * 鹽內嵌族的事,不入此帳)。PBKDF2 舊世代派生=deriveRecLegacyKek(單一本體,檢查對齊
492
+ * 收口:deriveRecKek 禁 PBKDF2 fallback——派生源雙模=C2 缺口形)。 */
493
+ async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string): Promise<CryptoKey> {
494
+ const hkdfTail = cfg.recKekHkdf as string;
495
+ const info = 'journal-kek-rec-v1:' + hkdfTail;
496
+ if (cfg.recKekSalt && !HEX32_RE.test(cfg.recKekSalt)) throw new Error('ERR_REC_KEK_SALT');
497
+ const salt = cfg.recKekSalt ? hexToBytes(cfg.recKekSalt) : new Uint8Array(0);
498
+ const hkdfBase = await crypto.subtle.importKey('raw', new TextEncoder().encode(recToken) as BufferSource, 'HKDF', false, ['deriveKey']);
499
+ return crypto.subtle.deriveKey(
500
+ { name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: new TextEncoder().encode(info) as BufferSource },
501
+ hkdfBase,
502
+ { name: 'AES-GCM', length: 256 },
503
+ false,
504
+ ['encrypt', 'decrypt'],
505
+ );
432
506
  }
433
507
 
434
508
  export async function wrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, noteKey: CryptoKey, recToken: string, identity: string): Promise<string> {
435
- const kek = await deriveRecKek(cfg, recToken, identity);
509
+ // 世代配對門(檢查對齊 C1/C2 收口):一前綴一份契約補完。
510
+ // 禁寫面=「前綴面 jr1r.×派生面 PBKDF2」(wrapRec-only,C1:寫得出讀不回)與
511
+ // 「前綴面 jr1w.×派生面 HKDF」(HKDF-only,C2:借 jr1w. 前綴 carrying 第二派生、
512
+ // 升級即失資料)——兩面都由部分配置自然接線產生,門 fail-closed 拒寫。
513
+ if (cfg.wrapRec || cfg.recKekHkdf) {
514
+ if (!cfg.wrapRec || !cfg.recKekHkdf) throw new Error('ERR_REC_CFG_PARTIAL');
515
+ const kek = await deriveRecKek(cfg, recToken);
516
+ return sealNoteKey(cfg.wrapRec, noteKey, kek, 'notekey-rec');
517
+ }
518
+ // 未配置世代(兩欄皆缺席=legacy 契約面永不變):cfg.wrap+PBKDF2+AAD 'notekey'
519
+ //(既有向量 byte 不變;cfg.wrap 缺席由 sealNoteKey 守衛承載——雙防線)。
520
+ const kek = await deriveRecLegacyKek(cfg, recToken, identity);
436
521
  return sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
437
522
  }
438
523
 
439
524
  export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped: string, recToken: string, identity: string): Promise<CryptoKey | null> {
440
525
  try {
526
+ // 次序契約:jr1r. 面只在兩欄齊備時試(HKDF 專責派生——cfg.wrap 面零 HKDF 腿,
527
+ // jr1w. 前綴只 carrying passphrase-PBKDF2 派生:「一個前綴一份契約」第二違例收口)。
528
+ if (cfg.wrapRec && cfg.recKekHkdf) {
529
+ const recKek = await deriveRecKek(cfg, recToken);
530
+ const fresh = await openNoteKey(wrapped, recKek, 'notekey-rec', cfg.wrapRec);
531
+ if (fresh) return fresh;
532
+ }
533
+ // 舊面回落(過渡保護):cfg.wrap 在場即試(部分配置讀面照走——寫面已被配對門
534
+ // 拒,讀面寬容=舊 blob 永遠可解的承載面)。
441
535
  if (!cfg.wrap) return null;
442
- const kek = await deriveRecKek(cfg, recToken, identity);
443
- return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
536
+ const legacyKek = await deriveRecLegacyKek(cfg, recToken, identity);
537
+ return await openNoteKey(wrapped, legacyKek, 'notekey', cfg.wrap);
444
538
  } catch {
445
539
  return null;
446
540
  }
447
541
  }
448
542
 
543
+ /** 舊契約派生本體(legacy 派生全案單一本體):PBKDF2(recToken,
544
+ * salt = recSaltPrefix ‖ identity)——永不變(配置形正確時既有 wrappedRec
545
+ * blob 逐位可解)。寫面未配置世代與讀面回落同源呼叫本體;deriveRecKek 改 HKDF 專責
546
+ * (檢查對齊 C2 收口:HKDF 派生禁借 cfg.wrap 前綴面,派生源不再雙模)。 */
547
+ async function deriveRecLegacyKek(cfg: NoteCryptoConfig, recToken: string, identity: string): Promise<CryptoKey> {
548
+ return deriveKek(recToken, new TextEncoder().encode(cfg.recSaltPrefix + identity));
549
+ }
550
+
449
551
  /**
450
552
  * 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。
451
553
  * 兩段 600k PBKDF2 相互獨立(pass 鹽隨機、rec 鹽=recSaltPrefix‖identity 定值)=並行
452
- *(t_7361b68c 目標 1:node 實測 133-152ms→68-84ms,與閘執行帳同源——round 2 MINOR-2 對齊)。
554
+ *(node 實測 133-152ms→68-84ms,與閘執行帳同源)。
453
555
  * AES-GCM derive 佔 600k 迴圈 99%,兩段 wrapped 各自隨機 iv(pass 段另隨機鹽 16B;rec 腿
454
- * KEK 鹽定值、回傳 salt 來自 pass 腿 w1——round 2 MINOR-2 措辭修正)——逐位等值帳由
556
+ * KEK 鹽定值、回傳 salt 來自 pass 腿 w1)——逐位等值帳由
455
557
  * 「兩段各自與 wrapNoteKey 直呼相等」承載。
456
558
  */
457
559
  export async function buildBindPayload(
@@ -475,11 +577,23 @@ export async function buildBindPayload(
475
577
 
476
578
  // ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
477
579
 
580
+ /**
581
+ * 本機包裹:KEK = deriveGuestKey(identity 派生、passphrase-free——
582
+ * session 期免重打密語的機制原樣),payload 改走專用前綴 cfg.wrapLocal(jr1l.)。
583
+ * 舊實作借用 cfg.wrap(jr1w. passphrase 包裹前綴)寫本機包裹=「一個前綴一份契約」
584
+ * 的第二違例(wrappedRec 專用前綴歸 v0.2.0 世代收口):本機包裹的 KDF 與 caller 不同、
585
+ * 儲存面(localStorage)不同、生命週期不同(clearLocalWrap 隨時摘除)——共前綴讓
586
+ * 「本機字串是否可能誤入 server 包裹欄」對帳失真。新前綴=讀舊寫新自癒:
587
+ * loadLocalWrap 先試 jr1l.(本體嚴格面),命中舊形(jr1w.)回落解密後即重包
588
+ * cfg.wrapLocal 回寫(自癒一次),寫入恆 jr1l.。
589
+ */
478
590
  export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, noteKey: CryptoKey): Promise<void> {
479
591
  try {
480
- if (!cfg.wrap) throw new Error('ERR_WRAP_NOT_CONFIGURED');
592
+ // 寫入恆專用前綴 cfg.wrapLocal(jr1l.;未配置=無寫入退場,讀取面照走舊形回落——
593
+ // 行為不變態)。單一真相:loadLocalWrap 自癒腿經本函式回寫(前綴契約不二寫)。
594
+ if (!cfg.wrapLocal) return;
481
595
  const guest = await deriveGuestKey(cfg, identity);
482
- const wrapped = await sealNoteKey(cfg.wrap, noteKey, guest, 'notekey-local');
596
+ const wrapped = await sealNoteKey(cfg.wrapLocal, noteKey, guest, 'notekey-local');
483
597
  cfg.store.set(cfg.store.noteKeyWrap(identity), wrapped);
484
598
  } catch { /* private mode:不阻擋主流程 */ }
485
599
  }
@@ -487,9 +601,23 @@ export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, no
487
601
  async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey | null> {
488
602
  try {
489
603
  const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
490
- if (!stored || !cfg.wrap) return null;
604
+ if (!stored) return null;
491
605
  const guest = await deriveGuestKey(cfg, identity);
492
- return await openNoteKey(stored, guest, 'notekey-local', cfg.wrap);
606
+ // 次序契約:新形必先於舊形試——新形 blob 在舊前綴面恆拒(family isolation);先新後舊
607
+ // 仍是要件(新形命中即不跑舊解包),但兩前綴互斥 startsWith 下單一三元只有一腿成立,
608
+ // 實際次序不可觀察。
609
+ const fresh = cfg.wrapLocal
610
+ ? await openNoteKey(stored, guest, 'notekey-local', cfg.wrapLocal) // 本體嚴格面(92B+rawHex hex 形)
611
+ : null;
612
+ if (fresh) return fresh;
613
+ const legacy = cfg.wrap
614
+ ? await openNoteKey(stored, guest, 'notekey-local', cfg.wrap)
615
+ : null;
616
+ if (legacy) {
617
+ await storeLocalWrap(cfg, identity, legacy);
618
+ return legacy;
619
+ }
620
+ return null;
493
621
  } catch {
494
622
  return null;
495
623
  }
@@ -534,7 +662,7 @@ export async function encryptNote(
534
662
  return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
535
663
  }
536
664
  if (!cfg.cipherGuest) throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
537
- // 空 identity 拒絕(t_7710c766 目標 3):K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
665
+ // 空 identity 拒絕:K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
538
666
  // 靜默產出「空帳號金鑰」=可解同樣空 identity 的任何 guest 密文(對稱面誤配)。fail-closed。
539
667
  const identity = idp.current();
540
668
  if (!identity) throw new Error('ERR_NO_IDENTITY');
@@ -552,7 +680,7 @@ export async function decryptNote(
552
680
  idp: IdentityProvider,
553
681
  ): Promise<string | null> {
554
682
  try {
555
- // guest 前綴正規化(follow-up ②,t_44239f5f):畸形配置 ''(空字串)會讓
683
+ // guest 前綴正規化:畸形配置 ''(空字串)會讓
556
684
  // startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
557
685
  // falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
558
686
  const guestPrefix = cfg.cipherGuest || undefined;
@@ -564,7 +692,7 @@ export async function decryptNote(
564
692
  return guestPrefix === undefined ? null : cipher;
565
693
  }
566
694
  if (isGuest) {
567
- // 空 identity 拒絕(t_7710c766 目標 3,對稱面):guest 密文在 identity 缺席時必 null
695
+ // 空 identity 拒絕(對稱面):guest 密文在 identity 缺席時必 null
568
696
  //(K_u 仍可派生但「空帳號金鑰」不是該密文作者的 intent;誤配下兩態皆 fail-closed)。
569
697
  const identity = idp.current();
570
698
  if (!identity) return null;
@@ -28,7 +28,7 @@ const IV_LEN = 12;
28
28
  const PAYLOAD_LEN = SALT_LEN + IV_LEN + 80;
29
29
 
30
30
  /** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
31
- * t_7710c766:noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
31
+ * noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
32
32
  * 要能再包裹/匯出)不是可調選項;tacet 消費端 TACET_PINLOCK 同步摘欄。 */
33
33
  export interface PinLockConfig {
34
34
  /** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
@@ -65,7 +65,7 @@ async function deriveLockKek(pinNorm: string, pinSalt: Uint8Array, cfg: PinLockC
65
65
 
66
66
  /** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。
67
67
  * 鹽內嵌家族(jr2w./jr3d./jr1p.)自帶三段組裝——sealNoteKey 是鹽外置家族形(iv‖ct),
68
- * 內嵌鹽的 pinSalt 前綴不在其契約內(t_7710c766 毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
68
+ * 內嵌鹽的 pinSalt 前綴不在其契約內(毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
69
69
  export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string> {
70
70
  if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
71
71
  const pinNorm = normalizePin(pin);
@@ -87,7 +87,7 @@ export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey,
87
87
  }
88
88
 
89
89
  /** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
90
- * t_7710c766:嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
90
+ * 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
91
91
  * hexToBytes fail-closed 由 try/catch 承接=行為不變)——不經 openNoteKey 本體
92
92
  * (openNoteKey 是鹽外置家族 92B 形,pinSalt 前綴不在其契約內)。 */
93
93
  export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null> {
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  buildBindPayload,
24
24
  ph1Of,
25
25
  normalizePin,
26
+ normalizePassphrase,
26
27
  recTokenHash,
27
28
  generateRecToken,
28
29
  PBKDF2_ITERATIONS,
@@ -38,10 +39,16 @@ export {
38
39
  unwrapNoteKey3,
39
40
  wrapNoteKeyDual3,
40
41
  unwrapNoteKeyDual3,
42
+ wrapNoteKey4,
43
+ unwrapNoteKey4,
44
+ wrapNoteKeyDual4,
45
+ unwrapNoteKeyDual4,
41
46
  wrapNoteKeyShare3,
42
47
  unwrapNoteKeyShare3,
43
48
  derivePh1Argon,
49
+ derivePh1ArgonV3,
44
50
  PH1_V2_SALT,
51
+ PH1_V3_SALT,
45
52
  ARGON_MEMORY_KIB,
46
53
  ARGON_ITERATIONS,
47
54
  ARGON_PARALLELISM,
@@ -87,4 +94,11 @@ export {
87
94
  generateSessionToken,
88
95
  timingSafeEq,
89
96
  } from './server/hash.ts';
90
- export type { Env } from './server/env.ts';
97
+ export type { Env } from './server/env.ts';
98
+ export {
99
+ PH2_LADDER_TABLE,
100
+ PH2_LADDER_KIND_LEGACY,
101
+ PH2_LADDER_KIND_V2,
102
+ type Ph2LadderKind,
103
+ type Ph2LadderRow,
104
+ } from './server/ladder.ts';
@@ -21,7 +21,7 @@
21
21
  * 不變量(勿破壞):
22
22
  * - 金鑰包裹欄組(wrapped+salt)缺一整組放棄——pair 檢查本體在 pickKeyPackage。
23
23
  * - recPkg 與 recHash 成對出現=呼叫端(fork store)職責:本層介面兩參各自可空、
24
- * 不做 pair 檢查(r2 MINOR-4 照此收口;pickKeyPackage 是同職責的成對檢查先例)。
24
+ * 不做 pair 檢查(裁定照此收口;pickKeyPackage 是同職責的成對檢查先例)。
25
25
  */
26
26
 
27
27
  import { corsResponse } from './cors.ts';
@@ -51,11 +51,11 @@ function escapeReLiteral(s: string): string {
51
51
  }
52
52
 
53
53
  /** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
54
- * never-match 守衛(t_580f9c54 N-7,opt-in 鐵律面;run 574 r2 實測校正):空字首組態會
54
+ * never-match 守衛(空字首組態防線,opt-in 鐵律面):空字首組態會
55
55
  * 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
56
56
  * (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
57
57
  * 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
58
- * (probe 實證 /tmp/t580f9c54-r2/n7-corrected-probe.mjs:`[]` 對純 b64 樣本 NON-NULL——
58
+ * (probe 實證:`[]` 對純 b64 樣本 NON-NULL——
59
59
  * 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
60
60
  function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
61
61
  const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
@@ -67,7 +67,7 @@ function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
67
67
  /**
68
68
  * 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
69
69
  * cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
70
- * 截到 cipherMax 入庫(r2 MINOR-5:JSDoc 幽靈參數 plainMax 摘除)。
70
+ * 截到 cipherMax 入庫(JSDoc 幽靈參數 plainMax 已摘除)。
71
71
  */
72
72
  export function makeInboundCipher(c: CipherFormats) {
73
73
  const RE = cipherPrefixRe(c.cipherPrefixes);
@@ -89,7 +89,7 @@ export function makeInboundCipher(c: CipherFormats) {
89
89
 
90
90
  export function isCipherFor(text: string | null | undefined, c: CipherFormats): boolean {
91
91
  if (typeof text !== 'string') return false;
92
- // 空字首組態同面守衛(t_580f9c54 N-7 範圍增補,run 574 r2):'' 態 startsWith('') 恆真=
92
+ // 空字首組態同面守衛(範圍增補):'' 態 startsWith('') 恆真=
93
93
  // 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
94
94
  // 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
95
95
  // opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
@@ -133,6 +133,17 @@ export interface AuthStore {
133
133
  getByUserKey(key: string): Promise<AuthRow | null>;
134
134
  getByRecHash(recHash: string): Promise<AuthRow | null>;
135
135
  setWrapped(userKey: string, ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
136
+ /** v3 重鑰:舊值入 ladder 表+ph2/wrapped/salt/wrappedRec/rec_hash
137
+ * 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork 實作三語句同批寫入)。 */
138
+ rekeyWithLadder?(userKey: string, oldPh2: string, oldKind: 'legacy' | 'v2', ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
139
+ /** ladder 查表:以「舊 ph2 值」(v2/legacy 形)查 ladder 表,
140
+ * 命中回帳戶 id(login route 的「不建幽靈、回舊帳+ph2Kind='legacy' 語意」守衛面);
141
+ * 未配置(optional)=查表面退場(fork 離線/無表態,行為不變)。 */
142
+ ladderLookup?(oldPh2: string): Promise<string | null>;
143
+ /** ladder 入表(遷移線用;upsert冪等)。 */
144
+ ladderInsert?(accountId: string, oldPh2: string, oldKind: 'legacy' | 'v2'): Promise<void>;
145
+ /** ladder 刪列(直銷面;遷移批次同批或後續呼叫端承擔)。 */
146
+ ladderDelete?(accountId: string): Promise<void>;
136
147
  revokeAllSessions(userKey: string): Promise<void>;
137
148
  insertSession(tokenHash: string, userKey: string, expiresAt: number): Promise<void>;
138
149
  }
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
5
5
  'Access-Control-Allow-Origin': '*',
6
6
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
7
7
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
8
- // preflight 快取(卡C t_7361b68c:缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
8
+ // preflight 快取(缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
9
9
  // 86400 落業界 600-86400 慣例上緣;瀏覽器端自行 clamp(Chromium 2h/WebKit 10m、Firefox
10
10
  // 照走)——快取命中不影響伺服器語意;消費端 inline OPTIONS 分流照 corsResponse 放行。
11
11
  'Access-Control-Max-Age': '86400',
@@ -0,0 +1,71 @@
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 查列」與「遷移時入表」原語,查表接線
23
+ * (現值 miss → ladder → 建幽靈)在 fork route(tacet loginRoute,v2-first 裁定)。
24
+ */
25
+
26
+ import type { Env } from './env.ts';
27
+
28
+ /** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
29
+ export const PH2_LADDER_TABLE = 'ph2_ladder';
30
+
31
+ /** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,遷移的正常前身)。
32
+ * 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
33
+ * 純粹是「這個舊值當年是什麼形」的語意記錄。 */
34
+ export type Ph2LadderKind = 'legacy' | 'v2';
35
+ export const PH2_LADDER_KIND_LEGACY = 'legacy';
36
+ export const PH2_LADDER_KIND_V2 = 'v2';
37
+
38
+ /** ladder 行形(fork schema 對位面)。 */
39
+ export interface Ph2LadderRow {
40
+ accountId: string;
41
+ ph2Kind: string;
42
+ createdAt: number;
43
+ }
44
+
45
+ /** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
46
+ export function makePh2LadderStore(env: Env): {
47
+ findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null>;
48
+ insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void>;
49
+ } {
50
+ return {
51
+ async findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null> {
52
+ const row = await env.DB.prepare(
53
+ `SELECT account_id, ph2_kind, upgraded_at FROM ${PH2_LADDER_TABLE} WHERE ph2 = ?`,
54
+ ).bind(oldPh2).first<{ account_id: string; ph2_kind: string; upgraded_at: number }>();
55
+ if (!row) return null;
56
+ return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
57
+ },
58
+ async insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void> {
59
+ // upsert ON CONFLICT(ph2)(值主權威,PK 定形):同一舊值恰一列,最新持有者勝——
60
+ // A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
61
+ // 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 寫入面另備 ladderDelete 給
62
+ // delete-account 清場面。
63
+ // fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
64
+ //(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
65
+ await env.DB.prepare(
66
+ `INSERT INTO ${PH2_LADDER_TABLE} (ph2, account_id, ph2_kind, upgraded_at) VALUES (?, ?, ?, ?)
67
+ ON CONFLICT(ph2) DO UPDATE SET account_id = excluded.account_id, ph2_kind = excluded.ph2_kind, upgraded_at = excluded.upgraded_at`,
68
+ ).bind(oldPh2, accountId, oldKind, Date.now()).run();
69
+ },
70
+ };
71
+ }
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * ratelimit.ts — per-IP fixed-window 限流(D1 計數,跨 isolate 有效;單句 UPSERT…RETURNING)。
3
3
  *
4
- * 設計(2026-10-04 t_b91ed07f,外部審查 MAJOR:原 4 往返條件寫入鏈 1 往返收口):
4
+ * 設計(2026-10-04 定案:原 4 往返條件寫入鏈 1 往返收口):
5
5
  * - 限流語意是 fixed window:以 window_start 為窗錨,窗過期整窗重置。
6
- * (原檔頭「滑動視窗」措辭錯誤——c7c8701 只修 README 未修本檔;本卡一併收口。)
6
+ * (原檔頭「滑動視窗」措辭錯誤——c7c8701 只修 README 未修本檔;本檔一併收口。)
7
7
  * - 單條 UPSERT…RETURNING 原子完成「計數+窗重置+讀回放行判準」:
8
8
  * 同 IP 併發首發不再有 INSERT/輸家競態(同語句同鍵衝突時第二發原子吃到
9
9
  * count+1;D1 實測 RETURNING 逐發回真值——同 IP 併發首發不 undercount)。
10
10
  * - fail-open 裁定不變(2026-09-06):限流儲存故障時放行(保護面不可反噬主功能),
11
11
  * 只擋明確超額。RETURNING 異形回應(results 空/alias 斷裂)同向 fail-open
12
- * 並 console.error 照實回報(r2 MINOR-3:異形不反向成 deny)。
12
+ * 並 console.error 照實回報(異形不反向成 deny)。
13
13
  *
14
14
  * 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
15
15
  * 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。