@tacet-ink/journal-core 0.3.5 → 0.4.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.
package/README.md CHANGED
@@ -69,7 +69,7 @@ run: `npm install @tacet-ink/journal-core && node example.mts`(0.3.4 起;Nod
69
69
  ```sh
70
70
  git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
71
71
  npm ci --ignore-scripts
72
- npm run verify # 531 assertions, all green, run against the real modules (no mocks); needs this repo checkout — the published package is dist-only since 0.3.4 (no gate, no src/, no scripts/)
72
+ npm run verify # 575 assertions, all green, run against the real modules (no mocks); needs this repo checkout — the published package is dist-only since 0.3.4 (no gate, no src/, no scripts/)
73
73
  ```
74
74
 
75
75
  上例為高階 Vault API;錯誤碼見[例外契約](#例外契約錯誤碼語意)(`VaultError` 表)。低階原語範例(note-crypto 兩時代加解密、前綴自配)與完整 API 見[使用(Usage)](#使用usage)節。Live product: [https://tacet.ink](https://tacet.ink)
@@ -101,9 +101,9 @@ npm run verify # 531 assertions, all green, run against the real modules (no m
101
101
  | `src/client/pinlock.ts` | 本機 PIN 鎖定包裹(jr1p.,開啟時鎖定) |
102
102
  | `src/client/bip39.ts` | 復原套件 24 詞 ⇄ hex64 轉寫層(BIP39,零依賴自製) |
103
103
  | `src/client/keys.ts` | 品牌前綴 localStorage 命名空間 |
104
- | `src/server/auth.ts` | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責 |
104
+ | `src/server/auth.ts` | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責;PH2 落庫形派生 `AuthStore.ph2Of?`(optional,0.4.0)=pepper 收口注入點(未注入=`sha256Hex(PH1)` 單查零變) |
105
105
  | `src/server/ladder.ts` | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert 值主權威)+幽靈守衛查表接線已內建 loginRouteCore——fork 只需配 `ladderLookup` store+0011 migration(未配置=單查行為零變) |
106
- | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效;IPv6 鍵經 `normalizeRateIp` 聚合至 /64;IPv4-mapped 位址回點分 IPv4 鍵(=原生同 /32 鍵)) |
106
+ | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效;IPv6 鍵經 `normalizeRateIp` 聚合至 /64;IPv4-mapped 位址回點分 IPv4 鍵(=原生同 /32 鍵);0.4.1 第二層粗桶 `normalizeRateIpCoarse`(/48|/24)+`checkRateLadder` 雙窗+`bumpRate` opts.fallbackInMemory 記憶體退避+`globalLoginBump` 全站計數——粗桶/全站計數表由呼叫端自建表+GC 承載(tacet maintenance 母型),core 不代建) |
107
107
  | `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
108
108
 
109
109
  發行形(0.3.0 起):`dist/`=tsc 原生 emit 的 ESM `.js`+`.d.ts`,`exports` 全部子路徑指向 dist。
@@ -111,7 +111,7 @@ npm run verify # 531 assertions, all green, run against the real modules (no m
111
111
  不再隨包(驗證閘與向量產生器為 repo 樹面,走源碼 clone 動線);稽核/閱讀路徑=GitHub 瀏覽 `src/`+
112
112
  [docs/format-spec.md](./docs/format-spec.md) 規格+[docs/vectors/](./docs/vectors/) 向量(`docs/` 仍隨包)。
113
113
 
114
- ## 前綴契約(家族表:13 個資料前綴+閘對照組 jr1g.)
114
+ ## 前綴契約(家族表:15 個資料前綴+閘對照組 jr1g.)
115
115
 
116
116
  前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。
117
117
  逐位元組布局、KDF 參數、AAD 與世代契約的完整規格見 [docs/format-spec.md](./docs/format-spec.md),
@@ -132,7 +132,8 @@ Tacet 部署實例(config 傳入):
132
132
  | `jr3d.` | PIN 第二因子合鑰(Argon2id 版) | Argon2id(pass)+Argon2id(pin) → HKDF-SHA256 | 同 jr2w. 形,108B,AAD `notekey2` |
133
133
  | `jr4w.` | passphrase 包裹(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass),NFKC-only) 同上參數 | 同 jr1w./jr3w. 形,AAD `notekey` |
134
134
  | `jr4d.` | PIN 第二因子合鑰(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass))+Argon2id(pin) → HKDF-SHA256 | 同 jr2w./jr3d. 形,108B,AAD `notekey2` |
135
- | `jr3s.` | 單篇分享連結包裹 | Argon2id(同上參數) | 同 jr1w. 形,AAD `notekey-share` |
135
+ | `jrsw.` | 分享包裹 V1(PBKDF2 版;建立面退役,讀取面兩代並行保留) | PBKDF2-SHA256 600k | 同 jr1w. 形,AAD `notekey-share`(⚠️ 不可傳入帳戶主鑰 noteKey——分享包裹的金鑰範圍=該次分享;傳主鑰=收分享者可解全帳戶(J1;tacet 親例)) |
136
+ | `jr3s.` | 單篇分享連結包裹(Argon2id 版;`createShareEnvelope` 高階組裝產出) | Argon2id(同上參數) | 同 jr1w. 形,AAD `notekey-share`(⚠️ 不可傳入帳戶主鑰 noteKey——分享包裹的金鑰範圍=該次分享;傳主鑰=收分享者可解全帳戶(J1;tacet 親例)) |
136
137
  | `jr1p.` | 本機 PIN 鎖定(開啟時鎖定) | PBKDF2-SHA256 600k(刻意不用 Argon2id:解鎖要即時) | 同 jr2w. 形,AAD `notekey-pinlock` |
137
138
 
138
139
  guest 密文前綴在閘對照組另驗 `jr1g.`(閘自造前綴,驗證品牌參數化本身;tacet 部署實例 guest 用 `jr1u.`)。
@@ -152,7 +153,7 @@ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob
152
153
 
153
154
  ```sh
154
155
  npm ci --ignore-scripts
155
- npm run verify # 531 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
156
+ npm run verify # 575 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
156
157
  # extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
157
158
  # server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
158
159
  # + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
@@ -164,10 +165,11 @@ npm run verify # 531 斷言對真模組(禁鏡像;限流單句 UPSERT…RE
164
165
  # +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
165
166
  # +包裹前綴快檢 [13-2](錯前綴試探免付 KDF:五入口行為+計時帽+計數錨)
166
167
  # +CI 供應鏈面 [21](npm ci --ignore-scripts+dependabot runner fromJSON 分流+腳本摘除+NIT-1 錨+r2 MINOR-1 URL 直傳;
167
- # 531 帳=repo 樹——workflow 錨在 .github=repo 樹恆在、四條全跑;包內 SKIP 對照帳段隨 0.3.4 dist-only 瘦身退役:發行包不再帶閘=無包內帳可言)
168
+ # 575 帳=repo 樹——workflow 錨在 .github=repo 樹恆在、四條全跑;包內 SKIP 對照帳段隨 0.3.4 dist-only 瘦身退役:發行包不再帶閘=無包內帳可言)
168
169
  # +發行 tarball 治理面 [22](pack 白名單零 open-card-pr.sh)
169
170
  # +格式規格+測試向量 [26](docs/format-spec.md+docs/vectors 存在性/位元組 shape/欄位名對規格/凍結 KAT 逐字對閘)
170
171
  # +高階 Vault API [27](roundtrip 三形/新舊前綴解鎖/upgrade/換密語/復原/VaultError 毒化/載體覆寫+Worker 回退鏈/vendor 孿生)
