@tacet-ink/journal-core 0.1.6 → 0.2.1

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.
@@ -26,14 +26,28 @@
26
26
  *
27
27
  * ⚠️ 鐵律:unwrap 輸出 noteKey 一律 extractable=true(要能再包裹);KEK/KEK2 nonextractable。
28
28
  * ⚠️ deriveArgon2id 禁 fallback(零知識):無載體即 throw,禁降級 PBKDF2 或 zeros 兜底。
29
- * ⚠️ wrappedRec(復原套件)維持 jr1w.:recToken 是 256-bit 實體因子,KDF 強度無意義,不動。
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')。
30
35
  */
31
36
 
32
- import { normalizePin } from './note-crypto.ts';
37
+ import { normalizePin, normalizePassphrase } from './note-crypto.ts';
33
38
  import {
34
39
  b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, openNoteKey, sealNoteKey,
35
40
  } from './note-crypto.ts';
36
41
 
42
+ /** KDF 派生輸入正規化收口(mirror normalizePin 先例):v3 入口 = normalizePassphrase(pass);raw 世代入口 = 原密語。
43
+ * 正規化永不進共用派生本體:deriveKekArgon/deriveKek2Argon 吃的是「派生輸入」——
44
+ * raw 契約(jr3d./jr2w.…)不經此面(帶內版本化「契約面永不變」的結構保證)。
45
+ * 三面單一真相:本體**委派** normalizePassphrase(語意唯一真相在
46
+ * note-crypto.ts 本體,本函式只是入口收口位命名)——禁自帶 .normalize 實作(閘形錨咬住); */
47
+ function deriveInput(passphrase: string): string {
48
+ return normalizePassphrase(passphrase);
49
+ }
50
+
37
51
  /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
38
52
  export const ARGON_RFC9106_EXPECTED =
39
53
  '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
@@ -55,7 +69,7 @@ export const PH1_V2_SALT = 'tacet-ph1-v1';
55
69
  * ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
56
70
  */
57
71
  export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array): Promise<string> {
58
- // PH1 鹽可注入(t_7710c766 目標 4):預設 PH1_V2_SALT 舊值=tacet 零行為變更;
72
+ // PH1 鹽可注入:預設 PH1_V2_SALT 舊值=tacet 零行為變更;
59
73
  // 其他產品可自選鹽但 per-product 恆固定(PH2 UNIQUE 約束——per-user 鹽會摧毀它)。
60
74
  return toHex(await deriveArgon2id(
61
75
  passphrase,
@@ -66,6 +80,35 @@ export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array):
66
80
  ));
67
81
  }
68
82
 
83
+ /**
84
+ * PH1 v3 鹽(密語正規化 v3 世代):'tacet-ph1-v2'——鹽域版本代沿
85
+ * PH1_V2_SALT(常數名='tacet-ph1-v1')既有先例模式:常數名=PH1 規格代、鹽域值=鹽域代。
86
+ * 鹽域世代分離:若續用 v2 鹽域,v3 世代(normalizePassphrase 後 'PASS'→'PASS')與
87
+ * v2 世代 raw 'PASS' 會派生出同一 ph2=跨世代帳戶空間混合;新鹽域把跨世代 identity
88
+ * 問題留在遷移層查表(v2-first 三腿查表職責),核心層零承擔。
89
+ * ⚠️ 鹽撞位警告(NFKC-effective 帳):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的
90
+ * `saltArg` 餵入——v3 派生輸入吃 normalizePassphrase,同鹽域值錯面餵入=NFKC-effective
91
+ * 密語與 v2 raw 密語同值撞 ph2。fork 對兩面自選鹽時兩值必須互異(README PH1 節同款警告)。
92
+ */
93
+ export const PH1_V3_SALT = 'tacet-ph1-v2';
94
+
95
+ /**
96
+ * PH1 v3:登入憑證 = Argon2id(normalizePassphrase(pass), 固定域鹽, m=64MiB, t=3, p=1) → hex64。
97
+ * 正規化收口在入口(密語 v3 契約 NFKC-only——normalizePassphrase 檔頭三面單一真相);
98
+ * 鹽可注入(saltArg 慣例:預設 PH1_V3_SALT;per-product 恆固定=PH2 UNIQUE 約束同 v2 母型)。
99
+ * 參數沿 jr3w./PH1 v2 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
100
+ * v2/legacy 契約面零動:derivePh1Argon 不吃正規化(舊世代 raw 派生,帶內版本化)。
101
+ */
102
+ export async function derivePh1ArgonV3(passphrase: string, saltArg?: Uint8Array): Promise<string> {
103
+ return toHex(await deriveArgon2id(
104
+ deriveInput(passphrase),
105
+ saltArg ?? new TextEncoder().encode(PH1_V3_SALT),
106
+ ARGON_MEMORY_KIB,
107
+ ARGON_ITERATIONS,
108
+ ARGON_PARALLELISM,
109
+ ));
110
+ }
111
+
69
112
  /** Argon2id 參數(jr3w./jr3d. pass 段;前綴即版本,參數寫死本模組)。 */
