@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.
- package/README.md +99 -15
- package/package.json +18 -3
- package/scripts/open-card-pr.sh +49 -0
- package/scripts/verify-core-crypto.ts +1181 -53
- package/src/client/argon2.ts +181 -10
- package/src/client/bip39.ts +1 -1
- package/src/client/note-crypto.ts +168 -40
- package/src/client/pinlock.ts +3 -3
- package/src/index.ts +15 -1
- package/src/server/auth.ts +16 -5
- package/src/server/cors.ts +1 -1
- package/src/server/ladder.ts +71 -0
- package/src/server/ratelimit.ts +3 -3
package/src/client/argon2.ts
CHANGED
|
@@ -26,14 +26,28 @@
|
|
|
26
26
|
*
|
|
27
27
|
* ⚠️ 鐵律:unwrap 輸出 noteKey 一律 extractable=true(要能再包裹);KEK/KEK2 nonextractable。
|
|
28
28
|
* ⚠️ deriveArgon2id 禁 fallback(零知識):無載體即 throw,禁降級 PBKDF2 或 zeros 兜底。
|
|
29
|
-
* ⚠️ wrappedRec
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|
package/src/client/bip39.ts
CHANGED
|
@@ -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
|
|
85
|
+
// ── 驗證閘固定種子樣本(flake 歸零)──────────────────────────────
|
|
86
86
|
//
|
|
87
87
|
// 背景:checksum 統計面「錯一詞 64 樣本 ≥63 被抓」用真隨機取樣時,P(漏 ≥2)≈0.4%,
|
|
88
88
|
// 偶發 62/64=閘 flake(2026-09-19 session 實證)。治本是固定種子:xorshift32 以
|