@tacet-ink/journal-core 0.1.2 → 0.1.4
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 +13 -5
- package/package.json +1 -1
- package/scripts/verify-core-crypto.ts +105 -3
- package/src/client/bip39.ts +30 -3
- package/src/client/note-crypto.ts +63 -8
- package/src/index.ts +4 -0
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @tacet-ink/journal-core
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@tacet-ink/journal-core)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](./package.json)
|
|
6
|
+
[](https://github.com/tacet-ink/journal-core/actions/workflows/verify.yml)
|
|
7
|
+
|
|
3
8
|
零知識日記核心:端對端加密、密語即身份的 auth、傳輸與限流原語。
|
|
4
9
|
伺服器看不到任何一個字:passphrase 永不過線,密鑰包裹與密文都在客戶端完成。
|
|
5
10
|
|
|
@@ -16,8 +21,9 @@
|
|
|
16
21
|
前綴全部可配置,AAD(防搬移繫結)由呼叫端傳入。
|
|
17
22
|
- **extractable 鐵律**:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
|
|
18
23
|
import 當下就必須 `extractable=true`;KEK/guest key 恆 nonextractable。
|
|
19
|
-
- **opt-in 原語**:選配契約(wrapDual/wrapShare/pinLock
|
|
20
|
-
|
|
24
|
+
- **opt-in 原語**:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal)
|
|
25
|
+
未配置即拒絕,不配置的產品行為完全不受影響(cipherGuest 未配置時 bound 路徑不受牽連,
|
|
26
|
+
解密面 guest 家族整面拒絕、不明字串不當明文顯示)。
|
|
21
27
|
|
|
22
28
|
## 模組
|
|
23
29
|
|
|
@@ -32,7 +38,7 @@
|
|
|
32
38
|
| `src/server/ratelimit.ts` | per-IP 滑動視窗限流(D1 計數,併發安全版) |
|
|
33
39
|
| `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
|
|
34
40
|
|
|
35
|
-
## 前綴契約(家族表:
|
|
41
|
+
## 前綴契約(家族表:10 個資料前綴+閘對照組 jr1g.)
|
|
36
42
|
|
|
37
43
|
前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。
|
|
38
44
|
Tacet 部署實例(config 傳入):
|
|
@@ -41,6 +47,8 @@ Tacet 部署實例(config 傳入):
|
|
|
41
47
|
| --- | --- | --- | --- |
|
|
42
48
|
| `jr1u.` | guest 時代密文 | K_u = SHA-256(guestKdfPrefix ‖ identity) | AES-GCM |
|
|
43
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` 欄 |
|
|
44
52
|
| `jr1w.` | passphrase 包裹+復原套件包裹 | PBKDF2-SHA256 600k | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey` |
|
|
45
53
|
| `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
|
|
46
54
|
| `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
|
|
@@ -54,7 +62,7 @@ guest 密文前綴在閘對照組另驗 `jr1g.`(閘自造前綴,驗證品牌
|
|
|
54
62
|
## 驗證
|
|
55
63
|
|
|
56
64
|
```sh
|
|
57
|
-
npm run verify #
|
|
65
|
+
npm run verify # 127 斷言對真模組(禁鏡像):roundtrip/AAD 防搬移/extractable/時代隔離/
|
|
58
66
|
# 跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組
|
|
59
67
|
```
|
|
60
68
|
|
|
@@ -85,7 +93,7 @@ const cfg = {
|
|
|
85
93
|
cipherBound: 'jr1b.',
|
|
86
94
|
wrap: 'jr1w.',
|
|
87
95
|
store: makeKeyStore({ brand: 'myapp' }),
|
|
88
|
-
// wrapDual / wrapShare / pinLock:選配,未配置即拒絕
|
|
96
|
+
// cipherGuest / wrapDual / wrapShare / pinLock / cipherAttach / cipherLocal:選配,未配置即拒絕
|
|
89
97
|
};
|
|
90
98
|
|
|
91
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
|
+
"version": "0.1.4",
|
|
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
|
|
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('
|
|
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,96 @@ 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
|
+
|
|
407
509
|
// ── 決議 ────────────────────────────────────────────────────────────────────
|
|
408
510
|
|
|
409
511
|
console.log(`\n${passed} 斷言全綠` + (failures.length ? `;${failures.length} 失敗` : ''));
|
package/src/client/bip39.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
/** 解密一則:按前綴選鑰匙;無前綴 =
|
|
457
|
+
/** 解密一則:按前綴選鑰匙;無前綴 = 舊版明文(相容層)原樣返回(guest 前綴未配置時
|
|
458
|
+
* 不明字串一律 null=guest 家族整面拒絕,跨前綴鐵律不降級);失敗回 null。 */
|
|
409
459
|
export async function decryptNote(
|
|
410
460
|
cfg: NoteCryptoConfig,
|
|
411
461
|
held: HeldKey,
|
|
@@ -414,12 +464,17 @@ export async function decryptNote(
|
|
|
414
464
|
idp: IdentityProvider,
|
|
415
465
|
): Promise<string | null> {
|
|
416
466
|
try {
|
|
417
|
-
const
|
|
467
|
+
const guestPrefix = cfg.cipherGuest;
|
|
468
|
+
const isGuest = guestPrefix !== undefined && cipher.startsWith(guestPrefix);
|
|
418
469
|
const isBound = cipher.startsWith(cfg.cipherBound);
|
|
419
|
-
if (!isGuest && !isBound)
|
|
470
|
+
if (!isGuest && !isBound) {
|
|
471
|
+
// 舊版明文(相容層)原樣返回;guest 前綴未配置=guest 家族整面拒絕(不明字串
|
|
472
|
+
// 一律 null——跨前綴鐵律「回 null 不降級」,避免他家族殘列被當明文顯示)。
|
|
473
|
+
return guestPrefix === undefined ? null : cipher;
|
|
474
|
+
}
|
|
420
475
|
if (isGuest) {
|
|
421
476
|
const guest = await deriveGuestKey(cfg, idp.current());
|
|
422
|
-
return await decryptWithKey(guest, unb64(cipher.slice(
|
|
477
|
+
return await decryptWithKey(guest, unb64(cipher.slice(guestPrefix!.length)), aad);
|
|
423
478
|
}
|
|
424
479
|
if (!held.get()) {
|
|
425
480
|
const identity = idp.current();
|