@tacet-ink/journal-core 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,8 +23,8 @@
23
23
  前綴全部可配置,AAD(防搬移綁結)由呼叫端傳入。
24
24
  - **extractable 鐵律**:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
25
25
  import 當下就必須 `extractable=true`;KEK/guest key 恆 nonextractable。
26
- - **opt-in 原語**:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal)
27
- 未配置即拒絕,各 fork 未選用的原語行為不受影響(cipherGuest 未配置時 bound 路徑不受牽連,
26
+ - **opt-in 原語**:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal/
27
+ wrap4/wrapDual4/wrapLocal)未配置即拒絕,各 fork 未選用的原語行為不受影響(cipherGuest 未配置時 bound 路徑不受牽連,
28
28
  解密面 guest 家族整面拒絕、不明字串與畸形空字串配置不當明文顯示)。
29
29
 
30
30
  ## 模組(Modules)
@@ -32,15 +32,16 @@
32
32
  | 模組 | 內容 |
33
33
  | --- | --- |
34
34
  | `src/client/note-crypto.ts` | 兩時代加解密、passphrase/復原套件/本機三路包裹、PIN 第二因子合鑰(jr2w.) |
35
- | `src/client/argon2.ts` | Argon2id 包裹原語(jr3w./jr3d./jr3s.)、PH1 v2 派生、RFC 9106 KAT、雙載體(node:crypto+hash-wasm) |
35
+ | `src/client/argon2.ts` | Argon2id 包裹原語(jr3w./jr3d./jr3s.+密語正規化 v3 世代 jr4w./jr4d.)、PH1 v2/v3 派生、RFC 9106 KAT、雙載體(node:crypto+hash-wasm) |
36
36
  | `src/client/pinlock.ts` | 本機 PIN 鎖定包裹(jr1p.,開啟時鎖定) |
37
37
  | `src/client/bip39.ts` | 復原套件 24 詞 ⇄ hex64 轉寫層(BIP39,零依賴自製) |
38
38
  | `src/client/keys.ts` | 品牌前綴 localStorage 命名空間 |
39
39
  | `src/server/auth.ts` | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責 |
40
+ | `src/server/ladder.ts` | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert 值主權威)——幽靈守衛的資料承载位(查表接線在 fork route) |
40
41
  | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效) |
41
42
  | `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
42
43
 
43
- ## 前綴契約(家族表:10 個資料前綴+閘對照組 jr1g.)
44
+ ## 前綴契約(家族表:13 個資料前綴+閘對照組 jr1g.)
44
45
 
45
46
  前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。
46
47
  Tacet 部署實例(config 傳入):
@@ -51,22 +52,40 @@ Tacet 部署實例(config 傳入):
51
52
  | `jr1b.` | 綁定時代密文 | 隨機 256-bit noteKey | AES-GCM,AAD 綁 noteId |
52
53
  | `jr1c.` | 附件密文(image attachments;選配) | 同筆記金鑰(noteKey/guest key 由呼叫端決定) | AES-GCM,AAD `jr1a:<noteId>:<attachId>` |
53
54
  | `jr1d.` | 本機 IDB stored 密文(notes store;選配) | 同筆記金鑰(呼叫端注入) | AES-GCM,AAD 綁 note_id,payload 自帶 `v` 欄 |
55
+ | `jr1l.` | 本機包裹(PWA session 期免重打密語;選配) | deriveGuestKey=K_u 同 guest 面(identity 派生、passphrase-free) | 同 jr1w. 形,AAD `notekey-local` |
54
56
  | `jr1w.` | passphrase 包裹+復原套件包裹 | PBKDF2-SHA256 600k | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey` |
55
57
  | `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
56
58
  | `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
57
59
  | `jr3d.` | PIN 第二因子合鑰(Argon2id 版) | Argon2id(pass)+Argon2id(pin) → HKDF-SHA256 | 同 jr2w. 形,108B,AAD `notekey2` |
60
+ | `jr4w.` | passphrase 包裹(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass),NFKC-only) 同上參數 | 同 jr1w./jr3w. 形,AAD `notekey` |
61
+ | `jr4d.` | PIN 第二因子合鑰(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass))+Argon2id(pin) → HKDF-SHA256 | 同 jr2w./jr3d. 形,108B,AAD `notekey2` |
58
62
  | `jr3s.` | 單篇分享連結包裹 | Argon2id(同上參數) | 同 jr1w. 形,AAD `notekey-share` |
59
63
  | `jr1p.` | 本機 PIN 鎖定(開啟時鎖定) | PBKDF2-SHA256 600k(刻意不用 Argon2id:解鎖要即時) | 同 jr2w. 形,AAD `notekey-pinlock` |
60
64
 
