@tacet-ink/journal-core 0.2.1 → 0.2.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
21
- npm run verify # 429 assertions, all green, run against the real modules (no mocks)
21
+ npm run verify # 432 assertions, all green, run against the real modules (no mocks)
22
22
  ```
23
23
 
24
24
  ```ts
@@ -48,6 +48,7 @@ run: `node --experimental-strip-types example.ts`
48
48
 
49
49
  完整 API 見[使用(Usage)](#使用usage)節。Live product: [https://tacet.ink](https://tacet.ink)
50
50
  (驗證頁:[https://tacet.ink/verify](https://tacet.ink/verify))。
51
+ 安全回報流程見 [SECURITY.md](./SECURITY.md)、版本歷史見 [CHANGELOG.md](./CHANGELOG.md)。
51
52
 
52
53
  ## 設計(Design)
53
54
 
@@ -91,7 +92,7 @@ Tacet 部署實例(config 傳入):
91
92
  | `jr1c.` | 附件密文(image attachments;選配) | 同筆記金鑰(noteKey/guest key 由呼叫端決定) | AES-GCM,AAD `jr1a:<noteId>:<attachId>` |
92
93
  | `jr1d.` | 本機 IDB stored 密文(notes store;選配) | 同筆記金鑰(呼叫端注入) | AES-GCM,AAD 綁 note_id,payload 自帶 `v` 欄 |
93
94
  | `jr1l.` | 本機包裹(PWA session 期免重打密語;選配) | deriveGuestKey=K_u 同 guest 面(identity 派生、passphrase-free) | 同 jr1w. 形,AAD `notekey-local` |
94
- | `jr1r.` | 復原套件包裹(wrappedRec 專用;v0.2.0 批卡⑤,選配,與 recKekHkdf 兩欄一體) | HKDF-SHA256(ikm=recToken, salt=recKekSalt?, info=`journal-kek-rec-v1:`+recKekHkdf)——兩欄齊備才寫 jr1r. 面 | 同 jr1w. 形,AAD `notekey-rec`(部分配置拒寫;舊契約面 jr1w.+PBKDF2+AAD `notekey` 永不變——新世代讀舊寫新雙試) |
95
+ | `jr1r.` | 復原套件包裹(wrappedRec 專用;v0.2.0 世代,選配,與 recKekHkdf 兩欄一體) | HKDF-SHA256(ikm=recToken, salt=recKekSalt?, info=`journal-kek-rec-v1:`+recKekHkdf)——兩欄齊備才寫 jr1r. 面 | 同 jr1w. 形,AAD `notekey-rec`(部分配置拒寫;舊契約面 jr1w.+PBKDF2+AAD `notekey` 永不變——新世代讀舊寫新雙試) |
95
96
  | `jr1w.` | passphrase 包裹+復原套件包裹(未配置世代) | PBKDF2-SHA256 600k(復原套件 KEK=PBKDF2(recToken, recSaltPrefix‖identity)) | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey`(復原套件與 passphrase 同 AAD 慣例=未配置世代借形) |
96
97
  | `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
97
98
  | `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
@@ -117,7 +118,7 @@ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob
117
118
  ## 驗證(Verification)
118
119
 
119
120
  ```sh
120
- npm run verify # 429 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
121
+ npm run verify # 432 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
121
122
  # extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
122
123
  # server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
123
124
  # + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
@@ -178,8 +179,8 @@ PH1 v3(密語正規化世代):`derivePh1ArgonV3(pass, saltArg?)` 預設鹽
178
179
  (`'tacet-ph1-v2'`,新鹽域=世代 ph2 命名空間分離——同鹽域會讓 v3 'PASS' 與 v2 世代
179
180
  raw 'PASS' 撞同一 ph2)。派生輸入吃 `normalizePassphrase`(NFKC-only;大小寫摺疊禁絕=
180
181
  大小寫差恆不同 ph2);v2/legacy 派生面零變更(舊帳戶憑證 raw 契約)。兩代 ph2 可並存查表=
181
- tacet 遷移層職責(v0.2.0 批卡②)。
182
- ⚠️ 鹽撞位警告(MINOR-5):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的 `saltArg` 餵入(反之亦然)——
182
+ tacet 遷移層職責(v0.2.0 世代收口)。
183
+ ⚠️ 鹽撞位警告:`PH1_V3_SALT` 禁當 `derivePh1Argon` 的 `saltArg` 餵入(反之亦然)——
183
184
  把 v3 鹽域值餵 v2 派生(或 fork 把同一 saltArg 帶到兩面)=NFKC-effective 密語與 v2 raw 密語
184
185
  同值撞 ph2,跨世代帳戶空間混合。v3 的 `saltArg` 必須異於本 product 的 v2 鹽域值。
185
186
 
@@ -210,7 +211,7 @@ hash-wasm 並以 `setArgonLoader()` 注入(詳 `src/client/argon2.ts` 檔頭
210
211
 
211
212
  版本歷史見 [CHANGELOG.md](./CHANGELOG.md)。
212
213
 
213
- MIT。安全問題聯絡 tacetink.csd@gmail.com(安全回報流程見 [SECURITY.md](./SECURITY.md))。
214
+ MIT。安全問題聯絡 security@tacet.ink(安全回報流程見 [SECURITY.md](./SECURITY.md))。
214
215
 
215
216
  ## English Summary
216
217
 
@@ -256,7 +257,7 @@ PH1→PH2 login flow, inbound cipher/package validation and paired key-package s
256
257
  (`auth.ts`; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
257
258
  fixed-window rate limiting on D1 (`ratelimit.ts`), shared CORS/hash utilities.
258
259
 
259
- **Verification.** `npm run verify` runs 429 assertions against the real modules (no mocks):
260
+ **Verification.** `npm run verify` runs 432 assertions against the real modules (no mocks):
260
261
  roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
261
262
  isolation, payload tampering, RFC 9106 KAT, and a 200-vector BIP39 cross-check.
262
263
 
@@ -272,4 +273,5 @@ knowledge.
272
273
 
273
274
  ## License
274
275
 
275
- MIT. Security contact: tacetink.csd@gmail.com.
276
+ MIT. Security contact: security@tacet.ink. Version history: [CHANGELOG.md](./CHANGELOG.md).
277
+ Security reporting process: [SECURITY.md](./SECURITY.md).
@@ -0,0 +1,31 @@
1
+ -- 0011-ph2-ladder.sql:密語正規化 v3 世代 ladder 表。
2
+ -- 前置:normalizePassphrase(NFKC-only)+jr4w./jr4d.
3
+ -- 前綴家族+PH1 v3 鹽域(PH1_V3_SALT='tacet-ph1-v2')+derivePh1ArgonV3。
4
+ --
5
+ -- (a+)方案(設計裁定):遷移時舊 ph2 值入 server 內部 ladder 表,
6
+ -- users 表零 schema 變更、ph2 欄 UNIQUE 恆動——client 舊 bundle 的單欄 v2-first 查表
7
+ -- 在遷移後仍對既有帳戶命中(查 users.ph2 現值=v2 形),行為不變。
8
+ -- ph2Kind='v3' 語意(帳戶已升 normalizePassphrase 世代)由 ladder 表承載:
9
+ -- login 現值未命中 → ladder 命中(同密語 v3 形)=帳戶是 v3 世代。
10
+ --
11
+ -- 統計面聚合量測(量測母型:只 COUNT,零 PII):
12
+ -- SELECT COALESCE(l.ph2_kind, 'v2') AS ph2_kind, COUNT(*) AS n FROM users u
13
+ -- LEFT JOIN ph2_ladder l ON l.ph2 = u.ph2 GROUP BY COALESCE(l.ph2_kind, 'v2');
14
+ -- (ph2Kind v3 收斂觀測點)
15
+ --
16
+ PRAGMA defer_foreign_keys = TRUE;
17
+
18
+ -- PK 面裁定(值主權威定形):ph2 本身為 PRIMARY KEY(值主權威——同一舊值恰一列,
19
+ -- 最新持有者勝:A 遷移落 ladder(V→A) 後,若 V 值幽靈帳戶後建並同樣遷移,upsert
20
+ -- 以 ON CONFLICT(ph2) 覆蓋為 (V→B),查表恆回唯一列)。account_id NOT NULL+UNIQUE
21
+ -- index(一帳戶可有多舊值歷列:legacy→v2→v3 每段遷移各存一段舊值);FK CASCADE
22
+ -- 隨帳戶刪除清列(fork delete-account 面零新語句)。表列可重建(v2 形重新遷移即重落)。
23
+ CREATE TABLE IF NOT EXISTS ph2_ladder (
24
+ ph2 TEXT PRIMARY KEY NOT NULL, -- 舊值本身(值主權威;查表鍵=主鍵)
25
+ account_id TEXT NOT NULL, -- 持有者(UNIQUE index;帳戶刪除即清)
26
+ ph2_kind TEXT NOT NULL, -- 舊值當年形:'legacy'|'v2'
27
+ upgraded_at INTEGER NOT NULL, -- 舊形被覆蓋的時刻(時鐘面;遷移率觀測)
28
+ FOREIGN KEY (account_id) REFERENCES users(account_id) ON DELETE CASCADE
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_ph2_ladder_ph2 ON ph2_ladder(ph2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacet-ink/journal-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
@@ -46,6 +46,7 @@
46
46
  "files": [
47
47
  "src",
48
48
  "scripts",
49
+ "migrations",
49
50
  "README.md",
50
51
  "LICENSE"
51
52
  ],
@@ -1372,6 +1372,8 @@ console.log('\n[16] 常駐毒化矩陣(v3 家族語意毒+舊家族不變毒
1372
1372
  // node_modules 走 symlink(worktree 慣例 cp 語意不跟隨):本閘執行樹一定有 node_modules
1373
1373
  //(@scure 對照組+typescript)——毒化樹補 symlink 即可;缺席環境(bare npm ci 後無 install)
1374
1374
  // 本閘自己在主樹就跑不起來,不是毒化矩陣的責任面。
1375
+ // ([18] 毒化窗無此補線——t_ce675413 r1 已知差異:拷貝樹環境缺 @scure 參照組時
1376
+ // 內層輸出恰帶 [2] 參照組 FAIL 1 面=可預期環境面,非缺陷;本機常態在場照綠。)
1375
1377
  exec16('ln', ['-s', nodeEnvOk ? coreNM : '', path16.join(tree16, 'node_modules')], { cwd: tree16, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
1376
1378
  };
1377
1379
  const runGate16 = (): { rc: number; out: string } => {
@@ -1759,8 +1761,8 @@ console.log('\n[18] 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊
1759
1761
  // ── 源碼窗靜態錨(本地段結構——毒化形即計數/窗錨變異) ──
1760
1762
  const ncSrc18 = await srcOf('../src/client/note-crypto.ts');
1761
1763
  const localWin18 = ncSrc18.slice(ncSrc18.indexOf('── 本機包裹'), ncSrc18.indexOf('── 日記密文入口'));
1762
- await A('[18] 源碼窗:窗尾 floor 三值帳(真窗 1984B/Δ_p18b≈70B→摘後 1933 持/餘裕 +74B——floor 是帳漂移承重件,隨窗漂移即帳錯)', localWin18.length > 1910);
1763
- await A('[18] 源碼窗:本機段零 throw 面(ERR_WRAP_NOT_CONFIGURED 禁令——未配置=無寫入無拋;P18d 毒化咬復活)',
1764
+ await A('[18] 源碼窗:窗尾 floor 插值帳(真窗 ' + localWin18.length + ' 字元當地量——floor 是帳漂移承重件,隨窗漂移即帳錯)', localWin18.length > 1910, 'win=' + localWin18.length);
1765
+ await A('[18] 源碼窗:本機段零 ERR_WRAP_NOT_CONFIGURED 禁令面(throw 復活即反——毒化咬復活)',
1764
1766
  !localWin18.includes('ERR_WRAP_NOT_CONFIGURED'));
1765
1767
  await A('[18] 源碼窗:寫面唯 cfg.wrapLocal seal+舊借形零殘留(v1 借用面復活即反)',
1766
1768
  localWin18.split('sealNoteKey(cfg.wrapLocal').length === 2 && !localWin18.includes('sealNoteKey(cfg.wrap,'));
@@ -1829,6 +1831,8 @@ console.log('\n[18] 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊
1829
1831
  const s0 = fs18!.readFileSync!(p, 'utf8');
1830
1832
  const cnt = s0.split(needle).length - 1;
1831
1833
  if (cnt !== 1) { await A(label + '(毒化 needle 對位自檢——錨缺席=runner 設計錯非閘病)', false, 'needle cnt=' + cnt); return; }
1834
+ // 本拷貝樹無 node_modules symlink(對照 [16] mkTree 補線形=[18] 已知差異——
1835
+ // 缺 @scure 參照組環境的內層輸出帶 [2] 參照組 FAIL 1 面=可預期環境面,非缺陷)。
1832
1836
  fs18!.writeFileSync!(p, s0.split(needle).join(replacement), 'utf8');
1833
1837
  const g = runGate18(dir);
1834
1838
  const failLines18 = g.out.split('\n').filter((l: string) => l.trimStart().startsWith('✗') && l.includes('[18]'));
@@ -1845,10 +1849,10 @@ console.log('\n[18] 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊
1845
1849
  fs18!.rmSync!(dir, { recursive: true, force: true });
1846
1850
  }
1847
1851
  };
1848
- // P18 摘 fresh-face(三元→null)→ stored jr1l. blob 通路讀死+源碼窗 floor 面翻(窗 shrink 1833<1910;throw 禁令面由 P18d 獨立承載)
1852
+ // P18 摘 fresh-face(三元→null)→ stored jr1l. blob 通路讀死+源碼窗 floor 面翻(窗 shrink 1829<1910;throw 禁令面由 P18d 獨立承載)。floor 標籤插值當地量(窗長一變硬寫數字即靜默失真帳面——標籤插值即封口)
1849
1853
  await withPoisonRun18('[18] 毒化 P18 gate-rerun(P18:摘 fresh-face 三元→null)',
1850
1854
  needlePA18, '\n const fresh = null;',
1851
- ['空-held decrypt 經本機包裹通路', '自癒後二次讀回', '窗尾 floor 三值帳'], 3);
1855
+ ['空-held decrypt 經本機包裹通路', '自癒後二次讀回', '窗尾 floor 插值帳'], 3);
1852
1856
  // P18b 摘自癒回寫 → 讀面照解(通路讀面不依賴回寫線)+回寫線計數窗錨翻面
1853
1857
  await withPoisonRun18('[18] 毒化 P18b gate-rerun(P18b:摘自癒回寫)',
1854
1858
  needlePB18, ' return legacy;',
@@ -1862,18 +1866,37 @@ console.log('\n[18] 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊
1862
1866
  await withPoisonRun18('[18] 毒化 P18d gate-rerun(P18d:storeLocalWrap 守衛復活 throw)',
1863
1867
  ' if (!cfg.wrapLocal) return;',
1864
1868
  " if (!cfg.wrapLocal) throw new Error('ERR_WRAP_NOT_CONFIGURED');",
1865
- ['本機段零 throw 面'], 1);
1866
- // 真樹 needle 對位錨+byte-exact:毒化全程跑
1867
- // 拷貝樹,真樹 sha256 毒前毒後恆等=零突變帳;needle 恰 1×4 計數防線=誤改斷言窗本體
1868
- // (含守衛行)即 RED——名實一致帳)。
1869
+ ['本機段零 ERR_WRAP_NOT_CONFIGURED 禁令面'], 1);
1870
+ // 真樹 needle 對位錨+byte-exact:毒化全程跑拷貝樹;真樹 sha256 毒前毒後恆等=
1871
+ // 零突變帳——對照兩端帶 hex64 形狀條;hex64 形狀條帶 designated 毒化案(卡面指定)=
1872
+ // 真守衛拒異形輸入(鍛造非 hex digest+undefined——regex.test 恆 false;缺席=斷言 RED 面,
1873
+ // shaOk18(undefined)=false);恆真形毒化對異形輸入放行→designated 案 RED;守衛毒化不過 withPoisonRun18
1874
+ //(拷貝樹型)——in-process 毒殺+還原一對帳,不動 tree18 封閉集。
1875
+ // needle 恰 1×4 計數防線=誤改斷言窗本體(含守衛行)即 RED——名實一致帳。
1876
+ const shaOk18 = (x: string): boolean => /^[0-9a-f]{64}$/.test(x);
1869
1877
  const srcAfter18 = await srcOf('../src/client/note-crypto.ts');
1870
- await A('[18] 還原 byte-exact 對照(真樹 sha256 毒前毒後恆等——' + shaBefore18.slice(0, 12) + '…)', sha18(srcAfter18) === shaBefore18);
1878
+ await A('[18] 還原 byte-exact 對照(真樹 sha256 毒前毒後恆且為 hex64——' + shaBefore18.slice(0, 12) + '…)',
1879
+ shaOk18(shaBefore18) && sha18(srcAfter18) === shaBefore18);
1871
1880
  await A('[18] 真樹 needle 對位錨(恰 1×4——真樹零突變的行程帶)',
1872
1881
  srcAfter18.split(needlePA18).length === 2 && srcAfter18.split(needlePB18).length === 2
1873
1882
  && srcAfter18.split(needlePC18).length === 2
1874
1883
  && srcAfter18.split(' if (!cfg.wrapLocal) return;').length === 2);
1875
1884
  await A('[18] 毒化樹零殘留(本 run 喚出 ' + trees18.length + ' 棵全清——封閉集判準)',
1876
1885
  trees18.length === 4 && trees18.every((t) => !fs18!.rmSync || !existsSync18(t)));
1886
+ // designated 毒化案(卡面指定——hex64 形狀條;in-process 毒殺即還原一對帳,不動 tree18 封閉集):
1887
+ // 守衛恆真形 → byte-exact 主帳對毒化守衛判 RED 預期;還原後真守衛復活=帳面復綠——
1888
+ // 「形條缺席咬不到」自我證明(卡面新增咬痕面=真形 false/惡形 true 兩面都咬)。
1889
+ {
1890
+ const shaOk18Evil = (x: string): boolean => true;
1891
+ const fakeDigest = 'z'.repeat(64); // 非 hex64 鍛造 digest(守衛缺席時必放行的形)
1892
+ const greenWithReal = shaOk18(shaBefore18) && !shaOk18(fakeDigest)
1893
+ && !shaOk18(undefined as unknown as string);
1894
+ const redWithEvil = !shaOk18Evil(shaBefore18) || shaOk18Evil(fakeDigest)
1895
+ || shaOk18Evil(undefined as unknown as string);
1896
+ await A('[18] hex64 守衛 designated 毒化(恆真形 → 鍛造非 hex digest+undefined 輸入放行;真守衛拒——形條缺席咬不到的形證)',
1897
+ greenWithReal && redWithEvil,
1898
+ 'green=' + String(greenWithReal) + ' red=' + String(redWithEvil));
1899
+ }
1877
1900
  }
1878
1901
  }
1879
1902
  }
@@ -2240,7 +2263,8 @@ console.log('\n[20] 復原套件專用前綴(wrapRec=jr1r. opt-in)+KEK_rec
2240
2263
  fs20!.rmSync!(dirP, { recursive: true, force: true });
2241
2264
  }
2242
2265
  }
2243
- // 還原自證:毒化樹零殘留(本 run 喚出 6 棵全清——封閉集判準;出生即棄拷貝樹+真樹 needle 計數自證)
2266
+ // 常駐自證:毒化樹零殘留(本 run 喚出 6 棵全清——封閉集判準;出生即棄拷貝樹+真樹 needle 計數自證)
2267
+ // 錨名:真樹 needle 對位錨(t_ce675413 命名同款)——樹出生即棄,非毒化還原動作。
2244
2268
  const srcAfter20 = (getBuiltin20('node:fs') as { readFileSync?: (p: string, e?: string) => string })!.readFileSync!(
2245
2269
  new URL('../src/client/note-crypto.ts', import.meta.url).pathname, 'utf8');
2246
2270
  await A('[20] 真樹 needle 對位錨(真樹零突變的行程帶)',