@tacet-ink/journal-core 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +40 -0
- package/scripts/verify-core-crypto.ts +414 -0
- package/src/client/argon2.ts +350 -0
- package/src/client/bip39.ts +118 -0
- package/src/client/keys.ts +27 -0
- package/src/client/note-crypto.ts +433 -0
- package/src/client/pinlock.ts +105 -0
- package/src/client/wordlist.ts +264 -0
- package/src/index.ts +83 -0
- package/src/server/auth.ts +146 -0
- package/src/server/cors.ts +18 -0
- package/src/server/env.ts +6 -0
- package/src/server/hash.ts +28 -0
- package/src/server/ratelimit.ts +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tacet-ink
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# @tacet-ink/journal-core
|
|
2
|
+
|
|
3
|
+
零知識日記核心:端對端加密、密語即身份的 auth、傳輸與限流原語。
|
|
4
|
+
伺服器看不到任何一個字:passphrase 永不過線,密鑰包裹與密文都在客戶端完成。
|
|
5
|
+
|
|
6
|
+
這是 [默·Tacet(tacet.ink)](https://tacet.ink) 的客戶端/伺服端核心原語層,
|
|
7
|
+
從多個姊妹產品共用的密碼學本體抽取而成,以品牌前綴參數化(前綴契約見下表)。
|
|
8
|
+
|
|
9
|
+
## 設計
|
|
10
|
+
|
|
11
|
+
- **兩時代金鑰模型**:未綁定(guest)時代以 `K_u = SHA-256(guestKdfPrefix ‖ identity)`
|
|
12
|
+
純客戶端派生(混淆級);綁定後改用隨機 256-bit noteKey 加密,
|
|
13
|
+
noteKey 再以 passphrase(KEK)與復原套件(KEK_rec)雙重包裹上傳。
|
|
14
|
+
此後 passphrase 不再過線:線上只送 PH1(雜湊形),伺服器存 PH2 = SHA-256(PH1)。
|
|
15
|
+
- **帶內版本化**:升級 KDF 參數 = 換新前綴(舊前綴照解、原地改語意是禁手)。
|
|
16
|
+
前綴全部可配置,AAD(防搬移繫結)由呼叫端傳入。
|
|
17
|
+
- **extractable 鐵律**:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
|
|
18
|
+
import 當下就必須 `extractable=true`;KEK/guest key 恆 nonextractable。
|
|
19
|
+
- **opt-in 原語**:選配契約(wrapDual/wrapShare/pinLock)未配置即拒絕,
|
|
20
|
+
不配置的產品行為完全不受影響。
|
|
21
|
+
|
|
22
|
+
## 模組
|
|
23
|
+
|
|
24
|
+
| 模組 | 內容 |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `src/client/note-crypto.ts` | 兩時代加解密、passphrase/復原套件/本機三路包裹、PIN 第二因子合鑰(jr2w.) |
|
|
27
|
+
| `src/client/argon2.ts` | Argon2id 包裹原語(jr3w./jr3d./jr3s.)、PH1 v2 派生、RFC 9106 KAT、雙載體(node:crypto+hash-wasm) |
|
|
28
|
+
| `src/client/pinlock.ts` | 本機 PIN 鎖定包裹(jr1p.,開啟時鎖定) |
|
|
29
|
+
| `src/client/bip39.ts` | 復原套件 24 詞 ⇄ hex64 轉寫層(BIP39,零依賴自製) |
|
|
30
|
+
| `src/client/keys.ts` | 品牌前綴 localStorage 命名空間 |
|
|
31
|
+
| `src/server/auth.ts` | 零知識 auth 核心:PH1/PH2 hash-ladder、PH2 UNIQUE、session 撤銷、包裹欄組成對契約 |
|
|
32
|
+
| `src/server/ratelimit.ts` | per-IP 滑動視窗限流(D1 計數,併發安全版) |
|
|
33
|
+
| `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
|
|
34
|
+
|
|
35
|
+
## 前綴契約(家族表:8 個資料前綴+閘對照組 jr1g.)
|
|
36
|
+
|
|
37
|
+
前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。
|
|
38
|
+
Tacet 部署實例(config 傳入):
|
|
39
|
+
|
|
40
|
+
| 前綴 | 語意 | KDF | payload |
|
|
41
|
+
| --- | --- | --- | --- |
|
|
42
|
+
| `jr1u.` | guest 時代密文 | K_u = SHA-256(guestKdfPrefix ‖ identity) | AES-GCM |
|
|
43
|
+
| `jr1b.` | 綁定時代密文 | 隨機 256-bit noteKey | AES-GCM,AAD 綁 noteId |
|
|
44
|
+
| `jr1w.` | passphrase 包裹+復原套件包裹 | PBKDF2-SHA256 600k | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey` |
|
|
45
|
+
| `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
|
|
46
|
+
| `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
|
|
47
|
+
| `jr3d.` | PIN 第二因子合鑰(Argon2id 版) | Argon2id(pass)+Argon2id(pin) → HKDF-SHA256 | 同 jr2w. 形,108B,AAD `notekey2` |
|
|
48
|
+
| `jr3s.` | 單篇分享連結包裹 | Argon2id(同上參數) | 同 jr1w. 形,AAD `notekey-share` |
|
|
49
|
+
| `jr1p.` | 本機 PIN 鎖定(開啟時鎖定) | PBKDF2-SHA256 600k(刻意不用 Argon2id:解鎖要即時) | 同 jr2w. 形,AAD `notekey-pinlock` |
|
|
50
|
+
|
|
51
|
+
guest 密文前綴在閘對照組另驗 `jr1g.`(閘自造前綴,驗證品牌參數化本身;tacet 部署實例 guest 用 `jr1u.`)。
|
|
52
|
+
跨前綴呼叫一律回 null(前綴守衛+AAD+長度把關),不拋、不降級。
|
|
53
|
+
|
|
54
|
+
## 驗證
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm run verify # 102 斷言對真模組(禁鏡像):roundtrip/AAD 防搬移/extractable/時代隔離/
|
|
58
|
+
# 跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- 產品碼**零執行時依賴**(WebCrypto 原語);devDependencies 僅閘用(typescript、@scure/bip39 對照、workers types)。
|
|
62
|
+
- Argon2id 雙載體:node 走 `node:crypto`(Node 26+ 原生)、瀏覽器走 hash-wasm(wasm 內嵌),
|
|
63
|
+
RFC 9106 標準向量逐位元一致;無載體即 throw(禁 fallback 鐵律)。
|
|
64
|
+
- BIP39 原語自製零依賴,與 @scure/bip39 參照 200 組雙向對照(僅 entropy↔words 轉寫層,禁用其 seed 派生)。
|
|
65
|
+
|
|
66
|
+
## 使用
|
|
67
|
+
|
|
68
|
+
TypeScript 原始碼發行(exports 直指 .ts):
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
# node 直接跑(Node 22+ strip-types)
|
|
72
|
+
node --experimental-strip-types your-script.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { generateNoteKey, encryptNote, decryptNote, makeHeldKey,
|
|
77
|
+
wrapNoteKey, unwrapNoteKey } from '@tacet-ink/journal-core/client/note-crypto';
|
|
78
|
+
import { makeKeyStore } from '@tacet-ink/journal-core/client/keys';
|
|
79
|
+
|
|
80
|
+
const cfg = {
|
|
81
|
+
guestKdfPrefix: 'myapp-note-u1',
|
|
82
|
+
recSaltPrefix: 'myapp-note-rec1:',
|
|
83
|
+
cipherGuest: 'jr1g.', // 換成你自己的品牌前綴家族
|
|
84
|
+
cipherBound: 'jr1b.',
|
|
85
|
+
wrap: 'jr1w.',
|
|
86
|
+
store: makeKeyStore({ brand: 'myapp' }),
|
|
87
|
+
// wrapDual / wrapShare / pinLock:選配,未配置即拒絕
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const held = makeHeldKey();
|
|
91
|
+
const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
|
|
92
|
+
const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
打包器(vite/esbuild)可直接 alias 到源碼目錄使用;Argon2id 瀏覽器載體需另裝
|
|
96
|
+
hash-wasm 並以 `setArgonLoader()` 注入(詳 `src/client/argon2.ts` 檔頭)。
|
|
97
|
+
|
|
98
|
+
## 界限(誠實面)
|
|
99
|
+
|
|
100
|
+
- guest 時代是混淆級不是 e2e(K_u 由 identity 可派生);e2e 承諾從綁定(隨機 noteKey)起。
|
|
101
|
+
- PH1 派生方式由各產品自定義(快雜湊或 Argon2id 派生皆可),本層只驗 hex64 形。
|
|
102
|
+
- 本 repo 不含產品層:路由/配額/訂閱/GC/webhook 在各產品 fork。
|
|
103
|
+
- 未經第三方正式稽核;密語遺失設計上無救援(伺服器零知識的代價)。
|
|
104
|
+
|
|
105
|
+
## 授權
|
|
106
|
+
|
|
107
|
+
MIT。安全問題聯絡 tacetink.csd@gmail.com。
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tacet-ink/journal-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-knowledge journal core — e2e crypto, passphrase-only auth, transport, ratelimit. The server never sees plaintext.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./client/note-crypto": "./src/client/note-crypto.ts",
|
|
11
|
+
"./client/keys": "./src/client/keys.ts",
|
|
12
|
+
"./client/argon2": "./src/client/argon2.ts",
|
|
13
|
+
"./client/pinlock": "./src/client/pinlock.ts",
|
|
14
|
+
"./client/bip39": "./src/client/bip39.ts",
|
|
15
|
+
"./client/wordlist": "./src/client/wordlist.ts",
|
|
16
|
+
"./server/auth": "./src/server/auth.ts",
|
|
17
|
+
"./server/ratelimit": "./src/server/ratelimit.ts",
|
|
18
|
+
"./server/cors": "./src/server/cors.ts",
|
|
19
|
+
"./server/hash": "./src/server/hash.ts",
|
|
20
|
+
"./server/env": "./src/server/env.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"scripts",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"verify": "node --experimental-strip-types scripts/verify-core-crypto.ts",
|
|
33
|
+
"postinstall": "echo 'zero runtime dependencies — devDependencies are gate-only (typescript / @scure/bip39 / workers types)'"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@cloudflare/workers-types": "^5.20260831.1",
|
|
37
|
+
"@scure/bip39": "^2.4.0",
|
|
38
|
+
"typescript": "~6.0.2"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-core-crypto.ts — core 抽取的驗證閘(對真模組,禁鏡像——extractable 教訓)。
|
|
3
|
+
* 執行:node --experimental-strip-types scripts/verify-core-crypto.ts
|
|
4
|
+
* 全綠輸出 CORE-CRYPTO-OK;任何失敗 exit 1。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// 對真模組(禁鏡像重寫金鑰邏輯——鏡像驗證抓不到真模組 bug 的生產教訓)
|
|
8
|
+
import {
|
|
9
|
+
makeHeldKey,
|
|
10
|
+
PBKDF2_ITERATIONS,
|
|
11
|
+
ph1Of,
|
|
12
|
+
decryptNote,
|
|
13
|
+
encryptNote,
|
|
14
|
+
generateNoteKey,
|
|
15
|
+
generateRecToken,
|
|
16
|
+
storeLocalWrap,
|
|
17
|
+
unwrapNoteKey,
|
|
18
|
+
unwrapNoteKeyWithRecToken,
|
|
19
|
+
wrapNoteKey,
|
|
20
|
+
wrapNoteKeyWithRecToken,
|
|
21
|
+
recTokenHash,
|
|
22
|
+
wrapNoteKeyDual,
|
|
23
|
+
unwrapNoteKeyDual,
|
|
24
|
+
normalizePin,
|
|
25
|
+
} from '../src/client/note-crypto.ts';
|
|
26
|
+
import {
|
|
27
|
+
verifyArgonKat,
|
|
28
|
+
wrapNoteKey3,
|
|
29
|
+
unwrapNoteKey3,
|
|
30
|
+
wrapNoteKeyDual3,
|
|
31
|
+
unwrapNoteKeyDual3,
|
|
32
|
+
ARGON_MEMORY_KIB,
|
|
33
|
+
ARGON_ITERATIONS,
|
|
34
|
+
ARGON_PARALLELISM,
|
|
35
|
+
ARGON_TAG_LEN,
|
|
36
|
+
derivePh1Argon,
|
|
37
|
+
PH1_V2_SALT,
|
|
38
|
+
type Argon3Config,
|
|
39
|
+
} from '../src/client/argon2.ts';
|
|
40
|
+
import { makeKeyStore } from '../src/client/keys.ts';
|
|
41
|
+
import type { NoteCryptoConfig } from '../src/client/note-crypto.ts';
|
|
42
|
+
|
|
43
|
+
const enc = new TextEncoder();
|
|
44
|
+
|
|
45
|
+
function b64(bytes: Uint8Array): string {
|
|
46
|
+
let bin = '';
|
|
47
|
+
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
|
|
48
|
+
return btoa(bin);
|
|
49
|
+
}
|
|
50
|
+
function hex(bytes: Uint8Array): string {
|
|
51
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
52
|
+
}
|
|
53
|
+
async function sha(text: string): Promise<string> {
|
|
54
|
+
return hex(new Uint8Array(await crypto.subtle.digest('SHA-256', enc.encode(text))));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 日記應用前綴(jr1 家族)+ 對照組(sn1 家族,驗證品牌參數化) ──────────────
|
|
58
|
+
|
|
59
|
+
const store = makeKeyStore({ brand: 'tacet' });
|
|
60
|
+
|
|
61
|
+
const TACET: NoteCryptoConfig = {
|
|
62
|
+
guestKdfPrefix: 'tacet-note-u1',
|
|
63
|
+
recSaltPrefix: 'tacet-note-rec1:',
|
|
64
|
+
cipherGuest: 'jr1g.',
|
|
65
|
+
cipherBound: 'jr1b.',
|
|
66
|
+
wrap: 'jr1w.',
|
|
67
|
+
store,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let passed = 0;
|
|
71
|
+
const failures: string[] = [];
|
|
72
|
+
async function A(name: string, cond: boolean | Promise<boolean>, detail = ''): Promise<void> {
|
|
73
|
+
const ok = cond instanceof Promise ? await cond : cond;
|
|
74
|
+
if (ok) { passed++; console.log(` ✓ ${name}`); }
|
|
75
|
+
else { failures.push(name); console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 1. 基礎向量 ─────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
console.log('\n[1] PH1 / PBKDF2 / hash-ladder 基礎');
|
|
81
|
+
await A('PH1 = SHA-256(pass) 穩定', (await ph1Of('test-pass-123')) === (await ph1Of('test-pass-123')));
|
|
82
|
+
await A('PH1 hex64 格式', /^[0-9a-f]{64}$/.test(await ph1Of('x')));
|
|
83
|
+
await A('PBKDF2_ITERATIONS = 600k', PBKDF2_ITERATIONS === 600_000);
|
|
84
|
+
|
|
85
|
+
// ── 2. roundtrip(guest 時代 + 綁定時代,AAD 由呼叫端) ───────────────────────
|
|
86
|
+
|
|
87
|
+
console.log('\n[2] roundtrip / AAD 防搬移');
|
|
88
|
+
const note = '今天寫了一點東西。';
|
|
89
|
+
const identityA = 'acct-aaaaaaaaaaaaaaaa';
|
|
90
|
+
const guest = await crypto.subtle.importKey('raw', enc.encode('seed').slice(0), 'PBKDF2', false, ['deriveKey']); // 型別哨兵:guest 非此路徑
|
|
91
|
+
void guest;
|
|
92
|
+
const held = makeHeldKey();
|
|
93
|
+
const cipherGuest = await encryptNote(TACET, held, note, 'noteId:n1', { current: () => identityA });
|
|
94
|
+
await A('guest 時代前綴 jr1g.', cipherGuest.startsWith('jr1g.'));
|
|
95
|
+
await A('guest roundtrip', (await decryptNote(TACET, held, cipherGuest, 'noteId:n1', { current: () => identityA })) === note);
|
|
96
|
+
|
|
97
|
+
const noteKey = await generateNoteKey();
|
|
98
|
+
held.set(noteKey);
|
|
99
|
+
const cipherBound = await encryptNote(TACET, held, note, 'noteId:n1', { current: () => identityA });
|
|
100
|
+
await A('綁定時代前綴 jr1b.', cipherBound.startsWith('jr1b.'));
|
|
101
|
+
await A('綁定 roundtrip', (await decryptNote(TACET, held, cipherBound, 'noteId:n1', { current: () => identityA })) === note);
|
|
102
|
+
|
|
103
|
+
// AAD 防搬移:AAD 不符 → null(不拋、不降級成別列內容)
|
|
104
|
+
await A('AAD 防搬移:跨 noteId 解密失敗回 null',
|
|
105
|
+
(await decryptNote(TACET, held, cipherBound, 'noteId:n2', { current: () => identityA })) === null);
|
|
106
|
+
await A('AAD 防搬移:跨身份 guest 解密失敗回 null',
|
|
107
|
+
(await decryptNote(TACET, held, cipherGuest, 'noteId:n1', { current: () => 'acct-bbbbbbbbbbbbbbbb' })) === null);
|
|
108
|
+
|
|
109
|
+
// 舊版明文相容層:無前綴 = 原樣返回
|
|
110
|
+
await A('舊明文原樣返回', (await decryptNote(TACET, held, '純舊明文', 'x', { current: () => identityA })) === '純舊明文');
|
|
111
|
+
|
|
112
|
+
// ── 3. 包裹三件套(pass / rec / 本機) ───────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
console.log('\n[3] 金鑰包裹(passphrase / 復原 / 本機)');
|
|
115
|
+
const pass = 'correct-horse-battery-staple-42';
|
|
116
|
+
const recToken = generateRecToken();
|
|
117
|
+
const { wrapped, salt } = await wrapNoteKey(TACET, noteKey, pass);
|
|
118
|
+
const wrappedRec = await (await import('../src/client/note-crypto.ts')).wrapNoteKeyWithRecToken(TACET, noteKey, recToken, identityA);
|
|
119
|
+
const recTokenHashValue = await recTokenHash(recToken);
|
|
120
|
+
await A('wrapped 前綴 jr1w.', wrapped.startsWith('jr1w.'));
|
|
121
|
+
await A('salt = 16B hex', /^[0-9a-f]{32}$/.test(salt));
|
|
122
|
+
await A('wrappedRec 前綴 jr1w.', wrappedRec.startsWith('jr1w.'));
|
|
123
|
+
await A('recTokenHash hex64', /^[0-9a-f]{64}$/.test(recTokenHashValue));
|
|
124
|
+
|
|
125
|
+
const unwrapped = await unwrapNoteKey(TACET, wrapped, pass, salt);
|
|
126
|
+
await A('pass unwrap 救回 noteKey(同 pass 同 salt)',
|
|
127
|
+
unwrapped !== null && hex(new Uint8Array(await crypto.subtle.exportKey('raw', unwrapped))) ===
|
|
128
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))));
|
|
129
|
+
await A('錯誤 pass unwrap → null', (await unwrapNoteKey(TACET, wrapped, 'wrong-pass', salt)) === null);
|
|
130
|
+
|
|
131
|
+
const recUnwrapped = await unwrapNoteKeyWithRecToken(TACET, wrappedRec, recToken, identityA);
|
|
132
|
+
await A('rec unwrap 救回 noteKey',
|
|
133
|
+
recUnwrapped !== null && hex(new Uint8Array(await crypto.subtle.exportKey('raw', recUnwrapped))) ===
|
|
134
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))));
|
|
135
|
+
await A('錯誤 recToken unwrap → null',
|
|
136
|
+
(await unwrapNoteKeyWithRecToken(TACET, wrappedRec, generateRecToken(), identityA)) === null);
|
|
137
|
+
|
|
138
|
+
// 改密語 O(1):解開舊包裹 → 新密語重包裹;密文零重加密
|
|
139
|
+
const { wrapped: rewrapped, salt: newSalt } = await wrapNoteKey(TACET, noteKey, 'new-pass-99');
|
|
140
|
+
await A('改密語:新包裹解得回同 noteKey',
|
|
141
|
+
(await unwrapNoteKey(TACET, rewrapped, 'new-pass-99', newSalt)) !== null &&
|
|
142
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', (await unwrapNoteKey(TACET, rewrapped, 'new-pass-99', newSalt))!))) ===
|
|
143
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))));
|
|
144
|
+
|
|
145
|
+
// 本機包裹
|
|
146
|
+
const localIdentity = 'acct-localwrap-test';
|
|
147
|
+
const heldLocal = makeHeldKey();
|
|
148
|
+
heldLocal.set(noteKey);
|
|
149
|
+
await storeLocalWrap(TACET, localIdentity, noteKey);
|
|
150
|
+
|
|
151
|
+
// ── 4. extractable 鐵律(2026-09-06 iOS 實機炸點回歸) ────────────────────────
|
|
152
|
+
|
|
153
|
+
console.log('\n[4] noteKey extractable 鐵律');
|
|
154
|
+
const rawNote = crypto.getRandomValues(new Uint8Array(32));
|
|
155
|
+
const nonExtractable = await crypto.subtle.importKey('raw', rawNote, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
156
|
+
let threwNotExtractable = false;
|
|
157
|
+
try { await crypto.subtle.exportKey('raw', nonExtractable); } catch (e) {
|
|
158
|
+
// 鐵律:錯誤名稱跨引擎不同(iOS Safari = InvalidAccessElement、Node 26 = InvalidAccessError),
|
|
159
|
+
// 斷言「有擲且訊息指明 extractable」,不綁單一瀏覽器錯誤名——對真模組驗證抓的是契約不是品牌。
|
|
160
|
+
const msg = (e as Error).message ?? '';
|
|
161
|
+
threwNotExtractable = /extractable/i.test(msg) || /extractable/i.test((e as Error).name);
|
|
162
|
+
}
|
|
163
|
+
await A('病徵重現:nonextractable exportKey 拋 not-extractable 類錯誤', threwNotExtractable);
|
|
164
|
+
const extractable = await crypto.subtle.importKey('raw', rawNote, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
|
165
|
+
await A('修法機制:extractable key exportKey 等值 roundtrip',
|
|
166
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', extractable))) === hex(rawNote));
|
|
167
|
+
// unwrap 出的 key(全部三路徑)都要能再包裹:與 noteKey 原值比對(非 rawNote——那是本段新建隨機值)
|
|
168
|
+
const origRaw = hex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
169
|
+
await A('unwrap 出的 noteKey 可再 exportKey(等值 noteKey)',
|
|
170
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', unwrapped!))) === origRaw);
|
|
171
|
+
await A('rec unwrap 出的 noteKey 可再 exportKey(等值 noteKey)',
|
|
172
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', recUnwrapped!))) === origRaw);
|
|
173
|
+
|
|
174
|
+
// ── 5. 時代隔離:guest 與 bound 前綴互不誤判 ─────────────────────────────────
|
|
175
|
+
|
|
176
|
+
console.log('\n[5] 時代隔離');
|
|
177
|
+
await A('guest cipher 不以 jr1b. 開頭', !cipherGuest.startsWith('jr1b.'));
|
|
178
|
+
await A('bound cipher 不以 jr1g. 開頭', !cipherBound.startsWith('jr1g.'));
|
|
179
|
+
await A('無 held key 的 bound 解密 → null',
|
|
180
|
+
(await decryptNote(TACET, makeHeldKey(), cipherBound, 'noteId:n1', { current: () => identityA })) === null);
|
|
181
|
+
|
|
182
|
+
// ── 6. 雙因子合鑰(jr2w.,2026-09-09 PIN 第二因子) ─────────────────────────
|
|
183
|
+
|
|
184
|
+
console.log('\n[6] 雙因子合鑰 KEK2(jr2w.)');
|
|
185
|
+
const TACET2: NoteCryptoConfig = { ...TACET, wrapDual: 'jr2w.', pinSaltPrefix: 'tacet-note-pin1:' };
|
|
186
|
+
const passD = 'dual-factor-passphrase-42';
|
|
187
|
+
const pinD = '2580ab';
|
|
188
|
+
const dual = await wrapNoteKeyDual(TACET2, noteKey, passD, pinD);
|
|
189
|
+
await A('wrapped2 前綴 jr2w.', dual.wrapped.startsWith('jr2w.'));
|
|
190
|
+
await A('dual salt1 = 16B hex', /^[0-9a-f]{32}$/.test(dual.salt));
|
|
191
|
+
const unwrapped2 = await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, pinD, dual.salt);
|
|
192
|
+
await A('dual unwrap 等值 noteKey(extractable 再 export)',
|
|
193
|
+
unwrapped2 !== null && hex(new Uint8Array(await crypto.subtle.exportKey('raw', unwrapped2))) ===
|
|
194
|
+
hex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))));
|
|
195
|
+
await A('錯 PIN → null', (await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, '999999', dual.salt)) === null);
|
|
196
|
+
await A('錯 pass → null', (await unwrapNoteKeyDual(TACET2, dual.wrapped, 'wrong-passphrase', pinD, dual.salt)) === null);
|
|
197
|
+
await A('缺 PIN → null', (await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, '', dual.salt)) === null);
|
|
198
|
+
await A('錯 pass 與錯 PIN 回傳同形(皆 null 不拋)',
|
|
199
|
+
(await unwrapNoteKeyDual(TACET2, dual.wrapped, 'wrong', 'bad!!', dual.salt)) === null);
|
|
200
|
+
await A('jr1w unwrapNoteKey 拒收 jr2w 字串', (await unwrapNoteKey(TACET2, dual.wrapped, passD, dual.salt)) === null);
|
|
201
|
+
await A('dual unwrapNoteKeyDual 拒收 jr1w 字串', (await unwrapNoteKeyDual(TACET2, wrapped, pass, pinD, dual.salt)) === null);
|
|
202
|
+
await A('既有 jr1w 包裹不受 dual 並存影響', (await unwrapNoteKey(TACET2, wrapped, pass, salt)) !== null);
|
|
203
|
+
|
|
204
|
+
// payload 竄改 → 一律 null(自描述完整性:pinSalt/hksalt/iv/ct 任一區)
|
|
205
|
+
function unb64(text: string): Uint8Array {
|
|
206
|
+
const bin = atob(text);
|
|
207
|
+
const bytes = new Uint8Array(bin.length);
|
|
208
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
209
|
+
return bytes;
|
|
210
|
+
}
|
|
211
|
+
const payload2 = unb64(dual.wrapped.slice('jr2w.'.length));
|
|
212
|
+
const tamperedAt = async (idx: number): Promise<boolean> => {
|
|
213
|
+
const copy = payload2.slice();
|
|
214
|
+
copy[idx] ^= 0x01;
|
|
215
|
+
let bin = '';
|
|
216
|
+
for (let i = 0; i < copy.byteLength; i++) bin += String.fromCharCode(copy[i]);
|
|
217
|
+
const tampered = 'jr2w.' + btoa(bin);
|
|
218
|
+
return (await unwrapNoteKeyDual(TACET2, tampered, passD, pinD, dual.salt)) === null;
|
|
219
|
+
};
|
|
220
|
+
await A('payload 竄改 pinSalt 區 → null', await tamperedAt(3));
|
|
221
|
+
await A('payload 竄改 hksalt 區 → null', await tamperedAt(20));
|
|
222
|
+
await A('payload 竄改 iv 區 → null', await tamperedAt(36));
|
|
223
|
+
await A('payload 竄改 ct 尾 → null', await tamperedAt(payload2.length - 1));
|
|
224
|
+
|
|
225
|
+
// PIN 正規化(NFKC → trim → lowercase):wrap/unwrap 同一正規化路徑
|
|
226
|
+
await A('PIN 大小寫不敏感', (await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, '2580AB', dual.salt)) !== null);
|
|
227
|
+
await A('PIN 兩端空白容忍', (await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, ' 2580ab ', dual.salt)) !== null);
|
|
228
|
+
await A('PIN 全形 NFKC 等價', (await unwrapNoteKeyDual(TACET2, dual.wrapped, passD, '2580AB', dual.salt)) !== null);
|
|
229
|
+
const dualNorm = await wrapNoteKeyDual(TACET2, noteKey, passD, ' 2580Ab ');
|
|
230
|
+
await A('wrap 端同一正規化(正規化後等價 unwrap)',
|
|
231
|
+
(await unwrapNoteKeyDual(TACET2, dualNorm.wrapped, passD, '2580ab', dualNorm.salt)) !== null);
|
|
232
|
+
await A('normalizePin 契約', normalizePin(' 2580Ab ') === '2580ab');
|
|
233
|
+
await A('未配置 wrapDual 拒絕 dual 包裹', (await unwrapNoteKeyDual(TACET, dual.wrapped, passD, pinD, '')) === null);
|
|
234
|
+
|
|
235
|
+
// ── 7. Argon2id 包裹(jr3w./jr3d.,2026-09-14 安全路線第 2 步) ───────────────
|
|
236
|
+
//
|
|
237
|
+
// 離線 oracle 中和:login 回應的 wrapped+salt 是戰利品,PBKDF2 對 GPU 友善,
|
|
238
|
+
// Argon2id 記憶體困難(64MiB)使每猜成本上升 1-2 個數量級。遷移契約 = 成功 unwrap
|
|
239
|
+
// 後客戶端靜默 re-wrap,舊前綴照解(下面有並存斷言)。
|
|
240
|
+
|
|
241
|
+
console.log('\n[7] Argon2id 包裹(jr3w./jr3d.)');
|
|
242
|
+
const TACET3: Argon3Config = { wrap3: 'jr3w.', wrapDual3: 'jr3d.', pinSalt3Prefix: 'tacet-note-pin3:' };
|
|
243
|
+
await A('RFC 9106 無 secret/ad 標準向量(當前載體正確性)', await verifyArgonKat());
|
|
244
|
+
await A('Argon 參數契約 m=64MiB t=3 p=1 tag=32',
|
|
245
|
+
ARGON_MEMORY_KIB === 65536 && ARGON_ITERATIONS === 3 && ARGON_PARALLELISM === 1 && ARGON_TAG_LEN === 32);
|
|
246
|
+
|
|
247
|
+
const w3 = await wrapNoteKey3(TACET3, noteKey, pass);
|
|
248
|
+
await A('wrapped 前綴 jr3w.', w3.wrapped.startsWith('jr3w.'));
|
|
249
|
+
await A('jr3w salt = 16B hex', /^[0-9a-f]{32}$/.test(w3.salt));
|
|
250
|
+
const unwrapped3 = await unwrapNoteKey3(TACET3, w3.wrapped, pass, w3.salt);
|
|
251
|
+
await A('jr3w unwrap 等值 noteKey(extractable 再 export)',
|
|
252
|
+
unwrapped3 !== null && hex(new Uint8Array(await crypto.subtle.exportKey('raw', unwrapped3))) === origRaw);
|
|
253
|
+
await A('jr3w 錯 pass → null', (await unwrapNoteKey3(TACET3, w3.wrapped, 'wrong-passphrase', w3.salt)) === null);
|
|
254
|
+
await A('jr3w 錯 salt → null', (await unwrapNoteKey3(TACET3, w3.wrapped, pass, 'ab'.repeat(16))) === null);
|
|
255
|
+
await A('jr3w unwrap 拒收 jr1w 字串', (await unwrapNoteKey3(TACET3, wrapped, pass, salt)) === null);
|
|
256
|
+
await A('jr1w unwrapNoteKey 拒收 jr3w 字串', (await unwrapNoteKey(TACET2, w3.wrapped, pass, w3.salt)) === null);
|
|
257
|
+
await A('既有 jr1w 包裹照解(遷移契約:舊前綴不解散)', (await unwrapNoteKey(TACET2, wrapped, pass, salt)) !== null);
|
|
258
|
+
// jr3w payload 竄改 → GCM 驗證失敗 → null
|
|
259
|
+
const payload3 = unb64(w3.wrapped.slice('jr3w.'.length));
|
|
260
|
+
const tampered3 = async (idx: number): Promise<boolean> => {
|
|
261
|
+
const copy = payload3.slice();
|
|
262
|
+
copy[idx] ^= 0x01;
|
|
263
|
+
let bin = '';
|
|
264
|
+
for (let i = 0; i < copy.byteLength; i++) bin += String.fromCharCode(copy[i]);
|
|
265
|
+
return (await unwrapNoteKey3(TACET3, 'jr3w.' + btoa(bin), pass, w3.salt)) === null;
|
|
266
|
+
};
|
|
267
|
+
await A('jr3w payload 竄改 iv 區 → null', await tampered3(3));
|
|
268
|
+
await A('jr3w payload 竄改 ct 尾 → null', await tampered3(payload3.length - 1));
|
|
269
|
+
|
|
270
|
+
// jr3d 雙因子(PIN 第二因子,Argon2id 版)
|
|
271
|
+
const dual3 = await wrapNoteKeyDual3(TACET3, noteKey, passD, pinD);
|
|
272
|
+
await A('dual3 前綴 jr3d.', dual3.wrapped.startsWith('jr3d.'));
|
|
273
|
+
await A('dual3 salt1 = 16B hex', /^[0-9a-f]{32}$/.test(dual3.salt));
|
|
274
|
+
const unwrappedD3 = await unwrapNoteKeyDual3(TACET3, dual3.wrapped, passD, pinD, dual3.salt);
|
|
275
|
+
await A('dual3 unwrap 等值 noteKey(extractable 再 export)',
|
|
276
|
+
unwrappedD3 !== null && hex(new Uint8Array(await crypto.subtle.exportKey('raw', unwrappedD3))) === origRaw);
|
|
277
|
+
await A('dual3 錯 PIN → null', (await unwrapNoteKeyDual3(TACET3, dual3.wrapped, passD, '999999', dual3.salt)) === null);
|
|
278
|
+
await A('dual3 缺 PIN → null', (await unwrapNoteKeyDual3(TACET3, dual3.wrapped, passD, '', dual3.salt)) === null);
|
|
279
|
+
await A('dual3 錯 pass → null', (await unwrapNoteKeyDual3(TACET3, dual3.wrapped, 'wrong-passphrase', pinD, dual3.salt)) === null);
|
|
280
|
+
await A('dual3 PIN 大小寫不敏感', (await unwrapNoteKeyDual3(TACET3, dual3.wrapped, passD, '2580AB', dual3.salt)) !== null);
|
|
281
|
+
await A('dual3 PIN 全形 NFKC 等價', (await unwrapNoteKeyDual3(TACET3, dual3.wrapped, passD, '2580AB', dual3.salt)) !== null);
|
|
282
|
+
|
|
283
|
+
// 跨家族隔離:jr3d payload 餵 jr2w 路徑、jr2w 餵 jr3d、jr3w 餵 jr1w、單因子交叉,全部 null
|
|
284
|
+
// (HKDF info 同名但 KDF bits 不同 → KEK2 值天然互斥;GCM tag 保證不會假成功)
|
|
285
|
+
await A('jr3d payload 餵 jr2w 路徑 → null(家族隔離)',
|
|
286
|
+
(await unwrapNoteKeyDual(TACET2, dual3.wrapped, passD, pinD, dual3.salt)) === null);
|
|
287
|
+
await A('jr2w payload 餵 jr3d 路徑 → null(家族隔離)',
|
|
288
|
+
(await unwrapNoteKeyDual3(TACET3, dual.wrapped, passD, pinD, dual.salt)) === null);
|
|
289
|
+
await A('jr3w unwrap 拒收 jr3d 字串', (await unwrapNoteKey3(TACET3, dual3.wrapped, passD, dual3.salt)) === null);
|
|
290
|
+
await A('jr3d unwrap 拒收 jr3w 字串', (await unwrapNoteKeyDual3(TACET3, w3.wrapped, pass, pinD, w3.salt)) === null);
|
|
291
|
+
|
|
292
|
+
// 未配置拒絕(兄弟 fork 行為不變)
|
|
293
|
+
const throwsJr3w = async (): Promise<boolean> => { try { await wrapNoteKey3({}, noteKey, pass); return false; } catch { return true; } };
|
|
294
|
+
const throwsJr3d = async (): Promise<boolean> => { try { await wrapNoteKeyDual3({}, noteKey, pass, pinD); return false; } catch { return true; } };
|
|
295
|
+
await A('未配置 wrap3 → wrapNoteKey3 拒絕', await throwsJr3w());
|
|
296
|
+
await A('未配置 wrapDual3 → wrapNoteKeyDual3 拒絕', await throwsJr3d());
|
|
297
|
+
await A('未配置 wrapDual3 → unwrap 拒收 jr3d',
|
|
298
|
+
(await unwrapNoteKeyDual3({ wrap3: 'jr3w.' }, dual3.wrapped, passD, pinD, dual3.salt)) === null);
|
|
299
|
+
|
|
300
|
+
// ── 8. PH1 v2(登入憑證 Argon2id 派生,2026-09-10 安全路線)──────────────────
|
|
301
|
+
//
|
|
302
|
+
// 動機(安全路線第 4 步分析):DB 全洩後 ph2 = sha256(ph1) 是離線爆破讀日記的最後一個
|
|
303
|
+
// 快雜湊面——攻擊者拿候選密語重算 sha256(sha256(g)) 對 ph2 命中即 g 就是密語,
|
|
304
|
+
// 再以 DB salt1 算 Argon2id(g, salt1) 解 wrapped=日記全開。ph1 改 Argon2id 派生後
|
|
305
|
+
// 每猜成本從 ~ns(SHA-256×2)升到 ~0.1-0.18s CPU=×10⁴-10⁶。
|
|
306
|
+
// 固定域鹽是被迫設計(per-user 鹽會摧毀 PH2 UNIQUE=幽靈帳號機制基礎);改參數 = 換鹽尾碼(v2/v3)重遷移。
|
|
307
|
+
|
|
308
|
+
console.log('\n[8] PH1 v2(登入憑證 Argon2id 派生)');
|
|
309
|
+
await A('RFC 9106 KAT 先行(載體正確性,本節所有 Argon 斷言的前提)', await verifyArgonKat());
|
|
310
|
+
const ph1v2a = await derivePh1Argon('probe-determinism-pass-42');
|
|
311
|
+
await A('ph1v2 確定性(同 pass 兩跑逐位元同)', ph1v2a === (await derivePh1Argon('probe-determinism-pass-42')));
|
|
312
|
+
await A('ph1v2 hex64 形(validHash64 可收)', /^[0-9a-f]{64}$/.test(ph1v2a));
|
|
313
|
+
// 與 legacy ph1(SHA-256 形)必然不同值=雙查表兩鍵語意成立
|
|
314
|
+
await A('ph1v2 ≠ legacy ph1(SHA-256 形)', ph1v2a !== (await ph1Of('probe-determinism-pass-42')));
|
|
315
|
+
// 鹽用途隔離:PH1_V2_SALT 不等於任何包裹鹽前綴(固定鹽專用域,與 jr3w. per-user 隨機鹽分流)
|
|
316
|
+
await A('PH1_V2_SALT = "tacet-ph1-v1"(12B 專用域,與包裹鹽前綴無重疊)',
|
|
317
|
+
PH1_V2_SALT === 'tacet-ph1-v1' && !PH1_V2_SALT.startsWith('tacet-note') && !PH1_V2_SALT.startsWith('tacet-pinlock'));
|
|
318
|
+
// 不同 pass 零碰撞(抽 50 組,兩兩相異)
|
|
319
|
+
{
|
|
320
|
+
const seen = new Set<string>();
|
|
321
|
+
for (let i = 0; i < 50; i++) seen.add(await derivePh1Argon(`collision-probe-${i}-random-${crypto.randomUUID()}`));
|
|
322
|
+
await A('不同 pass 零碰撞(50 組兩兩相異)', seen.size === 50, `unique=${seen.size}`);
|
|
323
|
+
}
|
|
324
|
+
// 參數常數同源(單一碼路:ph1 v2 沿 jr3w. 同一組 Argon 參數常數)
|
|
325
|
+
await A('ph1v2 參數與 jr3w. 家族常數同源(m=64MiB t=3 p=1)',
|
|
326
|
+
ARGON_MEMORY_KIB === 65536 && ARGON_ITERATIONS === 3 && ARGON_PARALLELISM === 1 && ARGON_TAG_LEN === 32);
|
|
327
|
+
// 錯誤密語 ≠ 正確密語輸出(形狀面 sanity:非恆等映射)
|
|
328
|
+
await A('ph1v2 非恆等(pass ≠ 輸出)', ph1v2a !== 'probe-determinism-pass-42');
|
|
329
|
+
|
|
330
|
+
// ── 9. BIP39 復原套件契約(words↔hex64 轉寫層;線上契約 recToken hex64 不變) ──
|
|
331
|
+
|
|
332
|
+
console.log('\n[9] BIP39 復原套件(24 詞 ⇄ entropy 32B ⇄ hex64)');
|
|
333
|
+
const bip = await import('../src/client/bip39.ts');
|
|
334
|
+
const wl = await import('../src/client/wordlist.ts');
|
|
335
|
+
|
|
336
|
+
const kitWords = await bip.generateBip39Words();
|
|
337
|
+
await A('24 詞', kitWords.length === 24);
|
|
338
|
+
await A('全在詞表', kitWords.every(w => wl.wordlist.includes(w)));
|
|
339
|
+
await A('詞表 2048 詞', wl.wordlist.length === 2048);
|
|
340
|
+
await A('詞全小寫字母(轉寫容錯面)', wl.wordlist.every(w => /^[a-z]+$/.test(w)));
|
|
341
|
+
|
|
342
|
+
const kitHex64 = await bip.wordsToRecToken(kitWords);
|
|
343
|
+
await A('words→hex64 格式', kitHex64 !== null && /^[0-9a-f]{64}$/.test(kitHex64));
|
|
344
|
+
await A('words→hex64 roundtrip(同詞同值)', (await bip.wordsToRecToken(kitWords)) === kitHex64);
|
|
345
|
+
await A('不同套件不同 hex64', (await bip.wordsToRecToken(await bip.generateBip39Words())) !== kitHex64);
|
|
346
|
+
|
|
347
|
+
// hex64 舊套件 ⇄ 詞轉換雙向(相容層:舊紙本照走現行鏈)
|
|
348
|
+
const hexToWords = await bip.recTokenToWords(kitHex64!);
|
|
349
|
+
await A('hex64→24 詞 roundtrip', hexToWords !== null && (await bip.wordsToRecToken(hexToWords)) === kitHex64);
|
|
350
|
+
await A('舊 hex64(generateRecToken 產物)可轉詞',
|
|
351
|
+
(await bip.recTokenToWords(generateRecToken())) !== null);
|
|
352
|
+
|
|
353
|
+
// normalize 容錯:大寫/全形空格/換行/多空白
|
|
354
|
+
const messy = kitWords.map((w, i) => (i % 2 ? w.toUpperCase() : w)).join(' ');
|
|
355
|
+
await A('全形空格+大寫容錯', (await bip.wordsToRecToken(messy)) === kitHex64);
|
|
356
|
+
await A('換行+多空白容錯', (await bip.wordsToRecToken(kitWords.join('\n '))) === kitHex64);
|
|
357
|
+
|
|
358
|
+
// 失敗面統一 null(不洩漏哪類錯)
|
|
359
|
+
await A('23 詞 → null', (await bip.wordsToRecToken(kitWords.slice(0, 23).join(' '))) === null);
|
|
360
|
+
await A('25 詞 → null', (await bip.wordsToRecToken([...kitWords, kitWords[0]].join(' '))) === null);
|
|
361
|
+
await A('詞表外 → null', (await bip.wordsToRecToken(kitWords.slice(0, 23).concat('notaword').join(' '))) === null);
|
|
362
|
+
|
|
363
|
+
// checksum 面:錯一詞(詞表內不同詞)→ 幾乎必被 checksum 抓;64 樣本全抓(理論 255/256,樣本容許 ≥63)
|
|
364
|
+
const wlArr: readonly string[] = wl.wordlist;
|
|
365
|
+
let checksumCaught = 0;
|
|
366
|
+
for (let t = 0; t < 64; t++) {
|
|
367
|
+
const ws = [...(await bip.generateBip39Words())];
|
|
368
|
+
const idx = t % 24;
|
|
369
|
+
let alt = wlArr[(wlArr.indexOf(ws[idx]) + 1 + t) % 2048];
|
|
370
|
+
if (alt === ws[idx]) alt = wlArr[(wlArr.indexOf(ws[idx]) + 1) % 2048];
|
|
371
|
+
ws[idx] = alt;
|
|
372
|
+
if ((await bip.wordsToRecToken(ws.join(' '))) === null) checksumCaught++;
|
|
373
|
+
}
|
|
374
|
+
await A('錯一詞 64 樣本 ≥63 被抓(checksum 8-bit)', checksumCaught >= 63, `caught=${checksumCaught}/64`);
|
|
375
|
+
|
|
376
|
+
// spot-check 抽驗索引
|
|
377
|
+
const picks = bip.spotCheckIndexes();
|
|
378
|
+
await A('抽驗 4 位置', picks.length === 4);
|
|
379
|
+
await A('位置 0-23 不重複', new Set(picks).size === 4 && picks.every(p => p >= 0 && p <= 23));
|
|
380
|
+
await A('抽驗索引排序(顯示穩定)', picks.every((p, i) => i === 0 || picks[i - 1] < p));
|
|
381
|
+
|
|
382
|
+
// 參照實作對照(@scure/bip39 devDependencies,僅本閘用;產品碼零依賴)
|
|
383
|
+
try {
|
|
384
|
+
const scureMod = await import('@scure/bip39');
|
|
385
|
+
const scureWl = (await import('@scure/bip39/wordlists/english.js')).wordlist;
|
|
386
|
+
const { entropyToMnemonic, mnemonicToEntropy } = scureMod;
|
|
387
|
+
let refOk = true;
|
|
388
|
+
for (let t = 0; t < 200; t++) {
|
|
389
|
+
const ent = crypto.getRandomValues(new Uint8Array(32));
|
|
390
|
+
const refWords: string[] = entropyToMnemonic(ent, scureWl).split(' ');
|
|
391
|
+
const ours = await bip.recTokenToWords(hex(new Uint8Array(ent)));
|
|
392
|
+
if (!ours || ours.join(' ') !== refWords.join(' ')) { refOk = false; break; }
|
|
393
|
+
if ((await bip.wordsToRecToken(refWords)) !== hex(new Uint8Array(mnemonicToEntropy(refWords.join(' '), scureWl)))) { refOk = false; break; }
|
|
394
|
+
}
|
|
395
|
+
await A('與 @scure/bip39 參照 200 組雙向一致', refOk);
|
|
396
|
+
} catch {
|
|
397
|
+
await A('與 @scure/bip39 參照 200 組雙向一致', false, '參照套件未安裝(devDependencies @scure/bip39)');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 與現行包裹鏈相容:words 造的 hex64 走 recTokenHash/wrapNoteKeyWithRecToken 原樣
|
|
401
|
+
const kitHexAsToken = (await bip.wordsToRecToken(kitWords))!;
|
|
402
|
+
const kitWrapped = await wrapNoteKeyWithRecToken(TACET, noteKey, kitHexAsToken, identityA);
|
|
403
|
+
await A('words 派生 hex64 包裹前綴 jr1w.', kitWrapped.startsWith('jr1w.'));
|
|
404
|
+
await A('words 派生 hex64 unwrap 救回 noteKey',
|
|
405
|
+
(await unwrapNoteKeyWithRecToken(TACET, kitWrapped, kitHexAsToken, identityA)) !== null);
|
|
406
|
+
|
|
407
|
+
// ── 決議 ────────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
console.log(`\n${passed} 斷言全綠` + (failures.length ? `;${failures.length} 失敗` : ''));
|
|
410
|
+
if (failures.length) {
|
|
411
|
+
console.error('失敗項:', failures);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
console.log('CORE-CRYPTO-VERIFY-OK');
|