@tacet-ink/journal-core 0.1.5 → 0.2.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/README.md +123 -14
- package/package.json +7 -4
- package/scripts/verify-core-crypto.ts +1437 -10
- package/src/client/argon2.ts +201 -40
- package/src/client/note-crypto.ts +175 -41
- package/src/client/pinlock.ts +13 -8
- package/src/index.ts +18 -1
- package/src/server/auth.ts +58 -19
- package/src/server/cors.ts +4 -0
- package/src/server/ladder.ts +71 -0
- package/src/server/ratelimit.ts +50 -34
package/src/client/argon2.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* AAD 與長度把關,跨前綴呼叫一律回 null(unwrap 有前綴守衛,驗證閘有跨協議斷言)。
|
|
17
17
|
*
|
|
18
18
|
* 雙載體(同一 spec 兩端一致,交叉驗證逐位元一致):
|
|
19
|
-
* - node(驗證閘/prod smoke):node:crypto.argon2(
|
|
19
|
+
* - node(驗證閘/prod smoke):node:crypto.argon2(24.7.0 落地版,26.8.1 實測)
|
|
20
20
|
* - 瀏覽器:hash-wasm argon2id(純 wasm 內嵌 base64,零網路請求,4.12.0 實測 64MiB t=3 ≈ 180ms)
|
|
21
21
|
* 標準向量:RFC 9106 無 secret/associated-data Argon2id(t=3, m=32, p=4, pwd=32B 0x01, salt=16B 0x00)
|
|
22
22
|
* = 72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503(兩載體實測 MATCH)。
|
|
@@ -29,11 +29,20 @@
|
|
|
29
29
|
* ⚠️ wrappedRec(復原套件)維持 jr1w.:recToken 是 256-bit 實體因子,KDF 強度無意義,不動。
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import { normalizePin, normalizePassphrase } from './note-crypto.ts';
|
|
33
33
|
import {
|
|
34
|
-
b64, unb64, toHex, hexToBytes,
|
|
34
|
+
b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, openNoteKey, sealNoteKey,
|
|
35
35
|
} from './note-crypto.ts';
|
|
36
36
|
|
|
37
|
+
/** KDF 派生輸入正規化收口(mirror normalizePin 先例):v3 入口 = normalizePassphrase(pass);raw 世代入口 = 原密語。
|
|
38
|
+
* 正規化永不進共用派生本體:deriveKekArgon/deriveKek2Argon 吃的是「派生輸入」——
|
|
39
|
+
* raw 契約(jr3d./jr2w.…)不經此面(帶內版本化「契約面永不變」的結構保證)。
|
|
40
|
+
* 三面單一真相(r2 CRITICAL-1):本體**委派** normalizePassphrase(語意唯一真相在
|
|
41
|
+
* note-crypto.ts 本體,本函式只是入口收口位命名)——禁自帶 .normalize 實作(閘形錨咬住); */
|
|
42
|
+
function deriveInput(passphrase: string): string {
|
|
43
|
+
return normalizePassphrase(passphrase);
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
/** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
|
|
38
47
|
export const ARGON_RFC9106_EXPECTED =
|
|
39
48
|
'72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
|
|
@@ -54,10 +63,41 @@ export const PH1_V2_SALT = 'tacet-ph1-v1';
|
|
|
54
63
|
* 重算 sha256(sha256(g)) 對 ph2 命中即 g 是密語)。ph1 改 Argon2id 派生後每猜成本
|
|
55
64
|
* ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
|
|
56
65
|
*/
|
|
57
|
-
export async function derivePh1Argon(passphrase: string): Promise<string> {
|
|
66
|
+
export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array): Promise<string> {
|
|
67
|
+
// PH1 鹽可注入(t_7710c766 目標 4):預設 PH1_V2_SALT 舊值=tacet 零行為變更;
|
|
68
|
+
// 其他產品可自選鹽但 per-product 恆固定(PH2 UNIQUE 約束——per-user 鹽會摧毀它)。
|
|
58
69
|
return toHex(await deriveArgon2id(
|
|
59
70
|
passphrase,
|
|
60
|
-
new TextEncoder().encode(PH1_V2_SALT),
|
|
71
|
+
saltArg ?? new TextEncoder().encode(PH1_V2_SALT),
|
|
72
|
+
ARGON_MEMORY_KIB,
|
|
73
|
+
ARGON_ITERATIONS,
|
|
74
|
+
ARGON_PARALLELISM,
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* PH1 v3 鹽(v0.2.0 批卡①,密語正規化 v3 世代):'tacet-ph1-v2'——鹽域版本代沿
|
|
80
|
+
* PH1_V2_SALT(常數名='tacet-ph1-v1')既有先例模式:常數名=PH1 規格代、鹽域值=鹽域代。
|
|
81
|
+
* 鹽域世代分離:若續用 v2 鹽域,v3 世代(normalizePassphrase 後 'PASS'→'PASS')與
|
|
82
|
+
* v2 世代 raw 'PASS' 會派生出同一 ph2=跨世代帳戶空間混合;新鹽域把跨世代 identity
|
|
83
|
+
* 問題留在遷移層查表(v0.2.0 批卡② v2-first 三腿查表職責),核心層零承擔。
|
|
84
|
+
* ⚠️ 鹽撞位警告(MINOR-5,NFKC-effective 帳):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的
|
|
85
|
+
* `saltArg` 餵入——v3 派生輸入吃 normalizePassphrase,同鹽域值錯面餵入=NFKC-effective
|
|
86
|
+
* 密語與 v2 raw 密語同值撞 ph2。fork 對兩面自選鹽時兩值必須互異(README PH1 節同款警告)。
|
|
87
|
+
*/
|
|
88
|
+
export const PH1_V3_SALT = 'tacet-ph1-v2';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PH1 v3:登入憑證 = Argon2id(normalizePassphrase(pass), 固定域鹽, m=64MiB, t=3, p=1) → hex64。
|
|
92
|
+
* 正規化收口在入口(密語 v3 契約 NFKC-only——normalizePassphrase 檔頭三面單一真相);
|
|
93
|
+
* 鹽可注入(卡A #4 saltArg 慣例:預設 PH1_V3_SALT;per-product 恆固定=PH2 UNIQUE 約束同 v2 母型)。
|
|
94
|
+
* 參數沿 jr3w./PH1 v2 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
|
|
95
|
+
* v2/legacy 契約面零動:derivePh1Argon 不吃正規化(舊世代 raw 派生,帶內版本化)。
|
|
96
|
+
*/
|
|
97
|
+
export async function derivePh1ArgonV3(passphrase: string, saltArg?: Uint8Array): Promise<string> {
|
|
98
|
+
return toHex(await deriveArgon2id(
|
|
99
|
+
deriveInput(passphrase),
|
|
100
|
+
saltArg ?? new TextEncoder().encode(PH1_V3_SALT),
|
|
61
101
|
ARGON_MEMORY_KIB,
|
|
62
102
|
ARGON_ITERATIONS,
|
|
63
103
|
ARGON_PARALLELISM,
|
|
@@ -89,6 +129,10 @@ export interface Argon3Config {
|
|
|
89
129
|
pinSalt3Prefix?: string;
|
|
90
130
|
/** 分享包裹前綴(jr3s.,單篇分享連結 Argon2id 版)。未配置 = 分享 API 拒絕(兄弟 fork 行為不變)。 */
|
|
91
131
|
wrapShare3?: string;
|
|
132
|
+
/** 密語正規化 v3 世代單因子包裹前綴(jr4w.,KEK=Argon2id(normalizePassphrase(pass)))。未配置 = 拒絕。 */
|
|
133
|
+
wrap4?: string;
|
|
134
|
+
/** 密語正規化 v3 世代雙因子包裹前綴(jr4d.,pass 段正規化+PIN 段照舊)。未配置 = 拒絕。 */
|
|
135
|
+
wrapDual4?: string;
|
|
92
136
|
}
|
|
93
137
|
|
|
94
138
|
interface HashWasmArgon2id {
|
|
@@ -195,24 +239,55 @@ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passph
|
|
|
195
239
|
if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
196
240
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
197
241
|
const kek = await deriveKekArgon(passphrase, salt);
|
|
198
|
-
const
|
|
199
|
-
const wrapped = cfg.wrap3 + await encryptWithKey(kek, rawHex, 'notekey');
|
|
242
|
+
const wrapped = await sealNoteKey(cfg.wrap3, noteKey, kek, 'notekey');
|
|
200
243
|
return { wrapped, salt: toHex(salt) };
|
|
201
244
|
}
|
|
202
245
|
|
|
203
|
-
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
246
|
+
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
247
|
+
* 收口 openNoteKey(t_7710c766):嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
|
|
248
|
+
* 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
|
|
249
|
+
* own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
|
|
204
250
|
export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
205
251
|
try {
|
|
206
|
-
if (!cfg.wrap3
|
|
252
|
+
if (!cfg.wrap3) return null;
|
|
207
253
|
const salt = hexToBytes(saltHex);
|
|
208
254
|
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
209
|
-
const payload = unb64(wrapped.slice(cfg.wrap3.length));
|
|
210
|
-
// 嚴格長度:iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 92B 固定(salt 在 users.salt 不內嵌)
|
|
211
|
-
if (payload.length !== IV_LEN + 80) return null;
|
|
212
255
|
const kek = await deriveKekArgon(passphrase, salt);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return
|
|
256
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap3);
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── jr4w. 單因子包裹(密語正規化 v3 世代;v0.2.0 批卡①) ────────────────────
|
|
263
|
+
//
|
|
264
|
+
// 帶內版本化:密語 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴,
|
|
265
|
+
// 舊前綴契約面永不變——jr4w. 的函式體與 jr3w. 同構(openNoteKey 共用核心、payload 嚴格
|
|
266
|
+
// 92B、AAD 'notekey'、嚴格長度全在 openNoteKey 本體),唯一差異=入口吃 normalizePassphrase(pass)
|
|
267
|
+
// 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
|
|
268
|
+
// pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
|
|
269
|
+
|
|
270
|
+
/** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
|
|
271
|
+
export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
272
|
+
if (!cfg.wrap4) throw new Error('ERR_JR4W_NOT_CONFIGURED');
|
|
273
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
274
|
+
const kek = await deriveKekArgon(deriveInput(passphrase), salt);
|
|
275
|
+
const wrapped = await sealNoteKey(cfg.wrap4, noteKey, kek, 'notekey');
|
|
276
|
+
return { wrapped, salt: toHex(salt) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** jr4w. 解包(v3 密語):任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
280
|
+
* 家族守衛(wrap4 未配置回 null)+快速前綴快檢(NIT-2:錯前綴 junk 免付 64 MiB Argon)+salt hex-形檢+
|
|
281
|
+
* KEK 派生(normalizePassphrase 收口在入口)——payload 形檢在 openNoteKey 本體(鹽外置家族:嚴格 92B+
|
|
282
|
+
* rawHex 形+hex fail-closed)。 */
|
|
283
|
+
export async function unwrapNoteKey4(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
284
|
+
try {
|
|
285
|
+
if (!cfg.wrap4) return null;
|
|
286
|
+
if (!wrapped.startsWith(cfg.wrap4)) return null; // 快速前綴快檢(NIT-2):錯前綴 junk 免付 64 MiB Argon 成本
|
|
287
|
+
const salt = hexToBytes(saltHex);
|
|
288
|
+
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
289
|
+
const kek = await deriveKekArgon(deriveInput(passphrase), salt);
|
|
290
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap4);
|
|
216
291
|
} catch {
|
|
217
292
|
return null;
|
|
218
293
|
}
|
|
@@ -225,6 +300,9 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
|
|
|
225
300
|
// info 字串與 jr2w. 家族同名(journal-kek2-v1):HKDF 合成步驟同構,兩家族 KEK2 值
|
|
226
301
|
// 因 KDF bits 不同而天然互斥(驗證閘有跨家族隔離斷言)。
|
|
227
302
|
// 兩段 Argon2 bits 用完即棄;KEK2 import 當下 nonextractable;輸出 noteKey extractable=true。
|
|
303
|
+
// HKDF info 由呼叫端帶入(hkdfInfo 參數):jr3d. 預設域 = 'journal-kek2-v1:' + cfg.wrapDual3——
|
|
304
|
+
// jr4d. 必帶自有域 'journal-kek2-v1:' + cfg.wrapDual4,KEK2 域分離由 info 承載(兩入參數同輸入
|
|
305
|
+
// 而派生域不同——同鹽同段 bits 在 NFKC-effective 密語下會同 KEK2;域即世代空間)。
|
|
228
306
|
|
|
229
307
|
async function deriveKek2Argon(
|
|
230
308
|
cfg: Argon3Config,
|
|
@@ -232,8 +310,12 @@ async function deriveKek2Argon(
|
|
|
232
310
|
pinNorm: string,
|
|
233
311
|
salt1: Uint8Array,
|
|
234
312
|
pinSalt: Uint8Array,
|
|
313
|
+
hkdfInfo: string,
|
|
235
314
|
): Promise<CryptoKey> {
|
|
236
315
|
if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
316
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2 禁並行(t_7361b68c):純 wasm Argon2id 共享記憶體池,
|
|
317
|
+
// 兩實例並行在部分引擎靜態直 throw——這裡故意保持串行(與 jr2w. PBKDF2 版
|
|
318
|
+
// deriveKek2 的 Promise.all 並行不同:那裡並行的前提是 crypto.subtle 原生派生)。
|
|
237
319
|
const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
|
|
238
320
|
const pinBits = await deriveArgon2id(
|
|
239
321
|
pinNorm,
|
|
@@ -247,7 +329,7 @@ async function deriveKek2Argon(
|
|
|
247
329
|
ikm.set(pinBits, passBits.byteLength);
|
|
248
330
|
const hkdfBase = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveKey']);
|
|
249
331
|
return crypto.subtle.deriveKey(
|
|
250
|
-
{ name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode(
|
|
332
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode(hkdfInfo) as BufferSource },
|
|
251
333
|
hkdfBase,
|
|
252
334
|
{ name: 'AES-GCM', length: 256 },
|
|
253
335
|
false,
|
|
@@ -256,24 +338,18 @@ async function deriveKek2Argon(
|
|
|
256
338
|
}
|
|
257
339
|
|
|
258
340
|
/** jr3d. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 由呼叫端存 users.salt。 */
|
|
259
|
-
export async function wrapNoteKeyDual3(
|
|
260
|
-
cfg: Argon3Config,
|
|
261
|
-
noteKey: CryptoKey,
|
|
262
|
-
passphrase: string,
|
|
263
|
-
pin: string,
|
|
264
|
-
): Promise<{ wrapped: string; salt: string }> {
|
|
341
|
+
export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
265
342
|
if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
266
343
|
const pinNorm = normalizePin(pin);
|
|
267
|
-
if (!pinNorm) throw new Error('
|
|
344
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY');
|
|
268
345
|
const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
269
346
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
270
|
-
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
347
|
+
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
|
|
271
348
|
const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
|
|
272
|
-
const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
273
349
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
274
350
|
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
|
|
275
351
|
kek2,
|
|
276
|
-
new TextEncoder().encode(
|
|
352
|
+
new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)))) as BufferSource,
|
|
277
353
|
));
|
|
278
354
|
const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
|
|
279
355
|
payload.set(pinSalt, 0);
|
|
@@ -282,7 +358,10 @@ export async function wrapNoteKeyDual3(
|
|
|
282
358
|
return { wrapped: cfg.wrapDual3 + b64(payload), salt: toHex(salt1) };
|
|
283
359
|
}
|
|
284
360
|
|
|
285
|
-
/** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
361
|
+
/** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
362
|
+
* t_7710c766:嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
|
|
363
|
+
* + iv 12+ct 80)+ decryptWithKey 後 rawHex hex 形檢查(hexToBytes fail-closed),
|
|
364
|
+
* 不經 openNoteKey 本體(鹽外置 92B 形);與 openNoteKey 同嚴格度。 */
|
|
286
365
|
export async function unwrapNoteKeyDual3(
|
|
287
366
|
cfg: Argon3Config,
|
|
288
367
|
wrapped: string,
|
|
@@ -296,15 +375,78 @@ export async function unwrapNoteKeyDual3(
|
|
|
296
375
|
const salt1 = hexToBytes(salt1Hex);
|
|
297
376
|
if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex)) return null;
|
|
298
377
|
const payload = unb64(wrapped.slice(cfg.wrapDual3.length));
|
|
299
|
-
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex
|
|
378
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
|
|
300
379
|
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
301
380
|
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
302
381
|
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
303
382
|
const pinNorm = normalizePin(pin);
|
|
304
383
|
if (!pinNorm) return null;
|
|
305
|
-
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
384
|
+
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
|
|
306
385
|
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
307
|
-
if (!rawHex) return null;
|
|
386
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
387
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── jr4d. 雙因子合鑰(密語正規化 v3 世代=jr3d. 的 v3 後繼;v0.2.0 批卡①) ────
|
|
394
|
+
//
|
|
395
|
+
// 帶內版本化:pass 段 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴;
|
|
396
|
+
// PIN 段契約照舊(normalizePin:NFKC→trim→lowercase,pinlock/dual 面不動)。
|
|
397
|
+
// KEK2 合成步驟與 jr3d. 同構:deriveKek2Argon 共用本體(pass 段正規化收口在 wrap/unwrap
|
|
398
|
+
// 入口層——deriveInput 收口、正規化永不進共用派生函式,jr3d. raw 契約面零動如實不經測不變);
|
|
399
|
+
// payload 嚴格 108B+rawHex 形檢在 own 本體(鹽內嵌族 openNoteKey 不覆蓋);
|
|
400
|
+
// HKDF info 沿 'journal-kek2-v1:' 合成名+hkdfInfo 呼叫端帶入(域=jr4d.:KDF 互斥由 bits+info 域承載)。
|
|
401
|
+
|
|
402
|
+
/** jr4d. 包裹(v3 密語):payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 呼叫端存 users.salt。 */
|
|
403
|
+
export async function wrapNoteKeyDual4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
404
|
+
if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR4D_NOT_CONFIGURED');
|
|
405
|
+
const pinNorm = normalizePin(pin);
|
|
406
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(t_7710c766 語意分離)
|
|
407
|
+
const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
408
|
+
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
409
|
+
const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
|
|
410
|
+
const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
|
|
411
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
412
|
+
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
|
|
413
|
+
kek2,
|
|
414
|
+
new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)))) as BufferSource,
|
|
415
|
+
));
|
|
416
|
+
const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
|
|
417
|
+
payload.set(pinSalt, 0);
|
|
418
|
+
payload.set(iv, DUAL_SALT_LEN);
|
|
419
|
+
payload.set(ct, DUAL_SALT_LEN + DUAL_IV_LEN);
|
|
420
|
+
return { wrapped: cfg.wrapDual4 + b64(payload), salt: toHex(salt1) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// jr4d. 解包(v3 密語):pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
424
|
+
// 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex 形檢,不經 openNoteKey 92B 鹽外置本體);
|
|
425
|
+
// pass 段正規化收口在入口(deriveInput=normalizePassphrase 同語意本體收口),pinNorm 與 jr3d. 同契約。
|
|
426
|
+
// KEK2 HKDF info='journal-kek2-v1:' + cfg.wrapDual4(自有域——jr3d/jr4d 兩入參數同輸入而域分離;
|
|
427
|
+
// NFKC-effective 密語下 bits 段同值,域由 info 承載——凍結 KAT 兩 blob 互解 null 承載)。
|
|
428
|
+
export async function unwrapNoteKeyDual4(
|
|
429
|
+
cfg: Argon3Config,
|
|
430
|
+
wrapped: string,
|
|
431
|
+
passphrase: string,
|
|
432
|
+
pin: string,
|
|
433
|
+
salt1Hex: string,
|
|
434
|
+
): Promise<CryptoKey | null> {
|
|
435
|
+
try {
|
|
436
|
+
if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix) return null;
|
|
437
|
+
if (!wrapped.startsWith(cfg.wrapDual4)) return null;
|
|
438
|
+
const salt1 = hexToBytes(salt1Hex);
|
|
439
|
+
if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex)) return null;
|
|
440
|
+
const payload = unb64(wrapped.slice(cfg.wrapDual4.length));
|
|
441
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
|
|
442
|
+
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
443
|
+
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
444
|
+
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
445
|
+
const pinNorm = normalizePin(pin);
|
|
446
|
+
if (!pinNorm) return null;
|
|
447
|
+
const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
|
|
448
|
+
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
449
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
308
450
|
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
309
451
|
} catch {
|
|
310
452
|
return null;
|
|
@@ -326,25 +468,44 @@ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, s
|
|
|
326
468
|
if (!cfg.wrapShare3) throw new Error('ERR_JR3S_NOT_CONFIGURED');
|
|
327
469
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
328
470
|
const kek = await deriveKekArgon(sharePass, salt);
|
|
329
|
-
const
|
|
330
|
-
const wrapped = cfg.wrapShare3 + await encryptWithKey(kek, rawHex, 'notekey-share');
|
|
471
|
+
const wrapped = await sealNoteKey(cfg.wrapShare3, noteKey, kek, 'notekey-share');
|
|
331
472
|
return { wrapped, salt: toHex(salt) };
|
|
332
473
|
}
|
|
333
474
|
|
|
334
|
-
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
475
|
+
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
476
|
+
* 收口 openNoteKey(t_7710c766):鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
|
|
477
|
+
* 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
|
|
335
478
|
export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
336
479
|
try {
|
|
337
|
-
if (!cfg.wrapShare3
|
|
480
|
+
if (!cfg.wrapShare3) return null;
|
|
338
481
|
const salt = hexToBytes(saltHex);
|
|
339
482
|
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
340
|
-
const payload = unb64(wrapped.slice(cfg.wrapShare3.length));
|
|
341
|
-
// 嚴格長度:iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 92B 固定(salt 在 shares.salt 不內嵌)
|
|
342
|
-
if (payload.length !== IV_LEN + 80) return null;
|
|
343
483
|
const kek = await deriveKekArgon(sharePass, salt);
|
|
344
|
-
|
|
345
|
-
if (!rawHex) return null;
|
|
346
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
|
|
484
|
+
return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare3);
|
|
347
485
|
} catch {
|
|
348
486
|
return null;
|
|
349
487
|
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt(v0.2.0 批卡② t_ce216a63) ────
|
|
491
|
+
//
|
|
492
|
+
// 為何同構再開一支:v3 遷移(登入即重遷)會旋轉 salt1(wrapNoteKeyDual4 落新值);
|
|
493
|
+
// login 舊形面帶回的 salt(v2 世代的 salt1)在遷移後已解不開 v3 新包裹──呼叫端
|
|
494
|
+
// (tacet loginAndUnwrap 的 jr4d. 腿)以「login 回應當下帶回的新 salt」重取入參
|
|
495
|
+
// 重試 unwrap 就是本變體唯一承載面。本體與 unwrapNoteKeyDual4 完全同構(同嚴格
|
|
496
|
+
// 檢查、同 HKDF info 域、同輸出契約)──開支出零差異;命名開新支是「重取鹽」的
|
|
497
|
+
// 語意承載位(呼叫端 grep 可審計),零新增實作面。
|
|
498
|
+
//
|
|
499
|
+
// 補記(t_b91ed07f 家族嚴格度):salt1 形檢/payload 108B/rawHex 形檢全在
|
|
500
|
+
// unwrapNoteKeyDual4 本體(共用);本變體純語意薄身。
|
|
501
|
+
|
|
502
|
+
/** jr4d. 解包(重取 salt1 面):pinSalt 內嵌自描述,salt1 由呼叫端重取後帶入(ladder 遷移後的新値);任何不符回 null,不拋。 */
|
|
503
|
+
export async function unwrapNoteKeyDual4WithSalt(
|
|
504
|
+
cfg: Argon3Config,
|
|
505
|
+
wrapped: string,
|
|
506
|
+
passphrase: string,
|
|
507
|
+
pin: string,
|
|
508
|
+
salt1Hex: string,
|
|
509
|
+
): Promise<CryptoKey | null> {
|
|
510
|
+
return unwrapNoteKeyDual4(cfg, wrapped, passphrase, pin, salt1Hex);
|
|
350
511
|
}
|