172
+ # +分享 envelope 高階 API [28](createShareEnvelope:一次性 shareKey 與主鑰隔離+jr3s. 包裹三件組+無狀態純函數毒化錨)
171
173
  ```
172
174
 
173
175
  - 產品碼**零執行時依賴**(WebCrypto 原語);devDependencies 僅閘用(typescript、@scure/bip39 對照、workers types)。
@@ -245,6 +247,20 @@ const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
245
247
  const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
246
248
  ```
247
249
 
250
+ 單篇分享 envelope(`client/argon2`,0.3.6):一次性 `shareKey`+該次快照密文+`jr3s.` 包裹
251
+ 三件一組——`shareKey` 與帳戶主鑰隔離(J1:主鑰過分享包=收分享者可解全帳戶,tacet 親例已修),
252
+ 無狀態純函數(`shareKey` 每次新生成,module 層無快取殘留):
253
+
254
+ ```ts
255
+ import { createShareEnvelope, unwrapNoteKeyShare3 } from '@tacet-ink/journal-core/client/argon2';
256
+ // createShareEnvelope 亦經根 barrel 公開匯出('@tacet-ink/journal-core')
257
+
258
+ const argon = { wrapShare3: 'jr3s.' }; // Argon3Config:wrapShare3 選配(未配置=分享 API 拒絕)
259
+ const env = await createShareEnvelope(cfg, argon, '今天寫了一點東西。', 'noteId:n1', 'share-pass', identity);
260
+ // env = { ciphertext, wrapped, salt }——存 shares 表(salt 外置 hex);密文前綴 cfg.cipherBound(held 恆 set 路徑)
261
+ const shareKey = await unwrapNoteKeyShare3(argon, env.wrapped, 'share-pass', env.salt); // 收分享端
262
+ ```
263
+
248
264
  PH1 派生(登入憑證):`derivePh1Argon(pass, saltArg?)` 預設鹽 `PH1_V2_SALT`
249
265
  (`'tacet-ph1-v1'`,Tacet 實例,raw pass)。其他產品可自選**固定域鹽**傳入 `saltArg`——
250
266
  鹽一經選定即 per-product 恆固定(PH2 UNIQUE 約束:同一密語必須恆生同一 PH2;
@@ -256,6 +272,11 @@ raw 'PASS' 撞同一 ph2)。派生輸入吃 `normalizePassphrase`(NFKC-only
256
272
  大小寫差恆不同 ph2);v2/legacy 派生面零變更(舊帳戶憑證 raw 契約)。兩代 ph2 可並存查表=
257
273
  tacet 遷移層職責(v0.2.0 世代收口)。
258
274
 
275
+ `ph1Of`(legacy,`SHA-256(pass)`)標 `@deprecated`:無塩 SHA-256 過登入 wire,DB 洩後
276
+ ph2 離線爆破面=快雜湊重算命中——不可與 Argon2id PH1 同時作為上線探證。新整合恆改用
277
+ `derivePh1ArgonV3`/`deriveVaultPh1`(Argon2id 派生、固定域鹽)。函式本體零動(legacy 平台
278
+ 登入與 deleteAccount 證明面仍在用;退場窗口=J2a 批)。
279
+
259
280
  server 登入動線(fork 端):`loginRouteCore(env, store, ip, ph1)` 內建
260
281
  ladder 查表守衛——現值 miss 時經 `AuthStore.ladderLookup`(optional)查 ph2_ladder,
261
282
  命中回舊帳不建幽靈、兩面 miss 才 `createUser`;未配置=行為與 0.2.3 單查恆等。
