@tacet-ink/journal-core 0.2.0 → 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 +60 -10
- package/package.json +16 -2
- package/scripts/open-card-pr.sh +49 -0
- package/scripts/verify-core-crypto.ts +463 -157
- package/src/client/argon2.ts +23 -18
- package/src/client/bip39.ts +1 -1
- package/src/client/note-crypto.ts +128 -42
- package/src/client/pinlock.ts +3 -3
- package/src/server/auth.ts +8 -8
- package/src/server/cors.ts +1 -1
- package/src/server/ladder.ts +5 -5
- package/src/server/ratelimit.ts +3 -3
package/src/client/argon2.ts
CHANGED
|
@@ -26,7 +26,12 @@
|
|
|
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
37
|
import { normalizePin, normalizePassphrase } from './note-crypto.ts';
|
|
@@ -37,7 +42,7 @@ import {
|
|
|
37
42
|
/** KDF 派生輸入正規化收口(mirror normalizePin 先例):v3 入口 = normalizePassphrase(pass);raw 世代入口 = 原密語。
|
|
38
43
|
* 正規化永不進共用派生本體:deriveKekArgon/deriveKek2Argon 吃的是「派生輸入」——
|
|
39
44
|
* raw 契約(jr3d./jr2w.…)不經此面(帶內版本化「契約面永不變」的結構保證)。
|
|
40
|
-
*
|
|
45
|
+
* 三面單一真相:本體**委派** normalizePassphrase(語意唯一真相在
|
|
41
46
|
* note-crypto.ts 本體,本函式只是入口收口位命名)——禁自帶 .normalize 實作(閘形錨咬住); */
|
|
42
47
|
function deriveInput(passphrase: string): string {
|
|
43
48
|
return normalizePassphrase(passphrase);
|
|
@@ -64,7 +69,7 @@ export const PH1_V2_SALT = 'tacet-ph1-v1';
|
|
|
64
69
|
* ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
|
|
65
70
|
*/
|
|
66
71
|
export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array): Promise<string> {
|
|
67
|
-
// PH1
|
|
72
|
+
// PH1 鹽可注入:預設 PH1_V2_SALT 舊值=tacet 零行為變更;
|
|
68
73
|
// 其他產品可自選鹽但 per-product 恆固定(PH2 UNIQUE 約束——per-user 鹽會摧毀它)。
|
|
69
74
|
return toHex(await deriveArgon2id(
|
|
70
75
|
passphrase,
|
|
@@ -76,12 +81,12 @@ export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array):
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
/**
|
|
79
|
-
* PH1 v3
|
|
84
|
+
* PH1 v3 鹽(密語正規化 v3 世代):'tacet-ph1-v2'——鹽域版本代沿
|
|
80
85
|
* PH1_V2_SALT(常數名='tacet-ph1-v1')既有先例模式:常數名=PH1 規格代、鹽域值=鹽域代。
|
|
81
86
|
* 鹽域世代分離:若續用 v2 鹽域,v3 世代(normalizePassphrase 後 'PASS'→'PASS')與
|
|
82
87
|
* v2 世代 raw 'PASS' 會派生出同一 ph2=跨世代帳戶空間混合;新鹽域把跨世代 identity
|
|
83
|
-
* 問題留在遷移層查表(
|
|
84
|
-
* ⚠️ 鹽撞位警告(
|
|
88
|
+
* 問題留在遷移層查表(v2-first 三腿查表職責),核心層零承擔。
|
|
89
|
+
* ⚠️ 鹽撞位警告(NFKC-effective 帳):`PH1_V3_SALT` 禁當 `derivePh1Argon` 的
|
|
85
90
|
* `saltArg` 餵入——v3 派生輸入吃 normalizePassphrase,同鹽域值錯面餵入=NFKC-effective
|
|
86
91
|
* 密語與 v2 raw 密語同值撞 ph2。fork 對兩面自選鹽時兩值必須互異(README PH1 節同款警告)。
|
|
87
92
|
*/
|
|
@@ -90,7 +95,7 @@ export const PH1_V3_SALT = 'tacet-ph1-v2';
|
|
|
90
95
|
/**
|
|
91
96
|
* PH1 v3:登入憑證 = Argon2id(normalizePassphrase(pass), 固定域鹽, m=64MiB, t=3, p=1) → hex64。
|
|
92
97
|
* 正規化收口在入口(密語 v3 契約 NFKC-only——normalizePassphrase 檔頭三面單一真相);
|
|
93
|
-
*
|
|
98
|
+
* 鹽可注入(saltArg 慣例:預設 PH1_V3_SALT;per-product 恆固定=PH2 UNIQUE 約束同 v2 母型)。
|
|
94
99
|
* 參數沿 jr3w./PH1 v2 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
|
|
95
100
|
* v2/legacy 契約面零動:derivePh1Argon 不吃正規化(舊世代 raw 派生,帶內版本化)。
|
|
96
101
|
*/
|
|
@@ -244,7 +249,7 @@ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passph
|
|
|
244
249
|
}
|
|
245
250
|
|
|
246
251
|
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
247
|
-
* 收口 openNoteKey
|
|
252
|
+
* 收口 openNoteKey:嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
|
|
248
253
|
* 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
|
|
249
254
|
* own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
|
|
250
255
|
export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
@@ -259,7 +264,7 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
|
|
|
259
264
|
}
|
|
260
265
|
}
|
|
261
266
|
|
|
262
|
-
// ── jr4w. 單因子包裹(密語正規化 v3
|
|
267
|
+
// ── jr4w. 單因子包裹(密語正規化 v3 世代) ────────────────────
|
|
263
268
|
//
|
|
264
269
|
// 帶內版本化:密語 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴,
|
|
265
270
|
// 舊前綴契約面永不變——jr4w. 的函式體與 jr3w. 同構(openNoteKey 共用核心、payload 嚴格
|
|
@@ -277,13 +282,13 @@ export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passph
|
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
/** jr4w. 解包(v3 密語):任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
280
|
-
* 家族守衛(wrap4 未配置回 null
|
|
285
|
+
* 家族守衛(wrap4 未配置回 null)+快速前綴快檢(錯前綴 junk 免付 64 MiB Argon)+salt hex-形檢+
|
|
281
286
|
* KEK 派生(normalizePassphrase 收口在入口)——payload 形檢在 openNoteKey 本體(鹽外置家族:嚴格 92B+
|
|
282
287
|
* rawHex 形+hex fail-closed)。 */
|
|
283
288
|
export async function unwrapNoteKey4(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
284
289
|
try {
|
|
285
290
|
if (!cfg.wrap4) return null;
|
|
286
|
-
if (!wrapped.startsWith(cfg.wrap4)) return null; //
|
|
291
|
+
if (!wrapped.startsWith(cfg.wrap4)) return null; // 快速前綴快檢:錯前綴 junk 免付 64 MiB Argon 成本
|
|
287
292
|
const salt = hexToBytes(saltHex);
|
|
288
293
|
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
289
294
|
const kek = await deriveKekArgon(deriveInput(passphrase), salt);
|
|
@@ -313,7 +318,7 @@ async function deriveKek2Argon(
|
|
|
313
318
|
hkdfInfo: string,
|
|
314
319
|
): Promise<CryptoKey> {
|
|
315
320
|
if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
316
|
-
// ⚠️ 瀏覽器 hash-wasm Argon2
|
|
321
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2 禁並行:純 wasm Argon2id 共享記憶體池,
|
|
317
322
|
// 兩實例並行在部分引擎靜態直 throw——這裡故意保持串行(與 jr2w. PBKDF2 版
|
|
318
323
|
// deriveKek2 的 Promise.all 並行不同:那裡並行的前提是 crypto.subtle 原生派生)。
|
|
319
324
|
const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
|
|
@@ -359,7 +364,7 @@ export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, pa
|
|
|
359
364
|
}
|
|
360
365
|
|
|
361
366
|
/** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。
|
|
362
|
-
*
|
|
367
|
+
* 嚴格檢查為鹽內嵌族自有——payload 嚴格 108B(pinSalt 16
|
|
363
368
|
* + iv 12+ct 80)+ decryptWithKey 後 rawHex hex 形檢查(hexToBytes fail-closed),
|
|
364
369
|
* 不經 openNoteKey 本體(鹽外置 92B 形);與 openNoteKey 同嚴格度。 */
|
|
365
370
|
export async function unwrapNoteKeyDual3(
|
|
@@ -390,7 +395,7 @@ export async function unwrapNoteKeyDual3(
|
|
|
390
395
|
}
|
|
391
396
|
}
|
|
392
397
|
|
|
393
|
-
// ── jr4d. 雙因子合鑰(密語正規化 v3 世代=jr3d. 的 v3
|
|
398
|
+
// ── jr4d. 雙因子合鑰(密語正規化 v3 世代=jr3d. 的 v3 後繼) ────
|
|
394
399
|
//
|
|
395
400
|
// 帶內版本化:pass 段 KEK 輸入契約改變(raw → normalizePassphrase NFKC-only)=新前綴;
|
|
396
401
|
// PIN 段契約照舊(normalizePin:NFKC→trim→lowercase,pinlock/dual 面不動)。
|
|
@@ -403,7 +408,7 @@ export async function unwrapNoteKeyDual3(
|
|
|
403
408
|
export async function wrapNoteKeyDual4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
404
409
|
if (!cfg.wrapDual4 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR4D_NOT_CONFIGURED');
|
|
405
410
|
const pinNorm = normalizePin(pin);
|
|
406
|
-
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠
|
|
411
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
|
|
407
412
|
const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
408
413
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
409
414
|
const kek2 = await deriveKek2Argon(cfg, deriveInput(passphrase), pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual4);
|
|
@@ -473,7 +478,7 @@ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, s
|
|
|
473
478
|
}
|
|
474
479
|
|
|
475
480
|
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
476
|
-
* 收口 openNoteKey
|
|
481
|
+
* 收口 openNoteKey:鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
|
|
477
482
|
* 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
|
|
478
483
|
export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
479
484
|
try {
|
|
@@ -487,7 +492,7 @@ export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sh
|
|
|
487
492
|
}
|
|
488
493
|
}
|
|
489
494
|
|
|
490
|
-
// ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt
|
|
495
|
+
// ── ladder 重試鹽面:unwrapNoteKeyDual4WithSalt ────
|
|
491
496
|
//
|
|
492
497
|
// 為何同構再開一支:v3 遷移(登入即重遷)會旋轉 salt1(wrapNoteKeyDual4 落新值);
|
|
493
498
|
// login 舊形面帶回的 salt(v2 世代的 salt1)在遷移後已解不開 v3 新包裹──呼叫端
|
|
@@ -496,7 +501,7 @@ export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sh
|
|
|
496
501
|
// 檢查、同 HKDF info 域、同輸出契約)──開支出零差異;命名開新支是「重取鹽」的
|
|
497
502
|
// 語意承載位(呼叫端 grep 可審計),零新增實作面。
|
|
498
503
|
//
|
|
499
|
-
//
|
|
504
|
+
// 補記(家族嚴格度):salt1 形檢/payload 108B/rawHex 形檢全在
|
|
500
505
|
// unwrapNoteKeyDual4 本體(共用);本變體純語意薄身。
|
|
501
506
|
|
|
502
507
|
/** jr4d. 解包(重取 salt1 面):pinSalt 內嵌自描述,salt1 由呼叫端重取後帶入(ladder 遷移後的新値);任何不符回 null,不拋。 */
|
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 以
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 血統:抽取自姊妹專案的日記加密模組(37 斷言驗證閘母型);
|
|
5
5
|
* fork diff 已證實:除品牌前綴(guest KDF 前綴、密文前綴家族、KDF salt 前綴)
|
|
6
|
-
*
|
|
6
|
+
* 與產品附加機制外,密碼學本體在多個產品間完全一致——本檔承載該本體。
|
|
7
7
|
*
|
|
8
8
|
* 兩時代金鑰模型(不變量,勿破壞):
|
|
9
9
|
* 時代 1(未綁定 guest):K_u = SHA-256(guestKdfPrefix ‖ identity) — 純客戶端派生,混淆級
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* KEK = PBKDF2(pass, salt, 600000, SHA-256) 包裹成 wrapped 上傳;復原套件 wrappedRec
|
|
12
12
|
* = 同款包裹在 KEK_rec = PBKDF2(recToken, salt = recSaltPrefix ‖ identity) 下。
|
|
13
13
|
* passphrase 從此不過線:線上憑證送 PH1(雜湊形,派生方式由各產品自定義),伺服器存 PH2。
|
|
14
|
+
* 復原套件 v0.2.0 世代:前綴專用化(wrapRec=jr1r.)+KEK_rec 派生
|
|
15
|
+
* 世代化(recKekHkdf 配置=HKDF-SHA256,info='journal-kek-rec-v1:'+recKekHkdf)+
|
|
16
|
+
* AAD 帶內切換('notekey'→'notekey-rec');新世代讀舊寫新——unwrap 新面先行、回落
|
|
17
|
+
* 舊契約面(PBKDF2+AAD 'notekey')雙試(過渡保護承諾:舊 blob 永遠可解;摘雙試
|
|
18
|
+
* 時點=遷移率裁定非日曆)。
|
|
14
19
|
*
|
|
15
20
|
* 前綴全部可配置(帶內版本化:升級 KDF 參數 = 換新前綴)。AAD 由呼叫端傳入——
|
|
16
21
|
* 例如綁 `noteId`(跨列搬移必解密失敗)、`lifeId:day`(逐日日記)或留言綁 `tst:<lifeId>`。
|
|
@@ -27,16 +32,38 @@ export interface NoteCryptoConfig {
|
|
|
27
32
|
guestKdfPrefix: string;
|
|
28
33
|
/** 復原包裹 KDF salt 前綴,如 'journal-note-rec1:'。 */
|
|
29
34
|
recSaltPrefix: string;
|
|
30
|
-
/** 密文前綴:guest 時代,如 'jr1g.'。未配置 = guest API 拒絕(未配置行為不變;2026-09-19
|
|
35
|
+
/** 密文前綴:guest 時代,如 'jr1g.'。未配置 = guest API 拒絕(未配置行為不變;2026-09-19 轉選配)。 */
|
|
31
36
|
cipherGuest?: string;
|
|
32
37
|
/** 密文前綴:綁定時代,如 'jr1b.'。 */
|
|
33
38
|
cipherBound: string;
|
|
34
39
|
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
35
40
|
wrap: string;
|
|
36
|
-
/** 本機包裹前綴(jr1l
|
|
41
|
+
/** 本機包裹前綴(jr1l.:本機包裹不再借用 cfg.wrap——「一個前綴一份契約」
|
|
37
42
|
* 的第二違例收口)。未配置 = storeLocalWrap/loadLocalWrap 退場(無寫入/讀取照走
|
|
38
|
-
*
|
|
43
|
+
* 舊形回落,零資料損失——cipherLocal opt-in 母型;寫面功能退場——v1 可用者升級後
|
|
44
|
+
* 需配置 wrapLocal 才保留本機包裹寫入)。 */
|
|
39
45
|
wrapLocal?: string;
|
|
46
|
+
/** 復原套件專用前綴(jr1r.:wrappedRec 不再借用 cfg.wrap——同前例收口)。
|
|
47
|
+
* AAD 亦帶內切換 'notekey'→'notekey-rec'(共用 AAD 收口)。與 recKekHkdf **兩欄一體**
|
|
48
|
+
* (同缺同在;部分配置=ERR_REC_CFG_PARTIAL 拒寫——世代配對門,fail-closed 禁寫出
|
|
49
|
+
* 「前綴面 jr1r.×派生面 PBKDF2」跨契約 blob)。兩欄皆缺席 = wrap/unwrap 復原套件面
|
|
50
|
+
* 照走舊契約(cfg.wrap+PBKDF2+AAD 'notekey')=行為不變態(配置形正確
|
|
51
|
+
* 時舊 wrappedRec blob 永遠可解——HKDF 腿誤配拋點整面 fail-closed 既有契約)。 */
|
|
52
|
+
wrapRec?: string;
|
|
53
|
+
/** 復原套件「KEK 派生」世代開關:KEK_rec = HKDF-SHA256(
|
|
54
|
+
* ikm = recToken(256-bit 實體因子), salt = recKekSalt?:16B hex(未配置=零鹽),
|
|
55
|
+
* info = 'journal-kek-rec-v1:' + recKekHkdf, L = 32 )——info 合成名沿 argon2 家族
|
|
56
|
+
* 慣例(呼叫端帶入;KEK 派生世代空間由前綴+info 承載)。HKDF 世代 identity 不入
|
|
57
|
+
* KEK(identity-free by design——256-bit 實體因子足綁;identity 綁域由
|
|
58
|
+
* 舊契約面 recSaltPrefix‖identity 專屬承載,兩世代綁域刻意分離)。與 wrapRec 兩欄
|
|
59
|
+
* 一體(同缺同在;部分配置=ERR_REC_CFG_PARTIAL 拒寫)。未配置 = PBKDF2 舊世代
|
|
60
|
+
* (裁定:HKDF 強化屬 v0.2.0 世代;強度差異對 256-bit recToken 無意義,
|
|
61
|
+
* 收的是「派生域+前綴契約面分離」的語意收口)。 */
|
|
62
|
+
recKekHkdf?: string;
|
|
63
|
+
/** HKDF salt:16B hex 專屬鹽域(與 PBKDF2 面 recSaltPrefix 前綴域分離
|
|
64
|
+
* ——KEK 派生世代鹽)。未配置 = HKDF 零鹽(HKDF salt 欄位本就 optional;語意不變,
|
|
65
|
+
* 域由 info 承載)。 */
|
|
66
|
+
recKekSalt?: string;
|
|
40
67
|
/** 雙因子合鑰包裹前綴(jr2w.,PIN 第二因子)。未配置 = dual API 拒絕(未配置行為不變)。 */
|
|
41
68
|
wrapDual?: string;
|
|
42
69
|
/** 雙因子 pin 段 PBKDF2 salt 前綴,如 'journal-note-pin1:'(pinSalt 內嵌 payload 後拼接)。 */
|
|
@@ -57,7 +84,7 @@ export const IV_LEN = 12;
|
|
|
57
84
|
|
|
58
85
|
const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
|
|
59
86
|
/**
|
|
60
|
-
* hex 合法性(fail-closed
|
|
87
|
+
* hex 合法性(fail-closed):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
|
|
61
88
|
* 每雙位元組真 hex。
|
|
62
89
|
*/
|
|
63
90
|
const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
|
|
@@ -65,11 +92,11 @@ const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
|
|
|
65
92
|
// ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
|
|
66
93
|
|
|
67
94
|
export function b64(bytes: Uint8Array): string {
|
|
68
|
-
// 分塊 String.fromCharCode.apply
|
|
69
|
-
// node 端分塊形 132-181ms→18ms,與閘執行帳同源——
|
|
70
|
-
//
|
|
95
|
+
// 分塊 String.fromCharCode.apply(瀏覽器實測 4MiB 222ms→27ms;
|
|
96
|
+
// node 端分塊形 132-181ms→18ms,與閘執行帳同源——Node-only toBase64 的 1ms 帳是
|
|
97
|
+
// 不同形,非本體帳)。瀏覽器端恆走本形——core 是 isomorphic TS
|
|
71
98
|
// 源碼發行,不引入 Node-only
|
|
72
|
-
// toBase64
|
|
99
|
+
// toBase64 分支(評估後否決的路線)。apply 參數上限安全窗 8192;輸出 byte 等價
|
|
73
100
|
// (驗證閘 [14] 有 0/1/7/8191/8192/8193/65539 邊界逐位元組對照+unb64 roundtrip)。
|
|
74
101
|
let binary = '';
|
|
75
102
|
for (let i = 0; i < bytes.byteLength; i += 8192) {
|
|
@@ -90,15 +117,15 @@ export function toHex(bytes: Uint8Array): string {
|
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
export function hexToBytes(hex: string): Uint8Array {
|
|
93
|
-
// fail-closed
|
|
120
|
+
// fail-closed:非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
|
|
94
121
|
// 拋 ERR_BAD_HEX(呼叫端 unwrap 全部 try/catch 回 null;非 catch 端僅 bip39
|
|
95
122
|
// HEX64_RE 前置自守)。奇數長度、非 hex、空字串皆拒;大小寫/全形 NFKC 收容。
|
|
96
|
-
// 測與 parse
|
|
123
|
+
// 測與 parse 同一(正規化後)真相——形檢吃正規化串、
|
|
97
124
|
// parseInt 吃原始串=全形 hex digit NaN 歸零殘形(README 例外契約自穿透),收口殲滅。
|
|
98
|
-
// NFKC
|
|
125
|
+
// NFKC 寬容=設計面刻意:'⑩'→'10' 等相容字元收容後是真 hex
|
|
99
126
|
// 值(⑩→[16] 實帳),非缺陷形(NaN 歸零已殲滅=輸出恆數學等值);rawHex 檢查同式(HEX_RE 同行後置
|
|
100
127
|
// =rawHex 前置守衛);鹽欄同為同行後置(note-crypto/argon2 各 unwrap 面鹽欄形檢——行號帳不寫死,
|
|
101
|
-
//
|
|
128
|
+
// 新增段推移行號=註解行號帳漂移,同病禁再犯)。
|
|
102
129
|
// 行為恆 null(純註解);正式輸入面形檢在場,公開原語只保證「正規化後真 hex 恆等值、垃圾恆拒」。
|
|
103
130
|
// 若要 ASCII-only 收緊=帶內版本化(換前綴),禁原地改語意。
|
|
104
131
|
const norm = hex.normalize('NFKC').toLowerCase();
|
|
@@ -131,7 +158,7 @@ export async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): P
|
|
|
131
158
|
|
|
132
159
|
async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
133
160
|
// 這裡的 importKey 是 deriveKey 用途集——與 derivePbkdf2Bits 的 deriveBits 面不是同一形,
|
|
134
|
-
// 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;
|
|
161
|
+
// 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;probe 實證)。
|
|
135
162
|
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveKey']);
|
|
136
163
|
return crypto.subtle.deriveKey(
|
|
137
164
|
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
@@ -162,7 +189,7 @@ export function normalizePin(pin: string): string {
|
|
|
162
189
|
}
|
|
163
190
|
|
|
164
191
|
/**
|
|
165
|
-
* 密語正規化契約 v3(
|
|
192
|
+
* 密語正規化契約 v3(NFKC-only 裁定):NFKC——**大小寫摺疊禁絕:區分大小寫
|
|
166
193
|
* (大小寫差=不同 KEK/ph2)+首尾空白保留(不 trim)**。
|
|
167
194
|
* 消費面只有 v3 世代:jr4w./jr4d. 包裹家族與 PH1 v3(derivePh1ArgonV3)——
|
|
168
195
|
* 舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw pass 派生,帶內版本化=
|
|
@@ -182,12 +209,12 @@ async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations:
|
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
async function deriveKek2(cfg: NoteCryptoConfig, passphrase: string, pin: string, salt1: Uint8Array, pinSalt: Uint8Array): Promise<CryptoKey> {
|
|
185
|
-
// pass 段與 pin 段 PBKDF2 相互獨立(不同 salt
|
|
212
|
+
// pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行。
|
|
186
213
|
// ⚠️ 瀏覽器 hash-wasm Argon2(jr3d. 對應形 deriveKek2Argon)禁並行——純 wasm Argon2id
|
|
187
|
-
// 共享記憶體池、兩實例並行在部分引擎靜態直 throw
|
|
188
|
-
// 兩段各自 importKeyRawForKdf(
|
|
214
|
+
// 共享記憶體池、兩實例並行在部分引擎靜態直 throw(設計已明示);並行只收 PBKDF2 家族。
|
|
215
|
+
// 兩段各自 importKeyRawForKdf(keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
|
|
189
216
|
// 輸出序=ikm 組裝序=passBits@0 ‖ pinBits@32(與原串行逐位一致);閘錨綁左手側 destructure,
|
|
190
|
-
// 對調滑接=既有 jr2w.
|
|
217
|
+
// 對調滑接=既有 jr2w. 帳戶全不可解(一手實證)。
|
|
191
218
|
const [passBits, pinBits] = await Promise.all([
|
|
192
219
|
derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS),
|
|
193
220
|
derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS),
|
|
@@ -212,7 +239,7 @@ const DUAL_IV_LEN = 12;
|
|
|
212
239
|
export async function wrapNoteKeyDual(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
213
240
|
if (!cfg.wrapDual || !cfg.pinSaltPrefix) throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
214
241
|
const pinNorm = normalizePin(pin);
|
|
215
|
-
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠
|
|
242
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
|
|
216
243
|
const salt1 = crypto.getRandomValues(new Uint8Array(16));
|
|
217
244
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
218
245
|
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
@@ -356,8 +383,11 @@ export function recTokenHash(recToken: string): Promise<string> {
|
|
|
356
383
|
const NOTEKEY_HEX_LEN = 64;
|
|
357
384
|
|
|
358
385
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
386
|
+
* 共用包裹核心(收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
|
|
387
|
+
* 鹽外置家族字首家系清點(七呼叫端):jr1w.(wrapNoteKey)/jrsw.
|
|
388
|
+
*(wrapNoteKeyShare)/jr3w.(wrapNoteKey3)/jr4w.(wrapNoteKey4)/jr3s.
|
|
389
|
+
*(wrapNoteKeyShare3)/jr1l.(storeLocalWrap)/jr1r.+jr1w.(wrapNoteKeyWithRecToken——
|
|
390
|
+
* HKDF 世代專用前綴+AAD 'notekey-rec'、未配置世代 legacy 面=cfg.wrap+AAD 'notekey')
|
|
361
391
|
* 同一本體;KDF 差異在呼叫端 KEK、payload 形狀(salt 外置 vs 內嵌)由各家族維持。
|
|
362
392
|
* prefix null/undefined=未配置拒絕(呼叫端亦可先行拒絕,帶自家錯誤碼形;雙防線)。
|
|
363
393
|
*/
|
|
@@ -369,14 +399,17 @@ export async function sealNoteKey(prefix: string | null | undefined, noteKey: Cr
|
|
|
369
399
|
}
|
|
370
400
|
|
|
371
401
|
/**
|
|
372
|
-
*
|
|
402
|
+
* 共用解包核心(收口):任何不符——前綴、base64 形、payload 長度、
|
|
373
403
|
* GCM(金鑰/AAD/密文損壞)、rawHex 長度/hex 形——恆回 null 不拋。
|
|
374
|
-
*
|
|
404
|
+
* 修正紀錄:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
|
|
375
405
|
* 本體吞收 null(公開原語誠實契約;呼叫端 unwrap 家族 try/catch 是雙防線非依賴面)。
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
406
|
+
* 家族嚴格度收口實況(校正+殘餘校正;
|
|
407
|
+
* 七呼叫端帳同步——鹽外置家族字首家系清點,6 點帳已過期):
|
|
408
|
+
* 本體嚴格 92B 終結七呼叫端=note-crypto 四點(unwrapNoteKey=jr1w/
|
|
409
|
+
* unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken(legacy 面 jr1w./HKDF 面 jr1r.)
|
|
410
|
+
* /loadLocalWrap=jr1l.)
|
|
411
|
+
* +argon2 三點(unwrapNoteKey3=jr3w/unwrapNoteKey4=jr4w/unwrapNoteKeyShare3=jr3s;
|
|
412
|
+
* own 92B 檢已隨收口摘除);
|
|
380
413
|
* 鹽內嵌 3 點(jr2w/jr3d/jr1p)own 108B+rawHex 形檢——與本體同嚴格度、刻意不經本體
|
|
381
414
|
* (pinSalt 前綴不在鹽外置形契約內)。維護本體時鹽內嵌三族不隨行。
|
|
382
415
|
*/
|
|
@@ -444,32 +477,83 @@ export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string,
|
|
|
444
477
|
}
|
|
445
478
|
}
|
|
446
479
|
|
|
447
|
-
/**
|
|
448
|
-
|
|
449
|
-
|
|
480
|
+
/** 復原套件 KEK 派生:HKDF 專責(本體只承載 recKekHkdf 配置世代——
|
|
481
|
+
* 呼叫端須先過世代配對門)。KEK_rec = HKDF-SHA256(
|
|
482
|
+
* ikm = recToken(TextEncoder UTF-8;recToken 恆 hex64 256-bit 實體因子),
|
|
483
|
+
* salt = recKekSalt?(16B hex → 16 raw bytes;未配置=零鹽),
|
|
484
|
+
* info = 'journal-kek-rec-v1:' + recKekHkdf, L = 32 )——輸出 AES-GCM 256 nonextractable。
|
|
485
|
+
* HKDF 世代 identity 不入 KEK(identity-free by design——256-bit 實體因子足綁;
|
|
486
|
+
* identity 綁域由舊契約面 recSaltPrefix‖identity 專屬承載,兩世代綁域刻意分離)。
|
|
487
|
+
* 強度語意:recToken 256-bit 實體因子,KDF 強度無意義——收的是
|
|
488
|
+
* 派生域(HKDF info)+前綴契約面(jr1r.)的世代分離;非防爆破強化帳。
|
|
489
|
+
* 鹽域二分(鹽家族):HKDF salt 屬「派生面內嵌 cfg、payload 零內嵌」——
|
|
490
|
+
* 92B 帳純粹來自 hex(noteKey) 64B+iv 12+tag 16(pinSalt 內嵌 payload 是 jr2w./jr3d.
|
|
491
|
+
* 鹽內嵌族的事,不入此帳)。PBKDF2 舊世代派生=deriveRecLegacyKek(單一本體,檢查對齊
|
|
492
|
+
* 收口:deriveRecKek 禁 PBKDF2 fallback——派生源雙模=C2 缺口形)。 */
|
|
493
|
+
async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string): Promise<CryptoKey> {
|
|
494
|
+
const hkdfTail = cfg.recKekHkdf as string;
|
|
495
|
+
const info = 'journal-kek-rec-v1:' + hkdfTail;
|
|
496
|
+
if (cfg.recKekSalt && !HEX32_RE.test(cfg.recKekSalt)) throw new Error('ERR_REC_KEK_SALT');
|
|
497
|
+
const salt = cfg.recKekSalt ? hexToBytes(cfg.recKekSalt) : new Uint8Array(0);
|
|
498
|
+
const hkdfBase = await crypto.subtle.importKey('raw', new TextEncoder().encode(recToken) as BufferSource, 'HKDF', false, ['deriveKey']);
|
|
499
|
+
return crypto.subtle.deriveKey(
|
|
500
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: new TextEncoder().encode(info) as BufferSource },
|
|
501
|
+
hkdfBase,
|
|
502
|
+
{ name: 'AES-GCM', length: 256 },
|
|
503
|
+
false,
|
|
504
|
+
['encrypt', 'decrypt'],
|
|
505
|
+
);
|
|
450
506
|
}
|
|
451
507
|
|
|
452
508
|
export async function wrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, noteKey: CryptoKey, recToken: string, identity: string): Promise<string> {
|
|
453
|
-
|
|
509
|
+
// 世代配對門(檢查對齊 C1/C2 收口):一前綴一份契約補完。
|
|
510
|
+
// 禁寫面=「前綴面 jr1r.×派生面 PBKDF2」(wrapRec-only,C1:寫得出讀不回)與
|
|
511
|
+
// 「前綴面 jr1w.×派生面 HKDF」(HKDF-only,C2:借 jr1w. 前綴 carrying 第二派生、
|
|
512
|
+
// 升級即失資料)——兩面都由部分配置自然接線產生,門 fail-closed 拒寫。
|
|
513
|
+
if (cfg.wrapRec || cfg.recKekHkdf) {
|
|
514
|
+
if (!cfg.wrapRec || !cfg.recKekHkdf) throw new Error('ERR_REC_CFG_PARTIAL');
|
|
515
|
+
const kek = await deriveRecKek(cfg, recToken);
|
|
516
|
+
return sealNoteKey(cfg.wrapRec, noteKey, kek, 'notekey-rec');
|
|
517
|
+
}
|
|
518
|
+
// 未配置世代(兩欄皆缺席=legacy 契約面永不變):cfg.wrap+PBKDF2+AAD 'notekey'
|
|
519
|
+
//(既有向量 byte 不變;cfg.wrap 缺席由 sealNoteKey 守衛承載——雙防線)。
|
|
520
|
+
const kek = await deriveRecLegacyKek(cfg, recToken, identity);
|
|
454
521
|
return sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
455
522
|
}
|
|
456
523
|
|
|
457
524
|
export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped: string, recToken: string, identity: string): Promise<CryptoKey | null> {
|
|
458
525
|
try {
|
|
526
|
+
// 次序契約:jr1r. 面只在兩欄齊備時試(HKDF 專責派生——cfg.wrap 面零 HKDF 腿,
|
|
527
|
+
// jr1w. 前綴只 carrying passphrase-PBKDF2 派生:「一個前綴一份契約」第二違例收口)。
|
|
528
|
+
if (cfg.wrapRec && cfg.recKekHkdf) {
|
|
529
|
+
const recKek = await deriveRecKek(cfg, recToken);
|
|
530
|
+
const fresh = await openNoteKey(wrapped, recKek, 'notekey-rec', cfg.wrapRec);
|
|
531
|
+
if (fresh) return fresh;
|
|
532
|
+
}
|
|
533
|
+
// 舊面回落(過渡保護):cfg.wrap 在場即試(部分配置讀面照走——寫面已被配對門
|
|
534
|
+
// 拒,讀面寬容=舊 blob 永遠可解的承載面)。
|
|
459
535
|
if (!cfg.wrap) return null;
|
|
460
|
-
const
|
|
461
|
-
return await openNoteKey(wrapped,
|
|
536
|
+
const legacyKek = await deriveRecLegacyKek(cfg, recToken, identity);
|
|
537
|
+
return await openNoteKey(wrapped, legacyKek, 'notekey', cfg.wrap);
|
|
462
538
|
} catch {
|
|
463
539
|
return null;
|
|
464
540
|
}
|
|
465
541
|
}
|
|
466
542
|
|
|
543
|
+
/** 舊契約派生本體(legacy 派生全案單一本體):PBKDF2(recToken,
|
|
544
|
+
* salt = recSaltPrefix ‖ identity)——永不變(配置形正確時既有 wrappedRec
|
|
545
|
+
* blob 逐位可解)。寫面未配置世代與讀面回落同源呼叫本體;deriveRecKek 改 HKDF 專責
|
|
546
|
+
* (檢查對齊 C2 收口:HKDF 派生禁借 cfg.wrap 前綴面,派生源不再雙模)。 */
|
|
547
|
+
async function deriveRecLegacyKek(cfg: NoteCryptoConfig, recToken: string, identity: string): Promise<CryptoKey> {
|
|
548
|
+
return deriveKek(recToken, new TextEncoder().encode(cfg.recSaltPrefix + identity));
|
|
549
|
+
}
|
|
550
|
+
|
|
467
551
|
/**
|
|
468
552
|
* 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。
|
|
469
553
|
* 兩段 600k PBKDF2 相互獨立(pass 鹽隨機、rec 鹽=recSaltPrefix‖identity 定值)=並行
|
|
470
|
-
*(
|
|
554
|
+
*(node 實測 133-152ms→68-84ms,與閘執行帳同源)。
|
|
471
555
|
* AES-GCM derive 佔 600k 迴圈 99%,兩段 wrapped 各自隨機 iv(pass 段另隨機鹽 16B;rec 腿
|
|
472
|
-
* KEK 鹽定值、回傳 salt 來自 pass 腿 w1
|
|
556
|
+
* KEK 鹽定值、回傳 salt 來自 pass 腿 w1)——逐位等值帳由
|
|
473
557
|
* 「兩段各自與 wrapNoteKey 直呼相等」承載。
|
|
474
558
|
*/
|
|
475
559
|
export async function buildBindPayload(
|
|
@@ -494,10 +578,10 @@ export async function buildBindPayload(
|
|
|
494
578
|
// ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
|
|
495
579
|
|
|
496
580
|
/**
|
|
497
|
-
*
|
|
581
|
+
* 本機包裹:KEK = deriveGuestKey(identity 派生、passphrase-free——
|
|
498
582
|
* session 期免重打密語的機制原樣),payload 改走專用前綴 cfg.wrapLocal(jr1l.)。
|
|
499
583
|
* 舊實作借用 cfg.wrap(jr1w. passphrase 包裹前綴)寫本機包裹=「一個前綴一份契約」
|
|
500
|
-
* 的第二違例(wrappedRec
|
|
584
|
+
* 的第二違例(wrappedRec 專用前綴歸 v0.2.0 世代收口):本機包裹的 KDF 與 caller 不同、
|
|
501
585
|
* 儲存面(localStorage)不同、生命週期不同(clearLocalWrap 隨時摘除)——共前綴讓
|
|
502
586
|
* 「本機字串是否可能誤入 server 包裹欄」對帳失真。新前綴=讀舊寫新自癒:
|
|
503
587
|
* loadLocalWrap 先試 jr1l.(本體嚴格面),命中舊形(jr1w.)回落解密後即重包
|
|
@@ -519,7 +603,9 @@ async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<C
|
|
|
519
603
|
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
520
604
|
if (!stored) return null;
|
|
521
605
|
const guest = await deriveGuestKey(cfg, identity);
|
|
522
|
-
// 次序契約:新形必先於舊形試——新形 blob 在舊前綴面恆拒(family isolation
|
|
606
|
+
// 次序契約:新形必先於舊形試——新形 blob 在舊前綴面恆拒(family isolation);先新後舊
|
|
607
|
+
// 仍是要件(新形命中即不跑舊解包),但兩前綴互斥 startsWith 下單一三元只有一腿成立,
|
|
608
|
+
// 實際次序不可觀察。
|
|
523
609
|
const fresh = cfg.wrapLocal
|
|
524
610
|
? await openNoteKey(stored, guest, 'notekey-local', cfg.wrapLocal) // 本體嚴格面(92B+rawHex hex 形)
|
|
525
611
|
: null;
|
|
@@ -576,7 +662,7 @@ export async function encryptNote(
|
|
|
576
662
|
return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
|
|
577
663
|
}
|
|
578
664
|
if (!cfg.cipherGuest) throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
|
|
579
|
-
// 空 identity
|
|
665
|
+
// 空 identity 拒絕:K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
|
|
580
666
|
// 靜默產出「空帳號金鑰」=可解同樣空 identity 的任何 guest 密文(對稱面誤配)。fail-closed。
|
|
581
667
|
const identity = idp.current();
|
|
582
668
|
if (!identity) throw new Error('ERR_NO_IDENTITY');
|
|
@@ -594,7 +680,7 @@ export async function decryptNote(
|
|
|
594
680
|
idp: IdentityProvider,
|
|
595
681
|
): Promise<string | null> {
|
|
596
682
|
try {
|
|
597
|
-
// guest
|
|
683
|
+
// guest 前綴正規化:畸形配置 ''(空字串)會讓
|
|
598
684
|
// startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
|
|
599
685
|
// falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
|
|
600
686
|
const guestPrefix = cfg.cipherGuest || undefined;
|
|
@@ -606,7 +692,7 @@ export async function decryptNote(
|
|
|
606
692
|
return guestPrefix === undefined ? null : cipher;
|
|
607
693
|
}
|
|
608
694
|
if (isGuest) {
|
|
609
|
-
// 空 identity
|
|
695
|
+
// 空 identity 拒絕(對稱面):guest 密文在 identity 缺席時必 null
|
|
610
696
|
//(K_u 仍可派生但「空帳號金鑰」不是該密文作者的 intent;誤配下兩態皆 fail-closed)。
|
|
611
697
|
const identity = idp.current();
|
|
612
698
|
if (!identity) return null;
|
package/src/client/pinlock.ts
CHANGED
|
@@ -28,7 +28,7 @@ const IV_LEN = 12;
|
|
|
28
28
|
const PAYLOAD_LEN = SALT_LEN + IV_LEN + 80;
|
|
29
29
|
|
|
30
30
|
/** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
|
|
31
|
-
*
|
|
31
|
+
* noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
|
|
32
32
|
* 要能再包裹/匯出)不是可調選項;tacet 消費端 TACET_PINLOCK 同步摘欄。 */
|
|
33
33
|
export interface PinLockConfig {
|
|
34
34
|
/** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
|
|
@@ -65,7 +65,7 @@ async function deriveLockKek(pinNorm: string, pinSalt: Uint8Array, cfg: PinLockC
|
|
|
65
65
|
|
|
66
66
|
/** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。
|
|
67
67
|
* 鹽內嵌家族(jr2w./jr3d./jr1p.)自帶三段組裝——sealNoteKey 是鹽外置家族形(iv‖ct),
|
|
68
|
-
* 內嵌鹽的 pinSalt
|
|
68
|
+
* 內嵌鹽的 pinSalt 前綴不在其契約內(毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
|
|
69
69
|
export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string> {
|
|
70
70
|
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
|
|
71
71
|
const pinNorm = normalizePin(pin);
|
|
@@ -87,7 +87,7 @@ export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey,
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
|
|
90
|
-
*
|
|
90
|
+
* 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
|
|
91
91
|
* hexToBytes fail-closed 由 try/catch 承接=行為不變)——不經 openNoteKey 本體
|
|
92
92
|
* (openNoteKey 是鹽外置家族 92B 形,pinSalt 前綴不在其契約內)。 */
|
|
93
93
|
export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null> {
|
package/src/server/auth.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* 不變量(勿破壞):
|
|
22
22
|
* - 金鑰包裹欄組(wrapped+salt)缺一整組放棄——pair 檢查本體在 pickKeyPackage。
|
|
23
23
|
* - recPkg 與 recHash 成對出現=呼叫端(fork store)職責:本層介面兩參各自可空、
|
|
24
|
-
* 不做 pair
|
|
24
|
+
* 不做 pair 檢查(裁定照此收口;pickKeyPackage 是同職責的成對檢查先例)。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { corsResponse } from './cors.ts';
|
|
@@ -51,11 +51,11 @@ function escapeReLiteral(s: string): string {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
|
|
54
|
-
* never-match
|
|
54
|
+
* never-match 守衛(空字首組態防線,opt-in 鐵律面):空字首組態會
|
|
55
55
|
* 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
|
|
56
56
|
* (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
|
|
57
57
|
* 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
|
|
58
|
-
* (probe
|
|
58
|
+
* (probe 實證:`[]` 對純 b64 樣本 NON-NULL——
|
|
59
59
|
* 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
|
|
60
60
|
function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
|
|
61
61
|
const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
|
|
@@ -67,7 +67,7 @@ function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
|
|
|
67
67
|
/**
|
|
68
68
|
* 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
|
|
69
69
|
* cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
|
|
70
|
-
* 截到 cipherMax 入庫(
|
|
70
|
+
* 截到 cipherMax 入庫(JSDoc 幽靈參數 plainMax 已摘除)。
|
|
71
71
|
*/
|
|
72
72
|
export function makeInboundCipher(c: CipherFormats) {
|
|
73
73
|
const RE = cipherPrefixRe(c.cipherPrefixes);
|
|
@@ -89,7 +89,7 @@ export function makeInboundCipher(c: CipherFormats) {
|
|
|
89
89
|
|
|
90
90
|
export function isCipherFor(text: string | null | undefined, c: CipherFormats): boolean {
|
|
91
91
|
if (typeof text !== 'string') return false;
|
|
92
|
-
//
|
|
92
|
+
// 空字首組態同面守衛(範圍增補):'' 態 startsWith('') 恆真=
|
|
93
93
|
// 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
|
|
94
94
|
// 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
|
|
95
95
|
// opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
|
|
@@ -133,10 +133,10 @@ export interface AuthStore {
|
|
|
133
133
|
getByUserKey(key: string): Promise<AuthRow | null>;
|
|
134
134
|
getByRecHash(recHash: string): Promise<AuthRow | null>;
|
|
135
135
|
setWrapped(userKey: string, ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
136
|
-
/** v3
|
|
137
|
-
* 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork
|
|
136
|
+
/** v3 重鑰:舊值入 ladder 表+ph2/wrapped/salt/wrappedRec/rec_hash
|
|
137
|
+
* 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork 實作三語句同批寫入)。 */
|
|
138
138
|
rekeyWithLadder?(userKey: string, oldPh2: string, oldKind: 'legacy' | 'v2', ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
139
|
-
/** ladder
|
|
139
|
+
/** ladder 查表:以「舊 ph2 值」(v2/legacy 形)查 ladder 表,
|
|
140
140
|
* 命中回帳戶 id(login route 的「不建幽靈、回舊帳+ph2Kind='legacy' 語意」守衛面);
|
|
141
141
|
* 未配置(optional)=查表面退場(fork 離線/無表態,行為不變)。 */
|
|
142
142
|
ladderLookup?(oldPh2: string): Promise<string | null>;
|
package/src/server/cors.ts
CHANGED
|
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
|
|
5
5
|
'Access-Control-Allow-Origin': '*',
|
|
6
6
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
7
7
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
8
|
-
// preflight
|
|
8
|
+
// preflight 快取(缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
|
|
9
9
|
// 86400 落業界 600-86400 慣例上緣;瀏覽器端自行 clamp(Chromium 2h/WebKit 10m、Firefox
|
|
10
10
|
// 照走)——快取命中不影響伺服器語意;消費端 inline OPTIONS 分流照 corsResponse 放行。
|
|
11
11
|
'Access-Control-Max-Age': '86400',
|
package/src/server/ladder.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ladder.ts — 密語正規化 v3 世代 ladder 表(
|
|
2
|
+
* ladder.ts — 密語正規化 v3 世代 ladder 表(server 內部表)。
|
|
3
3
|
*
|
|
4
|
-
* (a
|
|
4
|
+
* (a+)方案(設計裁定):遷移時舊 ph2 值入 server 內部表(ph2_ladder),
|
|
5
5
|
* users 表零 schema 變更、ph2 欄 UNIQUE 恆動──client 舊 bundle 的單欄 v2-first 查表
|
|
6
6
|
* 在遷移後仍對既有帳戶命中(查 users.ph2 現值=v2 形),行為不變。
|
|
7
7
|
*
|
|
@@ -28,7 +28,7 @@ import type { Env } from './env.ts';
|
|
|
28
28
|
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
29
29
|
export const PH2_LADDER_TABLE = 'ph2_ladder';
|
|
30
30
|
|
|
31
|
-
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2
|
|
31
|
+
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,遷移的正常前身)。
|
|
32
32
|
* 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
|
|
33
33
|
* 純粹是「這個舊值當年是什麼形」的語意記錄。 */
|
|
34
34
|
export type Ph2LadderKind = 'legacy' | 'v2';
|
|
@@ -56,9 +56,9 @@ export function makePh2LadderStore(env: Env): {
|
|
|
56
56
|
return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
|
|
57
57
|
},
|
|
58
58
|
async insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void> {
|
|
59
|
-
// upsert ON CONFLICT(ph2)
|
|
59
|
+
// upsert ON CONFLICT(ph2)(值主權威,PK 定形):同一舊值恰一列,最新持有者勝——
|
|
60
60
|
// A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
|
|
61
|
-
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork
|
|
61
|
+
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 寫入面另備 ladderDelete 給
|
|
62
62
|
// delete-account 清場面。
|
|
63
63
|
// fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
|
|
64
64
|
//(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
|