@tacet-ink/journal-core 0.2.5 → 0.3.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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * argon2.ts — Argon2id 包裹原語(jr3w./jr3d.,安全路線第 2 步,2026-09-14)。
3
+ *
4
+ * 動機(安全路線定案):離線 oracle 的戰利品是 wrapped(/auth/login 回應帶 wrapped+salt,
5
+ * 拿到一次回應即可永久離線猜)。PBKDF2-SHA256 600k 對 GPU 友善;Argon2id 的記憶體困難
6
+ * 讓每猜成本上升一至兩個數量級,oracle 實際價值大幅中和。OPAQUE 降級為後續精益求精。
7
+ *
8
+ * 帶內版本化契約(不變量 5):升級 KDF = 換新前綴,禁原地改語意。
9
+ * jr3w. 單因子:結構與 jr1w. 同構(salt 獨立存 users.salt;wrapped = prefix + b64(iv[12] ‖ ct),
10
+ * ct = AES-GCM(KEK, hex(noteKey), aad='notekey')),只換 KEK 派生:
11
+ * KEK = Argon2id(pass, salt, m=64MiB, t=3, p=1, tag=32B) → AES-GCM key。
12
+ * jr3d. 雙因子(jr2w. 後繼):payload 同 jr2w.(pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2')),
13
+ * KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, pinSalt3Prefix‖hex(pinSalt))[32],
14
+ * salt = pinSalt, info = 'journal-kek2-v1:' + wrapDual3, L = 32 )。
15
+ * 兩 payload 長度相同(皆 108B):客戶端按前綴分流(jr3w.→單因子、jr3d.→雙因子),形狀互斥由
16
+ * AAD 與長度把關,跨前綴呼叫一律回 null(unwrap 有前綴守衛,驗證閘有跨協議斷言)。
17
+ *
18
+ * 雙載體(同一 spec 兩端一致,交叉驗證逐位元一致):
19
+ * - node(驗證閘/prod smoke):node:crypto.argon2(24.7.0 落地版,26.8.1 實測)
20
+ * - 瀏覽器:hash-wasm argon2id(純 wasm 內嵌 base64,零網路請求,4.12.0 實測 64MiB t=3 ≈ 180ms)
21
+ * 標準向量:RFC 9106 無 secret/associated-data Argon2id(t=3, m=32, p=4, pwd=32B 0x01, salt=16B 0x00)
22
+ * = 72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503(兩載體實測 MATCH)。
23
+ *
24
+ * 遷移契約(禁強制重置):既有 jr1w./jr2w. 帳戶在下次成功 unwrap 後由客戶端靜默
25
+ * re-wrap 推 jr3w./jr3d.(手上有 noteKey、同一 noteKey 重包,資料零換鑰);舊前綴照解。
26
+ *
27
+ * ⚠️ 鐵律:unwrap 輸出 noteKey 一律 extractable=true(要能再包裹);KEK/KEK2 nonextractable。
28
+ * ⚠️ deriveArgon2id 禁 fallback(零知識):無載體即 throw,禁降級 PBKDF2 或 zeros 兜底。
29
+ * ⚠️ wrappedRec(復原套件)世代化:KEK_rec 派生世代(HKDF,recKekHkdf 配置面)
30
+ * +專用前綴 jr1r.(wrapRec)落地在 note-crypto.ts;本檔 recToken 256-bit 實體因子、
31
+ * KEK 強度無意義的原判不變(世代收的是派生域+契約面分離)。檢查對齊收口:wrapRec+
32
+ * recKekHkdf 兩欄一體(部分配置=ERR_REC_CFG_PARTIAL 拒寫);HKDF 世代前綴面專屬 jr1r.
33
+ * +AAD 'notekey-rec',jr1w. 前綴回歸僅 carrying passphrase-PBKDF2 派生(未配置世代
34
+ * wrappedRec 續走 jr1w.+PBKDF2+'notekey')。
35
+ */
36
+ import { normalizePin, normalizePassphrase } from './note-crypto.js';
37
+ import { b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, openNoteKey, sealNoteKey, } from './note-crypto.js';
38
+ /** KDF 派生輸入正規化收口(mirror normalizePin 先例):v3 入口 = normalizePassphrase(pass);raw 世代入口 = 原密語。
39
+ * 正規化永不進共用派生本體:deriveKekArgon/deriveKek2Argon 吃的是「派生輸入」——
40
+ * raw 契約(jr3d./jr2w.…)不經此面(帶內版本化「契約面永不變」的結構保證)。
41
+ * 三面單一真相:本體**委派** normalizePassphrase(語意唯一真相在
42
+ * note-crypto.ts 本體,本函式只是入口收口位命名)——禁自帶 .normalize 實作(閘形錨咬住); */
43
+ function deriveInput(passphrase) {
44
+ return normalizePassphrase(passphrase);
45
+ }
46
+ /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
47
+ export const ARGON_RFC9106_EXPECTED = '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
48
+ /**
49
+ * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
50
+ *
51
+ * 固定鹽是被迫設計:per-user 鹽會摧毀 PH2 UNIQUE(同一密語必須恒生同一 ph2,
52
+ * 幽靈帳號機制與「同密語不可開第二帳戶」都靠它)。帶內版本化:改參數 = 換鹽尾碼
53
+ * (v2/v3…)重遷移,禁原地改語意。實長 12B(RFC 9106 鹽最小 8B;Task 0 探針實證)。
54
+ */
55
+ export const PH1_V2_SALT = 'tacet-ph1-v1';
56
+ /**
57
+ * PH1 v2:登入憑證 = Argon2id(pass, 固定域鹽, m=64MiB, t=3, p=1) → hex64。
58
+ *
59
+ * 動機:DB 全洩後 ph2 = sha256(ph1) 是離線爆破讀日記的最後一個快雜湊面(候選密語
60
+ * 重算 sha256(sha256(g)) 對 ph2 命中即 g 是密語)。ph1 改 Argon2id 派生後每猜成本
61
+ * ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
62
+ */
63
+ export async function derivePh1Argon(passphrase, saltArg) {
64
+ // PH1 鹽可注入:預設 PH1_V2_SALT 舊值=tacet 零行為變更;
65
+ // 其他產品可自選鹽但 per-product 恆固定(PH2 UNIQUE 約束——per-user 鹽會摧毀它)。
66
+ return toHex(await deriveArgon2id(passphrase, saltArg ?? new TextEncoder().encode(PH1_V2_SALT), ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
67
+ }
68
+ /**
69
+ * PH1 v3 鹽(密語正規化 v3 世代):'tacet-ph1-v2'——鹽域版本代沿
70
+ * PH1_V2_SALT(常數名='tacet-ph1-v1')既有先例模式:常數名=PH1 規格代、鹽域值=鹽域代。
71
+ * 鹽域世代分離:若續用 v2 鹽域,v3 世代(normalizePassphrase 後 'PASS'→'PASS')與
72
+ * v2 世代 raw 'PASS' 會派生出同一 ph2=跨世代帳戶空間混合;新鹽域把跨世代 identity
73
+ * 問題留在遷移層查表(v2-first 三腿查表職責),核心層零承擔。
74
+ * ⚠️ 鹽撞位警告(NFKC-effective 帳):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的
75
+ * `saltArg` 餵入——v3 派生輸入吃 normalizePassphrase,同鹽域值錯面餵入=NFKC-effective
76
+ * 密語與 v2 raw 密語同值撞 ph2。fork 對兩面自選鹽時兩值必須互異(README PH1 節同款警告)。
77
+ */
78
+ export const PH1_V3_SALT = 'tacet-ph1-v2';
79
+ /**
80
+ * PH1 v3:登入憑證 = Argon2id(normalizePassphrase(pass), 固定域鹽, m=64MiB, t=3, p=1) → hex64。
81
+ * 正規化收口在入口(密語 v3 契約 NFKC-only——normalizePassphrase 檔頭三面單一真相);
82
+ * 鹽可注入(saltArg 慣例:預設 PH1_V3_SALT;per-product 恆固定=PH2 UNIQUE 約束同 v2 母型)。
83
+ * 參數沿 jr3w./PH1 v2 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
84
+ * v2/legacy 契約面零動:derivePh1Argon 不吃正規化(舊世代 raw 派生,帶內版本化)。
85
+ */
86
+ export async function derivePh1ArgonV3(passphrase, saltArg) {
87
+ return toHex(await deriveArgon2id(deriveInput(passphrase), saltArg ?? new TextEncoder().encode(PH1_V3_SALT), ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
88
+ }
89
+ /** Argon2id 參數(jr3w./jr3d. pass 段;前綴即版本,參數寫死本模組)。 */
90
+ export const ARGON_MEMORY_KIB = 65536; // 64 MiB(手機實測基準;瀏覽器 wasm ~180ms)
91
+ export const ARGON_ITERATIONS = 3; // t
92
+ export const ARGON_PARALLELISM = 1; // p(瀏覽器 wasm 無平行收益,定 1 兩端一致)
93
+ export const ARGON_TAG_LEN = 32; // 256-bit AES key
94
+ /** 雙因子 pin 段 Argon2id 參數(PIN 弱 → 記憶體補償;與 pass 段同級)。 */
95
+ export const ARGON_PIN_MEMORY_KIB = 65536;
96
+ export const ARGON_PIN_ITERATIONS = 3;
97
+ export const ARGON_PIN_PARALLELISM = 1;
98
+ const SALT_LEN = 16;
99
+ const DUAL_SALT_LEN = 16;
100
+ const DUAL_IV_LEN = 12;
101
+ let injectedLoader = null;
102
+ /** 載體注入(瀏覽器端由接線層指定 hash-wasm;node 端不注入走 node:crypto 原生)。 */
103
+ export function setArgonLoader(loader) {
104
+ injectedLoader = loader;
105
+ }
106
+ /**
107
+ * Argon2id 派生 32B。雙載體:injectedLoader(瀏覽器 hash-wasm)優先;
108
+ * 無注入時走 node:crypto 原生(僅 node 執行環境可達)。皆不可得即 throw(禁 fallback)。
109
+ */
110
+ async function deriveArgon2id(password, salt, memoryKib, passes, parallelism) {
111
+ if (injectedLoader) {
112
+ const mod = await injectedLoader();
113
+ const out = await mod.argon2id({
114
+ password,
115
+ salt,
116
+ iterations: passes,
117
+ parallelism,
118
+ memorySize: memoryKib,
119
+ hashLength: ARGON_TAG_LEN,
120
+ outputType: 'binary',
121
+ });
122
+ const bytes = typeof out === 'string' ? hexToBytes(out) : new Uint8Array(out);
123
+ if (bytes.length !== ARGON_TAG_LEN)
124
+ throw new Error('ERR_ARGON2_TAGLEN');
125
+ return bytes;
126
+ }
127
+ const nodeCrypto = globalThis.process?.getBuiltinModule?.('node:crypto');
128
+ if (!nodeCrypto?.argon2)
129
+ throw new Error('ERR_ARGON2_UNAVAILABLE');
130
+ return new Promise((resolve, reject) => {
131
+ nodeCrypto.argon2('argon2id', {
132
+ message: password,
133
+ nonce: salt,
134
+ memory: memoryKib,
135
+ passes,
136
+ parallelism,
137
+ tagLength: ARGON_TAG_LEN,
138
+ maxmem: 2 * 1024 * 1024 * 1024, // 2GiB:node 原生預設 1GiB 上限對 m+128*p*8 記帳過緊
139
+ }, (err, key) => (err ? reject(err) : resolve(new Uint8Array(key))));
140
+ });
141
+ }
142
+ /** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
143
+ export async function verifyArgonKat() {
144
+ const raw = await deriveArgon2id(new Uint8Array(32).fill(1), new Uint8Array(16), 32, 3, 4);
145
+ return toHex(raw) === ARGON_RFC9106_EXPECTED;
146
+ }
147
+ // ── jr3w. 單因子包裹 ──────────────────────────────────────────────────────────
148
+ async function argonKekFromRaw(raw) {
149
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
150
+ }
151
+ /** KEK = Argon2id(pass, salt, m=64MiB, t=3, p=1) → AES-GCM key(nonextractable)。 */
152
+ export async function deriveKekArgon(password, salt) {
153
+ return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
154
+ }
155
+ /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
156
+ export async function wrapNoteKey3(cfg, noteKey, passphrase) {
157
+ if (!cfg.wrap3)
158
+ throw new Error('ERR_JR3W_NOT_CONFIGURED');
159
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
160
+ const kek = await deriveKekArgon(passphrase, salt);
161
+ const wrapped = await sealNoteKey(cfg.wrap3, noteKey, kek, 'notekey');
162
+ return { wrapped, salt: toHex(salt) };
163
+ }
164
+ /** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
165
+ * 快速前綴快檢在場(deriveKekArgon 前——t_87ef62dd;unwrapNoteKey4 母型):錯前綴 junk 免付 Argon。
166
+ * 收口 openNoteKey:嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
167
+ * 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
168
+ * own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
169
+ export async function unwrapNoteKey3(cfg, wrapped, passphrase, saltHex) {
170
+ try {
171
+ if (!cfg.wrap3)
172
+ return null;
173
+ if (!wrapped.startsWith(cfg.wrap3))
174
+ return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 64 MiB Argon 成本
175
+ const salt = hexToBytes(saltHex);
176
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex))
177
+ return null;
178
+ const kek = await deriveKekArgon(passphrase, salt);
179
+ return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap3);
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ // ── jr4w. 單因子包裹(密語正規化 v3 世代) ────────────────────
186
+ //
187
+ // 帶內版本化:密語 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴,
188
+ // 舊前綴契約面永不變——jr4w. 的函式體與 jr3w. 同構(openNoteKey 共用核心、payload 嚴格
189
+ // 92B、AAD 'notekey'、嚴格長度全在 openNoteKey 本體),唯一差異=入口吃 normalizePassphrase(pass)
190
+ // 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
191
+ // pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
192
+ /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
193
+ export async function wrapNoteKey4(cfg, noteKey, passphrase) {
194
+ if (!cfg.wrap4)
195
+ throw new Error('ERR_JR4W_NOT_CONFIGURED');
196
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
197
+ const kek = await deriveKekArgon(deriveInput(passphrase), salt);
198
+ const wrapped = await sealNoteKey(cfg.wrap4, noteKey, kek, 'notekey');
199
+ return { wrapped, salt: toHex(salt) };
200
+ }
201
+ /** jr4w. 解包(v3 密語):任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
202
+ * 家族守衛(wrap4 未配置回 null)+快速前綴快檢(錯前綴 junk 免付 64 MiB Argon)+salt hex-形檢+
203
+ * KEK 派生(normalizePassphrase 收口在入口)——payload 形檢在 openNoteKey 本體(鹽外置家族:嚴格 92B+
204
+ * rawHex 形+hex fail-closed)。 */
205
+ export async function unwrapNoteKey4(cfg, wrapped, passphrase, saltHex) {
206
+ try {
207
+ if (!cfg.wrap4)
208
+ return null;
209
+ if (!wrapped.startsWith(cfg.wrap4))
210
+ return null; // 快速前綴快檢:錯前綴 junk 免付 64 MiB Argon 成本
211
+ const salt = hexToBytes(saltHex);
212
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex))
213
+ return null;
214
+ const kek = await deriveKekArgon(deriveInput(passphrase), salt);
215
+ return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap4);
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ }
221
+ // ── jr3d. 雙因子合鑰(PIN 第二因子,jr2w. 的 Argon2id 版) ─────────────────────
222
+ //
223
+ // KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, prefix‖hex(pinSalt))[32],
224
+ // salt = pinSalt, info = 'journal-kek2-v1:' + wrapDual3, L = 32 )
225
+ // info 字串與 jr2w. 家族同名(journal-kek2-v1):HKDF 合成步驟同構,兩家族 KEK2 值
226
+ // 因 KDF bits 不同而天然互斥(驗證閘有跨家族隔離斷言)。
227
+ // 兩段 Argon2 bits 用完即棄;KEK2 import 當下 nonextractable;輸出 noteKey extractable=true。
228
+ // HKDF info 由呼叫端帶入(hkdfInfo 參數):jr3d. 預設域 = 'journal-kek2-v1:' + cfg.wrapDual3——
229
+ // jr4d. 必帶自有域 'journal-kek2-v1:' + cfg.wrapDual4,KEK2 域分離由 info 承載(兩入參數同輸入
230
+ // 而派生域不同——同鹽同段 bits 在 NFKC-effective 密語下會同 KEK2;域即世代空間)。
231
+ async function deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, hkdfInfo) {
232
+ if (!cfg.pinSalt3Prefix)
233
+ throw new Error('ERR_JR3W_NOT_CONFIGURED');
234
+ // ⚠️ 瀏覽器 hash-wasm Argon2 禁並行:純 wasm Argon2id 共享記憶體池,
235
+ // 兩實例並行在部分引擎靜態直 throw——這裡故意保持串行(與 jr2w. PBKDF2 版
236
+ // deriveKek2 的 Promise.all 並行不同:那裡並行的前提是 crypto.subtle 原生派生)。
237
+ const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
238
+ const pinBits = await deriveArgon2id(pinNorm, new TextEncoder().encode(cfg.pinSalt3Prefix + toHex(pinSalt)), ARGON_PIN_MEMORY_KIB, ARGON_PIN_ITERATIONS, ARGON_PIN_PARALLELISM);
239
+ const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
240
+ ikm.set(passBits, 0);
241
+ ikm.set(pinBits, passBits.byteLength);
242
+ const hkdfBase = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveKey']);
243
+ return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: pinSalt, info: new TextEncoder().encode(hkdfInfo) }, hkdfBase, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
244
+ }
245
+ /** jr3d. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 由呼叫端存 users.salt。 */
246
+ export async function wrapNoteKeyDual3(cfg, noteKey, passphrase, pin) {
247
+ if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix)
248
+ throw new Error('ERR_JR3W_NOT_CONFIGURED');
249
+ const pinNorm = normalizePin(pin);
250
+ if (!pinNorm)
251
+ throw new Error('ERR_PIN_EMPTY');
252
+ const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
253
+ const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
254
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
255
+ const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
256
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') }, kek2, new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))))));
257
+ const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
258
+ payload.set(pinSalt, 0);
259
+ payload.set(iv, DUAL_SALT_LEN);
260
+ payload.set(ct, DUAL_SALT_LEN + DUAL_IV_LEN);
261
+ return { wrapped: cfg.wrapDual3 + b64(payload), salt: toHex(salt1) };
262
+ }
263
+ /** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
264
+ * 嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
265
+ * + iv 12+ct 80)+ decryptWithKey 後 rawHex hex 形檢查(hexToBytes fail-closed),
266
+ * 不經 openNoteKey 本體(鹽外置 92B 形);與 openNoteKey 同嚴格度。 */
267
+ export async function unwrapNoteKeyDual3(cfg, wrapped, passphrase, pin, salt1Hex) {
268
+ try {
269
+ if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix)
270
+ return null;
271
+ if (!wrapped.startsWith(cfg.wrapDual3))
272
+ return null;
273
+ const salt1 = hexToBytes(salt1Hex);
274
+ if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex))
275
+ return null;
276
+ const payload = unb64(wrapped.slice(cfg.wrapDual3.length));
277
+ // 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
278
+ if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80)
279
+ return null;
280
+ const pinSalt = payload.slice(0, DUAL_SALT_LEN);
281
+ const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
282
+ const pinNorm = normalizePin(pin);
283
+ if (!pinNorm)
284
+ return null;
285
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
286
+ const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
287
+ if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex))
288
+ return null;
289
+ return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ // ── jr4d. 雙因子合鑰(密語正規化 v3 世代=jr3d. 的 v3 後繼) ────
296
+ //
297
+ // 帶內版本化:pass 段 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴;
298
+ // PIN 段契約照舊(normalizePin:NFKC→trim→lowercase,pinlock/dual 面不動)。
299
+ // KEK2 合成步驟與 jr3d. 同構:deriveKek2Argon 共用本體(pass 段正規化收口在 wrap/unwrap
300
+ // 入口層——deriveInput 收口、正規化永不進共用派生函式,jr3d. raw 契約面零動如實不經測不變);
301
+ // payload 嚴格 108B+rawHex 形檢在 own 本體(鹽內嵌族 openNoteKey 不覆蓋);
302
+ // HKDF info 沿 'journal-kek2-v1:' 合成名+hkdfInfo 呼叫端帶入(域=jr4d.:KDF 互斥由 bits+info 域承載)。
303
+ /** jr4d. 包裹(v3 密語):payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 呼叫端存 users.salt。 */
304
+ export async function wrapNoteKeyDual4(cfg, noteKey, passphrase, pin) {
305
+ if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix)
306
+ throw new Error('ERR_JR4D_NOT_CONFIGURED');
307
+ const pinNorm = normalizePin(pin);
308
+ if (!pinNorm)
309
+ throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
310
+ const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
311
+ const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
312
+ const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
313
+ const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
314
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') }, kek2, new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey))))));
315
+ const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
316
+ payload.set(pinSalt, 0);
317
+ payload.set(iv, DUAL_SALT_LEN);
318
+ payload.set(ct, DUAL_SALT_LEN + DUAL_IV_LEN);
319
+ return { wrapped: cfg.wrapDual4 + b64(payload), salt: toHex(salt1) };
320
+ }
321
+ // jr4d. 解包(v3 密語):pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
322
+ // 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex 形檢,不經 openNoteKey 92B 鹽外置本體);
323
+ // pass 段正規化收口在入口(deriveInput=normalizePassphrase 同語意本體收口),pinNorm 與 jr3d. 同契約。
324
+ // KEK2 HKDF info='journal-kek2-v1:' + cfg.wrapDual4(自有域——jr3d/jr4d 兩入參數同輸入而域分離;
325
+ // NFKC-effective 密語下 bits 段同值,域由 info 承載——凍結 KAT 兩 blob 互解 null 承載)。
326
+ export async function unwrapNoteKeyDual4(cfg, wrapped, passphrase, pin, salt1Hex) {
327
+ try {
328
+ if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix)
329
+ return null;
330
+ if (!wrapped.startsWith(cfg.wrapDual4))
331
+ return null;
332
+ const salt1 = hexToBytes(salt1Hex);
333
+ if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex))
334
+ return null;
335
+ const payload = unb64(wrapped.slice(cfg.wrapDual4.length));
336
+ // 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
337
+ if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80)
338
+ return null;
339
+ const pinSalt = payload.slice(0, DUAL_SALT_LEN);
340
+ const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
341
+ const pinNorm = normalizePin(pin);
342
+ if (!pinNorm)
343
+ return null;
344
+ const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
345
+ const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
346
+ if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex))
347
+ return null;
348
+ return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
349
+ }
350
+ catch {
351
+ return null;
352
+ }
353
+ }
354
+ // ── jr3s. 分享包裹(分享連結 V2,安全路線商用前清單;jrsw. 的 Argon2id 版) ───
355
+ //
356
+ // KEK_share = Argon2id(sharePass, salt, m=64MiB, t=3, p=1, tag=32B)(與 jr3w. 同級參數),
357
+ // payload = iv[12] ‖ GCM(KEK_share, hex(noteKey), aad='notekey-share')(與 jrsw. 同構只換 KDF),
358
+ // wrapped = 'jr3s.' + b64(payload),嚴格 92B;salt 由呼叫端存 shares.salt(與 jr1w./jr3w. 同形)。
359
+ // AAD 沿用 'notekey-share':兩代分享包裹結構同構,跨代誤用由 KDF 差異與前綴守衛雙層把關。
360
+ // 帶內版本化:share KDF 升級 = 換新前綴(jrsw. → jr3s.),舊前綴語意不動;讀取端兩代並行,
361
+ // 建立端只收 jr3s.(關閉弱 KDF 建立面)。
362
+ // ⚠️ unwrap 輸出 noteKey 一律 extractable=true(要能解日記密文;鐵律 4)。
363
+ /** jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
364
+ export async function wrapNoteKeyShare3(cfg, noteKey, sharePass) {
365
+ if (!cfg.wrapShare3)
366
+ throw new Error('ERR_JR3S_NOT_CONFIGURED');
367
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
368
+ const kek = await deriveKekArgon(sharePass, salt);
369
+ const wrapped = await sealNoteKey(cfg.wrapShare3, noteKey, kek, 'notekey-share');
370
+ return { wrapped, salt: toHex(salt) };
371
+ }
372
+ /** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
373
+ * 快速前綴快檢在場(deriveKekArgon 前——t_87ef62dd;unwrapNoteKey4 母型):錯前綴 junk 免付 Argon。
374
+ * 收口 openNoteKey:鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
375
+ * 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
376
+ export async function unwrapNoteKeyShare3(cfg, wrapped, sharePass, saltHex) {
377
+ try {
378
+ if (!cfg.wrapShare3)
379
+ return null;
380
+ if (!wrapped.startsWith(cfg.wrapShare3))
381
+ return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 64 MiB Argon 成本
382
+ const salt = hexToBytes(saltHex);
383
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex))
384
+ return null;
385
+ const kek = await deriveKekArgon(sharePass, salt);
386
+ return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare3);
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
392
+ // ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt ────
393
+ //
394
+ // 為何同構再開一支:v3 遷移(登入即重遷)會旋轉 salt1(wrapNoteKeyDual4 落新值);
395
+ // login 舊形面帶回的 salt(v2 世代的 salt1)在遷移後已解不開 v3 新包裹──呼叫端
396
+ // (tacet loginAndUnwrap 的 jr4d. 腿)以「login 回應當下帶回的新 salt」重取入參
397
+ // 重試 unwrap 就是本變體唯一承載面。本體與 unwrapNoteKeyDual4 完全同構(同嚴格
398
+ // 檢查、同 HKDF info 域、同輸出契約)──開支出零差異;命名開新支是「重取鹽」的
399
+ // 語意承載位(呼叫端 grep 可審計),零新增實作面。
400
+ //
401
+ // 補記(家族嚴格度):salt1 形檢/payload 108B/rawHex 形檢全在
402
+ // unwrapNoteKeyDual4 本體(共用);本變體純語意薄身。
403
+ /** jr4d. 解包(重取 salt1 面):pinSalt 內嵌自描述,salt1 由呼叫端重取後帶入(ladder 遷移後的新値);任何不符回 null,不拋。 */
404
+ export async function unwrapNoteKeyDual4WithSalt(cfg, wrapped, passphrase, pin, salt1Hex) {
405
+ return unwrapNoteKeyDual4(cfg, wrapped, passphrase, pin, salt1Hex);
406
+ }
@@ -0,0 +1,11 @@
1
+ /** 產生 24 詞套件(entropy 32B 隨機 → +8-bit checksum → 24 詞)。
2
+ * entropyBytes 可注入(驗證閘固定種子樣本用;產品面不傳=真隨機契約不變)。 */
3
+ export declare function generateBip39Words(entropyBytes?: Uint8Array): Promise<string[]>;
4
+ /** 固定種子樣本集:seed 給定 → 32B 確定(xorshift32 常數種子流)。 */
5
+ export declare function seededSampleBytes(seed: number): Uint8Array;
6
+ /** 24 詞 → recToken hex64。任何不符(字數/詞表外/checksum)回 null,不洩漏哪類錯。 */
7
+ export declare function wordsToRecToken(input: string | string[]): Promise<string | null>;
8
+ /** recToken hex64 → 24 詞(既有套件顯示需求;非 hex64 或格式不符回 null)。 */
9
+ export declare function recTokenToWords(recToken: string): Promise<string[] | null>;
10
+ /** 抄寫抽驗位置:24 詞中抽 4 個不重複位置(0-23),排序回傳(顯示順序穩定)。 */
11
+ export declare function spotCheckIndexes(): number[];
@@ -0,0 +1,139 @@
1
+ /**
2
+ * bip39.ts — 復原套件轉寫層(BIP39 英文 24 詞 ⇄ entropy 32B ⇄ recToken hex64)。
3
+ *
4
+ * 契約裁定(2026-09-10 復原套件三件):
5
+ * - **轉寫層,非 KDF 升級**:24 詞 ⇄ hex64 可逆互轉;線上契約(rec_hash = SHA-256(hex64)、
6
+ * wrappedRec = jr1w. + GCM(PBKDF2(recToken, ...)))完全不變,伺服器零改動。
7
+ * 熵等價:24 詞 = 256-bit entropy + 8-bit checksum(BIP39 標準);與舊 hex64 同熵。
8
+ * - **checksum 是轉寫品質閘**:單詞打錯 255/256 機率當場被抓(64 hex 轉寫無此偵測);
9
+ * 解析失敗一律回 null(不洩漏哪類錯:字數/詞表外/checksum 統一,UI 統一訊息)。
10
+ * - **只收 24 詞**:12 詞是降級禁用(security-roadmap 裁定);15/18/21 不收(契約面單一)。
11
+ * - **相容層永久**:舊帳戶 hex64 紙本照走現行鏈;recTokenToWords 供既有套件顯示用。
12
+ * - **零依賴**:SHA-256 用 WebCrypto(全 async API);bits 打包自製(閘 [9] 以
13
+ * @scure/bip39 參照實作 200 組雙向對照守證)。禁用 @scure 的 mnemonicToSeed
14
+ * (PBKDF2-SHA512 seed 派生與 Tacet 契約無關,不可混入)。
15
+ */
16
+ import { wordlist } from './wordlist.js';
17
+ const HEX64_RE = /^[0-9a-f]{64}$/;
18
+ async function sha256(data) {
19
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', data));
20
+ }
21
+ function hexToBytes(hexStr) {
22
+ const out = new Uint8Array(hexStr.length / 2);
23
+ for (let i = 0; i < out.length; i++)
24
+ out[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16);
25
+ return out;
26
+ }
27
+ function bytesToHex(bytes) {
28
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
29
+ }
30
+ /** 輸入正規化:NFKC → 換行/全形空白/多空白收斂單半形空格 → 小寫(wordlist 全小寫,輸入容錯)。 */
31
+ function normalizeWords(input) {
32
+ return input.normalize('NFKC').trim().toLowerCase().split(/\s+/).filter(w => w.length > 0);
33
+ }
34
+ /** 11-bit 打包(BIP39 標準位元累積器;carry 恆 < 19 bits,索引 = 低位遮罩 0x7ff)。 */
35
+ function entropyToIndexes(entropy, checksum) {
36
+ const bytes = new Uint8Array(entropy.length + 1);
37
+ bytes.set(entropy);
38
+ bytes[entropy.length] = checksum; // 32B entropy → checksum 8 bits = SHA-256 首位元組全值
39
+ const indexes = [];
40
+ let carry = 0;
41
+ let bits = 0;
42
+ for (const byte of bytes) {
43
+ carry = (carry << 8) | byte;
44
+ bits += 8;
45
+ if (bits >= 11) {
46
+ bits -= 11;
47
+ indexes.push((carry >>> bits) & 0x7ff);
48
+ carry &= (1 << bits) - 1;
49
+ }
50
+ }
51
+ return indexes;
52
+ }
53
+ /** 11-bit 逆打包:24 個索引 → 33 bytes(entropy 32B + checksum 1B)。 */
54
+ function indexesToBytes(indexes) {
55
+ const out = new Uint8Array(33);
56
+ let carry = 0;
57
+ let bits = 0;
58
+ let pos = 0;
59
+ for (const idx of indexes) {
60
+ carry = (carry << 11) | idx;
61
+ bits += 11;
62
+ while (bits >= 8) {
63
+ bits -= 8;
64
+ out[pos++] = (carry >>> bits) & 0xff;
65
+ carry &= (1 << bits) - 1;
66
+ }
67
+ }
68
+ return out; // 24 詞 = 264 bits = 33 bytes 整除,無餘位(11-bit 打包自洽)
69
+ }
70
+ /** 產生 24 詞套件(entropy 32B 隨機 → +8-bit checksum → 24 詞)。
71
+ * entropyBytes 可注入(驗證閘固定種子樣本用;產品面不傳=真隨機契約不變)。 */
72
+ export async function generateBip39Words(entropyBytes) {
73
+ const entropy = entropyBytes ?? crypto.getRandomValues(new Uint8Array(32));
74
+ const checksum = (await sha256(entropy))[0];
75
+ return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
76
+ }
77
+ // ── 驗證閘固定種子樣本(flake 歸零)──────────────────────────────
78
+ //
79
+ // 背景:checksum 統計面「錯一詞 64 樣本 ≥63 被抓」用真隨機取樣時,P(漏 ≥2)≈0.4%,
80
+ // 偶發 62/64=閘 flake(2026-09-19 session 實證)。治本是固定種子:xorshift32 以
81
+ // 常數 seed 產生確定性位元組流(種子決定樣本集=向量可重放、閘輸出逐輪恆定)。
82
+ // 樣本集僅供驗證閘使用(checksum 是 BIP39 公開數學,非機密;產品面
83
+ // generateBip39Words() 不帶參數=crypto.getRandomValues 真隨機契約不變)。
84
+ function xorshift32Bytes(seed, len) {
85
+ let s = seed >>> 0;
86
+ if (s === 0)
87
+ s = 0x9e3779b9; // xorshift 全零態死鎖防護
88
+ const out = new Uint8Array(len);
89
+ for (let i = 0; i < len; i++) {
90
+ s ^= s << 13;
91
+ s >>>= 0;
92
+ s ^= s >>> 17;
93
+ s ^= s << 5;
94
+ s >>>= 0;
95
+ out[i] = s & 0xff;
96
+ }
97
+ return out;
98
+ }
99
+ /** 固定種子樣本集:seed 給定 → 32B 確定(xorshift32 常數種子流)。 */
100
+ export function seededSampleBytes(seed) {
101
+ return xorshift32Bytes(seed, 32);
102
+ }
103
+ /** 24 詞 → recToken hex64。任何不符(字數/詞表外/checksum)回 null,不洩漏哪類錯。 */
104
+ export async function wordsToRecToken(input) {
105
+ const words = typeof input === 'string' ? normalizeWords(input) : input.map(w => w.normalize('NFKC').trim().toLowerCase());
106
+ if (words.length !== 24)
107
+ return null;
108
+ const indexes = [];
109
+ for (const w of words) {
110
+ const idx = wordlist.indexOf(w);
111
+ if (idx < 0)
112
+ return null;
113
+ indexes.push(idx);
114
+ }
115
+ const bytes = indexesToBytes(indexes);
116
+ const entropy = bytes.slice(0, 32);
117
+ const checksum = bytes[32];
118
+ const expected = (await sha256(entropy))[0];
119
+ if (checksum !== expected)
120
+ return null;
121
+ return bytesToHex(entropy);
122
+ }
123
+ /** recToken hex64 → 24 詞(既有套件顯示需求;非 hex64 或格式不符回 null)。 */
124
+ export async function recTokenToWords(recToken) {
125
+ if (!HEX64_RE.test(recToken))
126
+ return null;
127
+ const entropy = hexToBytes(recToken);
128
+ const checksum = (await sha256(entropy))[0];
129
+ return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
130
+ }
131
+ /** 抄寫抽驗位置:24 詞中抽 4 個不重複位置(0-23),排序回傳(顯示順序穩定)。 */
132
+ export function spotCheckIndexes() {
133
+ const pool = Array.from({ length: 24 }, (_, i) => i);
134
+ for (let i = pool.length - 1; i > 0; i--) {
135
+ const j = crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
136
+ [pool[i], pool[j]] = [pool[j], pool[i]];
137
+ }
138
+ return pool.slice(0, 4).sort((a, b) => a - b);
139
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * keys.ts — storage key 命名空間。各產品以 config 指定前綴(如 journal 的 <brand>_)。
3
+ * 理由:fork diff 證實 noteCrypto 差異一半在 localStorage key 前綴——參數化收進 core。
4
+ */
5
+ export interface KeyStoreConfig {
6
+ /** 產品前綴,如 'tacet'(不含底線)。 */
7
+ brand: string;
8
+ }
9
+ export declare function makeKeyStore(config: KeyStoreConfig): {
10
+ get: (k: string) => string | null;
11
+ set: (k: string, v: string) => void;
12
+ remove: (k: string) => void;
13
+ noteKeyWrap: (soulKey: string) => string;
14
+ };
15
+ export type KeyStore = ReturnType<typeof makeKeyStore>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * keys.ts — storage key 命名空間。各產品以 config 指定前綴(如 journal 的 <brand>_)。
3
+ * 理由:fork diff 證實 noteCrypto 差異一半在 localStorage key 前綴——參數化收進 core。
4
+ */
5
+ export function makeKeyStore(config) {
6
+ const wrapKey = (soulKey) => `${config.brand}_notekey:${soulKey}`;
7
+ return {
8
+ get: (k) => {
9
+ try {
10
+ return localStorage.getItem(k);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ },
16
+ set: (k, v) => {
17
+ try {
18
+ localStorage.setItem(k, v);
19
+ }
20
+ catch { /* private mode:不阻擋主流程 */ }
21
+ },
22
+ remove: (k) => {
23
+ try {
24
+ localStorage.removeItem(k);
25
+ }
26
+ catch { /* noop */ }
27
+ },
28
+ noteKeyWrap: wrapKey,
29
+ };
30
+ }