@tacet-ink/journal-core 0.1.3 → 0.1.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
@@ -21,8 +21,9 @@
21
21
  前綴全部可配置,AAD(防搬移繫結)由呼叫端傳入。
22
22
  - **extractable 鐵律**:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
23
23
  import 當下就必須 `extractable=true`;KEK/guest key 恆 nonextractable。
24
- - **opt-in 原語**:選配契約(wrapDual/wrapShare/pinLock)未配置即拒絕,
25
- 不配置的產品行為完全不受影響。
24
+ - **opt-in 原語**:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal)
25
+ 未配置即拒絕,各 fork 未選用的原語行為不受影響(cipherGuest 未配置時 bound 路徑不受牽連,
26
+ 解密面 guest 家族整面拒絕、不明字串與畸形空字串配置不當明文顯示)。
26
27
 
27
28
  ## 模組
28
29
 
@@ -37,7 +38,7 @@
37
38
  | `src/server/ratelimit.ts` | per-IP 滑動視窗限流(D1 計數,併發安全版) |
38
39
  | `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
39
40
 
40
- ## 前綴契約(家族表:8 個資料前綴+閘對照組 jr1g.)
41
+ ## 前綴契約(家族表:10 個資料前綴+閘對照組 jr1g.)
41
42
 
42
43
  前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。
43
44
  Tacet 部署實例(config 傳入):
@@ -46,6 +47,8 @@ Tacet 部署實例(config 傳入):
46
47
  | --- | --- | --- | --- |
47
48
  | `jr1u.` | guest 時代密文 | K_u = SHA-256(guestKdfPrefix ‖ identity) | AES-GCM |
48
49
  | `jr1b.` | 綁定時代密文 | 隨機 256-bit noteKey | AES-GCM,AAD 綁 noteId |
50
+ | `jr1c.` | 附件密文(image attachments;選配) | 同筆記金鑰(noteKey/guest key 由呼叫端決定) | AES-GCM,AAD `jr1a:<noteId>:<attachId>` |
51
+ | `jr1d.` | 本機 IDB stored 密文(notes store;選配) | 同筆記金鑰(呼叫端注入) | AES-GCM,AAD 綁 note_id,payload 自帶 `v` 欄 |
49
52
  | `jr1w.` | passphrase 包裹+復原套件包裹 | PBKDF2-SHA256 600k | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey` |
50
53
  | `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
51
54
  | `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
