@tacet-ink/journal-core 0.1.6 → 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 +46 -12
- package/package.json +3 -2
- package/scripts/verify-core-crypto.ts +823 -1
- package/src/client/argon2.ts +170 -4
- package/src/client/note-crypto.ts +46 -4
- package/src/index.ts +15 -1
- package/src/server/auth.ts +11 -0
- package/src/server/ladder.ts +71 -0
package/src/client/argon2.ts
CHANGED
|
@@ -29,11 +29,20 @@
|
|
|
29
29
|
* ⚠️ wrappedRec(復原套件)維持 jr1w.:recToken 是 256-bit 實體因子,KDF 強度無意義,不動。
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { normalizePin } from './note-crypto.ts';
|
|
32
|
+
import { normalizePin, normalizePassphrase } from './note-crypto.ts';
|
|
33
33
|
import {
|
|
34
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';
|
|
@@ -66,6 +75,35 @@ export async function derivePh1Argon(passphrase: string, saltArg?: Uint8Array):
|
|
|
66
75
|
));
|
|
67
76
|
}
|
|
68
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),
|
|
101
|
+
ARGON_MEMORY_KIB,
|
|
102
|
+
ARGON_ITERATIONS,
|
|
103
|
+
ARGON_PARALLELISM,
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
|
|
69
107
|
/** Argon2id 參數(jr3w./jr3d. pass 段;前綴即版本,參數寫死本模組)。 */
|
|
70
108
|
export const ARGON_MEMORY_KIB = 65536; // 64 MiB(手機實測基準;瀏覽器 wasm ~180ms)
|
|
71
109
|
export const ARGON_ITERATIONS = 3; // t
|
|
@@ -91,6 +129,10 @@ export interface Argon3Config {
|
|
|
91
129
|
pinSalt3Prefix?: string;
|
|
92
130
|
/** 分享包裹前綴(jr3s.,單篇分享連結 Argon2id 版)。未配置 = 分享 API 拒絕(兄弟 fork 行為不變)。 */
|
|
93
131
|
wrapShare3?: string;
|
|
132
|
+
/** 密語正規化 v3 世代單因子包裹前綴(jr4w.,KEK=Argon2id(normalizePassphrase(pass)))。未配置 = 拒絕。 */
|
|
133
|
+
wrap4?: string;
|
|
134
|
+
/** 密語正規化 v3 世代雙因子包裹前綴(jr4d.,pass 段正規化+PIN 段照舊)。未配置 = 拒絕。 */
|
|
135
|
+
wrapDual4?: string;
|
|
94
136
|
}
|
|
95
137
|
|
|
96
138
|
interface HashWasmArgon2id {
|
|
@@ -217,6 +259,40 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
|
|
|
217
259
|
}
|
|
218
260
|
}
|
|
219
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);
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
220
296
|
// ── jr3d. 雙因子合鑰(PIN 第二因子,jr2w. 的 Argon2id 版) ─────────────────────
|
|
221
297
|
//
|
|
222
298
|
// KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, prefix‖hex(pinSalt))[32],
|
|
@@ -224,6 +300,9 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
|
|
|
224
300
|
// info 字串與 jr2w. 家族同名(journal-kek2-v1):HKDF 合成步驟同構,兩家族 KEK2 值
|
|
225
301
|
// 因 KDF bits 不同而天然互斥(驗證閘有跨家族隔離斷言)。
|
|
226
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;域即世代空間)。
|
|
227
306
|
|
|
228
307
|
async function deriveKek2Argon(
|
|
229
308
|
cfg: Argon3Config,
|
|
@@ -231,6 +310,7 @@ async function deriveKek2Argon(
|
|
|
231
310
|
pinNorm: string,
|
|
232
311
|
salt1: Uint8Array,
|
|
233
312
|
pinSalt: Uint8Array,
|
|
313
|
+
hkdfInfo: string,
|
|
234
314
|
): Promise<CryptoKey> {
|
|
235
315
|
if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
236
316
|
// ⚠️ 瀏覽器 hash-wasm Argon2 禁並行(t_7361b68c):純 wasm Argon2id 共享記憶體池,
|
|
@@ -249,7 +329,7 @@ async function deriveKek2Argon(
|
|
|
249
329
|
ikm.set(pinBits, passBits.byteLength);
|
|
250
330
|
const hkdfBase = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveKey']);
|
|
251
331
|
return crypto.subtle.deriveKey(
|
|
252
|
-
{ 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 },
|
|
253
333
|
hkdfBase,
|
|
254
334
|
{ name: 'AES-GCM', length: 256 },
|
|
255
335
|
false,
|
|
@@ -264,7 +344,7 @@ export async function wrapNoteKeyDual3(cfg: Argon3Config, noteKey: CryptoKey, pa
|
|
|
264
344
|
if (!pinNorm) throw new Error('ERR_PIN_EMPTY');
|
|
265
345
|
const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
266
346
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
267
|
-
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
347
|
+
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
|
|
268
348
|
const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
|
|
269
349
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
270
350
|
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
|
|
@@ -301,7 +381,70 @@ export async function unwrapNoteKeyDual3(
|
|
|
301
381
|
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
302
382
|
const pinNorm = normalizePin(pin);
|
|
303
383
|
if (!pinNorm) return null;
|
|
304
|
-
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
384
|
+
const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt, 'journal-kek2-v1:' + cfg.wrapDual3);
|
|
385
|
+
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
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);
|
|
305
448
|
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
306
449
|
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
307
450
|
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
@@ -342,4 +485,27 @@ export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sh
|
|
|
342
485
|
} catch {
|
|
343
486
|
return null;
|
|
344
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);
|
|
345
511
|
}
|
|
@@ -33,6 +33,10 @@ export interface NoteCryptoConfig {
|
|
|
33
33
|
cipherBound: string;
|
|
34
34
|
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
35
35
|
wrap: string;
|
|
36
|
+
/** 本機包裹前綴(jr1l.,v0.2.0 批卡③:本機包裹不再借用 cfg.wrap——「一個前綴一份契約」
|
|
37
|
+
* 的第二違例收口)。未配置 = storeLocalWrap/loadLocalWrap 退場(無寫入/讀取照走
|
|
38
|
+
* 舊形回落,行為不變——cipherLocal opt-in 母型)。 */
|
|
39
|
+
wrapLocal?: string;
|
|
36
40
|
/** 雙因子合鑰包裹前綴(jr2w.,PIN 第二因子)。未配置 = dual API 拒絕(未配置行為不變)。 */
|
|
37
41
|
wrapDual?: string;
|
|
38
42
|
/** 雙因子 pin 段 PBKDF2 salt 前綴,如 'journal-note-pin1:'(pinSalt 內嵌 payload 後拼接)。 */
|
|
@@ -157,6 +161,20 @@ export function normalizePin(pin: string): string {
|
|
|
157
161
|
return pin.normalize('NFKC').trim().toLowerCase();
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
/**
|
|
165
|
+
* 密語正規化契約 v3(v0.2.0 批卡①;卡D 裁定 NFKC-only):NFKC——**大小寫摺疊禁絕:區分大小寫
|
|
166
|
+
* (大小寫差=不同 KEK/ph2)+首尾空白保留(不 trim)**。
|
|
167
|
+
* 消費面只有 v3 世代:jr4w./jr4d. 包裹家族與 PH1 v3(derivePh1ArgonV3)——
|
|
168
|
+
* 舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw pass 派生,帶內版本化=
|
|
169
|
+
* KEK 輸入契約改變=換新前綴,禁原地改語意),禁把本函式接進舊家族入口。
|
|
170
|
+
* 與 normalizePin 契約並存不混(NFKC→trim→lowercase,pinlock/dual 面不動):
|
|
171
|
+
* 兩函式是正交契約,閘有「同輸入不同輸出」行為向量承載分離。
|
|
172
|
+
* 三面單一真相:本體(此處)+README 家族表+驗證閘 [15] 行為向量——改 NFKC 語意=帶內版本化(換世代),禁原地改。
|
|
173
|
+
*/
|
|
174
|
+
export function normalizePassphrase(passphrase: string): string {
|
|
175
|
+
return passphrase.normalize('NFKC');
|
|
176
|
+
}
|
|
177
|
+
|
|
160
178
|
async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
|
161
179
|
const km = await importKeyRawForKdf(password);
|
|
162
180
|
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, km, 256);
|
|
@@ -475,11 +493,23 @@ export async function buildBindPayload(
|
|
|
475
493
|
|
|
476
494
|
// ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
|
|
477
495
|
|
|
496
|
+
/**
|
|
497
|
+
* 本機包裹(v0.2.0 批卡③):KEK = deriveGuestKey(identity 派生、passphrase-free——
|
|
498
|
+
* session 期免重打密語的機制原樣),payload 改走專用前綴 cfg.wrapLocal(jr1l.)。
|
|
499
|
+
* 舊實作借用 cfg.wrap(jr1w. passphrase 包裹前綴)寫本機包裹=「一個前綴一份契約」
|
|
500
|
+
* 的第二違例(wrappedRec 專用前綴歸卡①批面):本機包裹的 KDF 與 caller 不同、
|
|
501
|
+
* 儲存面(localStorage)不同、生命週期不同(clearLocalWrap 隨時摘除)——共前綴讓
|
|
502
|
+
* 「本機字串是否可能誤入 server 包裹欄」對帳失真。新前綴=讀舊寫新自癒:
|
|
503
|
+
* loadLocalWrap 先試 jr1l.(本體嚴格面),命中舊形(jr1w.)回落解密後即重包
|
|
504
|
+
* cfg.wrapLocal 回寫(自癒一次),寫入恆 jr1l.。
|
|
505
|
+
*/
|
|
478
506
|
export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, noteKey: CryptoKey): Promise<void> {
|
|
479
507
|
try {
|
|
480
|
-
|
|
508
|
+
// 寫入恆專用前綴 cfg.wrapLocal(jr1l.;未配置=無寫入退場,讀取面照走舊形回落——
|
|
509
|
+
// 行為不變態)。單一真相:loadLocalWrap 自癒腿經本函式回寫(前綴契約不二寫)。
|
|
510
|
+
if (!cfg.wrapLocal) return;
|
|
481
511
|
const guest = await deriveGuestKey(cfg, identity);
|
|
482
|
-
const wrapped = await sealNoteKey(cfg.
|
|
512
|
+
const wrapped = await sealNoteKey(cfg.wrapLocal, noteKey, guest, 'notekey-local');
|
|
483
513
|
cfg.store.set(cfg.store.noteKeyWrap(identity), wrapped);
|
|
484
514
|
} catch { /* private mode:不阻擋主流程 */ }
|
|
485
515
|
}
|
|
@@ -487,9 +517,21 @@ export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, no
|
|
|
487
517
|
async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey | null> {
|
|
488
518
|
try {
|
|
489
519
|
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
490
|
-
if (!stored
|
|
520
|
+
if (!stored) return null;
|
|
491
521
|
const guest = await deriveGuestKey(cfg, identity);
|
|
492
|
-
|
|
522
|
+
// 次序契約:新形必先於舊形試——新形 blob 在舊前綴面恆拒(family isolation),次序可觀察。
|
|
523
|
+
const fresh = cfg.wrapLocal
|
|
524
|
+
? await openNoteKey(stored, guest, 'notekey-local', cfg.wrapLocal) // 本體嚴格面(92B+rawHex hex 形)
|
|
525
|
+
: null;
|
|
526
|
+
if (fresh) return fresh;
|
|
527
|
+
const legacy = cfg.wrap
|
|
528
|
+
? await openNoteKey(stored, guest, 'notekey-local', cfg.wrap)
|
|
529
|
+
: null;
|
|
530
|
+
if (legacy) {
|
|
531
|
+
await storeLocalWrap(cfg, identity, legacy);
|
|
532
|
+
return legacy;
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
493
535
|
} catch {
|
|
494
536
|
return null;
|
|
495
537
|
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
buildBindPayload,
|
|
24
24
|
ph1Of,
|
|
25
25
|
normalizePin,
|
|
26
|
+
normalizePassphrase,
|
|
26
27
|
recTokenHash,
|
|
27
28
|
generateRecToken,
|
|
28
29
|
PBKDF2_ITERATIONS,
|
|
@@ -38,10 +39,16 @@ export {
|
|
|
38
39
|
unwrapNoteKey3,
|
|
39
40
|
wrapNoteKeyDual3,
|
|
40
41
|
unwrapNoteKeyDual3,
|
|
42
|
+
wrapNoteKey4,
|
|
43
|
+
unwrapNoteKey4,
|
|
44
|
+
wrapNoteKeyDual4,
|
|
45
|
+
unwrapNoteKeyDual4,
|
|
41
46
|
wrapNoteKeyShare3,
|
|
42
47
|
unwrapNoteKeyShare3,
|
|
43
48
|
derivePh1Argon,
|
|
49
|
+
derivePh1ArgonV3,
|
|
44
50
|
PH1_V2_SALT,
|
|
51
|
+
PH1_V3_SALT,
|
|
45
52
|
ARGON_MEMORY_KIB,
|
|
46
53
|
ARGON_ITERATIONS,
|
|
47
54
|
ARGON_PARALLELISM,
|
|
@@ -87,4 +94,11 @@ export {
|
|
|
87
94
|
generateSessionToken,
|
|
88
95
|
timingSafeEq,
|
|
89
96
|
} from './server/hash.ts';
|
|
90
|
-
export type { Env } from './server/env.ts';
|
|
97
|
+
export type { Env } from './server/env.ts';
|
|
98
|
+
export {
|
|
99
|
+
PH2_LADDER_TABLE,
|
|
100
|
+
PH2_LADDER_KIND_LEGACY,
|
|
101
|
+
PH2_LADDER_KIND_V2,
|
|
102
|
+
type Ph2LadderKind,
|
|
103
|
+
type Ph2LadderRow,
|
|
104
|
+
} from './server/ladder.ts';
|
package/src/server/auth.ts
CHANGED
|
@@ -133,6 +133,17 @@ 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 重鑰(v0.2.0 批卡② t_ce216a63):舊值入 ladder 表+ph2/wrapped/salt/wrappedRec/rec_hash
|
|
137
|
+
* 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork 實作三語句批面)。 */
|
|
138
|
+
rekeyWithLadder?(userKey: string, oldPh2: string, oldKind: 'legacy' | 'v2', ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
139
|
+
/** ladder 查表(v0.2.0 批卡② t_ce216a63):以「舊 ph2 值」(v2/legacy 形)查 ladder 表,
|
|
140
|
+
* 命中回帳戶 id(login route 的「不建幽靈、回舊帳+ph2Kind='legacy' 語意」守衛面);
|
|
141
|
+
* 未配置(optional)=查表面退場(fork 離線/無表態,行為不變)。 */
|
|
142
|
+
ladderLookup?(oldPh2: string): Promise<string | null>;
|
|
143
|
+
/** ladder 入表(遷移線用;upsert冪等)。 */
|
|
144
|
+
ladderInsert?(accountId: string, oldPh2: string, oldKind: 'legacy' | 'v2'): Promise<void>;
|
|
145
|
+
/** ladder 刪列(直銷面;遷移批次同批或後續呼叫端承擔)。 */
|
|
146
|
+
ladderDelete?(accountId: string): Promise<void>;
|
|
136
147
|
revokeAllSessions(userKey: string): Promise<void>;
|
|
137
148
|
insertSession(tokenHash: string, userKey: string, expiresAt: number): Promise<void>;
|
|
138
149
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ladder.ts — 密語正規化 v3 世代 ladder 表(v0.2.0 批卡② t_ce216a63;server 內部表)。
|
|
3
|
+
*
|
|
4
|
+
* (a+)方案(主 session 收案裁定):遷移時舊 ph2 值入 server 內部表(ph2_ladder),
|
|
5
|
+
* users 表零 schema 變更、ph2 欄 UNIQUE 恆動──client 舊 bundle 的單欄 v2-first 查表
|
|
6
|
+
* 在遷移後仍對既有帳戶命中(查 users.ph2 現值=v2 形),行為不變。
|
|
7
|
+
*
|
|
8
|
+
* 語意面:
|
|
9
|
+
* - ladder hit(現值未命中 → ladder 命中)=不建幽靈、回舊帳(fork 語意面 ph2Kind='legacy')。
|
|
10
|
+
* v3 帳戶:鹽與包裹已旋轉成 v3 形;fork 語意「本 login 的舊形憑證 unwrap 必失敗;
|
|
11
|
+
* client 以 ladder 缺鹽面誠實告知升級」對應 v0.2.0 前的單欄舊 bundle(帶 ph1_v3 的
|
|
12
|
+
* 新 client 恰命中 v3 現值、前綴自描述走 v3 腿)。
|
|
13
|
+
* - ladder miss(兩面皆查無)=才建幽靈(fork createUser,v2 先行)。
|
|
14
|
+
* - 幽靈帳戶走完遷移後以同舊值 upsert(ON CONFLICT(ph2)=值主權威覆蓋,最新持有者勝)。
|
|
15
|
+
* - 表列隨帳戶刪除清理(FK ON DELETE CASCADE,fork delete-account 面零新語句);
|
|
16
|
+
* users 列是真值面,ladder 表列可重建(v2 形重新遷移即重落)——表列毀損不傷帳戶面。
|
|
17
|
+
* - 幽靈 GC(wrapped_key IS NULL 判準)不收 ladder 命中面(帳戶列仍在=判準不變)。
|
|
18
|
+
*
|
|
19
|
+
* 零知識照舊:ladder 只存 ph2 hash 與 account_id,零 PII、零密語材料。
|
|
20
|
+
*
|
|
21
|
+
* 職責分工(fork 對位面):表名/migration 由 fork migrations 承載(0011-ph2-ladder.sql,
|
|
22
|
+
* PK=ph2 值主權威形);本層提供「以舊 ph2 查列」與「遷移時入表」原語,查表接線
|
|
23
|
+
* (現值 miss → ladder → 建幽靈)在 fork route(tacet loginRoute,v2-first 裁定)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Env } from './env.ts';
|
|
27
|
+
|
|
28
|
+
/** ladder 表名(migration 同名;單一真相,fork migration 與本原語同字面)。 */
|
|
29
|
+
export const PH2_LADDER_TABLE = 'ph2_ladder';
|
|
30
|
+
|
|
31
|
+
/** ladder 行 kinds:'legacy'(SHA-256 快雜湊世代)與 'v2'(v2 形前身,card② 遷移的正常前身)。
|
|
32
|
+
* 「帳戶現在用什麼形」的真相在 users.ph2 班表行(ph2_ladder 只承載舊值);kind 欄
|
|
33
|
+
* 純粹是「這個舊值當年是什麼形」的語意記錄。 */
|
|
34
|
+
export type Ph2LadderKind = 'legacy' | 'v2';
|
|
35
|
+
export const PH2_LADDER_KIND_LEGACY = 'legacy';
|
|
36
|
+
export const PH2_LADDER_KIND_V2 = 'v2';
|
|
37
|
+
|
|
38
|
+
/** ladder 行形(fork schema 對位面)。 */
|
|
39
|
+
export interface Ph2LadderRow {
|
|
40
|
+
accountId: string;
|
|
41
|
+
ph2Kind: string;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 以「舊 ph2」查 ladder(fork store 對位面)。null = ladder miss。 */
|
|
46
|
+
export function makePh2LadderStore(env: Env): {
|
|
47
|
+
findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null>;
|
|
48
|
+
insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void>;
|
|
49
|
+
} {
|
|
50
|
+
return {
|
|
51
|
+
async findByOldPh2(oldPh2: string): Promise<Ph2LadderRow | null> {
|
|
52
|
+
const row = await env.DB.prepare(
|
|
53
|
+
`SELECT account_id, ph2_kind, upgraded_at FROM ${PH2_LADDER_TABLE} WHERE ph2 = ?`,
|
|
54
|
+
).bind(oldPh2).first<{ account_id: string; ph2_kind: string; upgraded_at: number }>();
|
|
55
|
+
if (!row) return null;
|
|
56
|
+
return { accountId: row.account_id, ph2Kind: row.ph2_kind, createdAt: row.upgraded_at };
|
|
57
|
+
},
|
|
58
|
+
async insert(accountId: string, oldPh2: string, oldKind: Ph2LadderKind): Promise<void> {
|
|
59
|
+
// upsert ON CONFLICT(ph2)(值主權威;run 590 定形):同一舊值恰一列,最新持有者勝——
|
|
60
|
+
// A 遷移落 (V→A) 後 V 值幽靈帳戶後建並遷移=覆蓋為 (V→B)。無 DELETE 語句:行留存
|
|
61
|
+
// 續供幽靈守衛(行隨帳戶刪除由 FK CASCADE 清);fork 批面另備 ladderDelete 給
|
|
62
|
+
// delete-account 清場面。
|
|
63
|
+
// fail-open(遷移率聚合面):插表失敗只讓下一輪 login 重試同一 upsert
|
|
64
|
+
//(v3 查表 miss 再走 ladder miss → 建 v2 幽靈的行為面)──遷移本體不因本表故障回退。
|
|
65
|
+
await env.DB.prepare(
|
|
66
|
+
`INSERT INTO ${PH2_LADDER_TABLE} (ph2, account_id, ph2_kind, upgraded_at) VALUES (?, ?, ?, ?)
|
|
67
|
+
ON CONFLICT(ph2) DO UPDATE SET account_id = excluded.account_id, ph2_kind = excluded.ph2_kind, upgraded_at = excluded.upgraded_at`,
|
|
68
|
+
).bind(oldPh2, accountId, oldKind, Date.now()).run();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|