@tacet-ink/journal-core 0.3.3 → 0.3.5

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
@@ -10,43 +10,69 @@
10
10
 
11
11
  > **English**: Zero-knowledge journaling core — E2E-encrypted notes, passphrase-as-identity auth, transport & rate-limiting primitives. The server never sees a single word: the passphrase never crosses the wire, and all key wrapping and encryption happen client-side. See the [English summary](#english-summary) below.
12
12
 
13
+ 高階 Vault API(`client/vault`):`createVault(pass, { app })` 零設定建 vault → 記錄包裹加解密 → 解鎖/24 詞復原 → PH1 登入憑證,一個子路徑走完。
14
+
13
15
  這是 [默·Tacet(tacet.ink)](https://tacet.ink) 的客戶端/伺服端核心原語層,
14
16
  從多個姊妹產品共用的密碼學本體抽取而成,以品牌前綴參數化(前綴契約見下表)。
15
17
 
16
- ## Quickstart
18
+ ## Live demo
17
19
 
18
- ```sh
19
- git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
20
- npm ci --ignore-scripts
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
- ```
20
+ [https://tacet.ink/secret](https://tacet.ink/secret):默·Tacet 的一次性分享站,也是本函式庫原語的線上演示。
21
+ read-once(讀取一次即失效)、零帳號、免安裝;伺服器永不落明文:加密與解密都在瀏覽器端完成(端對端),
22
+ 密文經 AES-GCM-256 加密、連結 id 是 SHA-256 截斷(兩者皆與本函式庫同組 WebCrypto 原語、同一呼叫形)。單頁、無框架;
23
+ 原始碼在 Tacet 主 repo(private),頁尾另有[驗證頁](https://tacet.ink/verify)入口。
24
+
25
+ **English**: Live demo: https://tacet.ink/secret. Read-once, zero-account, E2E secret sharing
26
+ built on this library's WebCrypto primitives. Single-page, no framework.
27
+
28
+ ## Quickstart
23
29
 
24
30
  ```ts
25
- // example.ts
26
- import { makeHeldKey, generateNoteKey, encryptNote, decryptNote } from './src/index.ts';
27
- import { makeKeyStore } from './src/client/keys.ts';
31
+ // example.mts
32
+ import { createVault, unlockVault, recoverVault, deriveVaultPh1, isVaultError }
33
+ from '@tacet-ink/journal-core/client/vault';
34
+
35
+ const PASS = 'correct horse battery staple';
36
+ const cfg = { app: 'myapp', recovery: true }; // 零設定:全前綴由 app 推導(myapp1v./myapp4w./myapp1r.…);recovery=復原套件 opt-in
37
+
38
+ // 1. 建 vault:隨機 noteKey+Argon2id 密語包裹。serverRecord=上傳伺服器的包裹欄;kit=復原套件(只顯示一次)
39
+ const { vault, serverRecord, recovery: kit } = await createVault(PASS, cfg);
40
+
41
+ // 2. 記錄加解密:AAD 綁 app+recordId——密文搬到別筆 recordId 即 ERR_VAULT_DECRYPT
42
+ const blob = await vault.encrypt('note-1', { title: '今天', body: '寫了一點東西。' });
43
+ console.log(blob.slice(0, 8)); // 'myapp1v.'
44
+ console.log(await vault.decrypt('note-1', blob)); // { title: '今天', body: '寫了一點東西。' }
45
+
46
+ // 3. 換裝置解鎖:密語+伺服器帶回的 serverRecord(同一 cfg)
47
+ const unlocked = await unlockVault(PASS, serverRecord, cfg);
48
+ console.log(await unlocked.decrypt('note-1', blob)); // 同上
49
+ try { await unlockVault('wrong passphrase', serverRecord, cfg); }
50
+ catch (e) { console.log(isVaultError(e, 'ERR_VAULT_WRONG_PASSPHRASE')); } // true(拋出碼恆 ERR_VAULT_WRAP_MISMATCH)
51
+
52
+ // 4. 忘記密語:24 詞復原(伺服器存 kit.wrappedRec+kit.recTokenHash;詞只給使用者抄寫)
53
+ if (!kit) throw new Error('recovery kit missing'); // cfg 未開 recovery=null
54
+ console.log(kit.words.length); // 24
55
+ const recovered = await recoverVault(kit.words, kit.wrappedRec, cfg);
56
+ console.log(await recovered.decrypt('note-1', blob)); // 同上
57
+ const newRecord = await recovered.changePassphrase('a new passphrase'); // 設新密語 → 覆存 serverRecord
58
+
59
+ // 5. 登入憑證:PH1(hex64)上線、密語本體不過線;鹽=per-product 固定域鹽(⚠️ 見下方「PH1 派生」節鹽域警告)
60
+ const ph1 = await deriveVaultPh1(PASS, new TextEncoder().encode('myapp-ph1-v1'));
61
+ console.log(ph1.length); // 64
62
+ // server(fork 端,'@tacet-ink/journal-core/server/auth'):return loginRouteCore(env, store, ip, ph1);
63
+ ```
28
64
 
29
- const cfg = {
30
- guestKdfPrefix: 'myapp-note-u1',
31
- recSaltPrefix: 'myapp-note-rec1:',
32
- cipherGuest: 'jr1g.', // your own brand prefix family (guest era, opt-in)
33
- cipherBound: 'jr1b.',
34
- wrap: 'jr1w.',
35
- store: makeKeyStore({ brand: 'myapp' }),
36
- };
65
+ run: `npm install @tacet-ink/journal-core && node example.mts`(0.3.4 起;Node ≥ 24.7——dist ESM 直載免 strip-types 旗標,`.mts` 不受 package.json `type` 影響;Argon2id 走內建 `node:crypto` 免注入 loader)
37
66
 
38
- const held = makeHeldKey();
39
- held.set(await generateNoteKey()); // bound era: random 256-bit noteKey
40
- const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
67
+ 源碼 clone 跑驗證閘:
41
68
 
42
- const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
43
- console.log(cipher.slice(0, 5)); // 'jr1b.' — ciphertext; nobody reads it without the key
44
- console.log(await decryptNote(cfg, held, cipher, 'noteId:n1', identity)); // '今天寫了一點東西。' — roundtrip
69
+ ```sh
70
+ git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
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/)
45
73
  ```
46
74
 
47
- run: `node --experimental-strip-types example.ts`
48
-
49
- 完整 API 見[使用(Usage)](#使用usage)節。Live product: [https://tacet.ink](https://tacet.ink)
75
+ 上例為高階 Vault API;錯誤碼見[例外契約](#例外契約錯誤碼語意)(`VaultError` 表)。低階原語範例(note-crypto 兩時代加解密、前綴自配)與完整 API 見[使用(Usage)](#使用usage)節。Live product: [https://tacet.ink](https://tacet.ink)
50
76
  (驗證頁:[https://tacet.ink/verify](https://tacet.ink/verify))。
51
77
  安全回報流程見 [SECURITY.md](./SECURITY.md)、版本歷史見 [CHANGELOG.md](./CHANGELOG.md)。
52
78
 
@@ -80,8 +106,10 @@ run: `node --experimental-strip-types example.ts`
80
106
  | `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效;IPv6 鍵經 `normalizeRateIp` 聚合至 /64;IPv4-mapped 位址回點分 IPv4 鍵(=原生同 /32 鍵)) |
81
107
  | `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
82
108
 
83
- 發行形(0.3.0 起):`dist/`=tsc 原生 emit 的 ESM `.js`+`.d.ts`,`exports` 全部子路徑指向 dist;
84
- `src/` 同包隨附(稽核/閱讀用,import 恆走 dist)。
109
+ 發行形(0.3.0 起):`dist/`=tsc 原生 emit 的 ESM `.js`+`.d.ts`,`exports` 全部子路徑指向 dist。
110
+ 0.3.4 起發行包 dist-only:`files` 白名單恰 `dist`/`docs`/`migrations`/README/LICENSE——`src/`、`scripts/`
111
+ 不再隨包(驗證閘與向量產生器為 repo 樹面,走源碼 clone 動線);稽核/閱讀路徑=GitHub 瀏覽 `src/`+
112
+ [docs/format-spec.md](./docs/format-spec.md) 規格+[docs/vectors/](./docs/vectors/) 向量(`docs/` 仍隨包)。
85
113
 
86
114
  ## 前綴契約(家族表:13 個資料前綴+閘對照組 jr1g.)
87
115
 
@@ -124,7 +152,7 @@ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob
124
152
 
125
153
  ```sh
126
154
  npm ci --ignore-scripts
127
- npm run verify # 522 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
155
+ npm run verify # 531 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
128
156
  # extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
129
157
  # server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
130
158
  # + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
@@ -136,7 +164,7 @@ npm run verify # 522 斷言對真模組(禁鏡像;限流單句 UPSERT…RE
136
164
  # +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
137
165
  # +包裹前綴快檢 [13-2](錯前綴試探免付 KDF:五入口行為+計時帽+計數錨)
138
166
  # +CI 供應鏈面 [21](npm ci --ignore-scripts+dependabot runner fromJSON 分流+腳本摘除+NIT-1 錨+r2 MINOR-1 URL 直傳;
139
- # 522 帳=repo 樹——發行包內無 .github=[21] 四條 workflow 錨收斂為 1 顯性 SKIP 行→519(=522−3;消費端帳數真相源=本行,隨版記帳——閘 SKIP 行摘死數字指回本行,0.3.3 實測),SKIP 顯形非靜默)
167
+ # 531 帳=repo 樹——workflow 錨在 .github=repo 樹恆在、四條全跑;包內 SKIP 對照帳段隨 0.3.4 dist-only 瘦身退役:發行包不再帶閘=無包內帳可言)
140
168
  # +發行 tarball 治理面 [22](pack 白名單零 open-card-pr.sh)
141
169
  # +格式規格+測試向量 [26](docs/format-spec.md+docs/vectors 存在性/位元組 shape/欄位名對規格/凍結 KAT 逐字對閘)
142
170
  # +高階 Vault API [27](roundtrip 三形/新舊前綴解鎖/upgrade/換密語/復原/VaultError 毒化/載體覆寫+Worker 回退鏈/vendor 孿生)
@@ -162,6 +190,35 @@ import { makeKeyStore, makeHeldKey, generateNoteKey, encryptNote, decryptNote }
162
190
  // 或子路徑:'@tacet-ink/journal-core/client/note-crypto'
163
191
  ```
164
192
 
193
+ 低階原語最小 roundtrip(源碼 clone 形,import `./src/*`):
194
+
195
+ ```ts
196
+ // example.ts
197
+ import { makeHeldKey, generateNoteKey, encryptNote, decryptNote } from './src/index.ts';
198
+ import { makeKeyStore } from './src/client/keys.ts';
199
+
200
+ const cfg = {
201
+ guestKdfPrefix: 'myapp-note-u1',
202
+ recSaltPrefix: 'myapp-note-rec1:',
203
+ cipherGuest: 'jr1g.', // your own brand prefix family (guest era, opt-in)
204
+ cipherBound: 'jr1b.',
205
+ wrap: 'jr1w.',
206
+ store: makeKeyStore({ brand: 'myapp' }),
207
+ };
208
+
209
+ const held = makeHeldKey();
210
+ held.set(await generateNoteKey()); // bound era: random 256-bit noteKey
211
+ const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
212
+
213
+ const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
214
+ console.log(cipher.slice(0, 5)); // 'jr1b.' — ciphertext; nobody reads it without the key
215
+ console.log(await decryptNote(cfg, held, cipher, 'noteId:n1', identity)); // '今天寫了一點東西。' — roundtrip
216
+ ```
217
+
218
+ run: `node --experimental-strip-types example.ts`
219
+
220
+ npm 安裝形(完整 cfg 欄位):
221
+
165
222
  ```ts
166
223
  import { generateNoteKey, encryptNote, decryptNote, makeHeldKey,
167
224
  wrapNoteKey, unwrapNoteKey } from '@tacet-ink/journal-core/client/note-crypto';
@@ -199,7 +256,7 @@ raw 'PASS' 撞同一 ph2)。派生輸入吃 `normalizePassphrase`(NFKC-only
199
256
  大小寫差恆不同 ph2);v2/legacy 派生面零變更(舊帳戶憑證 raw 契約)。兩代 ph2 可並存查表=
200
257
  tacet 遷移層職責(v0.2.0 世代收口)。
201
258
 
202
- server 登入動線(fork 端):`loginRouteCore(request-ish, store, ip, ph1)` 內建
259
+ server 登入動線(fork 端):`loginRouteCore(env, store, ip, ph1)` 內建
203
260
  ladder 查表守衛——現值 miss 時經 `AuthStore.ladderLookup`(optional)查 ph2_ladder,
204
261
  命中回舊帳不建幽靈、兩面 miss 才 `createUser`;未配置=行為與 0.2.3 單查恆等。
205
262
  接法:store 實作 `ladderLookup`(tacet 參考形=`SQL_FIND_PH2_LADDER_BY_OLD`+fail-open
@@ -209,6 +266,20 @@ catch)+套用 `migrations/0011-ph2-ladder.sql`(+0012 索引修正);
209
266
  把 v3 鹽域值餵 v2 派生(或 fork 把同一 saltArg 帶到兩面)=NFKC-effective 密語與 v2 raw 密語
210
267
  同值撞 ph2,跨世代帳戶空間混合。v3 的 `saltArg` 必須異於本 product 的 v2 鹽域值。
211
268
 
269
+ 高階 Vault API 零設定前綴(`resolveVaultConfig`,0.3.4):`createVault(pass, { app })` 最小設定
270
+ ——全前綴由 app 決定性推導:記錄密文 `<app>1v.`、寫面 `<app>4w.`/PIN `<app>4d.`、
271
+ 鹽域 `<app>3s:`、舊世代讀面 `<app>3w.`/`<app>3d.`/`<app>1w.`/`<app>2w.`/`<app>1s.`;
272
+ `recovery: true` opt-in(復原套件 `<app>1r.`+HKDF info 域 `<app>kek1`,recKekSalt 未指定=
273
+ 零鹽契約照舊)——**未指定恆不推導**(零鹽選擇不可靜默)。app 契約 `^[A-Za-z0-9_-]+$`
274
+ (前綴字面=線上 ASCII 契約)=**零設定路徑專載**——非契約 app 誤入零設定=
275
+ `ERR_VAULT_CONFIG` fail-closed 不猜;**顯式全配置路徑 app 字面照 0.3.3 既有契約零驗形**
276
+ (非 ASCII/帶點 app 走顯式全配置照舊——契約閘不跨路徑加寬、顯式回歸 diff 恆 0)。
277
+ **顯式路徑判別**:`argon` 或 `cipher` 任一在場=顯式全配置路徑(resolver 零推導——既有
278
+ 五值手填契約照舊、顯式配置回歸 diff 恆 0);半顯式態(argon 組推導+`legacy` 物件槽顯式欄位)
279
+ 推導值與顯式值同進前綴互斥檢查(例 `legacy: { wrap: <app>4w.bait }` 與推導 `<app>4w.` 互為
280
+ 前綴=拒——歧義配置不猜)。跨設定形前綴隔離照舊(推導包裹顯式設定不解、顯式包裹他設定不解
281
+ =`ERR_VAULT_WRAP_UNKNOWN`,app 即前綴契約域)。
282
+
212
283
  ### 例外契約(錯誤碼語意)
213
284
 
214
285
  **客戶端(client/)**
@@ -236,6 +307,31 @@ catch)+套用 `migrations/0011-ph2-ladder.sql`(+0012 索引修正);
236
307
  | `ERR_ARGON2_TAGLEN` | Argon2id 載體 tag 非 32B(載體契約面) |
237
308
  | `ERR_ARGON2_UNAVAILABLE` | 無 Argon2id 載體(node<24.7 且無 hash-wasm 注入——禁 fallback) |
238
309
 
310
+ **高階 Vault API(client/vault)**
311
+
312
+ 低階原語面不動(throw 'ERR_*' 字串錯誤+unwrap 恆 null 的混態照舊)——只在 vault.ts/argon-auto.ts 邊界轉譯:高階 API 一律 throw VaultError,呼叫端以 `code` 分流,不比對 message。
313
+ 失敗面刻意不細分「密語錯」與「密文竄改」(同一 GCM 失敗——細分=oracle)。
314
+
315
+ `VaultError`(`e.code`)+型別守衛 `isVaultError(e, code?)`:14 個拋出碼+1 個查詢別名。
316
+
317
+ | 錯誤碼 | 拋出面 |
318
+ | --- | --- |
319
+ | `ERR_VAULT_CONFIG` | 配置缺欄/畸形(app、cipher、argon.wrap4、recovery 兩欄一體…)——opt-in 未配置即拒 |
320
+ | `ERR_VAULT_BAD_RECORD` | serverRecord 缺 wrapped/salt 欄或形不符(不猜) |
321
+ | `ERR_VAULT_WRAP_UNKNOWN` | wrapped 前綴不屬任何已配置家族(未知世代/他產品前綴) |
322
+ | `ERR_VAULT_WRAP_MISMATCH` | 家族辨識成功但解包失敗:密語/PIN 錯或包裹竄改(刻意不分) |
323
+ | `ERR_VAULT_WRONG_PASSPHRASE` | 查詢別名(拋出面恆 WRAP_MISMATCH):`isVaultError(e, 'ERR_VAULT_WRONG_PASSPHRASE')` 與查 `ERR_VAULT_WRAP_MISMATCH` 雙向等值;本 API 不拋此碼 |
324
+ | `ERR_VAULT_PIN_REQUIRED` | 雙因子家族(jr2w./jr3d./jr4d. 形)缺 PIN |
325
+ | `ERR_VAULT_BAD_PASSPHRASE` | 密語空值/非字串 |
326
+ | `ERR_VAULT_BAD_PIN` | PIN 正規化後為空 |
327
+ | `ERR_VAULT_KDF_UNSUPPORTED` | Argon2id 載體缺席或失效(zero-fallback:禁 PBKDF2/zeros 兜底) |
328
+ | `ERR_VAULT_BAD_RECORD_ID` | recordId 非字串或空 |
329
+ | `ERR_VAULT_BAD_DATA` | encrypt 資料形不支援(undefined、BigInt、循環 JSON、非 Uint8Array 的 ArrayBuffer 族…) |
330
+ | `ERR_VAULT_BAD_BLOB` | 密文 blob 形不符(前綴/base64/長度/型別標記) |
331
+ | `ERR_VAULT_DECRYPT` | 密文解密失敗:recordId(AAD)不符、金鑰不符或密文竄改(刻意不分) |
332
+ | `ERR_VAULT_RECOVERY` | 復原套件形不符(recToken 非 hex64/24 詞不符、wrappedRec 前綴不符)或解包失敗 |
333
+ | `ERR_VAULT_INTERNAL` | 未預期的低階錯誤(`cause` 帶原錯) |
334
+
239
335
  **伺服端(server/)+閘**
240
336
 
241
337
  | 錯誤碼 | 拋出面 |
@@ -271,6 +367,17 @@ MIT。安全問題聯絡 security@tacet.ink(安全回報流程見 [SECURITY.md
271
367
  [默·Tacet (tacet.ink)](https://tacet.ink), a zero-knowledge journal: the server stores only ciphertext
272
368
  and salted hashes, and never learns your passphrase or the content of any note.
273
369
 
370
+ **High-level Vault API.** `@tacet-ink/journal-core/client/vault` (0.3.4+) wraps the primitives in a
371
+ single flow. `createVault(passphrase, { app: 'myapp', recovery: true })` derives every prefix from
372
+ `app` (zero config), wraps a random note key with Argon2id, and returns the `serverRecord` to upload
373
+ plus an opt-in 24-word recovery kit. `vault.encrypt(recordId, data)` / `vault.decrypt(recordId, blob)`
374
+ bind each record to `app:recordId` as AAD, so a ciphertext moved to another record fails to decrypt.
375
+ `unlockVault(passphrase, serverRecord, cfg)` reopens the vault on another device, and
376
+ `recoverVault(words, wrappedRec, cfg)` reopens it from the recovery kit. `deriveVaultPh1(passphrase, salt)`
377
+ produces the hex64 login credential your server passes to `loginRouteCore(env, store, ip, ph1)`.
378
+ Failures throw a `VaultError` with a stable `code`; a wrong passphrase and a tampered wrap
379
+ deliberately share one code, since telling them apart would create an oracle.
380
+
274
381
  **Security model.**
275
382
 
276
383
  - **Two key eras.** Before binding an account, notes are encrypted with a client-derived key
@@ -292,7 +399,7 @@ and salted hashes, and never learns your passphrase or the content of any note.
292
399
  - **Key hygiene.** Any key that must be exported/wrapped is imported with `extractable=true`;
293
400
  KEK and guest keys are permanently non-extractable.
294
401
 
295
- **Server login wiring.** `loginRouteCore(store, ip, ph1)` ships with the ladder guard built in
402
+ **Server login wiring.** `loginRouteCore(env, store, ip, ph1)` ships with the ladder guard built in
296
403
  (v0.2.4, external-review #8 ghost-account fix): on a current-value (PH2) miss it consults the
297
404
  `ph2_ladder` table via the optional `AuthStore.ladderLookup` — a ladder hit returns the
298
405
  existing account (no ghost creation); a ladder miss, an unconfigured lookup, or a
@@ -318,8 +425,10 @@ carrier: `node:crypto` ≥ Node 24.7 / hash-wasm in browsers, RFC 9106 test vect
318
425
  PH1→PH2 login flow, inbound cipher/package validation and paired key-package selection
319
426
  (`auth.ts`; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
320
427
  (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
- package ships `dist/` (tsc-emitted ESM `.js` + `.d.ts`); every `exports` subpath resolves to dist,
322
- and `src/` is included for reading and auditing only.
428
+ package ships `dist/` (tsc-emitted ESM `.js` + `.d.ts`); every `exports` subpath resolves to dist.
429
+ Since 0.3.4 the package is dist-only: `src/` and `scripts/` no longer ship (the tarball carries `dist/`,
430
+ `docs/`, `migrations/`, README and LICENSE). To read or audit the source, browse `src/` on GitHub
431
+ alongside the format spec and test vectors in `docs/`.
323
432
 
324
433
  **Format spec & test vectors.** The byte-level wire format of every prefix family — payload layout
325
434
  and field lengths, KDF parameters, AAD strings, and the in-band versioning contract — is specified
@@ -331,7 +440,7 @@ prefix family (`families.json`), known-answer vectors (RFC 9106 Argon2id, the ga
331
440
  `scripts/generate-vectors.ts` against the real primitives and committed as frozen evidence; the
332
441
  verify gate checks their presence and shape (section [26]) and does not regenerate them.
333
442
 
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):
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):
335
444
  roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
336
445
  isolation, payload tampering, RFC 9106 KAT, a 200-vector BIP39 cross-check, and the
337
446
  login ladder-guard behavior suite (external audit #8), and presence/shape checks for the format
@@ -341,7 +450,8 @@ spec and test vectors.
341
450
  and Workers can import it directly — no bundler and no strip-types needed (the 0.2.x "must bundle"
342
451
  requirement is retired). A source checkout can still run `src/` with `node --experimental-strip-types`.
343
452
  In browsers, inject the hash-wasm Argon2id carrier via `setArgonLoader()`; Node ≥ 24.7 uses the
344
- built-in `node:crypto`. See the code sample in the 使用 section above.
453
+ built-in `node:crypto`. The Quickstart opens with a runnable Vault walkthrough (create → encrypt →
454
+ unlock → recover → PH1); the low-level primitive samples are in the 使用 section.
345
455
 
346
456
  **Honest limits.** The guest era is obfuscation-grade, not E2E. PH1 derivation is defined by each
347
457
  product (fast hash or Argon2id); this layer only validates its hex64 form. Product concerns
@@ -9,3 +9,36 @@ export declare function wordsToRecToken(input: string | string[]): Promise<strin
9
9
  export declare function recTokenToWords(recToken: string): Promise<string[] | null>;
10
10
  /** 抄寫抽驗位置:24 詞中抽 4 個不重複位置(0-23),排序回傳(顯示順序穩定)。 */
11
11
  export declare function spotCheckIndexes(): number[];
12
+ /** 24 詞固定長 tuple(BIP39 英文——復原套件詞形契約面;wordsToRecToken/recTokenToWords 的載體形)。 */
13
+ export type Bip39Words = [
14
+ string,
15
+ string,
16
+ string,
17
+ string,
18
+ string,
19
+ string,
20
+ string,
21
+ string,
22
+ string,
23
+ string,
24
+ string,
25
+ string,
26
+ string,
27
+ string,
28
+ string,
29
+ string,
30
+ string,
31
+ string,
32
+ string,
33
+ string,
34
+ string,
35
+ string,
36
+ string,
37
+ string
38
+ ];
39
+ /**
40
+ * 詞形輸入正規化(normalizeWords 的公開收口):字串形=既有路(NFKC→trim→lower→split(/\s+/)→filter);
41
+ * 陣列形=每詞 NFKC→trim→lower 同構(wordsToRecToken 陣列腿同契約)、非字串元素入空串後濾除。
42
+ * 呼叫端:recoverVault 詞形入參先經此再走 wordsToRecToken——任何不符(錯數/詞表外/checksum)照舊 null。
43
+ */
44
+ export declare function normalizeWordInput(input: string | string[]): string[];
@@ -137,3 +137,15 @@ export function spotCheckIndexes() {
137
137
  }
138
138
  return pool.slice(0, 4).sort((a, b) => a - b);
139
139
  }
140
+ /**
141
+ * 詞形輸入正規化(normalizeWords 的公開收口):字串形=既有路(NFKC→trim→lower→split(/\s+/)→filter);
142
+ * 陣列形=每詞 NFKC→trim→lower 同構(wordsToRecToken 陣列腿同契約)、非字串元素入空串後濾除。
143
+ * 呼叫端:recoverVault 詞形入參先經此再走 wordsToRecToken——任何不符(錯數/詞表外/checksum)照舊 null。
144
+ */
145
+ export function normalizeWordInput(input) {
146
+ if (typeof input === 'string')
147
+ return normalizeWords(input);
148
+ return (Array.isArray(input) ? input : [])
149
+ .map((w) => (typeof w === 'string' ? w.normalize('NFKC').trim().toLowerCase() : ''))
150
+ .filter((w) => w.length > 0);
151
+ }
@@ -566,6 +566,12 @@ export async function encryptNote(cfg, held, plaintext, aad, idp) {
566
566
  * 不明字串一律 null=guest 家族整面拒絕,跨前綴鐵律不降級);失敗回 null。 */
567
567
  export async function decryptNote(cfg, held, cipher, aad, idp) {
568
568
  try {
569
+ // 空字串密文正規化(2026-10-18 t_b81f22cd 面1):trim 後空=拒(null)。
570
+ // 既有「無前綴=舊明文原樣返回」相容層對 ''/純空白會原樣回傳=空/空白密文
571
+ // 畸形進解密面、被當舊明文落 UI(truthiness 正規化缺失)。空/空白非合法舊明文,
572
+ // fail-closed 回 null(與本函式任何不符面同一 null 契約,不拋);非空字串零影響。
573
+ if (cipher.trim() === '')
574
+ return null;
569
575
  // guest 前綴正規化:畸形配置 ''(空字串)會讓
570
576
  // startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
571
577
  // falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
@@ -14,6 +14,10 @@ export type VaultErrorCode =
14
14
  | 'ERR_VAULT_WRAP_UNKNOWN'
15
15
  /** 家族辨識成功但解包失敗:密語/PIN 錯或包裹竄改(刻意不分)。 */
16
16
  | 'ERR_VAULT_WRAP_MISMATCH'
17
+ /** 查詢別名字面:`isVaultError(e, 'ERR_VAULT_WRONG_PASSPHRASE')` 對 code === 'ERR_VAULT_WRAP_MISMATCH'
18
+ * 回 true(反之亦然)。拋出面零動——仍拋 WRAP_MISMATCH;失敗面刻意不細分密語錯/竄改
19
+ * (同 GCM 失敗——細分=oracle)。別名只在查詢面:本 union 判準仍以拋出碼為單一真相。 */
20
+ | 'ERR_VAULT_WRONG_PASSPHRASE'
17
21
  /** 雙因子家族(jr2w./jr3d./jr4d.)缺 PIN。 */
18
22
  | 'ERR_VAULT_PIN_REQUIRED'
19
23
  /** 密語空值/非字串。 */
@@ -40,5 +44,4 @@ export declare class VaultError extends Error {
40
44
  cause?: unknown;
41
45
  });
42
46
  }
43
- /** 型別守衛:`isVaultError(e)` 或指定碼 `isVaultError(e, 'ERR_VAULT_DECRYPT')`。 */
44
47
  export declare function isVaultError(e: unknown, code?: VaultErrorCode): e is VaultError;
@@ -13,7 +13,14 @@ export class VaultError extends Error {
13
13
  this.code = code;
14
14
  }
15
15
  }
16
- /** 型別守衛:`isVaultError(e)` 或指定碼 `isVaultError(e, 'ERR_VAULT_DECRYPT')`。 */
16
+ /** 型別守衛:`isVaultError(e)` 或指定碼 `isVaultError(e, 'ERR_VAULT_DECRYPT')`。
17
+ * 查詢別名(雙向):`'ERR_VAULT_WRONG_PASSPHRASE'` 是 `'ERR_VAULT_WRAP_MISMATCH'` 的查詢面別名——
18
+ * 查詢帶別名、或錯誤實例帶別名(union 收了別名字面,手建實例可能),判定時兩側都投影到
19
+ * 拋出碼 WRAP_MISMATCH 再比;對其餘錯誤碼恆 false。拋出面恆 WRAP_MISMATCH(別名不產生第四碼形)。 */
20
+ const VAULT_ERROR_CODE_ALIAS = 'ERR_VAULT_WRONG_PASSPHRASE';
21
+ const canonVaultErrorCode = (c) => c === VAULT_ERROR_CODE_ALIAS ? 'ERR_VAULT_WRAP_MISMATCH' : c;
17
22
  export function isVaultError(e, code) {
18
- return e instanceof VaultError && (code === undefined || e.code === code);
23
+ const q = canonVaultErrorCode(code);
24
+ const ec = e instanceof VaultError ? canonVaultErrorCode(e.code) : undefined;
25
+ return e instanceof VaultError && (q === undefined || ec === q);
19
26
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * vault.ts — 高階 Vault API:createVault/unlockVault/recoverVault → VaultHandle。
2
+ * vault.ts — 高階 Vault API:createVault/unlockVault/recoverVault → VaultHandle+零設定前綴推導 resolver。
3
3
  *
4
4
  * 定位:低階原語(note-crypto.ts/argon2.ts)的**呼叫端**——additive-only,零改動原語語意。
5
5
  * 一個 vault=一把隨機 256-bit noteKey(generateNoteKey)+伺服器存的包裹欄(wrapped+salt)
@@ -20,11 +20,48 @@
20
20
  * tag:0x73 's'=字串(body=UTF-8)/0x6a 'j'=JSON(body=UTF-8(JSON.stringify))/0x62 'b'=位元組(body=原樣)。
21
21
  */
22
22
  import { type Argon3Config } from './argon2.ts';
23
+ import { type Bip39Words } from './bip39.ts';
23
24
  import { VaultError, isVaultError, type VaultErrorCode } from './vault-error.ts';
24
25
  import { type ArgonCarrierKind, type ArgonCarrierOptions } from './argon-auto.ts';
25
26
  export { VaultError, isVaultError, type VaultErrorCode };
26
27
  export { ensureArgonCarrier, currentArgonCarrier, resetArgonCarrier, DEFAULT_ARGON_CHAIN, type ArgonCarrierKind, type ArgonCarrierChoice, type ArgonCarrierOptions, type ArgonFallbackEvent, } from './argon-auto.ts';
27
28
  export { argonWorkerSource, createBlobArgonWorker, type ArgonWorkerLike } from './argon-worker.ts';
29
+ /** 復原套件規格形(= 既顯式物件形;resolver 對 app 推導同形輸出——名稱是接線層措語,型別零新增)。 */
30
+ export type VaultRecoverySpec = {
31
+ wrapRec: string;
32
+ recKekHkdf: string;
33
+ recKekSalt?: string;
34
+ };
35
+ /**
36
+ * resolveVaultConfig(input):零設定前綴推導(resolver)。
37
+ * 輸入 `{ app, ...任意顯式欄位 }`,輸出完整 VaultConfig+defaulted 旗標:
38
+ * `defaulted`=真時 cipher/argon/legacy/recovery 四組**整組**由 app 決定性推導(10 欄,recovery opt-in 時含)——
39
+ * 命名自定(語意=resolveVaultConfig:`{app}`+零設定前綴推導):前綴零手填、家族尾碼恆固定(契約三要素 app+尾碼+佈局;
40
+ * 帶內版本化:尾碼代即版本代,升級=新尾碼;家族佈局不隨字面——README 前綴契約節同源)。
41
+ * `defaulted`=假時輸入即顯式全配置(回歸 diff 恆 0——validateConfig 逐對檢查照守)。
42
+ *
43
+ * 推導表(app 契約 `^[A-Za-z0-9_-]+$`——前綴字面=線上 ASCII 契約;**契約閘=零設定(推導)路徑專載**:
44
+ * 零設定路徑上 app 在場但含契約外字元=ERR_VAULT_CONFIG fail-closed 不猜;顯式全配置路徑
45
+ * app 字面照 0.3.3 既有契約零驗形(非 ASCII/帶點 app 走顯式全配置照舊)——契約閘不跨路徑加寬):
46
+ * cipher='foo1v.'/wrap4='foo4w.'/wrapDual4='foo4d.'/pinSalt3Prefix='foo3s:'(鹽域 ':' 同 tacet 形)
47
+ * wrap3='foo3w.'/wrapDual3='foo3d.'/legacy wrap='foo1w.'/wrapDual='foo2w.'/pinSaltPrefix='foo1s.'
48
+ * recovery(`recovery: true` 時)wrapRec='foo1r.'/recKekHkdf='fookek1'(KEK info 域帶 k 代——刻意
49
+ * 異於 wrapRec 字面:HKDF info 域與前綴域恆非前綴關係,鹽外置 payload 邊界不重疊);
50
+ * wrap3/wrapDual3 舊世代讀面同由 app 推導(`app + '3w.'`/`app + '3d.'`——app 族半顯式 legacy 槽的推導殘部)。
51
+ * `foo`=示例形;任兩推導值互不為前綴(startsWith 唯一性前提);尾碼代=家族表 README 行。
52
+ *
53
+ * 顯式路徑判別:`argon` 或 `cipher` 任一在場=顯式全配置路徑(resolver 不再推導任何欄位——
54
+ * 顯式欄位優先、缺欄照 validateConfig 拒=opt-in 母型;app 契約閘不跨路徑加寬——**契約閘=
55
+ * 零設定路徑專載**(顯式全配置路徑 app 字面照 0.3.3 契約零驗;`resolveVaultConfig(opts)`
56
+ * 三入口單點接線共用)。
57
+ * recovery 物件形同判(呼叫端自管面)。半顯式態(argon 組推導+legacy 物件槽顯式欄位共存):
58
+ * 推導值與顯式值同進前綴互斥檢查(validateConfig——例 `legacy: { wrap: 'foo4w.bait' }` 與推導
59
+ * wrap4 'foo4w.' 互為前綴=CONFIG 拒;歧義配置不猜。
60
+ */
61
+ export declare function resolveVaultConfig(input: VaultOptions | VaultConfigInput): {
62
+ config: VaultConfig;
63
+ defaulted: boolean;
64
+ };
28
65
  export interface VaultConfig {
29
66
  /** 應用域(AAD 前段):記錄密文 AAD=`<app>:<recordId>`。必填非空。 */
30
67
  app: string;
@@ -33,18 +70,37 @@ export interface VaultConfig {
33
70
  /** Argon2id 家族前綴:`wrap4` 必填(寫面);`wrapDual4`+`pinSalt3Prefix`=PIN 寫面;
34
71
  * `wrap3`/`wrapDual3`=舊世代讀面(unlock+upgrade 來源)。`wrapShare3` 本 API 不用。 */
35
72
  argon: Argon3Config;
36
- /** PBKDF2 舊世代讀面(jr1w.=`wrap`;jr2w.=`wrapDual`+`pinSaltPrefix`)。只讀不寫。 */
73
+ /** PBKDF2 舊世代讀面(jr1w.=`wrap`;jr2w.=`wrapDual`+`pinSaltPrefix`)。只讀不寫。
74
+ * `wrap: false`=舊契約回落腿關閉(零設定 vault 預設語意——identity 綁域的 jr1w. 復原套件不屬本 API)。 */
37
75
  legacy?: {
38
- wrap?: string;
76
+ wrap?: string | false;
39
77
  wrapDual?: string;
40
78
  pinSaltPrefix?: string;
41
79
  };
42
- /** 復原套件(jr1r. HKDF 世代;兩欄一體)。未配置=createVault 不產復原套件、recoverVault 拒。 */
43
- recovery?: {
44
- wrapRec: string;
45
- recKekHkdf: string;
46
- recKekSalt?: string;
47
- };
80
+ /** 復原套件(jr1r. HKDF 世代;兩欄一體)。物件形=顯式(呼叫端自管前綴與鹽域);
81
+ * true=opt-in 零設定(wrapRec/recKekHkdf 由 resolver 以 app 推導;recKekSalt 未指定=零鹽契約);
82
+ * false/undefined=不產生復原套件(recoverVault 拒)。 */
83
+ recovery?: VaultRecoverySpec | boolean;
84
+ }
85
+ /** VaultConfigInput:resolveVaultConfig 輸入形(三入口共用——零設定 `{app}` 與顯式全配置同形進位;
86
+ * pin/carrier=入口進位欄,resolver 不消費)。 */
87
+ export interface VaultConfigInput {
88
+ /** app 契約 `^[A-Za-z0-9_-]+$`:零設定路徑 fail-closed(缺 app 亦拒——推導無從);顯式路徑放行顯式全配置。 */
89
+ app?: string;
90
+ /** 顯式記錄密文前綴(在場=顯式全配置路徑——resolver 不推導)。 */
91
+ cipher?: string;
92
+ /** 顯式 Argon3 家族前綴組(在場=顯式全配置路徑)。 */
93
+ argon?: Argon3Config;
94
+ /** 舊世代讀面 opt-in:true=全推導;false/undefined=falsable 預設 `{ wrap: false }`(wrap 舊契約回落腿關閉);
95
+ * 物件形=顯式欄位優先、缺欄由 app 推導(`wrap: false` 原樣保留)。 */
96
+ legacy?: VaultConfig['legacy'] | boolean;
97
+ /** 復原套件 opt-in:true=resolver 推導(wrapRec=`<app>1r.`、recKekHkdf=`<app>kek1`、零鹽契約);
98
+ * 物件形=顯式(呼叫端自管前綴與鹽域——=顯式路徑判別);false/undefined=無(recoverVault 拒)。 */
99
+ recovery?: VaultRecoverySpec | boolean;
100
+ /** PIN 第二因子(入口進位欄——resolver 不消費;VaultOptions 同欄)。 */
101
+ pin?: string;
102
+ /** Argon2id 載體選項(入口進位欄——resolver 不消費;VaultOptions 同欄)。 */
103
+ carrier?: ArgonCarrierOptions;
48
104
  }
49
105
  export interface VaultOptions extends VaultConfig {
50
106
  /** PIN 第二因子:create=寫 jr4d.;unlock=雙因子家族必帶。 */
@@ -60,6 +116,9 @@ export interface VaultServerRecord {
60
116
  /** 復原套件:recToken 只顯示一次(可經 bip39.ts 轉 24 詞);伺服器存 wrappedRec+recTokenHash。 */
61
117
  export interface VaultRecoveryKit {
62
118
  recToken: string;
119
+ /** 24 詞形(=recTokenToWords(recToken) 的 BIP39 轉寫;伺服器零存——bip39 轉寫層可逆再導出;
120
+ * recToken hex64 欄保留=雙形相容既有 kit)。 */
121
+ words: Bip39Words;
63
122
  wrappedRec: string;
64
123
  recTokenHash: string;
65
124
  }
@@ -94,14 +153,34 @@ export interface VaultHandle {
94
153
  pin?: string;
95
154
  }): Promise<VaultServerRecord>;
96
155
  }
97
- /** 新 vault:隨機 noteKey+最新家族包裹(pin=jr4d.,否則 jr4w.)+選配復原套件(jr1r.)。 */
98
- export declare function createVault(passphrase: string, opts: VaultOptions): Promise<{
156
+ /**
157
+ * deriveVaultPh1(passphrase, ph1Salt):高階 Vault API 的 PH1 登入憑證派生(hex64)。
158
+ * 本體委派 `derivePh1ArgonV3(pass, ph1Salt)`(PH1 v3:Argon2id(normalizePassphrase(pass), 鹽, m=64MiB, t=3, p=1)→hex64)
159
+ * ——語意單一真相同在 argon2.ts,本函式只是 vault 子路徑的入口收口位(呼叫端免跨 import argon2)。
160
+ *
161
+ * ⚠️ 契約三行(README PH1 節同款):
162
+ * 1. 鹽一經選定恆固定(per-product)——PH2 UNIQUE 約束:同一密語必須恆生同一 PH2;
163
+ * per-user 鹽會摧毀它。改鹽=帶內版本化換鹽尾碼重遷移,禁原地改語意。
164
+ * 2. `PH1_V3_SALT`('tacet-ph1-v2')禁當 saltArg 餵 v2 派生面 `derivePh1Argon`——
165
+ * 反之亦然:同鹽域值跨面餵入=NFKC-effective 密語與 v2 raw 密語同值撞 PH2(鹽撞位警告)。
166
+ * 3. 其他產品用自有鹽域——禁硬編 tacet 鹽當預設;必須顯式帶入 `ph1Salt`。
167
+ */
168
+ export declare function deriveVaultPh1(passphrase: string, ph1Salt: Uint8Array): Promise<string>;
169
+ /** 新 vault:隨機 noteKey+最新家族包裹(pin=<app>4d.,否則 <app>4w.)+選配復原套件(<app>1r.)。
170
+ * 前綴零手填態(resolver):`{ app }` 最小設定——全前綴由 app 決定性推導(resolveVaultConfig;
171
+ * `recovery: true` opt-in、PIN 帶值=同族尾碼推導 <app>4d. 不拒);顯式 `argon`/`cipher` 在場
172
+ * =顯式全配置路徑(零設定語意退出——缺欄照 validateConfig 拒)。輸入形 VaultOptions | VaultConfigInput。 */
173
+ export declare function createVault(passphrase: string, opts: VaultOptions | VaultConfigInput): Promise<{
99
174
  vault: VaultHandle;
100
175
  serverRecord: VaultServerRecord;
101
176
  recovery: VaultRecoveryKit | null;
102
177
  }>;
103
178
  /** 解鎖:serverRecord(wrapped+salt 成對)按前綴辨識家族(新→舊次序),舊世代照解;
104
- * needsUpgrade() 為真時呼叫端可於同一動線 upgrade(passphrase) 靜默重包(Argon 遷移契約同構)。 */
105
- export declare function unlockVault(passphrase: string, serverRecord: VaultServerRecord, opts: VaultOptions): Promise<VaultHandle>;
106
- /** 復原:recToken(hex64)+wrappedRec(jr1r.)→ handle(無包裹欄——呼叫端隨即 changePassphrase 設新密語)。 */
107
- export declare function recoverVault(recToken: string, wrappedRec: string, opts: VaultOptions): Promise<VaultHandle>;
179
+ * 前綴零手填態(resolver):整組前綴缺省=全前綴由 app 推導(resolveVaultConfig);顯式欄位
180
+ * 在場=顯式全配置路徑(零推導)。needsUpgrade() 為真時呼叫端可於同一動線
181
+ * upgrade(passphrase) 靜默重包(Argon 遷移契約同構)。輸入形 VaultOptions | VaultConfigInput。 */
182
+ export declare function unlockVault(passphrase: string, serverRecord: VaultServerRecord, opts: VaultOptions | VaultConfigInput): Promise<VaultHandle>;
183
+ /** 復原:recToken(hex64)+wrappedRec(<app>1r.)→ handle(無包裹欄——呼叫端隨即 changePassphrase 設新密語)。
184
+ * 前綴零手填態同上:recovery 未配置=ERR_VAULT_CONFIG(零鹽選擇不可靜默——opt-in 鐵律;resolver 對
185
+ * `recovery: true` 推導零鹽契約、物件形=顯式零動)。輸入形 VaultOptions | VaultConfigInput。 */
186
+ export declare function recoverVault(recToken: string | string[], wrappedRec: string, opts: VaultOptions | VaultConfigInput): Promise<VaultHandle>;