@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.
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  ```sh
19
19
  git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
20
20
  npm ci --ignore-scripts
21
- npm run verify # 506 assertions, all green, run against the real modules (no mocks)
21
+ npm run verify # 522 assertions, all green, run against the real modules (no mocks); 0.3.3: 519 inside the published package ([21] SKIP line)
22
22
  ```
23
23
 
24
24
  ```ts
@@ -77,7 +77,7 @@ run: `node --experimental-strip-types example.ts`
77
77
  | `src/client/keys.ts` | 品牌前綴 localStorage 命名空間 |
78
78
  | `src/server/auth.ts` | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責 |
79
79
  | `src/server/ladder.ts` | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert 值主權威)+幽靈守衛查表接線已內建 loginRouteCore——fork 只需配 `ladderLookup` store+0011 migration(未配置=單查行為零變) |
80
- | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效) |
80
+ | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效;IPv6 鍵經 `normalizeRateIp` 聚合至 /64;IPv4-mapped 位址回點分 IPv4 鍵(=原生同 /32 鍵)) |
81
81
  | `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
82
82
 
83
83
  發行形(0.3.0 起):`dist/`=tsc 原生 emit 的 ESM `.js`+`.d.ts`,`exports` 全部子路徑指向 dist;
@@ -124,7 +124,7 @@ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob
124
124
 