61
65
  guest 密文前綴在閘對照組另驗 `jr1g.`(閘自造前綴,驗證品牌參數化本身;tacet 部署實例 guest 用 `jr1u.`)。
62
66
  跨前綴呼叫一律回 null(前綴守衛+AAD+長度把關),不拋、不降級。
63
67
 
68
+ **世代命名兩軸**(jr4w./jr4d. 起):前綴數字軸=KDF 世代代(jr1w→jr2w→jr3w→jr4w,
69
+ KEK 輸入契約改變即換世代);「v3 家族」文案=PH1 規格代(PH1 v3 用 `derivePh1ArgonV3`)。
70
+ 密語正規化 v3 世代=KEK 吃 `normalizePassphrase`(NFKC-only:不 trim、不分大小寫——
71
+ 大小寫摺疊禁絕=大小寫差恆不同 KEK/ph2)——
72
+ 舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw 密語派生,帶內版本化禁原地改語意);
73
+ 跨前綴呼叫恆 null(家族隔離)。同密語寫法差(全形/NFD/NFC)在 v3 世代收容為同一 KEK/PH2,
74
+ 首尾空白與大小寫仍刻意分流(v3 契約只收容寫法差)。
75
+ jr4d. 的 KEK2 HKDF info 域=`'journal-kek2-v1:' + wrapDual4`(自有域——NFKC-effective 密語下
76
+ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob 互解 null 為證)。
77
+
64
78
  ## 驗證(Verification)
65
79
 
66
80
  ```sh
67
- npm run verify # 249 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
81
+ npm run verify # 384 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
68
82
  # extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
69
83
  # server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
84
+ # + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
85
+ # + 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊寫新自癒)
86
+ # +ladder 表與重取鹽面 [19](ph2_ladder 原語/upsert 值主權威行為面/unwrapNoteKeyDual4WithSalt)
87
+ # +常駐毒化矩陣 [16][18](/tmp 拷貝樹突變重跑=毒化證據隨每執行重建)
88
+ # +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
70
89
  ```
71
90
 
72
91
  - 產品碼**零執行時依賴**(WebCrypto 原語);devDependencies 僅閘用(typescript、@scure/bip39 對照、workers types)。
@@ -96,6 +115,7 @@ const cfg = {
96
115
  cipherGuest: 'jr1g.', // 換成你自己的品牌前綴家族
97
116
  cipherBound: 'jr1b.',
98
117
  wrap: 'jr1w.',
118
+ wrapLocal: 'jr1l.', // 選配:本機包裹專用前綴(未配置 = 讀舊形回落、寫面退場)
99
119
  store: makeKeyStore({ brand: 'myapp' }),
100
120
  // cipherGuest / wrapDual / wrapShare / pinLock / cipherAttach / cipherLocal:選配,未配置即拒絕
101
121
  };