70
113
  export const ARGON_MEMORY_KIB = 65536; // 64 MiB(手機實測基準;瀏覽器 wasm ~180ms)
71
114
  export const ARGON_ITERATIONS = 3; // t
@@ -91,6 +134,10 @@ export interface Argon3Config {
91
134
  pinSalt3Prefix?: string;
92
135
  /** 分享包裹前綴(jr3s.,單篇分享連結 Argon2id 版)。未配置 = 分享 API 拒絕(兄弟 fork 行為不變)。 */
93
136
  wrapShare3?: string;
137
+ /** 密語正規化 v3 世代單因子包裹前綴(jr4w.,KEK=Argon2id(normalizePassphrase(pass)))。未配置 = 拒絕。 */
138
+ wrap4?: string;
139
+ /** 密語正規化 v3 世代雙因子包裹前綴(jr4d.,pass 段正規化+PIN 段照舊)。未配置 = 拒絕。 */
140
+ wrapDual4?: string;
94
141
  }
95
142
 
96
143
  interface HashWasmArgon2id {
@@ -202,7 +249,7 @@ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passph
202
249
  }
203
250
 
204
251
  /** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
205
- * 收口 openNoteKey(t_7710c766):嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
252
+ * 收口 openNoteKey:嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
206
253
  * 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
207
254
  * own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
208
255
  export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
@@ -217,6 +264,40 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
217
264
  }
218
265
  }
219
266
 
267
+ // ── jr4w. 單因子包裹(密語正規化 v3 世代) ────────────────────
268
+ //
269
+ // 帶內版本化:密語 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴,
270
+ // 舊前綴契約面永不變——jr4w. 的函式體與 jr3w. 同構(openNoteKey 共用核心、payload 嚴格
271
+ // 92B、AAD 'notekey'、嚴格長度全在 openNoteKey 本體),唯一差異=入口吃 normalizePassphrase(pass)
272
+ // 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
273
+ // pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
274
+
275
+ /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
276
+ export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
277
+ if (!cfg.wrap4) throw new Error('ERR_JR4W_NOT_CONFIGURED');
278
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
279
+ const kek = await deriveKekArgon(deriveInput(passphrase), salt);
280
+ const wrapped = await sealNoteKey(cfg.wrap4, noteKey, kek, 'notekey');
281
+ return { wrapped, salt: toHex(salt) };
282
+ }
283
+
284
+ /** jr4w. 解包(v3 密語):任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
285
+ * 家族守衛(wrap4 未配置回 null)+快速前綴快檢(錯前綴 junk 免付 64 MiB Argon)+salt hex-形檢+
286
+ * KEK 派生(normalizePassphrase 收口在入口)——payload 形檢在 openNoteKey 本體(鹽外置家族:嚴格 92B+
287
+ * rawHex 形+hex fail-closed)。 */
288
+ export async function unwrapNoteKey4(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
289
+ try {
290
+ if (!cfg.wrap4) return null;
291
+ if (!wrapped.startsWith(cfg.wrap4)) return null; // 快速前綴快檢:錯前綴 junk 免付 64 MiB Argon 成本
292
+ const salt = hexToBytes(saltHex);
293
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
294
+ const kek = await deriveKekArgon(deriveInput(passphrase), salt);
295
+ return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap4);
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
220
301
  // ── jr3d. 雙因子合鑰(PIN 第二因子,jr2w. 的 Argon2id 版) ─────────────────────
221
302
  //
222
303
  // KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, prefix‖hex(pinSalt))[32],
@@ -224,6 +305,9 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
224
305
  // info 字串與 jr2w. 家族同名(journal-kek2-v1):HKDF 合成步驟同構,兩家族 KEK2 值
225
306
  // 因 KDF bits 不同而天然互斥(驗證閘有跨家族隔離斷言)。
226
307
  // 兩段 Argon2 bits 用完即棄;KEK2 import 當下 nonextractable;輸出 noteKey extractable=true。
308
+ // HKDF info 由呼叫端帶入(hkdfInfo 參數):jr3d. 預設域 = 'journal-kek2-v1:' + cfg.wrapDual3——
309
+ // jr4d. 必帶自有域 'journal-kek2-v1:' + cfg.wrapDual4,KEK2 域分離由 info 承載(兩入參數同輸入
310
+ // 而派生域不同——同鹽同段 bits 在 NFKC-effective 密語下會同 KEK2;域即世代空間)。
227
311
 
228
312
  async function deriveKek2Argon(
229
313
  cfg: Argon3Config,
@@ -231,9 +315,10 @@ async function deriveKek2Argon(
231
315
  pinNorm: string,
232
316
  salt1: Uint8Array,
233
317
  pinSalt: Uint8Array,
318
+ hkdfInfo: string,
234
319
  ): Promise<CryptoKey> {
235
320
  if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
236
- // ⚠️ 瀏覽器 hash-wasm Argon2 禁並行(t_7361b68c):純 wasm Argon2id 共享記憶體池,
321
+ // ⚠️ 瀏覽器 hash-wasm Argon2 禁並行:純 wasm Argon2id 共享記憶體池,
237
322
  // 兩實例並行在部分引擎靜態直 throw——這裡故意保持串行(與 jr2w. PBKDF2 版
238
323
  // deriveKek2 的 Promise.all 並行不同:那裡並行的前提是 crypto.subtle 原生派生)。
239
324
  const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
@@ -249,7 +334,7 @@ async function deriveKek2Argon(
249
334
  ikm.set(pinBits, passBits.byteLength);
250
335
  const hkdfBase = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveKey']);
251
336
  return crypto.subtle.deriveKey(
252
- { name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode('journal-kek2-v1:' + cfg.wrapDual3) as BufferSource },
337
+ { name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode(hkdfInfo) as BufferSource },
253
338
  hkdfBase,
254
339
  { name: 'AES-GCM', length: 256 },
255
340
  false,
@@ -264,7 +349,7 @@ export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, pa
264
349
  if (!pinNorm) throw new Error('ERR_PIN_EMPTY');
265
350
  const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
266
351
  const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
267
- const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
352
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
268
353
  const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
269
354
  const ct = new Uint8Array(await crypto.subtle.encrypt(
270
355
  { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
@@ -279,7 +364,7 @@ export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, pa
279
364
  }
280
365
 
281
366
  /** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
282
- * t_7710c766:嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
367
+ * 嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
283
368
  * + iv 12+ct 80)+ decryptWithKey 後 rawHex hex 形檢查(hexToBytes fail-closed),
284
369
  * 不經 openNoteKey 本體(鹽外置 92B 形);與 openNoteKey 同嚴格度。 */
285
370
  export async function unwrapNoteKeyDual3(
@@ -301,7 +386,70 @@ export async function unwrapNoteKeyDual3(
301
386
  const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
302
387
  const pinNorm = normalizePin(pin);
303
388
  if (!pinNorm) return null;
304
- const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
389
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
390
+ const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
391
+ if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
392
+ return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ // ── jr4d. 雙因子合鑰(密語正規化 v3 世代=jr3d. 的 v3 後繼) ────
399
+ //
400
+ // 帶內版本化:pass 段 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴;
401
+ // PIN 段契約照舊(normalizePin:NFKC→trim→lowercase,pinlock/dual 面不動)。
402
+ // KEK2 合成步驟與 jr3d. 同構:deriveKek2Argon 共用本體(pass 段正規化收口在 wrap/unwrap
403
+ // 入口層——deriveInput 收口、正規化永不進共用派生函式,jr3d. raw 契約面零動如實不經測不變);
404
+ // payload 嚴格 108B+rawHex 形檢在 own 本體(鹽內嵌族 openNoteKey 不覆蓋);
405
+ // HKDF info 沿 'journal-kek2-v1:' 合成名+hkdfInfo 呼叫端帶入(域=jr4d.:KDF 互斥由 bits+info 域承載)。
406
+
407
+ /** jr4d. 包裹(v3 密語):payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 呼叫端存 users.salt。 */
408
+ export async function wrapNoteKeyDual4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
409
+ if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR4D_NOT_CONFIGURED');
410
+ const pinNorm = normalizePin(pin);
411
+ if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
412
+ const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
413
+ const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
414
+ const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
415
+ const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
416
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
417
+ { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
418
+ kek2,
419
+ new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)))) as BufferSource,
420
+ ));
421
+ const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
422
+ payload.set(pinSalt, 0);
423
+ payload.set(iv, DUAL_SALT_LEN);
424
+ payload.set(ct, DUAL_SALT_LEN + DUAL_IV_LEN);
425
+ return { wrapped: cfg.wrapDual4 + b64(payload), salt: toHex(salt1) };
426
+ }
427
+
428
+ // jr4d. 解包(v3 密語):pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
429
+ // 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex 形檢,不經 openNoteKey 92B 鹽外置本體);
430
+ // pass 段正規化收口在入口(deriveInput=normalizePassphrase 同語意本體收口),pinNorm 與 jr3d. 同契約。
431
+ // KEK2 HKDF info='journal-kek2-v1:' + cfg.wrapDual4(自有域——jr3d/jr4d 兩入參數同輸入而域分離;
432
+ // NFKC-effective 密語下 bits 段同值,域由 info 承載——凍結 KAT 兩 blob 互解 null 承載)。
433
+ export async function unwrapNoteKeyDual4(
434
+ cfg: Argon3Config,
435
+ wrapped: string,
436
+ passphrase: string,
437
+ pin: string,
438
+ salt1Hex: string,
439
+ ): Promise<CryptoKey | null> {
440
+ try {
441
+ if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix) return null;
442
+ if (!wrapped.startsWith(cfg.wrapDual4)) return null;
443
+ const salt1 = hexToBytes(salt1Hex);
444
+ if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex)) return null;
445
+ const payload = unb64(wrapped.slice(cfg.wrapDual4.length));
446
+ // 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
447
+ if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
448
+ const pinSalt = payload.slice(0, DUAL_SALT_LEN);
449
+ const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
450
+ const pinNorm = normalizePin(pin);
451
+ if (!pinNorm) return null;
452
+ const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
305
453
  const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
306
454
  if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
307
455
  return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
@@ -330,7 +478,7 @@ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, s
330
478
  }
331
479
 
332
480
  /** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
333
- * 收口 openNoteKey(t_7710c766):鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
481
+ * 收口 openNoteKey:鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
334
482
  * 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
335
483
  export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
336
484
  try {
@@ -342,4 +490,27 @@ export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sh
342
490
  } catch {
343
491
  return null;
344
492
  }
493
+ }
494
+
495
+ // ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt ────
496
+ //
497
+ // 為何同構再開一支:v3 遷移(登入即重遷)會旋轉 salt1(wrapNoteKeyDual4 落新值);
498
+ // login 舊形面帶回的 salt(v2 世代的 salt1)在遷移後已解不開 v3 新包裹──呼叫端
499
+ // (tacet loginAndUnwrap 的 jr4d. 腿)以「login 回應當下帶回的新 salt」重取入參
500
+ // 重試 unwrap 就是本變體唯一承載面。本體與 unwrapNoteKeyDual4 完全同構(同嚴格
501
+ // 檢查、同 HKDF info 域、同輸出契約)──開支出零差異;命名開新支是「重取鹽」的
502
+ // 語意承載位(呼叫端 grep 可審計),零新增實作面。
503
+ //
504
+ // 補記(家族嚴格度):salt1 形檢/payload 108B/rawHex 形檢全在
505
+ // unwrapNoteKeyDual4 本體(共用);本變體純語意薄身。
506
+
507
+ /** jr4d. 解包(重取 salt1 面):pinSalt 內嵌自描述,salt1 由呼叫端重取後帶入(ladder 遷移後的新値);任何不符回 null,不拋。 */
508
+ export async function unwrapNoteKeyDual4WithSalt(
509
+ cfg: Argon3Config,
510
+ wrapped: string,
511
+ passphrase: string,
512
+ pin: string,
513
+ salt1Hex: string,
514
+ ): Promise<CryptoKey | null> {
515
+ return unwrapNoteKeyDual4(cfg, wrapped, passphrase, pin, salt1Hex);
345
516
  }
@@ -82,7 +82,7 @@ export async function generateBip39Words(entropyBytes?: Uint8Array): Promise<str
82
82
  return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
83
83
  }
84
84
 
85
- // ── 驗證閘固定種子樣本(flake 歸零;t_d1cf3846)──────────────────────────────
85
+ // ── 驗證閘固定種子樣本(flake 歸零)──────────────────────────────
86
86
  //
87
87
  // 背景:checksum 統計面「錯一詞 64 樣本 ≥63 被抓」用真隨機取樣時,P(漏 ≥2)≈0.4%,
88
88
  // 偶發 62/64=閘 flake(2026-09-19 session 實證)。治本是固定種子:xorshift32 以