125
125
  ```sh
126
126
  npm ci --ignore-scripts
127
- npm run verify # 506 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
127
+ npm run verify # 522 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
128
128
  # extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
129
129
  # server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
130
130
  # + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
@@ -136,7 +136,7 @@ npm run verify # 506 斷言對真模組(禁鏡像;限流單句 UPSERT…RE
136
136
  # +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
137
137
  # +包裹前綴快檢 [13-2](錯前綴試探免付 KDF:五入口行為+計時帽+計數錨)
138
138
  # +CI 供應鏈面 [21](npm ci --ignore-scripts+dependabot runner fromJSON 分流+腳本摘除+NIT-1 錨+r2 MINOR-1 URL 直傳;
139
- # 506 帳=repo 樹——發行包內無 .github=[21] 四條 workflow 錨收斂為 1 顯性 SKIP 行→503,SKIP 顯形非靜默)
139
+ # 522 帳=repo 樹——發行包內無 .github=[21] 四條 workflow 錨收斂為 1 顯性 SKIP 行→519(=522−3;消費端帳數真相源=本行,隨版記帳——閘 SKIP 行摘死數字指回本行,0.3.3 實測),SKIP 顯形非靜默)
140
140
  # +發行 tarball 治理面 [22](pack 白名單零 open-card-pr.sh)
141
141
  # +格式規格+測試向量 [26](docs/format-spec.md+docs/vectors 存在性/位元組 shape/欄位名對規格/凍結 KAT 逐字對閘)
142
142
  # +高階 Vault API [27](roundtrip 三形/新舊前綴解鎖/upgrade/換密語/復原/VaultError 毒化/載體覆寫+Worker 回退鏈/vendor 孿生)
@@ -317,7 +317,7 @@ carrier: `node:crypto` ≥ Node 24.7 / hash-wasm in browsers, RFC 9106 test vect
317
317
  @scure/bip39), brand-namespaced key storage (`keys.ts`). Server: zero-knowledge auth core with a
318
318
  PH1→PH2 login flow, inbound cipher/package validation and paired key-package selection
319
319
  (`auth.ts`; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
320
- fixed-window rate limiting on D1 (`ratelimit.ts`), shared CORS/hash utilities. Since 0.3.0 the
320
+ (IPv6 keyed per /64 prefix; IPv4-mapped addresses un-map to the native dotted IPv4 key) fixed-window rate limiting on D1 (`ratelimit.ts`), shared CORS/hash utilities. Since 0.3.0 the
321
321
  package ships `dist/` (tsc-emitted ESM `.js` + `.d.ts`); every `exports` subpath resolves to dist,
322
322
  and `src/` is included for reading and auditing only.
323
323
 
@@ -331,7 +331,7 @@ prefix family (`families.json`), known-answer vectors (RFC 9106 Argon2id, the ga
331
331
  `scripts/generate-vectors.ts` against the real primitives and committed as frozen evidence; the
332
332
  verify gate checks their presence and shape (section [26]) and does not regenerate them.
333
333
 
334
- **Verification.** `npm run verify` runs 506 assertions against the real modules (no mocks):
334
+ **Verification.** `npm run verify` runs 522 assertions against the real modules (no mocks; 0.3.3: the in-package count is 519 because the [21] workflow anchors skip as one line):
335
335
  roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
336
336
  isolation, payload tampering, RFC 9106 KAT, a 200-vector BIP39 cross-check, and the
337
337
  login ladder-guard behavior suite (external audit #8), and presence/shape checks for the format
@@ -35,6 +35,17 @@
35
35
  */
36
36
  /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
37
37
  export declare const ARGON_RFC9106_EXPECTED = "72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503";
38
+ /** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
39
+ * tagLen=派生本體固定輸出長(ARGON_TAG_LEN);pwd=passwordLen 位元組 passwordByte、salt=saltLen 位元組 0x00。 */
40
+ export declare const ARGON_RFC9106_PARAMS: Readonly<{
41
+ readonly memoryKib: 32;
42
+ readonly iterations: 3;
43
+ readonly parallelism: 4;
44
+ readonly tagLen: 32;
45
+ readonly passwordByte: 1;
46
+ readonly passwordLen: 32;
47
+ readonly saltLen: 16;
48
+ }>;
38
49
  /**
39
50
  * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
40
51
  *
@@ -116,7 +127,7 @@ export declare function getArgonLoader(): ArgonLoader | null;
116
127
  export declare function verifyArgonKat(): Promise<boolean>;
117
128
  /** KEK = Argon2id(pass, salt, m=64MiB, t=3, p=1) → AES-GCM key(nonextractable)。 */
118
129
  export declare function deriveKekArgon(password: string, salt: Uint8Array): Promise<CryptoKey>;
119
- /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
130
+ /** jr3w. 包裹:payload = iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,由呼叫端存 users.salt。 */
120
131
  export declare function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{
121
132
  wrapped: string;
122
133
  salt: string;
@@ -127,7 +138,7 @@ export declare function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, pass
127
138
  * 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
128
139
  * own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
129
140
  export declare function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null>;
130
- /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
141
+ /** jr4w. 包裹(v3 密語):payload = iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,呼叫端存 users.salt。 */
131
142
  export declare function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{
132
143
  wrapped: string;
133
144
  salt: string;
@@ -45,6 +45,11 @@ function deriveInput(passphrase) {
45
45
  }
46
46
  /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
47
47
  export const ARGON_RFC9106_EXPECTED = '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
48
+ /** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
49
+ * tagLen=派生本體固定輸出長(ARGON_TAG_LEN);pwd=passwordLen 位元組 passwordByte、salt=saltLen 位元組 0x00。 */
50
+ export const ARGON_RFC9106_PARAMS = Object.freeze({
51
+ memoryKib: 32, iterations: 3, parallelism: 4, tagLen: 32, passwordByte: 0x01, passwordLen: 32, saltLen: 16,
52
+ });
48
53
  /**
49
54
  * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
50
55
  *
@@ -145,7 +150,8 @@ async function deriveArgon2id(password, salt, memoryKib, passes, parallelism) {
145
150
  }
146
151
  /** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
147
152
  export async function verifyArgonKat() {
148
- const raw = await deriveArgon2id(new Uint8Array(32).fill(1), new Uint8Array(16), 32, 3, 4);
153
+ const k = ARGON_RFC9106_PARAMS;
154
+ const raw = await deriveArgon2id(new Uint8Array(k.passwordLen).fill(k.passwordByte), new Uint8Array(k.saltLen), k.memoryKib, k.iterations, k.parallelism);
149
155
  return toHex(raw) === ARGON_RFC9106_EXPECTED;
150
156
  }
151
157
  // ── jr3w. 單因子包裹 ──────────────────────────────────────────────────────────
@@ -156,7 +162,7 @@ async function argonKekFromRaw(raw) {
156
162
  export async function deriveKekArgon(password, salt) {
157
163
  return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
158
164
  }
159
- /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
165
+ /** jr3w. 包裹:payload = iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,由呼叫端存 users.salt。 */
160
166
  export async function wrapNoteKey3(cfg, noteKey, passphrase) {
161
167
  if (!cfg.wrap3)
162
168
  throw new Error('ERR_JR3W_NOT_CONFIGURED');
@@ -193,7 +199,7 @@ export async function unwrapNoteKey3(cfg, wrapped, passphrase, saltHex) {
193
199
  // 92B、AAD 'notekey'、嚴格長度全在 openNoteKey 本體),唯一差異=入口吃 normalizePassphrase(pass)
194
200
  // 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
195
201
  // pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
196
- /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
202
+ /** jr4w. 包裹(v3 密語):payload = iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,呼叫端存 users.salt。 */
197
203
  export async function wrapNoteKey4(cfg, noteKey, passphrase) {
198
204
  if (!cfg.wrap4)
199
205
  throw new Error('ERR_JR4W_NOT_CONFIGURED');
@@ -24,6 +24,7 @@
24
24
  * import 當下就必須 extractable=true。noteKey 全部產生路徑(generate/unwrap×3)
25
25
  * 一律顯式 true;KEK/guest key 保持 nonextractable(只加解密、永不 export)。
26
26
  */
27
+ import type { KeyStore } from './keys.js';
27
28
  export interface NoteCryptoConfig {
28
29
  /** guest 時代 KDF 前綴,如 'journal-note-u1'。 */
29
30
  guestKdfPrefix: string;
@@ -72,7 +73,7 @@ export interface NoteCryptoConfig {
72
73
  /** 本機 IDB 密文前綴(jr1d.,notes store stored 形)。未配置 = local API 拒絕(未配置行為不變)。 */
73
74
  cipherLocal?: string;
74
75
  /** localStorage key store(品牌前綴由 keys.ts 管理)。 */
75
- store: import('./keys').KeyStore;
76
+ store: KeyStore;
76
77
  }
77
78
  export declare const PBKDF2_ITERATIONS = 600000;
78
79
  export declare const PIN_PBKDF2_ITERATIONS = 2000000;
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { makeKeyStore, type KeyStore, type KeyStoreConfig } from './client/keys.
11
11
  export { wordlist } from './client/wordlist.ts';
12
12
  /** 伺服器端原語(Cloudflare Workers + D1)。 */
13
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, type RateWindow } from './server/ratelimit.ts';
14
+ export { checkRate, normalizeRateIp, 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
@@ -11,7 +11,7 @@ export { makeKeyStore } from './client/keys.js';
11
11
  export { wordlist } from './client/wordlist.js';
12
12
  /** 伺服器端原語(Cloudflare Workers + D1)。 */
13
13
  export { makeInboundCipher, isCipherFor, validWrappedKey, validHash64, validSalt, pickKeyPackage, loginRouteCore, errResponse, AUTH_RATE, } from './server/auth.js';
14
- export { checkRate } from './server/ratelimit.js';
14
+ export { checkRate, normalizeRateIp } 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';
@@ -1,6 +1,12 @@
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';
4
10
  export interface Env {
5
11
  DB: D1Database;
6
12
  }
@@ -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);
@@ -29,4 +33,15 @@ export interface RateWindow {
29
33
  export declare const SQL_RATE_BUMP = "INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)\n ON CONFLICT(ip) DO UPDATE SET\n count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,\n window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END\n RETURNING count AS n";
30
34
  /** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
31
35
  export declare function isRateAllowed(count: number, max: number): boolean;
36
+ /**
37
+ * 限流鍵正規化(單一真相;純函式、零 throw):
38
+ * - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
39
+ * 例 `2001:0DB8:0000:000A::1` → `2001:db8:0:a::/64`、`::1` → `::/64`;zone id(`%…`)先剝除。
40
+ * 介面 ID 歸零後末 4 組恆零=最長零串恆為尾串(前綴內零串至多 3 組),故壓縮形=前綴摘尾零組+`::`。
41
+ * - IPv4-mapped(::/96 前綴+映射尾組,RFC 4291 §2.5.5.2)→ 點分 IPv4 原鍵——mapped 位址就是 IPv4
42
+ * 節點的表示法,回 /32 與原生同鍵(hex 尾組 twin `::ffff:c000:201` 同鍵 192.0.2.1;禁併入 ::/64 全域桶)。
43
+ * - IPv4/`'unknown'`/空串/無法解析形:原樣返回(鍵照樣進表=可見面,不靜默丟鍵)。
44
+ * - 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
+ */
46
+ export declare function normalizeRateIp(ip: string): string;
32
47
  export declare function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean>;
@@ -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);
@@ -58,6 +62,56 @@ async function bumpRate(env, w, ip) {
58
62
  return true;
59
63
  }
60
64
  }
65
+ const HEXTET = /^[0-9a-f]{1,4}$/i;
66
+ const IPV4_OCTET = /^(0|[1-9]\d{0,2})$/;
67
+ /** IPv6 文字形 → 8 組 hextet 數值(RFC 4291 §2.2:`::` 至多一處、嵌入 IPv4 僅居末);非法形回 null。 */
68
+ function parseIpv6(s) {
69
+ const halves = s.split('::');
70
+ if (halves.length > 2)
71
+ return null;
72
+ const parts = halves.map((h) => (h === '' ? [] : h.split(':')));
73
+ const last = parts[parts.length - 1];
74
+ const v4 = [];
75
+ if (last.length > 0 && last[last.length - 1].includes('.')) {
76
+ const q = last.pop().split('.');
77
+ if (q.length !== 4 || !q.every((o) => IPV4_OCTET.test(o) && Number(o) <= 255))
78
+ return null;
79
+ v4.push(Number(q[0]) * 256 + Number(q[1]), Number(q[2]) * 256 + Number(q[3]));
80
+ }
81
+ if (!parts.every((p) => p.every((g) => HEXTET.test(g))))
82
+ return null;
83
+ const n = parts.reduce((a, p) => a + p.length, 0) + v4.length;
84
+ const hex = (p) => p.map((g) => parseInt(g, 16));
85
+ if (halves.length === 1)
86
+ return n === 8 ? [...hex(parts[0]), ...v4] : null;
87
+ if (n > 7)
88
+ return null;
89
+ return [...hex(parts[0]), ...new Array(8 - n).fill(0), ...hex(parts[1]), ...v4];
90
+ }
91
+ /**
92
+ * 限流鍵正規化(單一真相;純函式、零 throw):
93
+ * - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
94
+ * 例 `2001:0DB8:0000:000A::1` → `2001:db8:0:a::/64`、`::1` → `::/64`;zone id(`%…`)先剝除。
95
+ * 介面 ID 歸零後末 4 組恆零=最長零串恆為尾串(前綴內零串至多 3 組),故壓縮形=前綴摘尾零組+`::`。
96
+ * - IPv4-mapped(::/96 前綴+映射尾組,RFC 4291 §2.5.5.2)→ 點分 IPv4 原鍵——mapped 位址就是 IPv4
97
+ * 節點的表示法,回 /32 與原生同鍵(hex 尾組 twin `::ffff:c000:201` 同鍵 192.0.2.1;禁併入 ::/64 全域桶)。
98
+ * - IPv4/`'unknown'`/空串/無法解析形:原樣返回(鍵照樣進表=可見面,不靜默丟鍵)。
99
+ * - 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。
100
+ */
101
+ export function normalizeRateIp(ip) {
102
+ if (typeof ip !== 'string' || !ip.includes(':'))
103
+ return ip;
104
+ const pct = ip.indexOf('%');
105
+ const g = parseIpv6(pct >= 0 ? ip.slice(0, pct) : ip);
106
+ if (!g)
107
+ return ip;
108
+ if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff)
109
+ return [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join('.');
110
+ const net = g.slice(0, 4);
111
+ while (net.length > 0 && net[net.length - 1] === 0)
112
+ net.pop();
113
+ return net.map((h) => h.toString(16)).join(':') + '::/64';
114
+ }
61
115
  export function checkRate(env, w, ip) {
62
- return bumpRate(env, w, ip);
116
+ return bumpRate(env, w, normalizeRateIp(ip));
63
117
  }
@@ -351,7 +351,7 @@ PH1 鹽是 per-product 固定域鹽(PH2 UNIQUE 約束);v2/v3 鹽域值
351
351
  | `inputs` | 產生時的輸入(密語、PIN、identity、config 欄、外置鹽 `salt_hex`/`salt1_hex`、`note_key_hex` 或 `plaintext`) |
352
352
  | `wire` | 完整線上字串(`prefix + b64(payload)`) |
353
353
  | `payload_len` | Base64 解碼後位元組數 |
354
- | `fields` | payload 欄位切分陣列,每項 `name`(`pinSalt`/`iv`/`ct`/`tag`)、`offset`、`len`、`hex`;`len` 總和=`payload_len` |
354
+ | `fields` | payload 欄位切分陣列,每項 `fields[].name`(`pinSalt`/`iv`/`ct`/`tag`)、`fields[].offset`、`fields[].len`、`fields[].hex`;`fields[].len` 總和=`payload_len` |
355
355
 
356
356
  金鑰包裹家族的 `inputs.note_key_hex` 固定 `c3`×32(驗證閘 KAT 母型),以此 wire 依本規格解包須得回之;
357
357
  content 家族以 `inputs.plaintext`/`inputs.aad` 解密須得回原文。
@@ -360,10 +360,10 @@ content 家族以 `inputs.plaintext`/`inputs.aad` 解密須得回原文。
360
360
 
361
361
  | 欄位 | 意義 |
362
362
  | --- | --- |
363
- | `rfc9106` | Argon2id RFC 9106 §5.3 參數與 `expected_tag_hex` |
364
- | `frozen_blobs` | 驗證閘凍結 blob([14] `jr2w.`、[17] `jr3w.`/`jr3d.`/`jr4w.`/`jr4d.`、[20] `jr1r.`/`jr1w.` 復原舊契約):`prefix`、`wire`、解包輸入、`expected_note_key_hex`、`source`(閘段號) |
365
- | `credentials` | §5 憑證派生樣本:`kind`、`input`、`salt`、`expected_hex` |
363
+ | `rfc9106` | Argon2id RFC 9106 §5.3 參數:`rfc9106.algorithm`、`rfc9106.version`、`rfc9106.memory_kib`、`rfc9106.iterations`、`rfc9106.parallelism`、`rfc9106.tag_len`、`rfc9106.password_hex`、`rfc9106.salt_hex`、`rfc9106.secret`/`rfc9106.associated_data`(恆 null),與 `rfc9106.expected_tag_hex` |
364
+ | `frozen_blobs` | 驗證閘凍結 blob([14] `jr2w.`、[17] `jr3w.`/`jr3d.`/`jr4w.`/`jr4d.`、[20] `jr1r.`/`jr1w.` 復原舊契約):`frozen_blobs[].prefix`、`frozen_blobs[].wire`、`frozen_blobs[].payload_len`、`frozen_blobs[].inputs`(解包輸入)、`frozen_blobs[].expected_note_key_hex`、`frozen_blobs[].source`(閘段號) |
365
+ | `credentials` | §5 憑證派生樣本:`credentials[].kind`、`credentials[].input`、`credentials[].input_nfkc`(僅 `ph1_v3`:`input` 經 `normalizePassphrase` 後的值)、`credentials[].salt`、`credentials[].expected_hex` |
366
366
 
367
367
  ### 7.3 `bip39.json`——BIP39 抽樣
368
368
 
369
- `samples`(陣列)每筆:`entropy_hex`、`checksum_hex`、`words`(24 詞陣列)、`source`(`seeded:<n>`/`edge`)。
369
+ `samples`(陣列)每筆:`samples[].entropy_hex`、`samples[].checksum_hex`、`samples[].words`(24 詞陣列)、`samples[].source`(`seeded:<n>`/`edge`)。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": "journal-core-vectors/1",
3
- "package_version": "0.3.0",
3
+ "package_version": "0.3.1",
4
4
  "spec": "docs/format-spec.md",
5
5
  "generator": "scripts/generate-vectors.ts",
6
6
  "note": "Frozen evidence generated once by calling the real primitives; not recomputed in CI (random iv/salt). Verify gate [26] checks presence and shape only.",