@@ -409,6 +430,18 @@ on their store (the tacet reference: `d1AuthStore`) and applying `migrations/001
409
430
  — the lookup itself never writes (inserts stay on the `rekeyWithLadder` migration path,
410
431
  deletes on the fork's delete-account flow).
411
432
 
433
+ **PH2 pepper injection (0.4.0, J2b).** The PH2 stored-value derivation is now injectable via
434
+ the optional `AuthStore.ph2Of?(ph1Hex) → Promise<string>`: when a fork injects a peppered
435
+ form (tacet: `HMAC-SHA256(PH2_PEPPER, sha256Hex(PH1))`) `loginRouteCore` runs a dual-read
436
+ migration window — current value `ph2Of(PH1)` first (pepper-closed), then the legacy
437
+ `sha256(PH1)` form + ladder guard on a miss; ghost account creation always takes the
438
+ new form `createUser(ph2Of(PH1))` so fresh accounts are pepper-closed at rest. **Uninjected
439
+ (`undefined`) is byte-identical to the 0.3.x single `sha256Hex(PH1)` lookup** (sibling-fork
440
+ zero-impact iron rule; the optional field means a fork pinned to 0.3.5 builds unchanged).
441
+ The opportunistic rewrite of a legacy-form hit to the new form is fork-owned (this layer is
442
+ write-free); `ph2Of` throwing is surfaced (fail-closed, not fail-open — a missing/failed
443
+ pepper read is a loud 5xx, never a silent downgrade to the un-peppered form).
444
+
412
445
  **Opt-in primitives.** Optional contracts (`cipherGuest` / `wrapDual` / `wrapShare` / `pinLock` /
413
446
  `cipherAttach` / `cipherLocal` / `wrap4` / `wrapDual4` / `wrapLocal` / `wrapRec` (paired with
414
447
  `recKekHkdf`: configured together or not at all; partial configuration is rejected on write) /
@@ -440,7 +473,7 @@ prefix family (`families.json`), known-answer vectors (RFC 9106 Argon2id, the ga
440
473
  `scripts/generate-vectors.ts` against the real primitives and committed as frozen evidence; the
441
474
  verify gate checks their presence and shape (section [26]) and does not regenerate them.
442
475
 
443
- **Verification.** `npm run verify` runs 531 assertions against the real modules (no mocks; run it from a repo checkout — since 0.3.4 the published package is dist-only and carries no gate):
476
+ **Verification.** `npm run verify` runs 575 assertions against the real modules (no mocks; run it from a repo checkout — since 0.3.4 the published package is dist-only and carries no gate):
444
477
  roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
445
478
  isolation, payload tampering, RFC 9106 KAT, a 200-vector BIP39 cross-check, and the
446
479
  login ladder-guard behavior suite (external audit #8), and presence/shape checks for the format
@@ -33,6 +33,7 @@
33
33
  * +AAD 'notekey-rec',jr1w. 前綴回歸僅 carrying passphrase-PBKDF2 派生(未配置世代
34
34
  * wrappedRec 續走 jr1w.+PBKDF2+'notekey')。
35
35
  */
36
+ import { type NoteCryptoConfig, type IdentityProvider } from './note-crypto.ts';
36
37
  /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
37
38
  export declare const ARGON_RFC9106_EXPECTED = "72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503";
38
39
  /** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
@@ -164,8 +165,12 @@ export declare function wrapNoteKeyDual4(cfg: Argon3Config, noteKey: CryptoKey,
164
165
  salt: string;
165
166
  }>;
166
167
  export declare function unwrapNoteKeyDual4(cfg: Argon3Config, wrapped: string, passphrase: string, pin: string, salt1Hex: string): Promise<CryptoKey | null>;
167
- /** jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
168
- export declare function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, sharePass: string): Promise<{
168
+ /**
169
+ * jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。
170
+ * ⚠️ 不可傳入帳戶主鑰(noteKey)——分享包裹的金鑰範圍=該次分享;
171
+ * 傳主鑰=收分享者可解全帳戶(J1;tacet 親例)。
172
+ */
173
+ export declare function wrapNoteKeyShare3(cfg: Argon3Config, shareKey: CryptoKey, sharePass: string): Promise<{
169
174
  wrapped: string;
170
175
  salt: string;
171
176
  }>;
@@ -174,5 +179,16 @@ export declare function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey,
174
179
  * 收口 openNoteKey:鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
175
180
  * 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
176
181
  export declare function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null>;
182
+ /**
183
+ * 單篇分享 envelope:`generateNoteKey()` 一次性 shareKey → `encryptNote`(held 恆 set,
184
+ * 密文前綴 cfg.cipherBound)→ `wrapNoteKeyShare3`(jr3s.,sharePass 永不過線)。
185
+ * 回傳三件:ciphertext(該次快照密文)/wrapped(分享金鑰包裹)/salt(16B hex,呼叫端
186
+ * 存 shares.salt)。⚠️ 產出的包裹金鑰範圍=該次分享——絕非帳戶主鑰。
187
+ */
188
+ export declare function createShareEnvelope(cfg: NoteCryptoConfig, argon: Argon3Config, plaintext: string, aad: string, sharePass: string, idp: IdentityProvider): Promise<{
189
+ ciphertext: string;
190
+ wrapped: string;
191
+ salt: string;
192
+ }>;
177
193
  /** jr4d. 解包(重取 salt1 面):pinSalt 內嵌自描述,salt1 由呼叫端重取後帶入(ladder 遷移後的新値);任何不符回 null,不拋。 */
178
194
  export declare function unwrapNoteKeyDual4WithSalt(cfg: Argon3Config, wrapped: string, passphrase: string, pin: string, salt1Hex: string): Promise<CryptoKey | null>;
@@ -34,7 +34,7 @@
34
34
  * wrappedRec 續走 jr1w.+PBKDF2+'notekey')。
35
35
  */
36
36
  import { normalizePin, normalizePassphrase } from './note-crypto.js';
37
- import { b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, openNoteKey, sealNoteKey, } from './note-crypto.js';
37
+ import { b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, openNoteKey, sealNoteKey, generateNoteKey, makeHeldKey, encryptNote, } from './note-crypto.js';
38
38
  /** KDF 派生輸入正規化收口(mirror normalizePin 先例):v3 入口 = normalizePassphrase(pass);raw 世代入口 = 原密語。
39
39
  * 正規化永不進共用派生本體:deriveKekArgon/deriveKek2Argon 吃的是「派生輸入」——
40
40
  * raw 契約(jr3d./jr2w.…)不經此面(帶內版本化「契約面永不變」的結構保證)。
@@ -370,13 +370,17 @@ export async function unwrapNoteKeyDual4(cfg, wrapped, passphrase, pin, salt1Hex
370
370
  // 帶內版本化:share KDF 升級 = 換新前綴(jrsw. → jr3s.),舊前綴語意不動;讀取端兩代並行,
371
371
  // 建立端只收 jr3s.(關閉弱 KDF 建立面)。
372
372
  // ⚠️ unwrap 輸出 noteKey 一律 extractable=true(要能解日記密文;鐵律 4)。
373
- /** jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
374
- export async function wrapNoteKeyShare3(cfg, noteKey, sharePass) {
373
+ /**
374
+ * jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。
375
+ * ⚠️ 不可傳入帳戶主鑰(noteKey)——分享包裹的金鑰範圍=該次分享;
376
+ * 傳主鑰=收分享者可解全帳戶(J1;tacet 親例)。
377
+ */
378
+ export async function wrapNoteKeyShare3(cfg, shareKey, sharePass) {
375
379
  if (!cfg.wrapShare3)
376
380
  throw new Error('ERR_JR3S_NOT_CONFIGURED');
377
381
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
378
382
  const kek = await deriveKekArgon(sharePass, salt);
379
- const wrapped = await sealNoteKey(cfg.wrapShare3, noteKey, kek, 'notekey-share');
383
+ const wrapped = await sealNoteKey(cfg.wrapShare3, shareKey, kek, 'notekey-share');
380
384
  return { wrapped, salt: toHex(salt) };
381
385
  }
382
386
  /** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
@@ -399,6 +403,28 @@ export async function unwrapNoteKeyShare3(cfg, wrapped, sharePass, saltHex) {
399
403
  return null;
400
404
  }
401
405
  }
406
+ // ── 單篇分享高階組裝(J1 收口:core 單一真相,0.3.6) ─────────────────────────
407
+ //
408
+ // 分享建立動線的單一步驟 API:一次性 shareKey(與帳戶主鑰隔離——J1:主鑰過分享包=
409
+ // 收分享者可解全帳戶,tacet 親例已修)+該次快照密文(AAD 由呼叫端綁 noteId 同律)
410
+ // +jr3s. 分享包裹三件一組產出。無狀態純函數:shareKey 每次新生成(module 層無快取
411
+ // 殘留——「envelope 殘部快取 shareKey」否決案:狀態即舊主鑰事故的土壤)。
412
+ // held 恆 set 路徑為主契約:encryptNote 持 shareKey 走 cipherBound 面;idp 是
413
+ // encryptNote 簽名必填(guest 分支不可達——held 先 set,idp 值零影響)。
414
+ /**
415
+ * 單篇分享 envelope:`generateNoteKey()` 一次性 shareKey → `encryptNote`(held 恆 set,
416
+ * 密文前綴 cfg.cipherBound)→ `wrapNoteKeyShare3`(jr3s.,sharePass 永不過線)。
417
+ * 回傳三件:ciphertext(該次快照密文)/wrapped(分享金鑰包裹)/salt(16B hex,呼叫端
418
+ * 存 shares.salt)。⚠️ 產出的包裹金鑰範圍=該次分享——絕非帳戶主鑰。
419
+ */
420
+ export async function createShareEnvelope(cfg, argon, plaintext, aad, sharePass, idp) {
421
+ const shareKey = await generateNoteKey();
422
+ const sHeld = makeHeldKey();
423
+ sHeld.set(shareKey);
424
+ const ciphertext = await encryptNote(cfg, sHeld, plaintext, aad, idp);
425
+ const share = await wrapNoteKeyShare3(argon, shareKey, sharePass);
426
+ return { ciphertext, wrapped: share.wrapped, salt: share.salt };
427
+ }
402
428
  // ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt ────
403
429
  //
404
430
  // 為何同構再開一支:v3 遷移(登入即重遷)會旋轉 salt1(wrapNoteKeyDual4 落新值);
@@ -114,7 +114,12 @@ export declare function encryptLocal(cfg: NoteCryptoConfig, key: CryptoKey, plai
114
114
  export declare function decryptLocal(cfg: NoteCryptoConfig, key: CryptoKey, cipher: string, aad: string): Promise<string | null>;
115
115
  /** 綁定當下生成:random 256-bit AES-GCM key。必須 extractable(包裹靠 exportKey)。 */
116
116
  export declare function generateNoteKey(): Promise<CryptoKey>;
117
- /** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。 */
117
+ /** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。
118
+ * @deprecated 無塩 SHA-256(pass)——登入 wire 用 ph1_legacy 暴露 GPU 可全速爆破的密碼
119
+ * 指標(外部審查 2026-10-18 J2:不可與 Argon2id PH1 同時作為上線探證;DB 洩後 ph2 的
120
+ * 離線爆破面=快雜湊重算命中)。新整合改用 derivePh1ArgonV3/deriveVaultPh1(Argon2id
121
+ * 派生、固定域鹽)。函式本體零動——legacy 平台登入與 deleteAccount 證明面仍在用
122
+ * (帶內版本化:legacy 面不可原地改語意或摘除,退場窗口=J2a 批)。 */
118
123
  export declare function ph1Of(passphrase: string): Promise<string>;
119
124
  /** 復原種子:32 bytes hex,只顯示一次;伺服器只收 SHA-256。 */
120
125
  export declare function generateRecToken(): string;
@@ -151,8 +156,12 @@ export declare function wrapNoteKey(cfg: NoteCryptoConfig, noteKey: CryptoKey, p
151
156
  salt: string;
152
157
  }>;
153
158
  export declare function unwrapNoteKey(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null>;
154
- /** 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
155
- export declare function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey, sharePass: string): Promise<{
159
+ /**
160
+ * 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。
161
+ * ⚠️ 不可傳入帳戶主鑰(noteKey)——分享包裹的金鑰範圍=該次分享;
162
+ * 傳主鑰=收分享者可解全帳戶(J1;tacet 親例)。
163
+ */
164
+ export declare function wrapNoteKeyShare(cfg: NoteCryptoConfig, shareKey: CryptoKey, sharePass: string): Promise<{
156
165
  wrapped: string;
157
166
  salt: string;
158
167
  }>;
@@ -270,7 +270,12 @@ export async function generateNoteKey() {
270
270
  const raw = crypto.getRandomValues(new Uint8Array(32));
271
271
  return importAesGcm(raw, true);
272
272
  }
273
- /** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。 */
273
+ /** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。
274
+ * @deprecated 無塩 SHA-256(pass)——登入 wire 用 ph1_legacy 暴露 GPU 可全速爆破的密碼
275
+ * 指標(外部審查 2026-10-18 J2:不可與 Argon2id PH1 同時作為上線探證;DB 洩後 ph2 的
276
+ * 離線爆破面=快雜湊重算命中)。新整合改用 derivePh1ArgonV3/deriveVaultPh1(Argon2id
277
+ * 派生、固定域鹽)。函式本體零動——legacy 平台登入與 deleteAccount 證明面仍在用
278
+ * (帶內版本化:legacy 面不可原地改語意或摘除,退場窗口=J2a 批)。 */
274
279
  export function ph1Of(passphrase) {
275
280
  return sha256Hex(passphrase);
276
281
  }
@@ -366,13 +371,17 @@ export async function unwrapNoteKey(cfg, wrapped, passphrase, saltHex) {
366
371
  // payload = iv[12] ‖ AES-GCM(KEK_share, hex(noteKey), aad='notekey-share'),
367
372
  // wrapped = 'jrsw.' + b64(payload)。salt 由呼叫端存 D1(shares.salt);
368
373
  // 分享密語永不過線(與 passphrase 同律)。AAD 用獨立字串,兩種包裹結構不可互換。
369
- /** 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
370
- export async function wrapNoteKeyShare(cfg, noteKey, sharePass) {
374
+ /**
375
+ * 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。
376
+ * ⚠️ 不可傳入帳戶主鑰(noteKey)——分享包裹的金鑰範圍=該次分享;
377
+ * 傳主鑰=收分享者可解全帳戶(J1;tacet 親例)。
378
+ */
379
+ export async function wrapNoteKeyShare(cfg, shareKey, sharePass) {
371
380
  if (!cfg.wrapShare)
372
381
  throw new Error('ERR_SHARE_NOT_CONFIGURED');
373
382
  const salt = crypto.getRandomValues(new Uint8Array(16));
374
383
  const kek = await deriveKek(sharePass, salt);
375
- const wrapped = await sealNoteKey(cfg.wrapShare, noteKey, kek, 'notekey-share');
384
+ const wrapped = await sealNoteKey(cfg.wrapShare, shareKey, kek, 'notekey-share');
376
385
  return { wrapped, salt: toHex(salt) };
377
386
  }
378
387
  /** 分享解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。 */
package/dist/index.d.ts CHANGED
@@ -4,14 +4,14 @@
4
4
  * 客戶端原語(WebCrypto;Argon2id 瀏覽器載體見 argon2.ts 檔頭 setArgonLoader):
5
5
  */
6
6
  export { makeHeldKey, generateNoteKey, encryptNote, decryptNote, encryptAttach, decryptAttach, encryptLocal, decryptLocal, wrapNoteKey, unwrapNoteKey, wrapNoteKeyWithRecToken, unwrapNoteKeyWithRecToken, wrapNoteKeyDual, unwrapNoteKeyDual, storeLocalWrap, clearLocalWrap, buildBindPayload, ph1Of, normalizePin, normalizePassphrase, recTokenHash, generateRecToken, PBKDF2_ITERATIONS, PIN_PBKDF2_ITERATIONS, type NoteCryptoConfig, type IdentityProvider, type HeldKey, } from './client/note-crypto.ts';
7
- export { setArgonLoader, verifyArgonKat, wrapNoteKey3, unwrapNoteKey3, wrapNoteKeyDual3, unwrapNoteKeyDual3, wrapNoteKey4, unwrapNoteKey4, wrapNoteKeyDual4, unwrapNoteKeyDual4, wrapNoteKeyShare3, unwrapNoteKeyShare3, derivePh1Argon, derivePh1ArgonV3, PH1_V2_SALT, PH1_V3_SALT, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM, ARGON_TAG_LEN, type Argon3Config, } from './client/argon2.ts';
7
+ export { setArgonLoader, verifyArgonKat, wrapNoteKey3, unwrapNoteKey3, wrapNoteKeyDual3, unwrapNoteKeyDual3, wrapNoteKey4, unwrapNoteKey4, wrapNoteKeyDual4, unwrapNoteKeyDual4, wrapNoteKeyShare3, unwrapNoteKeyShare3, createShareEnvelope, derivePh1Argon, derivePh1ArgonV3, PH1_V2_SALT, PH1_V3_SALT, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM, ARGON_TAG_LEN, type Argon3Config, } from './client/argon2.ts';
8
8
  export { wrapNoteKeyPinLock, unwrapNoteKeyPinLock, PINLOCK_ITERATIONS, type PinLockConfig, } from './client/pinlock.ts';
9
9
  export { generateBip39Words, wordsToRecToken, recTokenToWords, spotCheckIndexes, } from './client/bip39.ts';
10
10
  export { makeKeyStore, type KeyStore, type KeyStoreConfig } from './client/keys.ts';
11
11
  export { wordlist } from './client/wordlist.ts';
12
12
  /** 伺服器端原語(Cloudflare Workers + D1)。 */
13
- export { makeInboundCipher, isCipherFor, validWrappedKey, validHash64, validSalt, pickKeyPackage, loginRouteCore, errResponse, AUTH_RATE, type AuthRow, type AuthStore, type CipherFormats, type KeyPackage, } from './server/auth.ts';
14
- export { checkRate, normalizeRateIp, type RateWindow } from './server/ratelimit.ts';
13
+ export { makeInboundCipher, isCipherFor, validWrappedKey, validHash64, validSalt, pickKeyPackage, loginRouteCore, errResponse, AUTH_RATE, AUTH_RATE_COARSE, type AuthRow, type AuthStore, type CipherFormats, type KeyPackage, } from './server/auth.ts';
14
+ export { checkRate, checkRateLadder, bumpRate, normalizeRateIp, normalizeRateIpCoarse, globalLoginBump, type RateWindow, } from './server/ratelimit.ts';
15
15
  export { corsResponse, CORS_HEADERS } from './server/cors.ts';
16
16
  export { sha256Hex, hashString, generateSessionToken, timingSafeEq, } from './server/hash.ts';
17
17
  export type { Env } from './server/env.ts';
package/dist/index.js CHANGED
@@ -4,14 +4,14 @@
4
4
  * 客戶端原語(WebCrypto;Argon2id 瀏覽器載體見 argon2.ts 檔頭 setArgonLoader):
5
5
  */
6
6
  export { makeHeldKey, generateNoteKey, encryptNote, decryptNote, encryptAttach, decryptAttach, encryptLocal, decryptLocal, wrapNoteKey, unwrapNoteKey, wrapNoteKeyWithRecToken, unwrapNoteKeyWithRecToken, wrapNoteKeyDual, unwrapNoteKeyDual, storeLocalWrap, clearLocalWrap, buildBindPayload, ph1Of, normalizePin, normalizePassphrase, recTokenHash, generateRecToken, PBKDF2_ITERATIONS, PIN_PBKDF2_ITERATIONS, } from './client/note-crypto.js';
7
- export { setArgonLoader, verifyArgonKat, wrapNoteKey3, unwrapNoteKey3, wrapNoteKeyDual3, unwrapNoteKeyDual3, wrapNoteKey4, unwrapNoteKey4, wrapNoteKeyDual4, unwrapNoteKeyDual4, wrapNoteKeyShare3, unwrapNoteKeyShare3, derivePh1Argon, derivePh1ArgonV3, PH1_V2_SALT, PH1_V3_SALT, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM, ARGON_TAG_LEN, } from './client/argon2.js';
7
+ export { setArgonLoader, verifyArgonKat, wrapNoteKey3, unwrapNoteKey3, wrapNoteKeyDual3, unwrapNoteKeyDual3, wrapNoteKey4, unwrapNoteKey4, wrapNoteKeyDual4, unwrapNoteKeyDual4, wrapNoteKeyShare3, unwrapNoteKeyShare3, createShareEnvelope, derivePh1Argon, derivePh1ArgonV3, PH1_V2_SALT, PH1_V3_SALT, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM, ARGON_TAG_LEN, } from './client/argon2.js';
8
8
  export { wrapNoteKeyPinLock, unwrapNoteKeyPinLock, PINLOCK_ITERATIONS, } from './client/pinlock.js';
9
9
  export { generateBip39Words, wordsToRecToken, recTokenToWords, spotCheckIndexes, } from './client/bip39.js';
10
10
  export { makeKeyStore } from './client/keys.js';
11
11
  export { wordlist } from './client/wordlist.js';
12
12
  /** 伺服器端原語(Cloudflare Workers + D1)。 */
13
- export { makeInboundCipher, isCipherFor, validWrappedKey, validHash64, validSalt, pickKeyPackage, loginRouteCore, errResponse, AUTH_RATE, } from './server/auth.js';
14
- export { checkRate, normalizeRateIp } from './server/ratelimit.js';
13
+ export { makeInboundCipher, isCipherFor, validWrappedKey, validHash64, validSalt, pickKeyPackage, loginRouteCore, errResponse, AUTH_RATE, AUTH_RATE_COARSE, } from './server/auth.js';
14
+ export { checkRate, checkRateLadder, bumpRate, normalizeRateIp, normalizeRateIpCoarse, globalLoginBump, } from './server/ratelimit.js';
15
15
  export { corsResponse, CORS_HEADERS } from './server/cors.js';
16
16
  export { sha256Hex, hashString, generateSessionToken, timingSafeEq, } from './server/hash.js';
17
17
  export { PH2_LADDER_TABLE, PH2_LADDER_KIND_LEGACY, PH2_LADDER_KIND_V2, } from './server/ladder.js';
@@ -81,6 +81,20 @@ export interface AuthStore {
81
81
  ladderInsert?(accountId: string, oldPh2: string, oldKind: 'legacy' | 'v2'): Promise<void>;
82
82
  /** ladder 刪列(直銷面;遷移批次同批或後續呼叫端承擔)。 */
83
83
  ladderDelete?(accountId: string): Promise<void>;
84
+ /**
85
+ * PH2 派生注入點(0.4.0,J2b pepper 收口):以「PH1 的 hex64」回傳**落庫形 PH2**。
86
+ * 未注入(undefined)=loginRouteCore 回落 `sha256Hex(PH1)` 單查,行為與 0.3.x
87
+ * byte-identical(兄弟 fork 零影響鐵律)。tacet 注入
88
+ * `HMAC-SHA256(PH2_PEPPER, sha256Hex(PH1))`——D1 外洩面由 pepper 收口(pepper 與 DB
89
+ * 分離存儲;遺失=整平台鎖死,已離線備份)。契約:
90
+ * - 純函數(同 ph1 恆回同值)=PH2 UNIQUE 查詢鍵的確定性來源;
91
+ * - 雙讀過渡=loginRouteCore 先查 `ph2Of(PH1)` 新形、miss 再查 `sha256(PH1)` 舊形
92
+ * (+ladder 守衛);舊形命中的「順手改寫新形」是 fork 責任(本層零寫入面);
93
+ * - 幽靈建帳恆新形(createUser 吃 `ph2Of(PH1)`)=新帳戶落庫即過 pepper 收口;
94
+ * - 錯誤外拋(不 fail-open):pepper 讀取缺失/故障=顯性 5xx(fork fail-closed 裁定——
95
+ * 靜默退回無 pepper 形=攻擊者部署面小錯差即整線降級)。
96
+ */
97
+ ph2Of?(ph1Hex: string): Promise<string>;
84
98
  revokeAllSessions(userKey: string): Promise<void>;
85
99
  insertSession(tokenHash: string, userKey: string, expiresAt: number): Promise<void>;
86
100
  }
@@ -95,5 +109,12 @@ export interface AuthRow {
95
109
  plan?: string;
96
110
  }
97
111
  export declare const AUTH_RATE: RateWindow;
112
+ /**
113
+ * 粗桶第二層帽(0.4.1 J7):/64 細窗(AUTH_RATE 10/min)擋不住「同 /48 輪換 50 個 /64」的
114
+ * 分散攻擊(PoC F:500/500 放行)——粗桶表以 normalizeRateIpCoarse 粗鍵(IPv6→/48、
115
+ * IPv4→/24)各計,60/min(報告建議值、卡面裁定)。表名由呼叫端建表+GC 承載(同
116
+ * login_rate 母型);本常數只是「第二窗 config」,呼叫端**可選**接線——未接=單窗零變。
117
+ */
118
+ export declare const AUTH_RATE_COARSE: RateWindow;
98
119
  export declare function errResponse(code: string, status: number): Response;
99
- export declare function loginRouteCore(env: Env & Record<string, unknown>, store: AuthStore, ip: string, ph1In: unknown): Promise<Response>;
120
+ export declare function loginRouteCore(env: Env & Record<string, unknown>, store: AuthStore, ip: string, ph1In: unknown, coarseRate?: RateWindow): Promise<Response>;
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { corsResponse } from './cors.js';
29
29
  import { sha256Hex, generateSessionToken } from './hash.js';
30
- import { checkRate } from './ratelimit.js';
30
+ import { checkRate, checkRateLadder } from './ratelimit.js';
31
31
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
32
32
  const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
33
33
  const HASH64_RE = /^[0-9a-f]{64}$/;
@@ -105,6 +105,13 @@ export function pickKeyPackage(body, wrapPrefix) {
105
105
  return { wrapped: wrapped && salt ? wrapped : null, salt: wrapped && salt ? salt : null };
106
106
  }
107
107
  export const AUTH_RATE = { table: 'login_rate', windowMs: 60_000, max: 10 };
108
+ /**
109
+ * 粗桶第二層帽(0.4.1 J7):/64 細窗(AUTH_RATE 10/min)擋不住「同 /48 輪換 50 個 /64」的
110
+ * 分散攻擊(PoC F:500/500 放行)——粗桶表以 normalizeRateIpCoarse 粗鍵(IPv6→/48、
111
+ * IPv4→/24)各計,60/min(報告建議值、卡面裁定)。表名由呼叫端建表+GC 承載(同
112
+ * login_rate 母型);本常數只是「第二窗 config」,呼叫端**可選**接線——未接=單窗零變。
113
+ */
114
+ export const AUTH_RATE_COARSE = { table: 'coarse_login_rate', windowMs: 60_000, max: 60 };
108
115
  export function errResponse(code, status) {
109
116
  return corsResponse(new Response(code, { status }));
110
117
  }
@@ -138,15 +145,41 @@ async function ladderGuard(store, ph2) {
138
145
  return null;
139
146
  return store.getByUserKey(ladderAcc);
140
147
  }
141
- export async function loginRouteCore(env, store, ip, ph1In) {
142
- if (!(await checkRate(env, AUTH_RATE, ip)))
148
+ export async function loginRouteCore(env, store, ip, ph1In, coarseRate) {
149
+ // 0.4.1 J7 粗桶第二層(可選):coarseRate 在場=雙窗 ladder(細窗 AUTH_RATE 10/min 照舊
150
+ // +粗窗粗鍵各計);未傳=原單窗零行為變(additive 參數——兄弟 fork/pin 0.3.x 零影響鐵律)。
151
+ // fallbackInMemory:auth 面 opt-in——限流表 D1 故障/異形退 per-isolate 記憶體計數
152
+ //(非全站 429 亦非完全放行;2026-09-06 fail-open 裁定的 auth 收口面)。
153
+ const rateOk = coarseRate
154
+ ? await checkRateLadder(env, AUTH_RATE, coarseRate, ip, { fallbackInMemory: true })
155
+ : await checkRate(env, AUTH_RATE, ip, { fallbackInMemory: true });
156
+ if (!rateOk)
143
157
  return errResponse('ERR_RATE_LIMITED', 429);
144
158
  const ph1 = validHash64(ph1In);
145
159
  if (!ph1)
146
160
  return errResponse('ERR_BAD_REQUEST', 400);
161
+ // 0.4.0 J2b pepper 收口(t_41d5bb18):ph2Of 注入點。ph2=現行 sha256(PH1) 舊形
162
+ // (恆算——未注入單查+ladder 守衛單一真相源=[23] 毒化錨字面承載);ph2New=ph2Of(PH1)
163
+ // 新形(HMAC 落庫形,只在注入時算)。ph2Of 拋錯外拋(fail-closed 不 fail-open:pepper
164
+ // 讀取缺失/故障=顯性 5xx——靜默退回無 pepper 形=攻擊者部署面小錯差即整線降級,fork 裁定)。
147
165
  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);
166
+ const ph2New = store.ph2Of ? await store.ph2Of(ph1) : null;
167
+ let user;
168
+ if (ph2New !== null) {
169
+ // 雙讀過渡:先查新形(pepper 收口)→ miss 再查舊形(sha256 舊形+ladder 守衛)。
170
+ // 舊形命中的「順手改寫新形」(users.ph2/ladder 對應值覆蓋)是 fork 責任——本層零寫入面
171
+ //(tacet 走自訂 loginRoute 顯性承載此面;用 loginRouteCore 的兄弟 fork 在 store 層承載)。
172
+ user = (await store.findByIdentityQuery(ph2New))
173
+ ?? (await store.findByIdentityQuery(ph2))
174
+ ?? (await ladderGuard(store, ph2));
175
+ }
176
+ else {
177
+ // 未注入=現行單查零變(byte-identical 0.3.x——兄弟 fork 零影響鐵律;[23] 錨字面恰此面)。
178
+ user = (await store.findByIdentityQuery(ph2)) ?? (await ladderGuard(store, ph2));
179
+ }
180
+ // 幽靈建帳恆新形(注入)/sha256 舊形(未注入)=新帳戶落庫即過 pepper 收口。
181
+ const ph2Create = ph2New ?? ph2;
182
+ const userKey = user ? user.userKey : await store.createUser(ph2Create);
150
183
  const token = generateSessionToken();
151
184
  const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // PWA:30 天
152
185
  await store.insertSession(await sha256Hex(token), userKey, expiresAt);
@@ -18,8 +18,25 @@
18
18
  * (單一終端可自由輪換 /64 內尾段=完整地址當鍵即每發新鍵、限流失效);IPv4-mapped
19
19
  * 位址回點分 IPv4(=原生 IPv4 同 /32 鍵,review r1 MAJOR-1 收口);IPv4 與非 IPv6 形原樣。
20
20
  *
21
+ * 第二層粗桶(2026-10-20 J7,外審報告 J7 條收口):/64 帽 10/min 擋不住「同 /48 輪換 50 個
22
+ * /64」的分散攻擊(PoC F 實證 500/500 放行)——checkRateLadder 第二窗以 normalizeRateIpCoarse
23
+ * 粗鍵(IPv6→/48、IPv4→/24;**第二層非取代**——/64 細窗是 2026-10-11 定案載體,本層加帽
24
+ * 非改鍵)+粗桶表(同構 UPSERT;呼叫端須建表+GC 承載,同 login_rate 母型)各計。
25
+ *
26
+ * fail-open 收口(J7,2026-10-20):auth 類端點「D1 故障=全放行」=攻擊者在 D1 故障期
27
+ * 全站登入無帽——**非 fail-closed 429**(單點故障反噬主功能,2026-09-06 裁定初諱)亦非
28
+ * 完全放行:bumpRate/checkRateLadder 支援 opts.fallbackInMemory——D1 出錯或 RETURNING
29
+ * 異形時退 per-isolate 記憶體計數(module-scope Map;isolate 生命週期恆有效,跨 isolate
30
+ * 失效=攻擊者換 isolate 也重置的誠實面,比完全放行嚴)。auth 面 opt-in(呼叫端顯式
31
+ * 傳 opts),其他端點(shares/notes…)零觸=完全零行為變。
32
+ *
33
+ * 全站登入計數(J7 觀測面):globalLoginBump=固定鍵 'global' 單列 UPSERT(同構;
34
+ * 呼叫端須建表+GC 承載)——每呼叫 +1 全站計數、回窗內真值;本層只做計數面,
35
+ * 告警通道(閾值觸發 webhook/log 行)由呼叫端承載(無現成通道則 log JSON 一行)。
36
+ *
21
37
  * 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
22
- * 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
38
+ * 每列 ~40 bytes,量級可忽略;GC 由呼叫端承載(tacet maintenance 母型——schema.sql 表
39
+ * 清單真相源+固定形 SQL_RATE_GC_<表> 常數+runRateTablesGc batch 接線)):
23
40
  * CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
24
41
  * 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
25
42
  */
@@ -33,6 +50,15 @@ export interface RateWindow {
33
50
  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";
34
51
  /** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
35
52
  export declare function isRateAllowed(count: number, max: number): boolean;
53
+ /**
54
+ * 單窗計數(0.1.6 公開面走 index.ts checkRate;ip 已正規化)。
55
+ * opts.fallbackInMemory(0.4.1 J7,opt-in):D1 出錯或 RETURNING 異形時退 per-isolate
56
+ * 記憶體計數(比完全放行嚴;auth 面才傳——limit 表故障≠全站登入狂、亦非全站 429)。
57
+ * 未傳 opts=原 fail-open 完全放行(其他端點零行為變鐵律)。
58
+ */
59
+ export declare function bumpRate(env: Env, w: RateWindow, ip: string, opts?: {
60
+ fallbackInMemory?: boolean;
61
+ }): Promise<boolean>;
36
62
  /**
37
63
  * 限流鍵正規化(單一真相;純函式、零 throw):
38
64
  * - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
@@ -44,4 +70,38 @@ export declare function isRateAllowed(count: number, max: number): boolean;
44
70
  * - embedded-IPv4 三家族刻意聚合進其所屬前綴的 /64(deliberate nondecision):IPv4-compatible(RFC 4291 §2.5.5.1 已棄)::192.0.2.1 → ::/64、SIIT ::ffff:0:192.0.2.1 → ::/64、NAT64 64:ff9b::192.0.2.1 → 64:ff9b::/64——僅 IPv4-mapped(§2.5.5.2)還原點分 IPv4。
45
71
  */
46
72
  export declare function normalizeRateIp(ip: string): string;
47
- export declare function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean>;
73
+ export declare function checkRate(env: Env, w: RateWindow, ip: string, opts?: {
74
+ fallbackInMemory?: boolean;
75
+ }): Promise<boolean>;
76
+ /**
77
+ * 限流粗鍵正規化(J7 第二層;純函式、零 throw):
78
+ * - IPv6 → /48 前綴+`/48` 標記(同 normalizeRateIp 的 RFC 5952 規範形:前導零摘除、小寫、
79
+ * 零串壓縮;前綴取前 3 組 hextet),例 `2001:db8:1234:5678::1` → `2001:db8:1234::/48`、
80
+ * `::1` → `::/48`;zone id(`%…`)先剝除。
81
+ * - IPv4-mapped → 先還原點分 IPv4(同 §2.5.5.2 裁定)再 /24:`::ffff:192.0.2.1` →
82
+ * `192.0.2.0/24`——mapped 位址回 IPv4 粗桶,禁併入 `::/48` 全域桶(同 /64 層裁定)。
83
+ * - IPv4 → 前三組 octet+`0/24`:`192.0.2.33` → `192.0.2.0/24`。
84
+ * - `'unknown'`/空串/無法解析形/octet 越界:原樣返回(與細層同律:鍵照樣進表=可見面)。
85
+ * - 粗層是**第二層非取代**:細層 /64 鍵(t_b30cbc5f 定案)與本層 /48|/24 鍵各入各表、
86
+ * 各自計數——同 /48 輪換 50 個 /64 的分散攻擊(PoC F)被本層 60/min 帽收斂。
87
+ */
88
+ export declare function normalizeRateIpCoarse(ip: string): string;
89
+ /**
90
+ * 雙窗 ladder(J7 第二層;0.4.1):同 IP 兩窗**各計**——細窗 w(/64 帽照舊)+粗窗 w2
91
+ * (粗鍵 normalizeRateIpCoarse+粗桶表)。任一窗超額即 false(計數不封頂語意兩窗照舊,
92
+ * 超額發兩窗皆照實累計=審計可見)。兩窗須為**不同表**(同表=雙 UPSERT 同鍵=計數翻倍,
93
+ * 呼叫端配置錯誤由 assertSafeTable 外的表面自證——本層不代判表異,契約由呼叫端常數承載)。
94
+ * opts.fallbackInMemory:任一窗 D1 故障/異形時該窗退 per-isolate 記憶體計數(同
95
+ * bumpRate 裁定——非全站 429 亦非全放行;auth 面 opt-in,其他端點零行為變)。
96
+ */
97
+ export declare function checkRateLadder(env: Env, w: RateWindow, w2: RateWindow, ip: string, opts?: {
98
+ fallbackInMemory?: boolean;
99
+ }): Promise<boolean>;
100
+ /**
101
+ * 全站登入計數(J7 觀測面;0.4.1):固定鍵 `'global'` 單列 UPSERT(同構 fixed window)——
102
+ * 每呼叫 +1、回**窗內實際 count**(計數不封頂、超額照實累計=審計面)。本層只做計數面
103
+ * 與故障 fail-open 放行(回 null=本發計數不可信,呼叫端跳過告警判斷);告警通道
104
+ * (閾值觸發 webhook/log JSON 行 `{'evt':'login_rate_global','n':N}`)由呼叫端承載——
105
+ * 本層零 I/O 副作用(console.error 除外的既有裁定)。
106
+ */
107
+ export declare function globalLoginBump(env: Env, w: RateWindow): Promise<number | null>;
@@ -18,8 +18,25 @@
18
18
  * (單一終端可自由輪換 /64 內尾段=完整地址當鍵即每發新鍵、限流失效);IPv4-mapped
19
19
  * 位址回點分 IPv4(=原生 IPv4 同 /32 鍵,review r1 MAJOR-1 收口);IPv4 與非 IPv6 形原樣。
20
20
  *
21
+ * 第二層粗桶(2026-10-20 J7,外審報告 J7 條收口):/64 帽 10/min 擋不住「同 /48 輪換 50 個
22
+ * /64」的分散攻擊(PoC F 實證 500/500 放行)——checkRateLadder 第二窗以 normalizeRateIpCoarse
23
+ * 粗鍵(IPv6→/48、IPv4→/24;**第二層非取代**——/64 細窗是 2026-10-11 定案載體,本層加帽
24
+ * 非改鍵)+粗桶表(同構 UPSERT;呼叫端須建表+GC 承載,同 login_rate 母型)各計。
25
+ *
26
+ * fail-open 收口(J7,2026-10-20):auth 類端點「D1 故障=全放行」=攻擊者在 D1 故障期
27
+ * 全站登入無帽——**非 fail-closed 429**(單點故障反噬主功能,2026-09-06 裁定初諱)亦非
28
+ * 完全放行:bumpRate/checkRateLadder 支援 opts.fallbackInMemory——D1 出錯或 RETURNING
29
+ * 異形時退 per-isolate 記憶體計數(module-scope Map;isolate 生命週期恆有效,跨 isolate
30
+ * 失效=攻擊者換 isolate 也重置的誠實面,比完全放行嚴)。auth 面 opt-in(呼叫端顯式
31
+ * 傳 opts),其他端點(shares/notes…)零觸=完全零行為變。
32
+ *
33
+ * 全站登入計數(J7 觀測面):globalLoginBump=固定鍵 'global' 單列 UPSERT(同構;
34
+ * 呼叫端須建表+GC 承載)——每呼叫 +1 全站計數、回窗內真值;本層只做計數面,
35
+ * 告警通道(閾值觸發 webhook/log 行)由呼叫端承載(無現成通道則 log JSON 一行)。
36
+ *
21
37
  * 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
22
- * 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
38
+ * 每列 ~40 bytes,量級可忽略;GC 由呼叫端承載(tacet maintenance 母型——schema.sql 表
39
+ * 清單真相源+固定形 SQL_RATE_GC_<表> 常數+runRateTablesGc batch 接線)):
23
40
  * CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
24
41
  * 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
25
42
  */
@@ -41,25 +58,50 @@ export const SQL_RATE_BUMP = `INSERT INTO {table} (ip, window_start, count) VALU
41
58
  export function isRateAllowed(count, max) {
42
59
  return count <= max;
43
60
  }
