@tacet-ink/journal-core 0.1.4 → 0.1.6
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 +87 -12
- package/package.json +5 -3
- package/scripts/verify-core-crypto.ts +628 -10
- package/src/client/argon2.ts +32 -37
- package/src/client/note-crypto.ts +137 -42
- package/src/client/pinlock.ts +13 -8
- package/src/index.ts +3 -0
- package/src/server/auth.ts +47 -19
- package/src/server/cors.ts +4 -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,9 +29,9 @@
|
|
|
29
29
|
* ⚠️ wrappedRec(復原套件)維持 jr1w.:recToken 是 256-bit 實體因子,KDF 強度無意義,不動。
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import { normalizePin } 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
37
|
/** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
|
|
@@ -54,10 +54,12 @@ export const PH1_V2_SALT = 'tacet-ph1-v1';
|
|
|
54
54
|
* 重算 sha256(sha256(g)) 對 ph2 命中即 g 是密語)。ph1 改 Argon2id 派生後每猜成本
|
|
55
55
|
* ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
|
|
56
56
|
*/
|
|
57
|
-
export async function derivePh1Argon(passphrase: string): Promise<string> {
|
|
57
|
+
export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array): Promise<string> {
|
|
58
|
+
// PH1 鹽可注入(t_7710c766 目標 4):預設 PH1_V2_SALT 舊值=tacet 零行為變更;
|
|
59
|
+
// 其他產品可自選鹽但 per-product 恆固定(PH2 UNIQUE 約束——per-user 鹽會摧毀它)。
|
|
58
60
|
return toHex(await deriveArgon2id(
|
|
59
61
|
passphrase,
|
|
60
|
-
new TextEncoder().encode(PH1_V2_SALT),
|
|
62
|
+
saltArg ?? new TextEncoder().encode(PH1_V2_SALT),
|
|
61
63
|
ARGON_MEMORY_KIB,
|
|
62
64
|
ARGON_ITERATIONS,
|
|
63
65
|
ARGON_PARALLELISM,
|
|
@@ -195,24 +197,21 @@ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passph
|
|
|
195
197
|
if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
196
198
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
197
199
|
const kek = await deriveKekArgon(passphrase, salt);
|
|
198
|
-
const
|
|
199
|
-
const wrapped = cfg.wrap3 + await encryptWithKey(kek, rawHex, 'notekey');
|
|
200
|
+
const wrapped = await sealNoteKey(cfg.wrap3, noteKey, kek, 'notekey');
|
|
200
201
|
return { wrapped, salt: toHex(salt) };
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
204
|
+
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
205
|
+
* 收口 openNoteKey(t_7710c766):嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
|
|
206
|
+
* 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
|
|
207
|
+
* own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
|
|
204
208
|
export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
205
209
|
try {
|
|
206
|
-
if (!cfg.wrap3
|
|
210
|
+
if (!cfg.wrap3) return null;
|
|
207
211
|
const salt = hexToBytes(saltHex);
|
|
208
212
|
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
213
|
const kek = await deriveKekArgon(passphrase, salt);
|
|
213
|
-
|
|
214
|
-
if (!rawHex) return null;
|
|
215
|
-
return importAesGcm(hexToBytes(rawHex), true);
|
|
214
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap3);
|
|
216
215
|
} catch {
|
|
217
216
|
return null;
|
|
218
217
|
}
|
|
@@ -234,6 +233,9 @@ async function deriveKek2Argon(
|
|
|
234
233
|
pinSalt: Uint8Array,
|
|
235
234
|
): Promise<CryptoKey> {
|
|
236
235
|
if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
236
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2 禁並行(t_7361b68c):純 wasm Argon2id 共享記憶體池,
|
|
237
|
+
// 兩實例並行在部分引擎靜態直 throw——這裡故意保持串行(與 jr2w. PBKDF2 版
|
|
238
|
+
// deriveKek2 的 Promise.all 並行不同:那裡並行的前提是 crypto.subtle 原生派生)。
|
|
237
239
|
const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
|
|
238
240
|
const pinBits = await deriveArgon2id(
|
|
239
241
|
pinNorm,
|
|
@@ -256,24 +258,18 @@ async function deriveKek2Argon(
|
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/** 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 }> {
|
|
261
|
+
export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
265
262
|
if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
266
263
|
const pinNorm = normalizePin(pin);
|
|
267
|
-
if (!pinNorm) throw new Error('
|
|
264
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY');
|
|
268
265
|
const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
269
266
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
270
267
|
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
271
268
|
const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
|
|
272
|
-
const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
273
269
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
274
270
|
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
|
|
275
271
|
kek2,
|
|
276
|
-
new TextEncoder().encode(
|
|
272
|
+
new TextEncoder().encode(toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)))) as BufferSource,
|
|
277
273
|
));
|
|
278
274
|
const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
|
|
279
275
|
payload.set(pinSalt, 0);
|
|
@@ -282,7 +278,10 @@ export async function wrapNoteKeyDual3(
|
|
|
282
278
|
return { wrapped: cfg.wrapDual3 + b64(payload), salt: toHex(salt1) };
|
|
283
279
|
}
|
|
284
280
|
|
|
285
|
-
/** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
281
|
+
/** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
282
|
+
* t_7710c766:嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
|
|
283
|
+
* + iv 12+ct 80)+ decryptWithKey 後 rawHex hex 形檢查(hexToBytes fail-closed),
|
|
284
|
+
* 不經 openNoteKey 本體(鹽外置 92B 形);與 openNoteKey 同嚴格度。 */
|
|
286
285
|
export async function unwrapNoteKeyDual3(
|
|
287
286
|
cfg: Argon3Config,
|
|
288
287
|
wrapped: string,
|
|
@@ -296,7 +295,7 @@ export async function unwrapNoteKeyDual3(
|
|
|
296
295
|
const salt1 = hexToBytes(salt1Hex);
|
|
297
296
|
if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex)) return null;
|
|
298
297
|
const payload = unb64(wrapped.slice(cfg.wrapDual3.length));
|
|
299
|
-
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex
|
|
298
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定
|
|
300
299
|
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
301
300
|
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
302
301
|
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
@@ -304,7 +303,7 @@ export async function unwrapNoteKeyDual3(
|
|
|
304
303
|
if (!pinNorm) return null;
|
|
305
304
|
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
306
305
|
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
307
|
-
if (!rawHex) return null;
|
|
306
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
308
307
|
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
309
308
|
} catch {
|
|
310
309
|
return null;
|
|
@@ -326,24 +325,20 @@ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, s
|
|
|
326
325
|
if (!cfg.wrapShare3) throw new Error('ERR_JR3S_NOT_CONFIGURED');
|
|
327
326
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
328
327
|
const kek = await deriveKekArgon(sharePass, salt);
|
|
329
|
-
const
|
|
330
|
-
const wrapped = cfg.wrapShare3 + await encryptWithKey(kek, rawHex, 'notekey-share');
|
|
328
|
+
const wrapped = await sealNoteKey(cfg.wrapShare3, noteKey, kek, 'notekey-share');
|
|
331
329
|
return { wrapped, salt: toHex(salt) };
|
|
332
330
|
}
|
|
333
331
|
|
|
334
|
-
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
332
|
+
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
333
|
+
* 收口 openNoteKey(t_7710c766):鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
|
|
334
|
+
* 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
|
|
335
335
|
export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
336
336
|
try {
|
|
337
|
-
if (!cfg.wrapShare3
|
|
337
|
+
if (!cfg.wrapShare3) return null;
|
|
338
338
|
const salt = hexToBytes(saltHex);
|
|
339
339
|
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
340
|
const kek = await deriveKekArgon(sharePass, salt);
|
|
344
|
-
|
|
345
|
-
if (!rawHex) return null;
|
|
346
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
|
|
341
|
+
return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare3);
|
|
347
342
|
} catch {
|
|
348
343
|
return null;
|
|
349
344
|
}
|
|
@@ -52,12 +52,25 @@ export const PIN_PBKDF2_ITERATIONS = 2_000_000; // 雙因子 pin 段(設計 v2
|
|
|
52
52
|
export const IV_LEN = 12;
|
|
53
53
|
|
|
54
54
|
const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
|
|
55
|
+
/**
|
|
56
|
+
* hex 合法性(fail-closed 目標 #2):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
|
|
57
|
+
* 每雙位元組真 hex。
|
|
58
|
+
*/
|
|
59
|
+
const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
|
|
55
60
|
|
|
56
61
|
// ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
|
|
57
62
|
|
|
58
63
|
export function b64(bytes: Uint8Array): string {
|
|
64
|
+
// 分塊 String.fromCharCode.apply(t_7361b68c 目標 2:review 實測瀏覽器 4MiB 222ms→27ms;
|
|
65
|
+
// node 端分塊形 132-181ms→18ms,與閘執行帳同源——review 的 Node-only toBase64 1ms 帳是
|
|
66
|
+
// 不同形,非本體帳:round 2 MINOR-2 對齊)。瀏覽器端恆走本形——core 是 isomorphic TS
|
|
67
|
+
// 源碼發行,不引入 Node-only
|
|
68
|
+
// toBase64 分支(卡面否決案)。apply 參數上限安全窗 8192;輸出 byte 等價
|
|
69
|
+
// (驗證閘 [14] 有 0/1/7/8191/8192/8193/65539 邊界逐位元組對照+unb64 roundtrip)。
|
|
59
70
|
let binary = '';
|
|
60
|
-
for (let i = 0; i < bytes.byteLength; i
|
|
71
|
+
for (let i = 0; i < bytes.byteLength; i += 8192) {
|
|
72
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192) as unknown as number[]);
|
|
73
|
+
}
|
|
61
74
|
return btoa(binary);
|
|
62
75
|
}
|
|
63
76
|
|
|
@@ -73,7 +86,22 @@ export function toHex(bytes: Uint8Array): string {
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
export function hexToBytes(hex: string): Uint8Array {
|
|
76
|
-
|
|
89
|
+
// fail-closed(目標 #2):非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
|
|
90
|
+
// 拋 ERR_BAD_HEX(呼叫端 unwrap 全部 try/catch 回 null;非 catch 端僅 bip39
|
|
91
|
+
// HEX64_RE 前置自守)。奇數長度、非 hex、空字串皆拒;大小寫/全形 NFKC 收容。
|
|
92
|
+
// 測與 parse 同一(正規化後)真相——round 1 審查 MINOR-4:形檢吃正規化串、
|
|
93
|
+
// parseInt 吃原始串=全形 hex digit NaN 歸零殘形(README 例外契約自穿透),收口殲滅。
|
|
94
|
+
// NFKC 寬容=設計面刻意(round 2 NIT 記錄 t_580f9c54):'⑩'→'10' 等相容字元收容後是真 hex
|
|
95
|
+
// 值(⑩→[16] 實帳),非缺陷形(NaN 歸零已殲滅=輸出恆數學等值);rawHex 檢查同式(HEX_RE 同行後置
|
|
96
|
+
// =rawHex 前置守衛);鹽欄同為同行後置(note-crypto/argon2 各 unwrap 面鹽欄形檢——行號帳不寫死,
|
|
97
|
+
// 新增段推移行號=註解行號帳漂移,NIT-a 同病禁再犯)。
|
|
98
|
+
// 行為恆 null(純註解);正式輸入面形檢在場,公開原語只保證「正規化後真 hex 恆等值、垃圾恆拒」。
|
|
99
|
+
// 若要 ASCII-only 收緊=帶內版本化(換前綴),禁原地改語意。
|
|
100
|
+
const norm = hex.normalize('NFKC').toLowerCase();
|
|
101
|
+
if (!HEX_RE.test(norm)) {
|
|
102
|
+
throw new Error('ERR_BAD_HEX');
|
|
103
|
+
}
|
|
104
|
+
return new Uint8Array((norm.match(/.{2}/g) ?? []).map(h => parseInt(h, 16)));
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
async function sha256Hex(text: string): Promise<string> {
|
|
@@ -98,6 +126,8 @@ export async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): P
|
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
129
|
+
// 這裡的 importKey 是 deriveKey 用途集——與 derivePbkdf2Bits 的 deriveBits 面不是同一形,
|
|
130
|
+
// 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;t_7361b68c probe 實證)。
|
|
101
131
|
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveKey']);
|
|
102
132
|
return crypto.subtle.deriveKey(
|
|
103
133
|
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
@@ -108,6 +138,11 @@ async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey>
|
|
|
108
138
|
);
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
/** raw → PBKDF2 import(僅供 deriveBits 用途;與 deriveKek 的 deriveKey 用途分行)。 */
|
|
142
|
+
async function importKeyRawForKdf(password: string): Promise<CryptoKey> {
|
|
143
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveBits']);
|
|
144
|
+
}
|
|
145
|
+
|
|
111
146
|
// ── 雙因子合鑰(jr2w.,PIN 第二因子;2026-09-09 定案 v2) ────────────────────
|
|
112
147
|
//
|
|
113
148
|
// KEK2 = HKDF-SHA256( ikm = PBKDF2(pass, salt1, 600k)[32B] ‖ PBKDF2(pin, pinSaltPrefix‖pinSalt, 2M)[32B],
|
|
@@ -123,14 +158,22 @@ export function normalizePin(pin: string): string {
|
|
|
123
158
|
}
|
|
124
159
|
|
|
125
160
|
async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
|
126
|
-
const
|
|
127
|
-
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' },
|
|
161
|
+
const km = await importKeyRawForKdf(password);
|
|
162
|
+
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, km, 256);
|
|
128
163
|
return new Uint8Array(bits);
|
|
129
164
|
}
|
|
130
165
|
|
|
131
166
|
async function deriveKek2(cfg: NoteCryptoConfig, passphrase: string, pin: string, salt1: Uint8Array, pinSalt: Uint8Array): Promise<CryptoKey> {
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
// pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行(t_7361b68c 目標 1)。
|
|
168
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2(jr3d. 對應形 deriveKek2Argon)禁並行——純 wasm Argon2id
|
|
169
|
+
// 共享記憶體池、兩實例並行在部分引擎靜態直 throw,卡面 review 明示;並行只收 PBKDF2 家族。
|
|
170
|
+
// 兩段各自 importKeyRawForKdf(round 2 NIT-2:keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
|
|
171
|
+
// 輸出序=ikm 組裝序=passBits@0 ‖ pinBits@32(與原串行逐位一致);閘錨綁左手側 destructure,
|
|
172
|
+
// 對調滑接=既有 jr2w. 帳戶全不可解(round 2 MAJOR-1 reviewer rev-harm 一手實證)。
|
|
173
|
+
const [passBits, pinBits] = await Promise.all([
|
|
174
|
+
derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS),
|
|
175
|
+
derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS),
|
|
176
|
+
]);
|
|
134
177
|
const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
|
|
135
178
|
ikm.set(passBits, 0);
|
|
136
179
|
ikm.set(pinBits, passBits.byteLength);
|
|
@@ -151,7 +194,7 @@ const DUAL_IV_LEN = 12;
|
|
|
151
194
|
export async function wrapNoteKeyDual(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
152
195
|
if (!cfg.wrapDual || !cfg.pinSaltPrefix) throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
153
196
|
const pinNorm = normalizePin(pin);
|
|
154
|
-
if (!pinNorm) throw new Error('
|
|
197
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(t_7710c766 語意分離)
|
|
155
198
|
const salt1 = crypto.getRandomValues(new Uint8Array(16));
|
|
156
199
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
157
200
|
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
@@ -175,9 +218,9 @@ export async function unwrapNoteKeyDual(cfg: NoteCryptoConfig, wrapped: string,
|
|
|
175
218
|
if (!cfg.wrapDual || !cfg.pinSaltPrefix) return null;
|
|
176
219
|
if (!wrapped.startsWith(cfg.wrapDual)) return null;
|
|
177
220
|
const salt1 = hexToBytes(salt1Hex);
|
|
178
|
-
if (salt1.length !== 16) return null;
|
|
221
|
+
if (salt1.length !== 16 || !HEX32_RE.test(salt1Hex)) return null;
|
|
179
222
|
const payload = unb64(wrapped.slice(cfg.wrapDual.length));
|
|
180
|
-
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex
|
|
223
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定;rawHex hex 形為本函式自有檢查(鹽內嵌族不經 openNoteKey 本體)
|
|
181
224
|
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
182
225
|
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
183
226
|
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
@@ -185,7 +228,7 @@ export async function unwrapNoteKeyDual(cfg: NoteCryptoConfig, wrapped: string,
|
|
|
185
228
|
if (!pinNorm) return null;
|
|
186
229
|
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
187
230
|
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
188
|
-
if (!rawHex) return null;
|
|
231
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
189
232
|
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
190
233
|
} catch {
|
|
191
234
|
return null;
|
|
@@ -291,24 +334,64 @@ export function recTokenHash(recToken: string): Promise<string> {
|
|
|
291
334
|
|
|
292
335
|
// ── 金鑰包裹 ────────────────────────────────────────────────────────────────
|
|
293
336
|
|
|
337
|
+
/** noteKey raw hex 長度(hex(noteKey) 64B = payload ct 面 80B 契約的一半)。 */
|
|
338
|
+
const NOTEKEY_HEX_LEN = 64;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 共用包裹核心(目標 #1 收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
|
|
342
|
+
* 全家族單因子包裹(jr1w./jrsw./jr3w./jr3s.)與復原套件(wrappedRec,前綴共用 cfg.wrap)
|
|
343
|
+
* 同一本體;KDF 差異在呼叫端 KEK、payload 形狀(salt 外置 vs 內嵌)由各家族維持。
|
|
344
|
+
* prefix null/undefined=未配置拒絕(呼叫端亦可先行拒絕,帶自家錯誤碼形;雙防線)。
|
|
345
|
+
*/
|
|
346
|
+
export async function sealNoteKey(prefix: string | null | undefined, noteKey: CryptoKey, kek: CryptoKey, aad: string): Promise<string> {
|
|
347
|
+
if (!prefix) throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
348
|
+
const raw = await crypto.subtle.exportKey('raw', noteKey);
|
|
349
|
+
if (raw.byteLength !== 32) throw new Error('ERR_WRAPTAMPER_LEN32');
|
|
350
|
+
return prefix + await encryptWithKey(kek, toHex(new Uint8Array(raw)), aad);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 共用解包核心(目標 #1 收口):任何不符——前綴、base64 形、payload 長度、
|
|
355
|
+
* GCM(金鑰/AAD/密文損壞)、rawHex 長度/hex 形——恆回 null 不拋。
|
|
356
|
+
* round 1 審查 MINOR-5:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
|
|
357
|
+
* 本體吞收 null(公開原語誠實契約;呼叫端 unwrap 家族 try/catch 是雙防線非依賴面)。
|
|
358
|
+
* 家族嚴格度收口實況(round 1 審查 MINOR-2 校正+r2 MINOR-1 殘餘校正 t_580f9c54):
|
|
359
|
+
* 鹽外置 6 點由本體嚴格 92B 終結——note-crypto 四點(unwrapNoteKey=jr1w/
|
|
360
|
+
* unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken=jr1wRec 同本體/loadLocalWrap)
|
|
361
|
+
* +argon2 兩點(unwrapNoteKey3=jr3w/unwrapNoteKeyShare3=jr3s;own 92B 檢已隨收口摘除);
|
|
362
|
+
* 鹽內嵌 3 點(jr2w/jr3d/jr1p)own 108B+rawHex 形檢——與本體同嚴格度、刻意不經本體
|
|
363
|
+
* (pinSalt 前綴不在鹽外置形契約內)。維護本體時鹽內嵌三族不隨行。
|
|
364
|
+
*/
|
|
365
|
+
export async function openNoteKey(wrapped: string, kek: CryptoKey, aad: string, prefix: string): Promise<CryptoKey | null> {
|
|
366
|
+
if (!wrapped.startsWith(prefix)) return null;
|
|
367
|
+
let payload: Uint8Array;
|
|
368
|
+
try {
|
|
369
|
+
payload = unb64(wrapped.slice(prefix.length)); // atob 非法字元拋點吞收 null(公開原語全 null 契約)
|
|
370
|
+
} catch {
|
|
371
|
+
return null; // 壞 base64=任何不符面之一(行為閘有壞 b64 向量承載)
|
|
372
|
+
}
|
|
373
|
+
if (payload.length !== IV_LEN + 80) return null; // 嚴格 92B:iv 12+ct 80(hex64 字串編碼 64B+tag 16)
|
|
374
|
+
const rawHex = await decryptWithKey(kek, payload, aad);
|
|
375
|
+
if (!rawHex || rawHex.length !== NOTEKEY_HEX_LEN || !HEX_RE.test(rawHex)) return null;
|
|
376
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹/解密(鐵律)
|
|
377
|
+
}
|
|
378
|
+
|
|
294
379
|
/** passphrase 包裹:salt 隨機 16 bytes(hex 存於 users.salt)。 */
|
|
295
380
|
export async function wrapNoteKey(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
296
381
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
382
|
+
if (!cfg.wrap) throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
297
383
|
const kek = await deriveKek(passphrase, salt);
|
|
298
|
-
const
|
|
299
|
-
const wrapped = cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
384
|
+
const wrapped = await sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
300
385
|
return { wrapped, salt: toHex(salt) };
|
|
301
386
|
}
|
|
302
387
|
|
|
303
388
|
export async function unwrapNoteKey(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
304
389
|
try {
|
|
305
|
-
if (!
|
|
390
|
+
if (!cfg.wrap) return null;
|
|
306
391
|
const salt = hexToBytes(saltHex);
|
|
307
392
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
308
393
|
const kek = await deriveKek(passphrase, salt);
|
|
309
|
-
|
|
310
|
-
if (!rawHex) return null;
|
|
311
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹
|
|
394
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
|
|
312
395
|
} catch {
|
|
313
396
|
return null;
|
|
314
397
|
}
|
|
@@ -326,8 +409,7 @@ export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey
|
|
|
326
409
|
if (!cfg.wrapShare) throw new Error('ERR_SHARE_NOT_CONFIGURED');
|
|
327
410
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
328
411
|
const kek = await deriveKek(sharePass, salt);
|
|
329
|
-
const
|
|
330
|
-
const wrapped = cfg.wrapShare + await encryptWithKey(kek, toHex(payload), 'notekey-share');
|
|
412
|
+
const wrapped = await sealNoteKey(cfg.wrapShare, noteKey, kek, 'notekey-share');
|
|
331
413
|
return { wrapped, salt: toHex(salt) };
|
|
332
414
|
}
|
|
333
415
|
|
|
@@ -335,13 +417,10 @@ export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey
|
|
|
335
417
|
export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
336
418
|
try {
|
|
337
419
|
if (!cfg.wrapShare) return null;
|
|
338
|
-
if (!wrapped.startsWith(cfg.wrapShare)) return null;
|
|
339
420
|
const salt = hexToBytes(saltHex);
|
|
340
421
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
341
422
|
const kek = await deriveKek(sharePass, salt);
|
|
342
|
-
|
|
343
|
-
if (!rawHex) return null;
|
|
344
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
|
|
423
|
+
return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare);
|
|
345
424
|
} catch {
|
|
346
425
|
return null;
|
|
347
426
|
}
|
|
@@ -354,23 +433,27 @@ async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string, identity: s
|
|
|
354
433
|
|
|
355
434
|
export async function wrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, noteKey: CryptoKey, recToken: string, identity: string): Promise<string> {
|
|
356
435
|
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
357
|
-
|
|
358
|
-
return cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
436
|
+
return sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
359
437
|
}
|
|
360
438
|
|
|
361
439
|
export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped: string, recToken: string, identity: string): Promise<CryptoKey | null> {
|
|
362
440
|
try {
|
|
363
|
-
if (!
|
|
441
|
+
if (!cfg.wrap) return null;
|
|
364
442
|
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
365
|
-
|
|
366
|
-
if (!rawHex) return null;
|
|
367
|
-
return importAesGcm(hexToBytes(rawHex), true); // 復原後要能重新包裹
|
|
443
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
|
|
368
444
|
} catch {
|
|
369
445
|
return null;
|
|
370
446
|
}
|
|
371
447
|
}
|
|
372
448
|
|
|
373
|
-
/**
|
|
449
|
+
/**
|
|
450
|
+
* 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。
|
|
451
|
+
* 兩段 600k PBKDF2 相互獨立(pass 鹽隨機、rec 鹽=recSaltPrefix‖identity 定值)=並行
|
|
452
|
+
*(t_7361b68c 目標 1:node 實測 133-152ms→68-84ms,與閘執行帳同源——round 2 MINOR-2 對齊)。
|
|
453
|
+
* AES-GCM derive 佔 600k 迴圈 99%,兩段 wrapped 各自隨機 iv(pass 段另隨機鹽 16B;rec 腿
|
|
454
|
+
* KEK 鹽定值、回傳 salt 來自 pass 腿 w1——round 2 MINOR-2 措辭修正)——逐位等值帳由
|
|
455
|
+
* 「兩段各自與 wrapNoteKey 直呼相等」承載。
|
|
456
|
+
*/
|
|
374
457
|
export async function buildBindPayload(
|
|
375
458
|
cfg: NoteCryptoConfig,
|
|
376
459
|
noteKey: CryptoKey,
|
|
@@ -378,11 +461,13 @@ export async function buildBindPayload(
|
|
|
378
461
|
recToken: string,
|
|
379
462
|
identity: string,
|
|
380
463
|
): Promise<{ wrapped: string; salt: string; wrappedRec: string; recTokenHash: string }> {
|
|
381
|
-
const
|
|
382
|
-
|
|
464
|
+
const [w1, wrappedRec] = await Promise.all([
|
|
465
|
+
wrapNoteKey(cfg, noteKey, passphrase),
|
|
466
|
+
wrapNoteKeyWithRecToken(cfg, noteKey, recToken, identity),
|
|
467
|
+
]);
|
|
383
468
|
return {
|
|
384
|
-
wrapped,
|
|
385
|
-
salt,
|
|
469
|
+
wrapped: w1.wrapped,
|
|
470
|
+
salt: w1.salt,
|
|
386
471
|
wrappedRec,
|
|
387
472
|
recTokenHash: await recTokenHash(recToken),
|
|
388
473
|
};
|
|
@@ -392,20 +477,19 @@ export async function buildBindPayload(
|
|
|
392
477
|
|
|
393
478
|
export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, noteKey: CryptoKey): Promise<void> {
|
|
394
479
|
try {
|
|
480
|
+
if (!cfg.wrap) throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
395
481
|
const guest = await deriveGuestKey(cfg, identity);
|
|
396
|
-
const
|
|
397
|
-
cfg.store.set(cfg.store.noteKeyWrap(identity),
|
|
482
|
+
const wrapped = await sealNoteKey(cfg.wrap, noteKey, guest, 'notekey-local');
|
|
483
|
+
cfg.store.set(cfg.store.noteKeyWrap(identity), wrapped);
|
|
398
484
|
} catch { /* private mode:不阻擋主流程 */ }
|
|
399
485
|
}
|
|
400
486
|
|
|
401
487
|
async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey | null> {
|
|
402
488
|
try {
|
|
403
489
|
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
404
|
-
if (!stored || !
|
|
490
|
+
if (!stored || !cfg.wrap) return null;
|
|
405
491
|
const guest = await deriveGuestKey(cfg, identity);
|
|
406
|
-
|
|
407
|
-
if (!rawHex) return null;
|
|
408
|
-
return importAesGcm(hexToBytes(rawHex), true);
|
|
492
|
+
return await openNoteKey(stored, guest, 'notekey-local', cfg.wrap);
|
|
409
493
|
} catch {
|
|
410
494
|
return null;
|
|
411
495
|
}
|
|
@@ -450,7 +534,11 @@ export async function encryptNote(
|
|
|
450
534
|
return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
|
|
451
535
|
}
|
|
452
536
|
if (!cfg.cipherGuest) throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
|
|
453
|
-
|
|
537
|
+
// 空 identity 拒絕(t_7710c766 目標 3):K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
|
|
538
|
+
// 靜默產出「空帳號金鑰」=可解同樣空 identity 的任何 guest 密文(對稱面誤配)。fail-closed。
|
|
539
|
+
const identity = idp.current();
|
|
540
|
+
if (!identity) throw new Error('ERR_NO_IDENTITY');
|
|
541
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
454
542
|
return cfg.cipherGuest + await encryptWithKey(guest, plaintext, aad);
|
|
455
543
|
}
|
|
456
544
|
|
|
@@ -464,7 +552,10 @@ export async function decryptNote(
|
|
|
464
552
|
idp: IdentityProvider,
|
|
465
553
|
): Promise<string | null> {
|
|
466
554
|
try {
|
|
467
|
-
|
|
555
|
+
// guest 前綴正規化(follow-up ②,t_44239f5f):畸形配置 ''(空字串)會讓
|
|
556
|
+
// startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
|
|
557
|
+
// falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
|
|
558
|
+
const guestPrefix = cfg.cipherGuest || undefined;
|
|
468
559
|
const isGuest = guestPrefix !== undefined && cipher.startsWith(guestPrefix);
|
|
469
560
|
const isBound = cipher.startsWith(cfg.cipherBound);
|
|
470
561
|
if (!isGuest && !isBound) {
|
|
@@ -473,7 +564,11 @@ export async function decryptNote(
|
|
|
473
564
|
return guestPrefix === undefined ? null : cipher;
|
|
474
565
|
}
|
|
475
566
|
if (isGuest) {
|
|
476
|
-
|
|
567
|
+
// 空 identity 拒絕(t_7710c766 目標 3,對稱面):guest 密文在 identity 缺席時必 null
|
|
568
|
+
//(K_u 仍可派生但「空帳號金鑰」不是該密文作者的 intent;誤配下兩態皆 fail-closed)。
|
|
569
|
+
const identity = idp.current();
|
|
570
|
+
if (!identity) return null;
|
|
571
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
477
572
|
return await decryptWithKey(guest, unb64(cipher.slice(guestPrefix!.length)), aad);
|
|
478
573
|
}
|
|
479
574
|
if (!held.get()) {
|
package/src/client/pinlock.ts
CHANGED
|
@@ -24,10 +24,12 @@ export const PINLOCK_ITERATIONS = 600_000;
|
|
|
24
24
|
|
|
25
25
|
const SALT_LEN = 16;
|
|
26
26
|
const IV_LEN = 12;
|
|
27
|
-
/** pinSalt(16) + iv(12) + ct(hex
|
|
27
|
+
/** pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定。 */
|
|
28
28
|
const PAYLOAD_LEN = SALT_LEN + IV_LEN + 80;
|
|
29
29
|
|
|
30
|
-
/** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
|
|
30
|
+
/** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
|
|
31
|
+
* t_7710c766:noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
|
|
32
|
+
* 要能再包裹/匯出)不是可調選項;tacet 消費端 TACET_PINLOCK 同步摘欄。 */
|
|
31
33
|
export interface PinLockConfig {
|
|
32
34
|
/** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
|
|
33
35
|
pinLock?: string;
|
|
@@ -35,8 +37,6 @@ export interface PinLockConfig {
|
|
|
35
37
|
pinLockSaltPrefix?: string;
|
|
36
38
|
/** AAD(鎖定包裹專用,與登入第二因子/分享包裹互斥)。未配置 = 拒絕。 */
|
|
37
39
|
pinLockAad?: string;
|
|
38
|
-
/** 解包輸出的 noteKey(要能再包裹/匯出,鐵律 1)。 */
|
|
39
|
-
noteKeyExtractable: boolean;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** KEK = PBKDF2-SHA256(pinNorm, pinLockSaltPrefix + hex(pinSalt), 600k) → AES-GCM key(nonextractable)。 */
|
|
@@ -63,7 +63,9 @@ async function deriveLockKek(pinNorm: string, pinSalt: Uint8Array, cfg: PinLockC
|
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。
|
|
66
|
+
/** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。
|
|
67
|
+
* 鹽內嵌家族(jr2w./jr3d./jr1p.)自帶三段組裝——sealNoteKey 是鹽外置家族形(iv‖ct),
|
|
68
|
+
* 內嵌鹽的 pinSalt 前綴不在其契約內(t_7710c766 毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
|
|
67
69
|
export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string> {
|
|
68
70
|
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
|
|
69
71
|
const pinNorm = normalizePin(pin);
|
|
@@ -84,7 +86,10 @@ export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey,
|
|
|
84
86
|
return cfg.pinLock + b64(payload);
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
/** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
|
|
89
|
+
/** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
|
|
90
|
+
* t_7710c766:嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
|
|
91
|
+
* hexToBytes fail-closed 由 try/catch 承接=行為不變)——不經 openNoteKey 本體
|
|
92
|
+
* (openNoteKey 是鹽外置家族 92B 形,pinSalt 前綴不在其契約內)。 */
|
|
88
93
|
export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null> {
|
|
89
94
|
try {
|
|
90
95
|
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) return null;
|
|
@@ -92,12 +97,12 @@ export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string,
|
|
|
92
97
|
const payload = unb64(wrapped.slice(cfg.pinLock.length));
|
|
93
98
|
if (payload.length !== PAYLOAD_LEN) return null;
|
|
94
99
|
const pinSalt = payload.slice(0, SALT_LEN);
|
|
95
|
-
const ivPrefixedCt = payload.slice(SALT_LEN); //
|
|
100
|
+
const ivPrefixedCt = payload.slice(SALT_LEN); // 鹽內嵌家族:payload = pinSalt[16] ‖ iv[12] ‖ ct
|
|
96
101
|
const pinNorm = normalizePin(pin);
|
|
97
102
|
if (!pinNorm) return null;
|
|
98
103
|
const kek = await deriveLockKek(pinNorm, pinSalt, cfg);
|
|
99
104
|
const rawHex = await decryptWithKey(kek, ivPrefixedCt, cfg.pinLockAad);
|
|
100
|
-
if (!rawHex) return null;
|
|
105
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
101
106
|
return importAesGcm(hexToBytes(rawHex), true);
|
|
102
107
|
} catch {
|
|
103
108
|
return null;
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export {
|
|
|
19
19
|
wrapNoteKeyDual,
|
|
20
20
|
unwrapNoteKeyDual,
|
|
21
21
|
storeLocalWrap,
|
|
22
|
+
clearLocalWrap,
|
|
23
|
+
buildBindPayload,
|
|
22
24
|
ph1Of,
|
|
23
25
|
normalizePin,
|
|
24
26
|
recTokenHash,
|
|
@@ -39,6 +41,7 @@ export {
|
|
|
39
41
|
wrapNoteKeyShare3,
|
|
40
42
|
unwrapNoteKeyShare3,
|
|
41
43
|
derivePh1Argon,
|
|
44
|
+
PH1_V2_SALT,
|
|
42
45
|
ARGON_MEMORY_KIB,
|
|
43
46
|
ARGON_ITERATIONS,
|
|
44
47
|
ARGON_PARALLELISM,
|