@@ -106,16 +126,26 @@ const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'note
106
126
  ```
107
127
 
108
128
  PH1 派生(登入憑證):`derivePh1Argon(pass, saltArg?)` 預設鹽 `PH1_V2_SALT`
109
- (`'tacet-ph1-v1'`,Tacet 實例)。其他產品可自選**固定域鹽**傳入 `saltArg`——
129
+ (`'tacet-ph1-v1'`,Tacet 實例,raw pass)。其他產品可自選**固定域鹽**傳入 `saltArg`——
110
130
  鹽一經選定即 per-product 恆固定(PH2 UNIQUE 約束:同一密語必須恆生同一 PH2;
111
131
  per-user 鹽會摧毀幽靈帳號機制)。改鹽=帶內版本化換鹽尾碼重遷移,禁原地改語意。
112
132
 
133
+ PH1 v3(密語正規化世代):`derivePh1ArgonV3(pass, saltArg?)` 預設鹽 `PH1_V3_SALT`
134
+ (`'tacet-ph1-v2'`,新鹽域=世代 ph2 命名空間分離——同鹽域會讓 v3 'PASS' 與 v2 世代
135
+ raw 'PASS' 撞同一 ph2)。派生輸入吃 `normalizePassphrase`(NFKC-only;大小寫摺疊禁絕=
136
+ 大小寫差恆不同 ph2);v2/legacy 派生面零變更(舊帳戶憑證 raw 契約)。兩代 ph2 可並存查表=
137
+ tacet 遷移層職責(v0.2.0 批卡②)。
138
+ ⚠️ 鹽撞位警告(MINOR-5):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的 `saltArg` 餵入(反之亦然)——
139
+ 把 v3 鹽域值餵 v2 派生(或 fork 把同一 saltArg 帶到兩面)=NFKC-effective 密語與 v2 raw 密語
140
+ 同值撞 ph2,跨世代帳戶空間混合。v3 的 `saltArg` 必須異於本 product 的 v2 鹽域值。
141
+
113
142
  ### 例外契約(錯誤碼語意)
114
143
 
115
144
  - 空 identity(guest 派生):加密面拋 `ERR_NO_IDENTITY`、解密面回 `null`
116
145
  (K_u = SHA-256(prefix ‖ '') 會靜默產出「空帳號金鑰」=誤配炸彈,兩面皆 fail-closed)。
117
- - 空 PIN(`wrapNoteKeyDual`/`wrapNoteKeyDual3`):拋 `ERR_PIN_EMPTY`——與
118
- 「功能未配置」(`ERR_DUAL_NOT_CONFIGURED`/`ERR_JR3W_NOT_CONFIGURED`)語意分離。
146
+ - 空 PIN(`wrapNoteKeyDual`/`wrapNoteKeyDual3`/`wrapNoteKeyDual4`):拋 `ERR_PIN_EMPTY`——與
147
+ 「功能未配置」(`ERR_DUAL_NOT_CONFIGURED`/`ERR_JR3W_NOT_CONFIGURED`/`ERR_JR4W_NOT_CONFIGURED`/
148
+ `ERR_JR4D_NOT_CONFIGURED`)語意分離。
119
149
  - `hexToBytes` fail-closed:非法 hex(奇數長度/非 hex 字元/空字串)拋 `ERR_BAD_HEX`,
120
150
  不再 parseInt 靜默歸零(假金鑰生產器)。unwrap 家族的 try/catch 承接=跨前綴鐵律
121
151
  (回 null 不拋)不變。
@@ -154,15 +184,19 @@ and salted hashes, and never learns your passphrase or the content of any note.
154
184
  (e.g. `jr1b.` for bound-era ciphertext, `jr3w.` for Argon2id-wrapped note keys). Upgrading KDF
155
185
  parameters always means a new prefix; old prefixes keep decrypting forever (no in-place
156
186
  semantic changes). Cross-prefix calls always return `null` — prefix guard + AAD + length
157
- checks; they never throw or fall back.
187
+ checks; they never throw or fall back. The passphrase-normalization generation follows the
188
+ same rule: `jr4w.`/`jr4d.` wrap with `normalizePassphrase` (NFKC-only — no trim, no
189
+ case-folding); older prefixes keep deriving from the raw passphrase, so every legacy payload
190
+ stays decryptable as-is.
158
191
  - **Tamper-evidence.** Every AES-GCM operation binds additional authenticated data (AAD) —
159
192
  e.g. ciphertext is bound to its note id, so records cannot be shuffled between notes.
160
193
  - **Key hygiene.** Any key that must be exported/wrapped is imported with `extractable=true`;
161
194
  KEK and guest keys are permanently non-extractable.
162
195
 
163
196
  **Opt-in primitives.** Optional contracts (`cipherGuest` / `wrapDual` / `wrapShare` / `pinLock` /
164
- `cipherAttach` / `cipherLocal`) are rejected unless explicitly configured, so forks that don't use
165
- a primitive are unaffected. Unknown or malformed strings never decrypt to plaintext.
197
+ `cipherAttach` / `cipherLocal` / `wrap4` / `wrapDual4` / `wrapLocal`) are rejected unless explicitly
198
+ configured, so forks that don't use a primitive are unaffected. Unknown or malformed strings never
199
+ decrypt to plaintext.
166
200
 
167
201
  **Modules.** Client: two-era crypto (`note-crypto.ts`), Argon2id wrapping (`argon2.ts`, dual
168
202
  carrier: `node:crypto` ≥ Node 24.7 / hash-wasm in browsers, RFC 9106 test vectors), local PIN lock
@@ -172,7 +206,7 @@ PH1→PH2 login flow, inbound cipher/package validation and paired key-package s
172
206
  (`auth.ts`; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
173
207
  fixed-window rate limiting on D1 (`ratelimit.ts`), shared CORS/hash utilities.
174
208
 
175
- **Verification.** `npm run verify` runs 249 assertions against the real modules (no mocks):
209
+ **Verification.** `npm run verify` runs 384 assertions against the real modules (no mocks):
176
210
  roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
177
211
  isolation, payload tampering, RFC 9106 KAT, and a 200-vector BIP39 cross-check.
178
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacet-ink/journal-core",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
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": {
@@ -26,7 +26,8 @@
26
26
  "./server/ratelimit": "./src/server/ratelimit.ts",
27
27
  "./server/cors": "./src/server/cors.ts",
28
28
  "./server/hash": "./src/server/hash.ts",
29
- "./server/env": "./src/server/env.ts"
29
+ "./server/env": "./src/server/env.ts",
30
+ "./server/ladder": "./src/server/ladder.ts"
30
31
  },
31
32
  "files": [
32
33
  "src",