44
- async function bumpRate(env, w, ip) {
61
+ /** @internal(0.4.1 J7)。per-isolate 記憶體退避計數器(module-scope;isolate 生命週期恆有效、
62
+ * 跨 isolate 失效=攻擊者換 isolate 也重置的誠實面)。key=`${table}:${rateKey}`。
63
+ * 只在 fallbackInMemory 且 D1 故障/異形時觸碰——健康路徑零成本、零寫入面。
64
+ * 無上限/無掃尾收口(r1 MINOR-2):D1 長故障期大 IPv6 段攻擊者可造大量 distinct 粗/細鍵推
65
+ * isolate 記憶體——size 過 MEM_FALLBACK_MAX(65536)整 Map 清(=攻擊者換 isolate 同向重置,
66
+ * 語意一致;誠實面:清後計數從 0 重起=D1 故障期短暫退避失效窗,故障期本就非封鎖面)。 */
67
+ const MEM_FALLBACK_MAX = 65536;
68
+ const memFallback = new Map();
69
+ function memBump(key, windowMs, max, now) {
70
+ const e = memFallback.get(key);
71
+ const start = e && now - e.start < windowMs ? e.start : now;
72
+ const count = e && now - e.start < windowMs ? e.count + 1 : 1;
73
+ memFallback.set(key, { start, count });
74
+ if (memFallback.size > MEM_FALLBACK_MAX)
75
+ memFallback.clear();
76
+ return isRateAllowed(count, max);
77
+ }
78
+ /**
79
+ * 單窗計數(0.1.6 公開面走 index.ts checkRate;ip 已正規化)。
80
+ * opts.fallbackInMemory(0.4.1 J7,opt-in):D1 出錯或 RETURNING 異形時退 per-isolate
81
+ * 記憶體計數(比完全放行嚴;auth 面才傳——limit 表故障≠全站登入狂、亦非全站 429)。
82
+ * 未傳 opts=原 fail-open 完全放行(其他端點零行為變鐵律)。
83
+ */
84
+ export async function bumpRate(env, w, ip, opts) {
45
85
  assertSafeTable(w.table);
46
86
  const now = Date.now();
87
+ const mem = () => memBump(`${w.table}:${ip}`, w.windowMs, w.max, now);
47
88
  try {
48
89
  const { results } = await env.DB.prepare(SQL_RATE_BUMP.replaceAll('{table}', w.table)).bind(ip, now, now - w.windowMs).run();
49
90
  const first = results[0];
50
91
  const n = first?.n;
51
92
  if (typeof n !== 'number' || !Number.isFinite(n)) {
52
- // 異形回應(results 空/alias 斷裂)與儲存故障同向 fail-open(2026-09-06 裁定:
53
- // 缺陷偏向放行,不反向成 deny);真 D1 不產生(探針實證)——守衛照實回報。
93
+ // 異形回應(results 空/alias 斷裂):原 fail-open 放行(2026-09-06 裁定);
94
+ // fallbackInMemory 面同退記憶體計數(異形=本窗判準不可信,仍擋明確超額)。
54
95
  console.error(`[ratelimit] ${w.table} unexpected RETURNING shape:`, first);
55
- return true;
96
+ return opts?.fallbackInMemory ? mem() : true;
56
97
  }
57
98
  return isRateAllowed(n, w.max);
58
99
  }
59
100
  catch (e) {
60
- // fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額
101
+ // fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額;
102
+ // fallbackInMemory 面退 per-isolate 記憶體計數(J7 auth 收口——非全站 429 亦非全放行)。
61
103
  console.error(`[ratelimit] ${w.table} update failed:`, e);
62
- return true;
104
+ return opts?.fallbackInMemory ? mem() : true;
63
105
  }
64
106
  }
65
107
  const HEXTET = /^[0-9a-f]{1,4}$/i;
@@ -112,6 +154,80 @@ export function normalizeRateIp(ip) {
112
154
  net.pop();
113
155
  return net.map((h) => h.toString(16)).join(':') + '::/64';
114
156
  }
115
- export function checkRate(env, w, ip) {
116
- return bumpRate(env, w, normalizeRateIp(ip));
157
+ export function checkRate(env, w, ip, opts) {
158
+ return bumpRate(env, w, normalizeRateIp(ip), opts);
159
+ }
160
+ /**
161
+ * 限流粗鍵正規化(J7 第二層;純函式、零 throw):
162
+ * - IPv6 → /48 前綴+`/48` 標記(同 normalizeRateIp 的 RFC 5952 規範形:前導零摘除、小寫、
163
+ * 零串壓縮;前綴取前 3 組 hextet),例 `2001:db8:1234:5678::1` → `2001:db8:1234::/48`、
164
+ * `::1` → `::/48`;zone id(`%…`)先剝除。
165
+ * - IPv4-mapped → 先還原點分 IPv4(同 §2.5.5.2 裁定)再 /24:`::ffff:192.0.2.1` →
166
+ * `192.0.2.0/24`——mapped 位址回 IPv4 粗桶,禁併入 `::/48` 全域桶(同 /64 層裁定)。
167
+ * - IPv4 → 前三組 octet+`0/24`:`192.0.2.33` → `192.0.2.0/24`。
168
+ * - `'unknown'`/空串/無法解析形/octet 越界:原樣返回(與細層同律:鍵照樣進表=可見面)。
169
+ * - 粗層是**第二層非取代**:細層 /64 鍵(t_b30cbc5f 定案)與本層 /48|/24 鍵各入各表、
170
+ * 各自計數——同 /48 輪換 50 個 /64 的分散攻擊(PoC F)被本層 60/min 帽收斂。
171
+ */
172
+ export function normalizeRateIpCoarse(ip) {
173
+ if (typeof ip !== 'string')
174
+ return ip;
175
+ if (ip.includes(':')) {
176
+ const pct = ip.indexOf('%');
177
+ const g = parseIpv6(pct >= 0 ? ip.slice(0, pct) : ip);
178
+ // 非法 IPv6 形原樣返回(同細層律;本行不與細層 needle 同字面——[12] 毒化錨恰 1 契約)。
179
+ if (g === null)
180
+ return ip;
181
+ if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) {
182
+ const a = g[6] >> 8, b = g[6] & 255, c = g[7] >> 8;
183
+ return `${a}.${b}.${c}.0/24`;
184
+ }
185
+ const net = g.slice(0, 3);
186
+ while (net.length > 0 && net[net.length - 1] === 0)
187
+ net.pop();
188
+ return net.map((h) => h.toString(16)).join(':') + '::/48';
189
+ }
190
+ const q = ip.split('.');
191
+ if (q.length === 4 && q.every((o) => IPV4_OCTET.test(o) && Number(o) <= 255)) {
192
+ return `${q[0]}.${q[1]}.${q[2]}.0/24`;
193
+ }
194
+ return ip;
195
+ }
196
+ /**
197
+ * 雙窗 ladder(J7 第二層;0.4.1):同 IP 兩窗**各計**——細窗 w(/64 帽照舊)+粗窗 w2
198
+ * (粗鍵 normalizeRateIpCoarse+粗桶表)。任一窗超額即 false(計數不封頂語意兩窗照舊,
199
+ * 超額發兩窗皆照實累計=審計可見)。兩窗須為**不同表**(同表=雙 UPSERT 同鍵=計數翻倍,
200
+ * 呼叫端配置錯誤由 assertSafeTable 外的表面自證——本層不代判表異,契約由呼叫端常數承載)。
201
+ * opts.fallbackInMemory:任一窗 D1 故障/異形時該窗退 per-isolate 記憶體計數(同
202
+ * bumpRate 裁定——非全站 429 亦非全放行;auth 面 opt-in,其他端點零行為變)。
203
+ */
204
+ export async function checkRateLadder(env, w, w2, ip, opts) {
205
+ const okFine = await bumpRate(env, w, normalizeRateIp(ip), opts);
206
+ const okCoarse = await bumpRate(env, w2, normalizeRateIpCoarse(ip), opts);
207
+ return okFine && okCoarse;
208
+ }
209
+ /**
210
+ * 全站登入計數(J7 觀測面;0.4.1):固定鍵 `'global'` 單列 UPSERT(同構 fixed window)——
211
+ * 每呼叫 +1、回**窗內實際 count**(計數不封頂、超額照實累計=審計面)。本層只做計數面
212
+ * 與故障 fail-open 放行(回 null=本發計數不可信,呼叫端跳過告警判斷);告警通道
213
+ * (閾值觸發 webhook/log JSON 行 `{'evt':'login_rate_global','n':N}`)由呼叫端承載——
214
+ * 本層零 I/O 副作用(console.error 除外的既有裁定)。
215
+ */
216
+ export async function globalLoginBump(env, w) {
217
+ assertSafeTable(w.table);
218
+ const now = Date.now();
219
+ try {
220
+ const { results } = await env.DB.prepare(SQL_RATE_BUMP.replaceAll('{table}', w.table)).bind('global', now, now - w.windowMs).run();
221
+ const n = results[0]?.n;
222
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
223
+ console.error(`[ratelimit] ${w.table} unexpected RETURNING shape (global):`, results[0]);
224
+ return null;
225
+ }
226
+ return n;
227
+ }
228
+ catch (e) {
229
+ // fail-open:計數面故障不得反噬登入主功能(2026-09-06 裁定同向)。
230
+ console.error(`[ratelimit] ${w.table} global bump failed:`, e);
231
+ return null;
232
+ }
117
233
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacet-ink/journal-core",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "description": "Zero-knowledge journal core — e2e crypto, passphrase-only auth, transport, ratelimit. The server never sees plaintext.",
5
5
  "homepage": "https://tacet.ink",
6
6
  "repository": {