@tacet-ink/journal-core 0.3.1 → 0.3.3

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.
@@ -90,7 +90,7 @@ import {
90
90
  AUTH_RATE,
91
91
  type CipherFormats,
92
92
  } from '../src/server/auth.ts';
93
- import { checkRate, isRateAllowed, SQL_RATE_BUMP, type RateWindow } from '../src/server/ratelimit.ts';
93
+ import { checkRate, isRateAllowed, normalizeRateIp, SQL_RATE_BUMP, type RateWindow } from '../src/server/ratelimit.ts';
94
94
  import { generateSessionToken, timingSafeEq } from '../src/server/hash.ts';
95
95
  import { CORS_HEADERS, corsResponse } from '../src/server/cors.ts';
96
96
  import {
@@ -157,7 +157,7 @@ async function A(name: string, cond: boolean | Promise<boolean>, detail = ''): P
157
157
  // 帳面契約:--only 態總帳顯形「本輪 X/選段帳 N(N 案非本輪載)」+OK 標記帶 (--ONLY …) 尾碼;skip 段帳面恆列「未跑非通過」;[24]/[25] runner 自證段隨每輪在場。
158
158
  // 段標記 helper(secOpen):全跑態輸出位元組恆等原 console.log('\n[N] …');--only 態僅選中段輸出段頭。
159
159
  // 毒化矩陣內層 rerun(execFileSync+POISON_GATE_INNER=1)不傳遞 --only=內層恆全帳(POISON_GATE_INNER 語意不變)。
160
- // 段帳=執行帳(healthy-repo 全跑 runtime 真值,2026-10-10 實測 506——A3 [27] +14);帳面語意契約(逐段「案 X/帳」
160
+ // 段帳=執行帳(healthy-repo 全跑 runtime 真值,2026-10-12 實測 522——[12] 限流鍵 /64 正規化 +14+IPv4-mapped 面 +1+NIT-b 接線錨 +1(NIT-a nonstring 五型 Object.is=既有案就地強化 +0));帳面語意契約(逐段「案 X/帳」
161
161
  // 對 secRan 執行值顯形):執行 < 帳 的段=本環境條件態未行使(非 skip 態),--only 期望算術對齊本帳。
162
162
  // [23] 執行 18 帳 21=[23]④ lookup 未配置(0.2.3 單查恆等)/[23]⑤ fail-open(表缺席)兩情境
163
163
  // 在本機樹由 [8](登入憑證 Argon2id 派生)內登入 lane 的訊號面真行使(try/catch 異常面)——車道分流非帳面虛報。
@@ -166,7 +166,7 @@ async function A(name: string, cond: boolean | Promise<boolean>, detail = ''): P
166
166
  // for (const [k] of secRan) if (!secCases[k] && k !== 22) FAIL(負向斷言本體在 [25] 段
167
167
  //——secCases 全摘即 RED);反向缺席段=帳面誠實「案 0/N · skip」。
168
168
  // secCases 載體三群:[24]/[25] self-account+--only 段存在面+teeth(段帳由 secRan 執行自記)。
169
- const secCases: Record<number, number> = { 1: 3, 2: 7, 3: 9, 4: 4, 5: 3, 6: 20, 7: 27, 8: 8, 9: 22, 10: 5, 11: 41, 12: 50, 13: 39, 14: 18, 15: 59, 16: 15, 17: 14, 18: 39, 19: 17, 20: 47, 21: 10, 23: 18, 24: 5, 25: 4, 26: 8, 27: 14 };
169
+ const secCases: Record<number, number> = { 1: 3, 2: 7, 3: 9, 4: 4, 5: 3, 6: 20, 7: 27, 8: 8, 9: 22, 10: 5, 11: 41, 12: 66, 13: 39, 14: 18, 15: 59, 16: 15, 17: 14, 18: 39, 19: 17, 20: 47, 21: 10, 23: 18, 24: 5, 25: 4, 26: 8, 27: 14 };
170
170
  function parseOnly(spec: string): { err: string | null; set: number[] } {
171
171
  const set = new Set<number>();
172
172
  const specStr = spec.trim();
@@ -773,7 +773,7 @@ secOpen(12, '[12] 伺服器端原語(auth/ratelimit/hash)+ 本機鎖定(
773
773
  // UPSERT…RETURNING 於 wrangler 4.129.0 workerd 逐發回真值;併發首發兩發計數 1→2)。
774
774
  type RateRow = { ip: string; window_start: number; count: number };
775
775
  const RATE: RateWindow = { table: 'login_rate', windowMs: 60_000, max: 3 };
776
- const makeRateDb = (): {
776
+ const makeRateDb = (cr: typeof checkRate = checkRate): {
777
777
  db: {
778
778
  exec: (s: string) => void;
779
779
  prepare: (s: string) => { all: (...p: unknown[]) => Array<Record<string, unknown>>; get: (...p: unknown[]) => Record<string, unknown> | undefined };
@@ -798,7 +798,7 @@ const makeRateDb = (): {
798
798
  runRaw: (sql) => db.exec(sql),
799
799
  // 真 checkRate 直驅:?1/?2/?3 有名參數語意在 node:sqlite 與 workerd 一致(端對端探針實證,見卡 comments)。
800
800
  check: (ip) =>
801
- checkRate(
801
+ cr(
802
802
  {
803
803
  DB: {
804
804
  prepare: (sql: string) => ({
@@ -871,6 +871,127 @@ await A('rate:RETURNING 異形回應守衛 fail-open(results 空/alias 斷
871
871
  })());
872
872
  await A('rate:SQL_RATE_BUMP 常數形(UPSERT…RETURNING…count AS n;{table} 佔位)',
873
873
  /^INSERT INTO \{table\} \(ip, window_start, count\) VALUES \(\?1, \?2, 1\)\n\s+ON CONFLICT\(ip\) DO UPDATE SET\n\s+count\s+= CASE WHEN window_start <= \?3 THEN 1 ELSE count \+ 1 END,\n\s+window_start = CASE WHEN window_start <= \?3 THEN \?2 ELSE window_start END\n\s+RETURNING count AS n$/.test(SQL_RATE_BUMP));
874
+ // 限流鍵正規化(t_b30cbc5f):checkRate 入口 normalizeRateIp——IPv6 聚合至 /64(尾段漂移不再每發新鍵)。
875
+ // v6 面謂詞以模組參數化:同一謂詞本體先對真模組(綠)、再對拷貝樹毒化模組(IPv6 聚合分支摘除)須逐案真翻紅。
876
+ type RateMod12 = { checkRate: typeof checkRate; normalizeRateIp: typeof normalizeRateIp };
877
+ const rateReal12: RateMod12 = { checkRate, normalizeRateIp };
878
+ const v6Cases12: Array<[string, (m: RateMod12) => Promise<boolean>]> = [
879
+ ['rate:IPv6 同 /64 尾段漂移兩地址(2001:db8:5678::1/2001:db8:5678:0:ffff:ffff:ffff:9)經真 checkRate 共用鍵 2001:db8:5678::/64 計數 2(裸地址不入表)', async (m) => {
880
+ const r = makeRateDb(m.checkRate);
881
+ await r.check('2001:db8:5678::1'); await r.check('2001:db8:5678:0:ffff:ffff:ffff:9');
882
+ return r.row('2001:db8:5678::/64')?.count === 2 && r.row('2001:db8:5678::1') === undefined;
883
+ }],
884
+ ['rate:IPv6 同 /64 輪換四個介面 ID → 三發 pass、第四發 reject(尾段漂移不再繞過 max)', async (m) => {
885
+ const r = makeRateDb(m.checkRate);
886
+ const seq: boolean[] = [];
887
+ for (const ip of ['2001:db8:77::1', '2001:db8:77::2', '2001:db8:77:0:a:b:c:d', '2001:db8:77::ffff']) seq.push(await r.check(ip));
888
+ return seq.join() === 'true,true,true,false';
889
+ }],
890
+ ['rate:IPv6 跨 /64 分鍵(2001:db8:5678::1/2001:db8:9999::1 → 兩 /64 鍵各計 1)', async (m) => {
891
+ const r = makeRateDb(m.checkRate);
892
+ await r.check('2001:db8:5678::1'); await r.check('2001:db8:9999::1');
893
+ return r.row('2001:db8:5678::/64')?.count === 1 && r.row('2001:db8:9999::/64')?.count === 1;
894
+ }],
895
+ ['rate:IPv6 同 /48 異 /64 分鍵(第 4 hextet 屬前綴:2001:db8:5678::1 → 2001:db8:5678::/64、2001:db8:5678:ffff::9 → 2001:db8:5678:ffff::/64 各計 1——聚合粒度恰 /64 非 /48)', async (m) => {
896
+ const r = makeRateDb(m.checkRate);
897
+ await r.check('2001:db8:5678::1'); await r.check('2001:db8:5678:ffff::9');
898
+ return r.row('2001:db8:5678::/64')?.count === 1 && r.row('2001:db8:5678:ffff::/64')?.count === 1;
899
+ }],
900
+ ['normalizeRateIp RFC 5952 形:2001:0DB8:0000:000A::1 → 2001:db8:0:a::/64(前導零摘、小寫、內部單零組不壓縮、尾零串 ::);2001:db8:a::1 → 2001:db8:a::/64 異鍵(零組不吞=前綴不碰撞)', async (m) =>
901
+ m.normalizeRateIp('2001:0DB8:0000:000A::1') === '2001:db8:0:a::/64' && m.normalizeRateIp('2001:db8:a::1') === '2001:db8:a::/64'],
902
+ ['normalizeRateIp 邊界:::1/0000:0000:0000:0000:1:2:3:4(全零前綴)/:: → 恆 ::/64(IPv4-mapped 位址另行回點分 IPv4,見下案)', async (m) =>
903
+ ['::1', '0000:0000:0000:0000:1:2:3:4', '::'].every((ip) => m.normalizeRateIp(ip) === '::/64')],
904
+ ['rate:normalizeRateIp IPv4-mapped 回點分 IPv4 鍵(/32 契約):::ffff:192.0.2.1 → 192.0.2.1;hex 尾組同主機 twin ::ffff:c000:201 同鍵 192.0.2.1(mapped 裸形絕不入表);兩台相異 mapped 主機經真 checkRate 各自鍵各計 1(不共 ::/64 桶)', async (m) => {
905
+ const r = makeRateDb(m.checkRate);
906
+ await r.check('::ffff:192.0.2.1'); await r.check('::ffff:c000:201'); await r.check('::ffff:192.0.2.2');
907
+ return m.normalizeRateIp('::ffff:192.0.2.1') === '192.0.2.1'
908
+ && m.normalizeRateIp('::ffff:c000:201') === '192.0.2.1'
909
+ && r.row('192.0.2.1')?.count === 2
910
+ && r.row('192.0.2.2')?.count === 1
911
+ && r.row('::ffff:192.0.2.1') === undefined && r.row('::ffff:c000:201') === undefined
912
+ && r.row('::/64') === undefined;
913
+ }],
914
+ ['normalizeRateIp zone id 剝除+輸出小寫+嵌入 IPv4 末組:fe80::1%eth0 → fe80::/64;FE80::ABCD → fe80::/64;1:2:3:4:5:6:1.2.3.4 → 1:2:3:4::/64', async (m) =>
915
+ m.normalizeRateIp('fe80::1%eth0') === 'fe80::/64' && m.normalizeRateIp('FE80::ABCD') === 'fe80::/64'
916
+ && m.normalizeRateIp('1:2:3:4:5:6:1.2.3.4') === '1:2:3:4::/64'],
917
+ ];
918
+ const plainCases12: Array<[string, (m: RateMod12) => Promise<boolean>]> = [
919
+ ['rate:IPv4 原樣鍵(192.0.2.1 經真 checkRate 鍵面=self、計數 1)', async (m) => {
920
+ const r = makeRateDb(m.checkRate);
921
+ await r.check('192.0.2.1');
922
+ return m.normalizeRateIp('192.0.2.1') === '192.0.2.1' && r.row('192.0.2.1')?.count === 1;
923
+ }],
924
+ ["rate:'unknown'/空串原樣鍵(經真 checkRate 照樣進表=可見面,不靜默丟鍵)", async (m) => {
925
+ const r = makeRateDb(m.checkRate);
926
+ await r.check('unknown'); await r.check('');
927
+ return m.normalizeRateIp('unknown') === 'unknown' && m.normalizeRateIp('') === ''
928
+ && r.row('unknown')?.count === 1 && r.row('')?.count === 1;
929
+ }],
930
+ ['normalizeRateIp 無法解析形原樣且零 throw(IPv4 五段/IPv6 單段 2001/缺組/雙 ::/:::/非 hex/9 組/:: 外 8 組/嵌入 IPv4 越界/方括號/前導空白;nonstring 五型 undefined/null/NaN/object+bad 字串面皆 Object.is 原樣回傳)', async (m) => {
931
+ const bad = ['1.2.3.4.5', '2001', '2001:db8', '1::2::3', ':::', '2001:db8::g', '1:2:3:4:5:6:7:8:9', '1:2:3:4:5:6:7:8::', '::ffff:999.0.0.1', '[2001:db8::1]', ' 2001:db8::1'];
932
+ const obj = { ip: '2001:db8::1' };
933
+ try {
934
+ return bad.every((ip) => Object.is(m.normalizeRateIp(ip), ip))
935
+ && [undefined, null, NaN, obj].every((v) => Object.is(m.normalizeRateIp(v as unknown as string), v));
936
+ } catch { return false; }
937
+ }],
938
+ ];
939
+ for (const [name, pred] of [...v6Cases12, ...plainCases12]) await A(name, await pred(rateReal12));
940
+ await A('index barrel 匯出 normalizeRateIp(=ratelimit 子路徑同一函式——單一真相)',
941
+ (await import('../src/index.ts')).normalizeRateIp === normalizeRateIp);
942
+ {
943
+ const src = await srcOf('../src/server/ratelimit.ts');
944
+ const needle = 'bumpRate(env, w, normalizeRateIp(ip))';
945
+ const n = src.split(needle).length - 1;
946
+ await A('checkRate 入口接線(單呼叫點):bumpRate 收 normalizeRateIp 產物(wiring 靜態錨恰 1——摘 normalize 直通形翻紅面)', n === 1, 'n=' + n);
947
+ }
948
+ // 毒化(拷貝樹 in-gate,[23] 母型):IPv6 聚合分支入口 `if (!g) return ip;` → `return ip;`(IPv6 一律原樣成鍵)
949
+ // → v6 面謂詞逐案真翻紅(false 非 crash)、非 v6 面照綠(毒面恰此面翻)。毒化跑在 /tmp 拷貝=本 repo 樹零接觸、樹出生即棄。
950
+ {
951
+ const gb12 = (id: string): unknown =>
952
+ (globalThis as unknown as { process?: { getBuiltinModule?: (i: string) => unknown } }).process?.getBuiltinModule?.(id);
953
+ const fs12 = gb12('node:fs') as {
954
+ mkdtempSync: (p: string) => string;
955
+ writeFileSync: (p: string, s: string, e: string) => void;
956
+ rmSync: (p: string, o?: unknown) => void;
957
+ existsSync: (p: string) => boolean;
958
+ } | undefined;
959
+ const os12 = gb12('node:os') as { tmpdir: () => string } | undefined;
960
+ const path12 = gb12('node:path') as { join: (...p: string[]) => string } | undefined;
961
+ const url12 = gb12('node:url') as { pathToFileURL: (p: string) => { href: string } } | undefined;
962
+ const needle12 = ' if (!g) return ip;\n';
963
+ const src12 = await srcOf('../src/server/ratelimit.ts');
964
+ const cnt12 = src12.split(needle12).length - 1;
965
+ await A('rate 毒化錨恰 1(IPv6 聚合分支入口單點承載)', cnt12 === 1, 'cnt=' + cnt12);
966
+ if (!fs12 || !os12 || !path12 || !url12) {
967
+ await A('rate 毒化面(原語缺席環境)=顯性 RED 行,毒化證據缺席顯形非 silent true', false, 'node fs/os/path/url 原語缺席');
968
+ } else if (cnt12 === 1) {
969
+ const tree12 = fs12.mkdtempSync(path12.join(os12.tmpdir(), 't12-ratekey-'));
970
+ const flipped12: string[] = [];
971
+ const held12: string[] = [];
972
+ let plainOk12 = false;
973
+ try {
974
+ fs12.writeFileSync(path12.join(tree12, 'ratelimit.ts'), src12.replace(needle12, ' return ip;\n'), 'utf8');
975
+ fs12.writeFileSync(path12.join(tree12, 'env.ts'), await srcOf('../src/server/env.ts'), 'utf8');
976
+ const modP12 = (await import(url12.pathToFileURL(path12.join(tree12, 'ratelimit.ts')).href)) as RateMod12;
977
+ for (const [name, pred] of v6Cases12) {
978
+ let res: string;
979
+ try { res = String(await pred(modP12)); } catch (e) { res = 'crash: ' + String((e as Error).message).slice(0, 80); }
980
+ (res === 'false' ? flipped12 : held12).push(name.slice(0, 40) + ' → ' + res);
981
+ }
982
+ plainOk12 = (await Promise.all(plainCases12.map(([, pred]) => pred(modP12)))).every((x) => x);
983
+ } catch (e) {
984
+ held12.push('poison import crash: ' + String((e as Error).message).slice(0, 120));
985
+ } finally {
986
+ fs12.rmSync(tree12, { recursive: true, force: true });
987
+ }
988
+ await A('rate 毒化(IPv6 聚合分支摘除)→ v6 面謂詞逐案真翻紅(' + v6Cases12.length + ' 案全 false、零 crash)',
989
+ held12.length === 0 && flipped12.length === v6Cases12.length,
990
+ 'flipped=' + flipped12.length + '/' + v6Cases12.length + (held12.length ? ';未翻:' + held12.join('|') : ''));
991
+ await A('rate 毒化面精準:非 v6 面照綠(IPv4/unknown/無效形——毒面恰 IPv6 聚合面翻)+毒化樹出生即棄(本 repo 樹零接觸)',
992
+ plainOk12 && !fs12.existsSync(tree12));
993
+ }
994
+ }
874
995
  await A('AUTH_RATE 契約(60s 窗 max 10)', AUTH_RATE.windowMs === 60_000 && AUTH_RATE.max === 10);
875
996
  const sessA = generateSessionToken();
876
997
  const sessB = generateSessionToken();
@@ -2540,7 +2661,10 @@ secOpen(21, '[21] CI 供應鏈面(npm ci --ignore-scripts+dependabot runner
2540
2661
  await A('[21] publish.yml:npm ci --ignore-scripts(tag 驅動無 actor 面;typecheck+verify 先行=腳本面零在場)',
2541
2662
  srcPy21.includes('npm ci --ignore-scripts'));
2542
2663
  } else {
2543
- await A('[21] CI 源碼面:.github 缺席環境(tarball 發行樹常態)=顯性 SKIP(workflow 斷言 4 收斂為 1 SKIP 行——消費端計數帳 503)',
2664
+ // F-1(t_0eb70367):僵字面帳數摘除——舊形寫死『消費端計數帳 503』每版必過期;執行期插值
2665
+ // 不可行(tarball 全帳含 secCases 體系外段,secCases 求和≠帳總——插值即印錯數,比僵字面更糟)。
2666
+ // 對帳值真相源=README「CI 供應鏈面」行(發布輪隨版維護);本行語意只聲明 SKIP 顯形與帳差 −3。
2667
+ await A('[21] CI 源碼面:.github 缺席環境(tarball 發行樹常態)=顯性 SKIP(workflow 斷言 4 收斂為 1 SKIP 行,帳差 −3;消費端計數帳對照 README「CI 供應鏈面」行隨版記帳)',
2544
2668
  true, 'no .github dir — consumption tree face');
2545
2669
  }
2546
2670
  // 摘除自證(t_760f44e8 治理掃蕩補漏;fs 原語缺席環境=顯性 FAIL 形自守衛——非 silent true)
@@ -2810,7 +2934,7 @@ secOpen(23, '[23] loginRouteCore ladder 查表守衛(外審 #8 幽靈帳——
2810
2934
  // 單向契約:源碼 → scripts/generate-vectors.ts → JSON(凍結證據,commit 入 repo)→ 本段只檢存在+shape。
2811
2935
  // 本段不依賴產生器、不重算 KDF(向量含隨機 iv/鹽=重算即漂移);BIP39 是確定性轉寫,逐組對照現行原語。
2812
2936
  // teeth:payload 位元組長度=b64 解碼真值、fields 拼接=payload 逐位元組、布局長度=規格表列、
2813
- // 欄位名逐一在規格檔、凍結 blob 逐字=本閘 KAT 字面([14]/[17]/[20])、RFC 9106 期望值=argon2.ts 常數。
2937
+ // 欄位名限定形逐一在規格對應節、凍結 blob 逐字=本閘 KAT 字面([14]/[17]/[20])、RFC 9106 期望值+參數=argon2.ts 常數。
2814
2938
  secOpen(26, '[26] 格式規格+測試向量(docs/format-spec.md+docs/vectors 存在性+shape)'); if (secEnter(26)) {
2815
2939
  {
2816
2940
  const readJson26 = async (rel: string): Promise<Record<string, unknown> | null> => {
@@ -2851,16 +2975,37 @@ secOpen(26, '[26] 格式規格+測試向量(docs/format-spec.md+docs/vecto
2851
2975
  const head = v.layout === 'salt-embedded' ? v.fields[0]?.name === 'pinSalt' && v.fields[0]?.len === 16 : v.fields[0]?.name === 'iv';
2852
2976
  return want === v.payload_len && head && specRows26.some((l) => l.startsWith('| `' + v.prefix + '`') && l.includes('| ' + v.layout + ' | ' + lenCol + ' |'));
2853
2977
  }));
2854
- const KKEYS26 = ['rfc9106', 'frozen_blobs', 'credentials', 'expected_tag_hex', 'expected_note_key_hex', 'expected_hex', 'samples', 'entropy_hex', 'checksum_hex', 'words', 'source'];
2855
- await A('[26] 欄位名對規格:families 每筆欄名+fields 子欄(name/offset/len/hex)+kat/bip39 欄名,逐一以 `欄名` 形出現在 format-spec.md',
2856
- spec26.length > 0 && [...HEAD26, ...VKEYS26, 'name', 'offset', 'len', 'hex', ...KKEYS26].every((k) => spec26.includes('`' + k + '`'))
2857
- && KKEYS26.slice(0, 3).every((k) => !!kat26 && k in kat26) && !!bip26 && 'samples' in bip26);
2978
+ // 欄位名限定形(通用字 name/len/hex/source… 在規格檔隨處可見=裸字檢查無咬力):頂層欄=§7 頭表/§7.1/§7.2 對應節
2979
+ // 的表列 `| \`欄\` |`;巢狀欄=限定形 `父[].欄`/`rfc9106.欄` 逐字在規格檔。欄名集取自向量檔實際鍵(新增欄未入規格=紅)。
2980
+ const sec26 = (from: string, to: string): string => { const i = spec26.indexOf(from); const j = to ? spec26.indexOf(to, i + 1) : spec26.length; return i < 0 || j < 0 ? '' : spec26.slice(i, j); };
2981
+ const s7h26 = sec26('## 7. ', '### 7.1 '), s71_26 = sec26('### 7.1 ', '### 7.2 '), s72_26 = sec26('### 7.2 ', '### 7.3 '), s73_26 = sec26('### 7.3 ', '');
2982
+ const row26 = (sec: string, k: string): boolean => sec.includes('\n| `' + k + '` |');
2983
+ const keysOf26 = (xs: unknown): string[] => [...new Set((Array.isArray(xs) ? xs : []).flatMap((x) => (x && typeof x === 'object' ? Object.keys(x) : [])))];
2984
+ const fieldKeys26 = keysOf26(vecs26.flatMap((v) => (Array.isArray(v.fields) ? v.fields : [])));
2985
+ const rfcKeys26 = Object.keys((kat26?.rfc9106 ?? {}) as object);
2986
+ const frozenKeys26 = keysOf26(kat26?.frozen_blobs), credKeys26 = keysOf26(kat26?.credentials), sampleKeys26 = keysOf26(bip26?.samples);
2987
+ // 反空轉下限:實際鍵集須涵蓋閘面讀取的欄(鍵集取自資料=資料缺欄時下限擋住空集合真值)
2988
+ const floor26 = (ks: string[], need: string[]): boolean => need.every((k) => ks.includes(k));
2989
+ await A('[26] 欄位名對規格(限定形):頂層欄=§7 頭表/§7.1 vectors 表/§7.2 kat 表逐列;巢狀欄逐一以 `fields[].欄`/`rfc9106.欄`/`frozen_blobs[].欄`/`credentials[].欄`/`samples[].欄` 出現在對應節(欄名集取自向量檔實鍵+閘讀欄下限)',
2990
+ spec26.length > 0 && !!kat26 && !!bip26
2991
+ && HEAD26.every((k) => row26(s7h26, k)) && keysOf26(vecs26).every((k) => row26(s71_26, k)) && VKEYS26.every((k) => row26(s71_26, k))
2992
+ && ['rfc9106', 'frozen_blobs', 'credentials'].every((k) => k in kat26! && row26(s72_26, k)) && 'samples' in bip26! && s73_26.includes('`samples`')
2993
+ && floor26(fieldKeys26, ['name', 'offset', 'len', 'hex']) && fieldKeys26.every((k) => s71_26.includes('`fields[].' + k + '`'))
2994
+ && floor26(rfcKeys26, ['memory_kib', 'iterations', 'parallelism', 'tag_len', 'password_hex', 'salt_hex', 'expected_tag_hex']) && rfcKeys26.every((k) => s72_26.includes('`rfc9106.' + k + '`'))
2995
+ && floor26(frozenKeys26, ['prefix', 'wire', 'payload_len', 'expected_note_key_hex', 'source']) && frozenKeys26.every((k) => s72_26.includes('`frozen_blobs[].' + k + '`'))
2996
+ && floor26(credKeys26, ['kind', 'expected_hex']) && credKeys26.every((k) => s72_26.includes('`credentials[].' + k + '`'))
2997
+ && floor26(sampleKeys26, ['entropy_hex', 'checksum_hex', 'words', 'source']) && sampleKeys26.every((k) => s73_26.includes('`samples[].' + k + '`')));
2858
2998
  const gate26 = await srcOf('./verify-core-crypto.ts');
2859
2999
  const frozen26 = (Array.isArray(kat26?.frozen_blobs) ? kat26!.frozen_blobs : []) as { prefix: string; wire: string; payload_len: number; expected_note_key_hex: string; source: string }[];
2860
3000
  const rfc26 = (kat26?.rfc9106 ?? {}) as Record<string, unknown>;
2861
3001
  const creds26 = (Array.isArray(kat26?.credentials) ? kat26!.credentials : []) as { kind: string; expected_hex: string }[];
2862
- await A('[26] kat 對帳:RFC 9106 expected_tag_hex=ARGON_RFC9106_EXPECTED(argon2.ts 常數);凍結 blob 7 筆逐字=本閘 KAT 字面([14]/[17]/[20])且 payload_len=解碼真長;憑證樣本 5 筆 hex64',
2863
- (await import('../src/client/argon2.ts')).ARGON_RFC9106_EXPECTED === rfc26.expected_tag_hex
3002
+ const argon26 = await import('../src/client/argon2.ts');
3003
+ const kp26 = argon26.ARGON_RFC9106_PARAMS;
3004
+ await A('[26] kat 對帳:RFC 9106 expected_tag_hex=ARGON_RFC9106_EXPECTED、參數 m/t/p/tag/pwd/salt=ARGON_RFC9106_PARAMS(argon2.ts 常數——verifyArgonKat/產生器同源);凍結 blob 7 筆逐字=本閘 KAT 字面([14]/[17]/[20])且 payload_len=解碼真長;憑證樣本 5 筆 hex64',
3005
+ argon26.ARGON_RFC9106_EXPECTED === rfc26.expected_tag_hex
3006
+ && rfc26.memory_kib === kp26.memoryKib && rfc26.iterations === kp26.iterations && rfc26.parallelism === kp26.parallelism
3007
+ && rfc26.tag_len === kp26.tagLen && kp26.tagLen === argon26.ARGON_TAG_LEN
3008
+ && rfc26.password_hex === kp26.passwordByte.toString(16).padStart(2, '0').repeat(kp26.passwordLen) && rfc26.salt_hex === '00'.repeat(kp26.saltLen)
2864
3009
  && frozen26.length === 7 && frozen26.every((b) => gate26.includes("'" + b.wire + "'") && b.wire.startsWith(b.prefix)
2865
3010
  && unb64Mod(b.wire.slice(b.prefix.length)).length === b.payload_len && /^[0-9a-f]{64}$/.test(b.expected_note_key_hex))
2866
3011
  && creds26.length === 5 && creds26.every((c) => /^[0-9a-f]{64}$/.test(c.expected_hex)));
@@ -52,6 +52,12 @@ function deriveInput(passphrase: string): string {
52
52
  export const ARGON_RFC9106_EXPECTED =
53
53
  '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
54
54
 
55
+ /** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
56
+ * tagLen=派生本體固定輸出長(ARGON_TAG_LEN);pwd=passwordLen 位元組 passwordByte、salt=saltLen 位元組 0x00。 */
57
+ export const ARGON_RFC9106_PARAMS = Object.freeze({
58
+ memoryKib: 32, iterations: 3, parallelism: 4, tagLen: 32, passwordByte: 0x01, passwordLen: 32, saltLen: 16,
59
+ } as const);
60
+
55
61
  /**
56
62
  * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
57
63
  *
@@ -229,7 +235,8 @@ type NodeArgonFn = (
229
235
 
230
236
  /** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
231
237
  export async function verifyArgonKat(): Promise<boolean> {
232
- const raw = await deriveArgon2id(new Uint8Array(32).fill(1), new Uint8Array(16), 32, 3, 4);
238
+ const k = ARGON_RFC9106_PARAMS;
239
+ const raw = await deriveArgon2id(new Uint8Array(k.passwordLen).fill(k.passwordByte), new Uint8Array(k.saltLen), k.memoryKib, k.iterations, k.parallelism);
233
240
  return toHex(raw) === ARGON_RFC9106_EXPECTED;
234
241
  }
235
242
 
@@ -244,7 +251,7 @@ export async function deriveKekArgon(password: string, salt: Uint8Array): Promis
244
251
  return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
245
252
  }
246
253
 
247
- /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
254
+ /** jr3w. 包裹:payload = iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,由呼叫端存 users.salt。 */
248
255
  export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
249
256
  if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
250
257
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
@@ -279,7 +286,7 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
279
286
  // 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
280
287
  // pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
281
288
 
282
- /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
289
+ /** jr4w. 包裹(v3 密語):payload = iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,呼叫端存 users.salt。 */
283
290
  export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
284
291
  if (!cfg.wrap4) throw new Error('ERR_JR4W_NOT_CONFIGURED');
285
292
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
@@ -27,6 +27,8 @@
27
27
 
28
28
  // ── 配置 ────────────────────────────────────────────────────────────────────
29
29
 
30
+ import type { KeyStore } from './keys.js';
31
+
30
32
  export interface NoteCryptoConfig {
31
33
  /** guest 時代 KDF 前綴,如 'journal-note-u1'。 */
32
34
  guestKdfPrefix: string;
@@ -75,7 +77,7 @@ export interface NoteCryptoConfig {
75
77
  /** 本機 IDB 密文前綴(jr1d.,notes store stored 形)。未配置 = local API 拒絕(未配置行為不變)。 */
76
78
  cipherLocal?: string;
77
79
  /** localStorage key store(品牌前綴由 keys.ts 管理)。 */
78
- store: import('./keys').KeyStore;
80
+ store: KeyStore;
79
81
  }
80
82
 
81
83
  export const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 建議值
package/src/index.ts CHANGED
@@ -86,7 +86,7 @@ export {
86
86
  type CipherFormats,
87
87
  type KeyPackage,
88
88
  } from './server/auth.ts';
89
- export { checkRate, type RateWindow } from './server/ratelimit.ts';
89
+ export { checkRate, normalizeRateIp, type RateWindow } from './server/ratelimit.ts';
90
90
  export { corsResponse, CORS_HEADERS } from './server/cors.ts';
91
91
  export {
92
92
  sha256Hex,
package/src/server/env.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * env.ts — core 對 Worker Env 的最小介面。各 fork 的 Env 以此為基底擴充。
3
+ *
4
+ * D1Database 型別以模組 import 承載(非全域 triple-slash reference):
5
+ * 消費者未帶 @cloudflare/workers-types 時,吃 client 面 .d.ts 不會被 env.d.ts 的
6
+ * 全域型別缺席炸掉(types= reference 在 types 套件缺席=TS2688 全檔紅;import type
7
+ * 缺席=該欄型別 any 化+TS2304 只在吃 env.d.ts 的檔案現形=影響面收斂)。
3
8
  */
9
+ import type { D1Database } from '@cloudflare/workers-types';
10
+
4
11
  export interface Env {
5
12
  DB: D1Database;
6
13
  }
@@ -14,6 +14,10 @@
14
14
  * 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
15
15
  * 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。
16
16
  *
17
+ * 限流鍵(2026-10-11 定案):checkRate 入口經 normalizeRateIp 正規化——IPv6 聚合至 /64
18
+ * (單一終端可自由輪換 /64 內尾段=完整地址當鍵即每發新鍵、限流失效);IPv4-mapped
19
+ * 位址回點分 IPv4(=原生 IPv4 同 /32 鍵,review r1 MAJOR-1 收口);IPv4 與非 IPv6 形原樣。
20
+ *
17
21
  * 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
18
22
  * 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
19
23
  * CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
@@ -71,6 +75,50 @@ async function bumpRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
71
75
  }
72
76
  }
73
77
 
78
+ const HEXTET = /^[0-9a-f]{1,4}$/i;
79
+ const IPV4_OCTET = /^(0|[1-9]\d{0,2})$/;
80
+
81
+ /** IPv6 文字形 → 8 組 hextet 數值(RFC 4291 §2.2:`::` 至多一處、嵌入 IPv4 僅居末);非法形回 null。 */
82
+ function parseIpv6(s: string): number[] | null {
83
+ const halves = s.split('::');
84
+ if (halves.length > 2) return null;
85
+ const parts = halves.map((h) => (h === '' ? [] : h.split(':')));
86
+ const last = parts[parts.length - 1];
87
+ const v4: number[] = [];
88
+ if (last.length > 0 && last[last.length - 1].includes('.')) {
89
+ const q = last.pop()!.split('.');
90
+ if (q.length !== 4 || !q.every((o) => IPV4_OCTET.test(o) && Number(o) <= 255)) return null;
91
+ v4.push(Number(q[0]) * 256 + Number(q[1]), Number(q[2]) * 256 + Number(q[3]));
92
+ }
93
+ if (!parts.every((p) => p.every((g) => HEXTET.test(g)))) return null;
94
+ const n = parts.reduce((a, p) => a + p.length, 0) + v4.length;
95
+ const hex = (p: string[]): number[] => p.map((g) => parseInt(g, 16));
96
+ if (halves.length === 1) return n === 8 ? [...hex(parts[0]), ...v4] : null;
97
+ if (n > 7) return null;
98
+ return [...hex(parts[0]), ...new Array<number>(8 - n).fill(0), ...hex(parts[1]), ...v4];
99
+ }
100
+
101
+ /**
102
+ * 限流鍵正規化(單一真相;純函式、零 throw):
103
+ * - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
104
+ * 例 `2001:0DB8:0000:000A::1` → `2001:db8:0:a::/64`、`::1` → `::/64`;zone id(`%…`)先剝除。
105
+ * 介面 ID 歸零後末 4 組恆零=最長零串恆為尾串(前綴內零串至多 3 組),故壓縮形=前綴摘尾零組+`::`。
106
+ * - IPv4-mapped(::/96 前綴+映射尾組,RFC 4291 §2.5.5.2)→ 點分 IPv4 原鍵——mapped 位址就是 IPv4
107
+ * 節點的表示法,回 /32 與原生同鍵(hex 尾組 twin `::ffff:c000:201` 同鍵 192.0.2.1;禁併入 ::/64 全域桶)。
108
+ * - IPv4/`'unknown'`/空串/無法解析形:原樣返回(鍵照樣進表=可見面,不靜默丟鍵)。
109
+ * - 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。
110
+ */
111
+ export function normalizeRateIp(ip: string): string {
112
+ if (typeof ip !== 'string' || !ip.includes(':')) return ip;
113
+ const pct = ip.indexOf('%');
114
+ const g = parseIpv6(pct >= 0 ? ip.slice(0, pct) : ip);
115
+ if (!g) return ip;
116
+ if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) return [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join('.');
117
+ const net = g.slice(0, 4);
118
+ while (net.length > 0 && net[net.length - 1] === 0) net.pop();
119
+ return net.map((h) => h.toString(16)).join(':') + '::/64';
120
+ }
121
+
74
122
  export function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
75
- return bumpRate(env, w, ip);
123
+ return bumpRate(env, w, normalizeRateIp(ip));
76
124
  }