@@ -59,7 +62,7 @@ guest 密文前綴在閘對照組另驗 `jr1g.`(閘自造前綴,驗證品牌
59
62
  ## 驗證
60
63
 
61
64
  ```sh
62
- npm run verify # 102 斷言對真模組(禁鏡像):roundtrip/AAD 防搬移/extractable/時代隔離/
65
+ npm run verify # 127 斷言對真模組(禁鏡像):roundtrip/AAD 防搬移/extractable/時代隔離/
63
66
  # 跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組
64
67
  ```
65
68
 
@@ -90,7 +93,7 @@ const cfg = {
90
93
  cipherBound: 'jr1b.',
91
94
  wrap: 'jr1w.',
92
95
  store: makeKeyStore({ brand: 'myapp' }),
93
- // wrapDual / wrapShare / pinLock:選配,未配置即拒絕
96
+ // cipherGuest / wrapDual / wrapShare / pinLock / cipherAttach / cipherLocal:選配,未配置即拒絕
94
97
  };
95
98
 
96
99
  const held = makeHeldKey();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacet-ink/journal-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {
@@ -22,6 +22,10 @@ import {
22
22
  wrapNoteKeyDual,
23
23
  unwrapNoteKeyDual,
24
24
  normalizePin,
25
+ encryptAttach,
26
+ decryptAttach,
27
+ encryptLocal,
28
+ decryptLocal,
25
29
  } from '../src/client/note-crypto.ts';
26
30
  import {
27
31
  verifyArgonKat,
@@ -360,18 +364,26 @@ await A('23 詞 → null', (await bip.wordsToRecToken(kitWords.slice(0, 23).join
360
364
  await A('25 詞 → null', (await bip.wordsToRecToken([...kitWords, kitWords[0]].join(' '))) === null);
361
365
  await A('詞表外 → null', (await bip.wordsToRecToken(kitWords.slice(0, 23).concat('notaword').join(' '))) === null);
362
366
 
363
- // checksum 面:錯一詞(詞表內不同詞)→ 幾乎必被 checksum 抓;64 樣本全抓(理論 255/256,樣本容許 ≥63)
367
+ // checksum 面:錯一詞(詞表內不同詞)→ 幾乎必被 checksum 抓;固定種子 64 樣本全抓。
368
+ // (t_d1cf3846:真隨機取樣時 P(漏 ≥2)≈0.4%=偶發 62/64 flake 實證——改 xorshift 常數
369
+ // 種子樣本集(bip39.seededSampleBytes),向量可重放、閘輸出逐輪恆定;種子窗 1..64
370
+ // 經五窗 320 樣本探針驗證全抓後採用,非挑窗)
364
371
  const wlArr: readonly string[] = wl.wordlist;
365
372
  let checksumCaught = 0;
366
373
  for (let t = 0; t < 64; t++) {
367
- const ws = [...(await bip.generateBip39Words())];
374
+ const ws = [...(await bip.generateBip39Words(bip.seededSampleBytes(1 + t)))];
368
375
  const idx = t % 24;
369
376
  let alt = wlArr[(wlArr.indexOf(ws[idx]) + 1 + t) % 2048];
370
377
  if (alt === ws[idx]) alt = wlArr[(wlArr.indexOf(ws[idx]) + 1) % 2048];
371
378
  ws[idx] = alt;
372
379
  if ((await bip.wordsToRecToken(ws.join(' '))) === null) checksumCaught++;
373
380
  }
374
- await A('錯一詞 64 樣本 ≥63 被抓(checksum 8-bit)', checksumCaught >= 63, `caught=${checksumCaught}/64`);
381
+ await A('錯一詞固定種子 64 樣本全抓(checksum 8-bit;flake 歸零)', checksumCaught === 64, `caught=${checksumCaught}/64`);
382
+ const detA = await bip.generateBip39Words(bip.seededSampleBytes(20260919));
383
+ const detB = await bip.generateBip39Words(bip.seededSampleBytes(20260919));
384
+ const detC = await bip.generateBip39Words(bip.seededSampleBytes(20260920));
385
+ await A('種子樣本確定性(同 seed 逐字同詞、異 seed 異詞;產品面無參數真隨機不變)',
386
+ detA.join(' ') === detB.join(' ') && detA.join(' ') !== detC.join(' '));
375
387
 
376
388
  // spot-check 抽驗索引
377
389
  const picks = bip.spotCheckIndexes();
@@ -404,6 +416,109 @@ await A('words 派生 hex64 包裹前綴 jr1w.', kitWrapped.startsWith('jr1w.'))
404
416
  await A('words 派生 hex64 unwrap 救回 noteKey',
405
417
  (await unwrapNoteKeyWithRecToken(TACET, kitWrapped, kitHexAsToken, identityA)) !== null);
406
418
 
419
+ // ── 10. 附件密文(jr1c.,image attachments;opt-in 未配置即拒) ───────────────
420
+
421
+ console.log('\n[10] 附件密文(jr1c. cipherAttach opt-in)');
422
+ const bareCfgAttach: NoteCryptoConfig = { ...TACET }; // 無 cipherAttach 欄
423
+ let attachCfgThrow = '';
424
+ try { await encryptAttach(bareCfgAttach, noteKey, '{"v":1}', 'jr1a:n1:a1'); } catch (e) { attachCfgThrow = (e as Error).message; }
425
+ await A('未配置 cipherAttach → encryptAttach throw', attachCfgThrow === 'ERR_ATTACH_NOT_CONFIGURED', attachCfgThrow);
426
+
427
+ const TACET_ATTACH: NoteCryptoConfig = { ...TACET, cipherAttach: 'jr1c.' };
428
+ const attachAad = 'jr1a:note-abc:att-001';
429
+ const attachPayload = JSON.stringify({ v: 1, kind: 'img', mime: 'image/jpeg', w: 2048, h: 1365, b64: b64(new Uint8Array(2048)) });
430
+ const attachCt = await encryptAttach(TACET_ATTACH, noteKey, attachPayload, attachAad);
431
+ await A('附件前綴 jr1c.', attachCt.startsWith('jr1c.'));
432
+ await A('附件 roundtrip(顯式 noteKey)',
433
+ (await decryptAttach(TACET_ATTACH, noteKey, attachCt, attachAad)) === attachPayload);
434
+ await A('附件 AAD 防搬移:錯 attachment_id → null',
435
+ (await decryptAttach(TACET_ATTACH, noteKey, attachCt, 'jr1a:note-abc:att-999')) === null);
436
+ await A('附件錯鑰匙 → null',
437
+ (await decryptAttach(TACET_ATTACH, await generateNoteKey(), attachCt, attachAad)) === null);
438
+
439
+ // ── 11. 本機 IDB 密文(jr1d. cipherLocal opt-in)+ guest 選配拒絕面 ──────────
440
+
441
+ console.log('\n[11] 本機 IDB 密文(jr1d. cipherLocal opt-in)+ guest 選配');
442
+ const bareCfgLocal: NoteCryptoConfig = { ...TACET }; // 無 cipherLocal 欄
443
+ let localCfgThrow = '';
444
+ try { await encryptLocal(bareCfgLocal, noteKey, '{}', 'jr1:n1'); } catch (e) { localCfgThrow = (e as Error).message; }
445
+ await A('未配置 cipherLocal → encryptLocal throw', localCfgThrow === 'ERR_LOCAL_NOT_CONFIGURED', localCfgThrow);
446
+
447
+ const TACET_LOCAL: NoteCryptoConfig = { ...TACET, cipherLocal: 'jr1d.' };
448
+ const localAad = 'jr1:note-abc';
449
+ const localPayload = JSON.stringify({ v: 1, text: '今天寫了一點東西。', title: '標題' });
450
+ const localCt = await encryptLocal(TACET_LOCAL, noteKey, localPayload, localAad);
451
+ await A('本機前綴 jr1d.', localCt.startsWith('jr1d.'));
452
+ await A('本機 roundtrip(bound noteKey)', (await decryptLocal(TACET_LOCAL, noteKey, localCt, localAad)) === localPayload);
453
+ await A('本機 AAD 防搬移:錯 note_id → null', (await decryptLocal(TACET_LOCAL, noteKey, localCt, 'jr1:note-zzz')) === null);
454
+ await A('本機錯鑰匙 → null', (await decryptLocal(TACET_LOCAL, await generateNoteKey(), localCt, localAad)) === null);
455
+
456
+ // Era 0 形:guest key(呼叫端派生注入,與 IDB store 層同構)
457
+ const localGuest = await (async () => {
458
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode('tacet-note-u1' + identityA)));
459
+ return crypto.subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
460
+ })();
461
+ const localCtGuest = await encryptLocal(TACET_LOCAL, localGuest, localPayload, localAad);
462
+ await A('本機 roundtrip(Era 0 guest key)', (await decryptLocal(TACET_LOCAL, localGuest, localCtGuest, localAad)) === localPayload);
463
+
464
+ // 跨前綴隔離:jr1d. 非 jr1c./jr1b./jr1u.;他家族入口不吃本機密文
465
+ await A('本機密文非附件/筆記前綴',
466
+ !localCt.startsWith('jr1c.') && !localCt.startsWith('jr1b.') && !localCt.startsWith('jr1u.'));
467
+ await A('decryptAttach 拒收 jr1d.', (await decryptAttach(TACET_ATTACH, noteKey, localCt, localAad)) === null);
468
+ await A('decryptLocal 拒收 jr1c.', (await decryptLocal(TACET_LOCAL, noteKey, attachCt, attachAad)) === null);
469
+ await A('decryptNote 對未配置的 jr1d.=未知前綴相容層原樣(不誤判不誤解)',
470
+ (await decryptNote({ ...TACET }, makeHeldKey(), localCt, localAad, { current: () => identityA })) === localCt);
471
+
472
+ // payload 竄改 → GCM 驗證失敗 → null(iv 區 byte 3/ct 尾最後一位元組;索引以解碼後
473
+ // 位元組面計——b64 字串索引會越界靜默 no-op=假紅,閘毒化同族教訓)
474
+ const tamperLocal = async (byteIdx: number): Promise<boolean> => {
475
+ const bin = atob(localCt.slice('jr1d.'.length));
476
+ const bytes = new Uint8Array(bin.length);
477
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
478
+ if (byteIdx < 0 || byteIdx >= bytes.length) return false; // 索引自守衛:越界即顯性失敗
479
+ bytes[byteIdx] ^= 0x01;
480
+ let out = '';
481
+ for (let i = 0; i < bytes.byteLength; i++) out += String.fromCharCode(bytes[i]);
482
+ return (await decryptLocal(TACET_LOCAL, noteKey, 'jr1d.' + btoa(out), localAad)) === null;
483
+ };
484
+ await A('本機 payload 竄改 iv 區 → null', await tamperLocal(3));
485
+ await A('篡改索引越界=顯性失敗(runner 自檢:靜默 no-op 即假紅)', await tamperLocal(-1) === false);
486
+ await A('本機 payload 竄改 ct 尾 → null', await tamperLocal(59));
487
+
488
+ // guest 選配未配置拒絕面(2026-09-19 收編:cipherGuest 轉選配;未配置即拒鐵律)
489
+ const TACET_NOGUEST: NoteCryptoConfig = { ...TACET, cipherGuest: undefined };
490
+ const guestThrowsMsg = async (): Promise<string | null> => {
491
+ try { await encryptNote(TACET_NOGUEST, makeHeldKey(), note, 'noteId:n1', { current: () => identityA }); return null; }
492
+ catch (e) { return (e as Error).message; }
493
+ };
494
+ await A('未配置 cipherGuest → encryptNote guest 路徑 throw ERR_GUEST_NOT_CONFIGURED',
495
+ (await guestThrowsMsg()) === 'ERR_GUEST_NOT_CONFIGURED');
496
+ const heldBound = makeHeldKey();
497
+ heldBound.set(noteKey);
498
+ await A('未配置 guest 的 bound 加密不受牽連',
499
+ (await encryptNote(TACET_NOGUEST, heldBound, note, 'noteId:n1', { current: () => identityA })).startsWith('jr1b.'));
500
+ await A('未配置 guest → decryptNote guest 密文 null',
501
+ (await decryptNote(TACET_NOGUEST, makeHeldKey(), cipherGuest, 'noteId:n1', { current: () => identityA })) === null);
502
+ await A('未配置 guest → decryptNote 不明字串 null(guest 家族整面拒絕、不當明文顯示)',
503
+ (await decryptNote(TACET_NOGUEST, makeHeldKey(), 'jr9x.somestring', 'x', { current: () => identityA })) === null);
504
+ await A('配置 guest → decryptNote 舊明文相容層原樣(既有契約不變)',
505
+ (await decryptNote(TACET, makeHeldKey(), '純舊明文', 'x', { current: () => identityA })) === '純舊明文');
506
+ await A('既有 guest 配置 roundtrip 不受選配收編影響',
507
+ (await decryptNote(TACET, makeHeldKey(), cipherGuest, 'noteId:n1', { current: () => identityA })) === note);
508
+
509
+ // 畸形配置 ''(空字串)正規化面(follow-up ②,t_44239f5f):空字串前綴=startsWith('') 恆真,
510
+ // bound 列會被誤導 guest 分支回 null。正規化後:加密面與未配置同拒、解密面 bound 照解、
511
+ // guest 密文(他家族形)null——與「未配置」行為完全同構。
512
+ const TACET_EMPTYGUEST: NoteCryptoConfig = { ...TACET, cipherGuest: '' };
513
+ await A("畸形 guest 前綴 '' → encryptNote guest 路徑同 ERR_GUEST_NOT_CONFIGURED(truthiness 對稱)",
514
+ (await (async () => { try { await encryptNote(TACET_EMPTYGUEST, makeHeldKey(), note, 'noteId:n1', { current: () => identityA }); return null; } catch (e) { return (e as Error).message; } })()) === 'ERR_GUEST_NOT_CONFIGURED');
515
+ await A("畸形 guest 前綴 '' → decryptNote bound 密文照解(不誤導 guest 分支回 null)",
516
+ (await decryptNote(TACET_EMPTYGUEST, heldBound, cipherBound, 'noteId:n1', { current: () => identityA })) === note);
517
+ await A("畸形 guest 前綴 '' → decryptNote guest 密文 null(guest 家族整面拒絕)",
518
+ (await decryptNote(TACET_EMPTYGUEST, makeHeldKey(), cipherGuest, 'noteId:n1', { current: () => identityA })) === null);
519
+ await A("畸形 guest 前綴 '' → decryptNote 舊明文相容層同「未配置」形(null,不當明文)",
520
+ (await decryptNote(TACET_EMPTYGUEST, makeHeldKey(), '純舊明文', 'x', { current: () => identityA })) === null);
521
+
407
522
  // ── 決議 ────────────────────────────────────────────────────────────────────
408
523
 
409
524
  console.log(`\n${passed} 斷言全綠` + (failures.length ? `;${failures.length} 失敗` : ''));
@@ -74,13 +74,40 @@ function indexesToBytes(indexes: number[]): Uint8Array {
74
74
  return out; // 24 詞 = 264 bits = 33 bytes 整除,無餘位(11-bit 打包自洽)
75
75
  }
76
76
 
77
- /** 產生 24 詞套件(entropy 32B 隨機 → +8-bit checksum → 24 詞)。 */
78
- export async function generateBip39Words(): Promise<string[]> {
79
- const entropy = crypto.getRandomValues(new Uint8Array(32));
77
+ /** 產生 24 詞套件(entropy 32B 隨機 → +8-bit checksum → 24 詞)。
78
+ * entropyBytes 可注入(驗證閘固定種子樣本用;產品面不傳=真隨機契約不變)。 */
79
+ export async function generateBip39Words(entropyBytes?: Uint8Array): Promise<string[]> {
80
+ const entropy = entropyBytes ?? crypto.getRandomValues(new Uint8Array(32));
80
81
  const checksum = (await sha256(entropy))[0];
81
82
  return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
82
83
  }
83
84
 
85
+ // ── 驗證閘固定種子樣本(flake 歸零;t_d1cf3846)──────────────────────────────
86
+ //
87
+ // 背景:checksum 統計面「錯一詞 64 樣本 ≥63 被抓」用真隨機取樣時,P(漏 ≥2)≈0.4%,
88
+ // 偶發 62/64=閘 flake(2026-09-19 session 實證)。治本是固定種子:xorshift32 以
89
+ // 常數 seed 產生確定性位元組流(種子決定樣本集=向量可重放、閘輸出逐輪恆定)。
90
+ // 樣本集僅供驗證閘使用(checksum 是 BIP39 公開數學,非機密;產品面
91
+ // generateBip39Words() 不帶參數=crypto.getRandomValues 真隨機契約不變)。
92
+
93
+ function xorshift32Bytes(seed: number, len: number): Uint8Array {
94
+ let s = seed >>> 0;
95
+ if (s === 0) s = 0x9e3779b9; // xorshift 全零態死鎖防護
96
+ const out = new Uint8Array(len);
97
+ for (let i = 0; i < len; i++) {
98
+ s ^= s << 13; s >>>= 0;
99
+ s ^= s >>> 17;
100
+ s ^= s << 5; s >>>= 0;
101
+ out[i] = s & 0xff;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** 固定種子樣本集:seed 給定 → 32B 確定(xorshift32 常數種子流)。 */
107
+ export function seededSampleBytes(seed: number): Uint8Array {
108
+ return xorshift32Bytes(seed, 32);
109
+ }
110
+
84
111
  /** 24 詞 → recToken hex64。任何不符(字數/詞表外/checksum)回 null,不洩漏哪類錯。 */
85
112
  export async function wordsToRecToken(input: string | string[]): Promise<string | null> {
86
113
  const words = typeof input === 'string' ? normalizeWords(input) : input.map(w => w.normalize('NFKC').trim().toLowerCase());
@@ -27,8 +27,8 @@ export interface NoteCryptoConfig {
27
27
  guestKdfPrefix: string;
28
28
  /** 復原包裹 KDF salt 前綴,如 'journal-note-rec1:'。 */
29
29
  recSaltPrefix: string;
30
- /** 密文前綴:guest 時代,如 'jr1g.'。 */
31
- cipherGuest: string;
30
+ /** 密文前綴:guest 時代,如 'jr1g.'。未配置 = guest API 拒絕(未配置行為不變;2026-09-19 選配收編)。 */
31
+ cipherGuest?: string;
32
32
  /** 密文前綴:綁定時代,如 'jr1b.'。 */
33
33
  cipherBound: string;
34
34
  /** 金鑰包裹前綴,如 'jr1w.'。 */
@@ -39,6 +39,10 @@ export interface NoteCryptoConfig {
39
39
  pinSaltPrefix?: string;
40
40
  /** 分享包裹前綴(jrsw.,單篇分享連結 V1)。未配置 = share API 拒絕(未配置行為不變)。 */
41
41
  wrapShare?: string;
42
+ /** 附件密文前綴(jr1c.,image attachments)。未配置 = attach API 拒絕(未配置行為不變)。 */
43
+ cipherAttach?: string;
44
+ /** 本機 IDB 密文前綴(jr1d.,notes store stored 形)。未配置 = local API 拒絕(未配置行為不變)。 */
45
+ cipherLocal?: string;
42
46
  /** localStorage key store(品牌前綴由 keys.ts 管理)。 */
43
47
  store: import('./keys').KeyStore;
44
48
  }
@@ -87,7 +91,7 @@ export async function importAesGcm(raw: Uint8Array, extractable = false): Promis
87
91
  return crypto.subtle.importKey('raw', raw as BufferSource, { name: 'AES-GCM' }, extractable, ['encrypt', 'decrypt']);
88
92
  }
89
93
 
90
- async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey> {
94
+ export async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey> {
91
95
  const material = new TextEncoder().encode(cfg.guestKdfPrefix + identity);
92
96
  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', material));
93
97
  return importAesGcm(digest);
@@ -218,6 +222,49 @@ export async function decryptWithKey(key: CryptoKey, payload: Uint8Array, aad: s
218
222
  }
219
223
  }
220
224
 
225
+ // ── 附件密文(jr1c.,image attachments;金鑰由呼叫端決定) ────────────────────
226
+ //
227
+ // 附件用既有 noteKey(綁定後)或 guest key(Era 0 純本地),零新金鑰管理;
228
+ // AAD 帶 'jr1a:<note_id>:<attachment_id>'(綁篇+綁附件,跨列搬移必失敗)。
229
+ // 前綴 cfg opt-in(未配置即拒):兄弟 fork 不配置=API 拒絕,行為不變。
230
+
231
+ export async function encryptAttach(cfg: NoteCryptoConfig, key: CryptoKey, plaintext: string, aad: string): Promise<string> {
232
+ if (!cfg.cipherAttach) throw new Error('ERR_ATTACH_NOT_CONFIGURED');
233
+ return cfg.cipherAttach + await encryptWithKey(key, plaintext, aad);
234
+ }
235
+
236
+ export async function decryptAttach(cfg: NoteCryptoConfig, key: CryptoKey, cipher: string, aad: string): Promise<string | null> {
237
+ try {
238
+ if (!cfg.cipherAttach) return null;
239
+ if (!cipher.startsWith(cfg.cipherAttach)) return null;
240
+ return await decryptWithKey(key, unb64(cipher.slice(cfg.cipherAttach.length)), aad);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ // ── 本機 IDB 密文(jr1d.,notes store stored 形;金鑰由呼叫端決定) ──────────
247
+ //
248
+ // IDB 持久形密文:bound 態=held noteKey、Era 0=guest key(兩者皆由呼叫端注入;
249
+ // 鑰匙狀態機在呼叫端 store 層,core 只承載前綴與對稱本體)。payload 自帶 {v:1,...}
250
+ // 版本欄(呼叫端 JSON 面),前綴即可辨識;AAD 綁 note_id(呼叫端契約,跨列搬移必失敗)。
251
+ // 前綴 cfg opt-in(未配置即拒):兄弟 fork 不配置=API 拒絕,行為不變。
252
+
253
+ export async function encryptLocal(cfg: NoteCryptoConfig, key: CryptoKey, plaintext: string, aad: string): Promise<string> {
254
+ if (!cfg.cipherLocal) throw new Error('ERR_LOCAL_NOT_CONFIGURED');
255
+ return cfg.cipherLocal + await encryptWithKey(key, plaintext, aad);
256
+ }
257
+
258
+ export async function decryptLocal(cfg: NoteCryptoConfig, key: CryptoKey, cipher: string, aad: string): Promise<string | null> {
259
+ try {
260
+ if (!cfg.cipherLocal) return null;
261
+ if (!cipher.startsWith(cfg.cipherLocal)) return null;
262
+ return await decryptWithKey(key, unb64(cipher.slice(cfg.cipherLocal.length)), aad);
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
221
268
  // ── noteKey(時代 2)─────────────────────────────────────────────────────────
222
269
 
223
270
  /** 綁定當下生成:random 256-bit AES-GCM key。必須 extractable(包裹靠 exportKey)。 */
@@ -385,7 +432,8 @@ export interface IdentityProvider {
385
432
  current(): string;
386
433
  }
387
434
 
388
- /** 加密一則:綁定時代持有 noteKey → cipherBound;否則 guest key → cipherGuest。
435
+ /** 加密一則:綁定時代持有 noteKey → cipherBound;否則 guest key → cipherGuest
436
+ * (guest 前綴選配:未配置=ERR_GUEST_NOT_CONFIGURED,bound 路徑不受牽連)。
389
437
  * held 為空時先試本機包裹(reload / PWA 續存期免重打密語)。 */
390
438
  export async function encryptNote(
391
439
  cfg: NoteCryptoConfig,
@@ -401,11 +449,13 @@ export async function encryptNote(
401
449
  if (held.get()) {
402
450
  return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
403
451
  }
452
+ if (!cfg.cipherGuest) throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
404
453
  const guest = await deriveGuestKey(cfg, idp.current());
405
454
  return cfg.cipherGuest + await encryptWithKey(guest, plaintext, aad);
406
455
  }
407
456
 
408
- /** 解密一則:按前綴選鑰匙;無前綴 = 舊版明文(相容層)原樣返回;失敗回 null。 */
457
+ /** 解密一則:按前綴選鑰匙;無前綴 = 舊版明文(相容層)原樣返回(guest 前綴未配置時
458
+ * 不明字串一律 null=guest 家族整面拒絕,跨前綴鐵律不降級);失敗回 null。 */
409
459
  export async function decryptNote(
410
460
  cfg: NoteCryptoConfig,
411
461
  held: HeldKey,
@@ -414,12 +464,20 @@ export async function decryptNote(
414
464
  idp: IdentityProvider,
415
465
  ): Promise<string | null> {
416
466
  try {
417
- const isGuest = cipher.startsWith(cfg.cipherGuest);
467
+ // guest 前綴正規化(follow-up ②,t_44239f5f):畸形配置 ''(空字串)會讓
468
+ // startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
469
+ // falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
470
+ const guestPrefix = cfg.cipherGuest || undefined;
471
+ const isGuest = guestPrefix !== undefined && cipher.startsWith(guestPrefix);
418
472
  const isBound = cipher.startsWith(cfg.cipherBound);
419
- if (!isGuest && !isBound) return cipher; // 舊版明文(相容層):原樣顯示
473
+ if (!isGuest && !isBound) {
474
+ // 舊版明文(相容層)原樣返回;guest 前綴未配置=guest 家族整面拒絕(不明字串
475
+ // 一律 null——跨前綴鐵律「回 null 不降級」,避免他家族殘列被當明文顯示)。
476
+ return guestPrefix === undefined ? null : cipher;
477
+ }
420
478
  if (isGuest) {
421
479
  const guest = await deriveGuestKey(cfg, idp.current());
422
- return await decryptWithKey(guest, unb64(cipher.slice(cfg.cipherGuest.length)), aad);
480
+ return await decryptWithKey(guest, unb64(cipher.slice(guestPrefix!.length)), aad);
423
481
  }
424
482
  if (!held.get()) {
425
483
  const identity = idp.current();
package/src/index.ts CHANGED
@@ -8,6 +8,10 @@ export {
8
8
  generateNoteKey,
9
9
  encryptNote,
10
10
  decryptNote,
11
+ encryptAttach,
12
+ decryptAttach,
13
+ encryptLocal,
14
+ decryptLocal,
11
15
  wrapNoteKey,
12
16
  unwrapNoteKey,
13
17
  wrapNoteKeyWithRecToken,