@tacet-ink/journal-core 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -14
- package/package.json +7 -4
- package/scripts/verify-core-crypto.ts +1437 -10
- package/src/client/argon2.ts +201 -40
- package/src/client/note-crypto.ts +175 -41
- package/src/client/pinlock.ts +13 -8
- package/src/index.ts +18 -1
- package/src/server/auth.ts +58 -19
- package/src/server/cors.ts +4 -0
- package/src/server/ladder.ts +71 -0
- package/src/server/ratelimit.ts +50 -34
|
@@ -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 後拼接)。 */
|
|
@@ -52,12 +56,25 @@ export const PIN_PBKDF2_ITERATIONS = 2_000_000; // 雙因子 pin 段(設計 v2
|
|
|
52
56
|
export const IV_LEN = 12;
|
|
53
57
|
|
|
54
58
|
const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
|
|
59
|
+
/**
|
|
60
|
+
* hex 合法性(fail-closed 目標 #2):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
|
|
61
|
+
* 每雙位元組真 hex。
|
|
62
|
+
*/
|
|
63
|
+
const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
|
|
55
64
|
|
|
56
65
|
// ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
|
|
57
66
|
|
|
58
67
|
export function b64(bytes: Uint8Array): string {
|
|
68
|
+
// 分塊 String.fromCharCode.apply(t_7361b68c 目標 2:review 實測瀏覽器 4MiB 222ms→27ms;
|
|
69
|
+
// node 端分塊形 132-181ms→18ms,與閘執行帳同源——review 的 Node-only toBase64 1ms 帳是
|
|
70
|
+
// 不同形,非本體帳:round 2 MINOR-2 對齊)。瀏覽器端恆走本形——core 是 isomorphic TS
|
|
71
|
+
// 源碼發行,不引入 Node-only
|
|
72
|
+
// toBase64 分支(卡面否決案)。apply 參數上限安全窗 8192;輸出 byte 等價
|
|
73
|
+
// (驗證閘 [14] 有 0/1/7/8191/8192/8193/65539 邊界逐位元組對照+unb64 roundtrip)。
|
|
59
74
|
let binary = '';
|
|
60
|
-
for (let i = 0; i < bytes.byteLength; i
|
|
75
|
+
for (let i = 0; i < bytes.byteLength; i += 8192) {
|
|
76
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192) as unknown as number[]);
|
|
77
|
+
}
|
|
61
78
|
return btoa(binary);
|
|
62
79
|
}
|
|
63
80
|
|
|
@@ -73,7 +90,22 @@ export function toHex(bytes: Uint8Array): string {
|
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
export function hexToBytes(hex: string): Uint8Array {
|
|
76
|
-
|
|
93
|
+
// fail-closed(目標 #2):非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
|
|
94
|
+
// 拋 ERR_BAD_HEX(呼叫端 unwrap 全部 try/catch 回 null;非 catch 端僅 bip39
|
|
95
|
+
// HEX64_RE 前置自守)。奇數長度、非 hex、空字串皆拒;大小寫/全形 NFKC 收容。
|
|
96
|
+
// 測與 parse 同一(正規化後)真相——round 1 審查 MINOR-4:形檢吃正規化串、
|
|
97
|
+
// parseInt 吃原始串=全形 hex digit NaN 歸零殘形(README 例外契約自穿透),收口殲滅。
|
|
98
|
+
// NFKC 寬容=設計面刻意(round 2 NIT 記錄 t_580f9c54):'⑩'→'10' 等相容字元收容後是真 hex
|
|
99
|
+
// 值(⑩→[16] 實帳),非缺陷形(NaN 歸零已殲滅=輸出恆數學等值);rawHex 檢查同式(HEX_RE 同行後置
|
|
100
|
+
// =rawHex 前置守衛);鹽欄同為同行後置(note-crypto/argon2 各 unwrap 面鹽欄形檢——行號帳不寫死,
|
|
101
|
+
// 新增段推移行號=註解行號帳漂移,NIT-a 同病禁再犯)。
|
|
102
|
+
// 行為恆 null(純註解);正式輸入面形檢在場,公開原語只保證「正規化後真 hex 恆等值、垃圾恆拒」。
|
|
103
|
+
// 若要 ASCII-only 收緊=帶內版本化(換前綴),禁原地改語意。
|
|
104
|
+
const norm = hex.normalize('NFKC').toLowerCase();
|
|
105
|
+
if (!HEX_RE.test(norm)) {
|
|
106
|
+
throw new Error('ERR_BAD_HEX');
|
|
107
|
+
}
|
|
108
|
+
return new Uint8Array((norm.match(/.{2}/g) ?? []).map(h => parseInt(h, 16)));
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
async function sha256Hex(text: string): Promise<string> {
|
|
@@ -98,6 +130,8 @@ export async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): P
|
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
133
|
+
// 這裡的 importKey 是 deriveKey 用途集——與 derivePbkdf2Bits 的 deriveBits 面不是同一形,
|
|
134
|
+
// 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;t_7361b68c probe 實證)。
|
|
101
135
|
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveKey']);
|
|
102
136
|
return crypto.subtle.deriveKey(
|
|
103
137
|
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
@@ -108,6 +142,11 @@ async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey>
|
|
|
108
142
|
);
|
|
109
143
|
}
|
|
110
144
|
|
|
145
|
+
/** raw → PBKDF2 import(僅供 deriveBits 用途;與 deriveKek 的 deriveKey 用途分行)。 */
|
|
146
|
+
async function importKeyRawForKdf(password: string): Promise<CryptoKey> {
|
|
147
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveBits']);
|
|
148
|
+
}
|
|
149
|
+
|
|
111
150
|
// ── 雙因子合鑰(jr2w.,PIN 第二因子;2026-09-09 定案 v2) ────────────────────
|
|
112
151
|
//
|
|
113
152
|
// KEK2 = HKDF-SHA256( ikm = PBKDF2(pass, salt1, 600k)[32B] ‖ PBKDF2(pin, pinSaltPrefix‖pinSalt, 2M)[32B],
|
|
@@ -122,15 +161,37 @@ export function normalizePin(pin: string): string {
|
|
|
122
161
|
return pin.normalize('NFKC').trim().toLowerCase();
|
|
123
162
|
}
|
|
124
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
|
+
|
|
125
178
|
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' },
|
|
179
|
+
const km = await importKeyRawForKdf(password);
|
|
180
|
+
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, km, 256);
|
|
128
181
|
return new Uint8Array(bits);
|
|
129
182
|
}
|
|
130
183
|
|
|
131
184
|
async function deriveKek2(cfg: NoteCryptoConfig, passphrase: string, pin: string, salt1: Uint8Array, pinSalt: Uint8Array): Promise<CryptoKey> {
|
|
132
|
-
|
|
133
|
-
|
|
185
|
+
// pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行(t_7361b68c 目標 1)。
|
|
186
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2(jr3d. 對應形 deriveKek2Argon)禁並行——純 wasm Argon2id
|
|
187
|
+
// 共享記憶體池、兩實例並行在部分引擎靜態直 throw,卡面 review 明示;並行只收 PBKDF2 家族。
|
|
188
|
+
// 兩段各自 importKeyRawForKdf(round 2 NIT-2:keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
|
|
189
|
+
// 輸出序=ikm 組裝序=passBits@0 ‖ pinBits@32(與原串行逐位一致);閘錨綁左手側 destructure,
|
|
190
|
+
// 對調滑接=既有 jr2w. 帳戶全不可解(round 2 MAJOR-1 reviewer rev-harm 一手實證)。
|
|
191
|
+
const [passBits, pinBits] = await Promise.all([
|
|
192
|
+
derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS),
|
|
193
|
+
derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS),
|
|
194
|
+
]);
|
|
134
195
|
const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
|
|
135
196
|
ikm.set(passBits, 0);
|
|
136
197
|
ikm.set(pinBits, passBits.byteLength);
|
|
@@ -151,7 +212,7 @@ const DUAL_IV_LEN = 12;
|
|
|
151
212
|
export async function wrapNoteKeyDual(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
152
213
|
if (!cfg.wrapDual || !cfg.pinSaltPrefix) throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
153
214
|
const pinNorm = normalizePin(pin);
|
|
154
|
-
if (!pinNorm) throw new Error('
|
|
215
|
+
if (!pinNorm) throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(t_7710c766 語意分離)
|
|
155
216
|
const salt1 = crypto.getRandomValues(new Uint8Array(16));
|
|
156
217
|
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
157
218
|
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
@@ -175,9 +236,9 @@ export async function unwrapNoteKeyDual(cfg: NoteCryptoConfig, wrapped: string,
|
|
|
175
236
|
if (!cfg.wrapDual || !cfg.pinSaltPrefix) return null;
|
|
176
237
|
if (!wrapped.startsWith(cfg.wrapDual)) return null;
|
|
177
238
|
const salt1 = hexToBytes(salt1Hex);
|
|
178
|
-
if (salt1.length !== 16) return null;
|
|
239
|
+
if (salt1.length !== 16 || !HEX32_RE.test(salt1Hex)) return null;
|
|
179
240
|
const payload = unb64(wrapped.slice(cfg.wrapDual.length));
|
|
180
|
-
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex
|
|
241
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定;rawHex hex 形為本函式自有檢查(鹽內嵌族不經 openNoteKey 本體)
|
|
181
242
|
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
182
243
|
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
183
244
|
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
@@ -185,7 +246,7 @@ export async function unwrapNoteKeyDual(cfg: NoteCryptoConfig, wrapped: string,
|
|
|
185
246
|
if (!pinNorm) return null;
|
|
186
247
|
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
187
248
|
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
188
|
-
if (!rawHex) return null;
|
|
249
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
|
|
189
250
|
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
190
251
|
} catch {
|
|
191
252
|
return null;
|
|
@@ -291,24 +352,64 @@ export function recTokenHash(recToken: string): Promise<string> {
|
|
|
291
352
|
|
|
292
353
|
// ── 金鑰包裹 ────────────────────────────────────────────────────────────────
|
|
293
354
|
|
|
355
|
+
/** noteKey raw hex 長度(hex(noteKey) 64B = payload ct 面 80B 契約的一半)。 */
|
|
356
|
+
const NOTEKEY_HEX_LEN = 64;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 共用包裹核心(目標 #1 收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
|
|
360
|
+
* 全家族單因子包裹(jr1w./jrsw./jr3w./jr3s.)與復原套件(wrappedRec,前綴共用 cfg.wrap)
|
|
361
|
+
* 同一本體;KDF 差異在呼叫端 KEK、payload 形狀(salt 外置 vs 內嵌)由各家族維持。
|
|
362
|
+
* prefix null/undefined=未配置拒絕(呼叫端亦可先行拒絕,帶自家錯誤碼形;雙防線)。
|
|
363
|
+
*/
|
|
364
|
+
export async function sealNoteKey(prefix: string | null | undefined, noteKey: CryptoKey, kek: CryptoKey, aad: string): Promise<string> {
|
|
365
|
+
if (!prefix) throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
366
|
+
const raw = await crypto.subtle.exportKey('raw', noteKey);
|
|
367
|
+
if (raw.byteLength !== 32) throw new Error('ERR_WRAPTAMPER_LEN32');
|
|
368
|
+
return prefix + await encryptWithKey(kek, toHex(new Uint8Array(raw)), aad);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 共用解包核心(目標 #1 收口):任何不符——前綴、base64 形、payload 長度、
|
|
373
|
+
* GCM(金鑰/AAD/密文損壞)、rawHex 長度/hex 形——恆回 null 不拋。
|
|
374
|
+
* round 1 審查 MINOR-5:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
|
|
375
|
+
* 本體吞收 null(公開原語誠實契約;呼叫端 unwrap 家族 try/catch 是雙防線非依賴面)。
|
|
376
|
+
* 家族嚴格度收口實況(round 1 審查 MINOR-2 校正+r2 MINOR-1 殘餘校正 t_580f9c54):
|
|
377
|
+
* 鹽外置 6 點由本體嚴格 92B 終結——note-crypto 四點(unwrapNoteKey=jr1w/
|
|
378
|
+
* unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken=jr1wRec 同本體/loadLocalWrap)
|
|
379
|
+
* +argon2 兩點(unwrapNoteKey3=jr3w/unwrapNoteKeyShare3=jr3s;own 92B 檢已隨收口摘除);
|
|
380
|
+
* 鹽內嵌 3 點(jr2w/jr3d/jr1p)own 108B+rawHex 形檢——與本體同嚴格度、刻意不經本體
|
|
381
|
+
* (pinSalt 前綴不在鹽外置形契約內)。維護本體時鹽內嵌三族不隨行。
|
|
382
|
+
*/
|
|
383
|
+
export async function openNoteKey(wrapped: string, kek: CryptoKey, aad: string, prefix: string): Promise<CryptoKey | null> {
|
|
384
|
+
if (!wrapped.startsWith(prefix)) return null;
|
|
385
|
+
let payload: Uint8Array;
|
|
386
|
+
try {
|
|
387
|
+
payload = unb64(wrapped.slice(prefix.length)); // atob 非法字元拋點吞收 null(公開原語全 null 契約)
|
|
388
|
+
} catch {
|
|
389
|
+
return null; // 壞 base64=任何不符面之一(行為閘有壞 b64 向量承載)
|
|
390
|
+
}
|
|
391
|
+
if (payload.length !== IV_LEN + 80) return null; // 嚴格 92B:iv 12+ct 80(hex64 字串編碼 64B+tag 16)
|
|
392
|
+
const rawHex = await decryptWithKey(kek, payload, aad);
|
|
393
|
+
if (!rawHex || rawHex.length !== NOTEKEY_HEX_LEN || !HEX_RE.test(rawHex)) return null;
|
|
394
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹/解密(鐵律)
|
|
395
|
+
}
|
|
396
|
+
|
|
294
397
|
/** passphrase 包裹:salt 隨機 16 bytes(hex 存於 users.salt)。 */
|
|
295
398
|
export async function wrapNoteKey(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
296
399
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
400
|
+
if (!cfg.wrap) throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
297
401
|
const kek = await deriveKek(passphrase, salt);
|
|
298
|
-
const
|
|
299
|
-
const wrapped = cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
402
|
+
const wrapped = await sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
300
403
|
return { wrapped, salt: toHex(salt) };
|
|
301
404
|
}
|
|
302
405
|
|
|
303
406
|
export async function unwrapNoteKey(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
304
407
|
try {
|
|
305
|
-
if (!
|
|
408
|
+
if (!cfg.wrap) return null;
|
|
306
409
|
const salt = hexToBytes(saltHex);
|
|
307
410
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
308
411
|
const kek = await deriveKek(passphrase, salt);
|
|
309
|
-
|
|
310
|
-
if (!rawHex) return null;
|
|
311
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹
|
|
412
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
|
|
312
413
|
} catch {
|
|
313
414
|
return null;
|
|
314
415
|
}
|
|
@@ -326,8 +427,7 @@ export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey
|
|
|
326
427
|
if (!cfg.wrapShare) throw new Error('ERR_SHARE_NOT_CONFIGURED');
|
|
327
428
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
328
429
|
const kek = await deriveKek(sharePass, salt);
|
|
329
|
-
const
|
|
330
|
-
const wrapped = cfg.wrapShare + await encryptWithKey(kek, toHex(payload), 'notekey-share');
|
|
430
|
+
const wrapped = await sealNoteKey(cfg.wrapShare, noteKey, kek, 'notekey-share');
|
|
331
431
|
return { wrapped, salt: toHex(salt) };
|
|
332
432
|
}
|
|
333
433
|
|
|
@@ -335,13 +435,10 @@ export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey
|
|
|
335
435
|
export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
336
436
|
try {
|
|
337
437
|
if (!cfg.wrapShare) return null;
|
|
338
|
-
if (!wrapped.startsWith(cfg.wrapShare)) return null;
|
|
339
438
|
const salt = hexToBytes(saltHex);
|
|
340
439
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
341
440
|
const kek = await deriveKek(sharePass, salt);
|
|
342
|
-
|
|
343
|
-
if (!rawHex) return null;
|
|
344
|
-
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
|
|
441
|
+
return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare);
|
|
345
442
|
} catch {
|
|
346
443
|
return null;
|
|
347
444
|
}
|
|
@@ -354,23 +451,27 @@ async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string, identity: s
|
|
|
354
451
|
|
|
355
452
|
export async function wrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, noteKey: CryptoKey, recToken: string, identity: string): Promise<string> {
|
|
356
453
|
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
357
|
-
|
|
358
|
-
return cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
454
|
+
return sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
359
455
|
}
|
|
360
456
|
|
|
361
457
|
export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped: string, recToken: string, identity: string): Promise<CryptoKey | null> {
|
|
362
458
|
try {
|
|
363
|
-
if (!
|
|
459
|
+
if (!cfg.wrap) return null;
|
|
364
460
|
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
365
|
-
|
|
366
|
-
if (!rawHex) return null;
|
|
367
|
-
return importAesGcm(hexToBytes(rawHex), true); // 復原後要能重新包裹
|
|
461
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
|
|
368
462
|
} catch {
|
|
369
463
|
return null;
|
|
370
464
|
}
|
|
371
465
|
}
|
|
372
466
|
|
|
373
|
-
/**
|
|
467
|
+
/**
|
|
468
|
+
* 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。
|
|
469
|
+
* 兩段 600k PBKDF2 相互獨立(pass 鹽隨機、rec 鹽=recSaltPrefix‖identity 定值)=並行
|
|
470
|
+
*(t_7361b68c 目標 1:node 實測 133-152ms→68-84ms,與閘執行帳同源——round 2 MINOR-2 對齊)。
|
|
471
|
+
* AES-GCM derive 佔 600k 迴圈 99%,兩段 wrapped 各自隨機 iv(pass 段另隨機鹽 16B;rec 腿
|
|
472
|
+
* KEK 鹽定值、回傳 salt 來自 pass 腿 w1——round 2 MINOR-2 措辭修正)——逐位等值帳由
|
|
473
|
+
* 「兩段各自與 wrapNoteKey 直呼相等」承載。
|
|
474
|
+
*/
|
|
374
475
|
export async function buildBindPayload(
|
|
375
476
|
cfg: NoteCryptoConfig,
|
|
376
477
|
noteKey: CryptoKey,
|
|
@@ -378,11 +479,13 @@ export async function buildBindPayload(
|
|
|
378
479
|
recToken: string,
|
|
379
480
|
identity: string,
|
|
380
481
|
): Promise<{ wrapped: string; salt: string; wrappedRec: string; recTokenHash: string }> {
|
|
381
|
-
const
|
|
382
|
-
|
|
482
|
+
const [w1, wrappedRec] = await Promise.all([
|
|
483
|
+
wrapNoteKey(cfg, noteKey, passphrase),
|
|
484
|
+
wrapNoteKeyWithRecToken(cfg, noteKey, recToken, identity),
|
|
485
|
+
]);
|
|
383
486
|
return {
|
|
384
|
-
wrapped,
|
|
385
|
-
salt,
|
|
487
|
+
wrapped: w1.wrapped,
|
|
488
|
+
salt: w1.salt,
|
|
386
489
|
wrappedRec,
|
|
387
490
|
recTokenHash: await recTokenHash(recToken),
|
|
388
491
|
};
|
|
@@ -390,22 +493,45 @@ export async function buildBindPayload(
|
|
|
390
493
|
|
|
391
494
|
// ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
|
|
392
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
|
+
*/
|
|
393
506
|
export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, noteKey: CryptoKey): Promise<void> {
|
|
394
507
|
try {
|
|
508
|
+
// 寫入恆專用前綴 cfg.wrapLocal(jr1l.;未配置=無寫入退場,讀取面照走舊形回落——
|
|
509
|
+
// 行為不變態)。單一真相:loadLocalWrap 自癒腿經本函式回寫(前綴契約不二寫)。
|
|
510
|
+
if (!cfg.wrapLocal) return;
|
|
395
511
|
const guest = await deriveGuestKey(cfg, identity);
|
|
396
|
-
const
|
|
397
|
-
cfg.store.set(cfg.store.noteKeyWrap(identity),
|
|
512
|
+
const wrapped = await sealNoteKey(cfg.wrapLocal, noteKey, guest, 'notekey-local');
|
|
513
|
+
cfg.store.set(cfg.store.noteKeyWrap(identity), wrapped);
|
|
398
514
|
} catch { /* private mode:不阻擋主流程 */ }
|
|
399
515
|
}
|
|
400
516
|
|
|
401
517
|
async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey | null> {
|
|
402
518
|
try {
|
|
403
519
|
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
404
|
-
if (!stored
|
|
520
|
+
if (!stored) return null;
|
|
405
521
|
const guest = await deriveGuestKey(cfg, identity);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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;
|
|
409
535
|
} catch {
|
|
410
536
|
return null;
|
|
411
537
|
}
|
|
@@ -450,7 +576,11 @@ export async function encryptNote(
|
|
|
450
576
|
return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
|
|
451
577
|
}
|
|
452
578
|
if (!cfg.cipherGuest) throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
|
|
453
|
-
|
|
579
|
+
// 空 identity 拒絕(t_7710c766 目標 3):K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
|
|
580
|
+
// 靜默產出「空帳號金鑰」=可解同樣空 identity 的任何 guest 密文(對稱面誤配)。fail-closed。
|
|
581
|
+
const identity = idp.current();
|
|
582
|
+
if (!identity) throw new Error('ERR_NO_IDENTITY');
|
|
583
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
454
584
|
return cfg.cipherGuest + await encryptWithKey(guest, plaintext, aad);
|
|
455
585
|
}
|
|
456
586
|
|
|
@@ -476,7 +606,11 @@ export async function decryptNote(
|
|
|
476
606
|
return guestPrefix === undefined ? null : cipher;
|
|
477
607
|
}
|
|
478
608
|
if (isGuest) {
|
|
479
|
-
|
|
609
|
+
// 空 identity 拒絕(t_7710c766 目標 3,對稱面):guest 密文在 identity 缺席時必 null
|
|
610
|
+
//(K_u 仍可派生但「空帳號金鑰」不是該密文作者的 intent;誤配下兩態皆 fail-closed)。
|
|
611
|
+
const identity = idp.current();
|
|
612
|
+
if (!identity) return null;
|
|
613
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
480
614
|
return await decryptWithKey(guest, unb64(cipher.slice(guestPrefix!.length)), aad);
|
|
481
615
|
}
|
|
482
616
|
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,8 +19,11 @@ export {
|
|
|
19
19
|
wrapNoteKeyDual,
|
|
20
20
|
unwrapNoteKeyDual,
|
|
21
21
|
storeLocalWrap,
|
|
22
|
+
clearLocalWrap,
|
|
23
|
+
buildBindPayload,
|
|
22
24
|
ph1Of,
|
|
23
25
|
normalizePin,
|
|
26
|
+
normalizePassphrase,
|
|
24
27
|
recTokenHash,
|
|
25
28
|
generateRecToken,
|
|
26
29
|
PBKDF2_ITERATIONS,
|
|
@@ -36,9 +39,16 @@ export {
|
|
|
36
39
|
unwrapNoteKey3,
|
|
37
40
|
wrapNoteKeyDual3,
|
|
38
41
|
unwrapNoteKeyDual3,
|
|
42
|
+
wrapNoteKey4,
|
|
43
|
+
unwrapNoteKey4,
|
|
44
|
+
wrapNoteKeyDual4,
|
|
45
|
+
unwrapNoteKeyDual4,
|
|
39
46
|
wrapNoteKeyShare3,
|
|
40
47
|
unwrapNoteKeyShare3,
|
|
41
48
|
derivePh1Argon,
|
|
49
|
+
derivePh1ArgonV3,
|
|
50
|
+
PH1_V2_SALT,
|
|
51
|
+
PH1_V3_SALT,
|
|
42
52
|
ARGON_MEMORY_KIB,
|
|
43
53
|
ARGON_ITERATIONS,
|
|
44
54
|
ARGON_PARALLELISM,
|
|
@@ -84,4 +94,11 @@ export {
|
|
|
84
94
|
generateSessionToken,
|
|
85
95
|
timingSafeEq,
|
|
86
96
|
} from './server/hash.ts';
|
|
87
|
-
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
|
@@ -1,33 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* auth.ts — 零知識伺服器端 auth 核心邏輯(品牌/schema 參數化)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 本檔實作的契約:
|
|
5
5
|
* - pass 明文永不過線:線上憑證送 PH1(雜湊形),伺服器存 PH2 = SHA-256(PH1)。
|
|
6
6
|
* PH1 派生方式由各產品自定義(快雜湊或 Argon2id 派生皆可)——本層只驗
|
|
7
7
|
* hex64 形,不驗 PH1 派生方式(伺服器零知識,無從也無需區分形別)。
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
8
|
+
* - 入庫前密文/包裹格式驗證(inboundCipher/isCipherFor/validWrappedKey/
|
|
9
|
+
* pickKeyPackage 等,前綴由 config 注入)。
|
|
10
|
+
* - /auth/login 核心動線(loginRouteCore):rate-limit 閘 → PH1 驗形 →
|
|
11
|
+
* PH2 → store 查詢/建號 → session token 簽發(30 天)。
|
|
12
|
+
*
|
|
13
|
+
* 呼叫端(fork)職責——本檔只定義介面,機制在呼叫端實作:
|
|
14
|
+
* - identity 生成:AuthStore.createUser 由各 fork 自決(隨機 account_id 或
|
|
15
|
+
* 客戶端帶入語意皆可);PH2 UNIQUE 衝突(兩個同時首登同密語)是 store
|
|
16
|
+
* 責任——tacet 參考實作:UNIQUE 失敗重查既有列回 userKey(非 5xx)。
|
|
17
|
+
* - hash-ladder 雙軌(舊式 PH1 直比 → 升級寫回 PH2)與密語重設後的 session
|
|
18
|
+
* 撤銷時機:fork 呼叫端接線(revokeAllSessions 介面已備)。
|
|
19
|
+
* - 常數時間比較 timingSafeEq:./hash.ts 匯出(login 動線經 PH2 查詢不直比)。
|
|
12
20
|
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
21
|
+
* 不變量(勿破壞):
|
|
22
|
+
* - 金鑰包裹欄組(wrapped+salt)缺一整組放棄——pair 檢查本體在 pickKeyPackage。
|
|
23
|
+
* - recPkg 與 recHash 成對出現=呼叫端(fork store)職責:本層介面兩參各自可空、
|
|
24
|
+
* 不做 pair 檢查(r2 MINOR-4 照此收口;pickKeyPackage 是同職責的成對檢查先例)。
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
import { corsResponse } from './cors.ts';
|
|
19
28
|
import { sha256Hex, generateSessionToken } from './hash.ts';
|
|
20
29
|
import { checkRate, type RateWindow } from './ratelimit.ts';
|
|
21
|
-
import type { Env } from './env';
|
|
30
|
+
import type { Env } from './env.ts';
|
|
22
31
|
|
|
23
32
|
// ── 格式驗證(自 noteCrypt.ts 抽出;前綴由 config 注入) ─────────────────────
|
|
24
33
|
|
|
25
34
|
export interface CipherFormats {
|
|
26
|
-
/**
|
|
35
|
+
/** 密文前綴家族字面(startsWith 語意,非 regex 來源),如 'jr1b.'。 */
|
|
27
36
|
cipherPrefixes: [string, string];
|
|
28
37
|
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
29
38
|
wrapPrefix: string;
|
|
30
|
-
/**
|
|
39
|
+
/** 密文上限:UTF-16 code unit 計數(.length 語意;非 UTF-8 bytes)。 */
|
|
31
40
|
cipherMax: number;
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -36,17 +45,32 @@ const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
|
|
|
36
45
|
const HASH64_RE = /^[0-9a-f]{64}$/;
|
|
37
46
|
const SALT32_RE = /^[0-9a-f]{32}$/;
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
/** regex 用字面跳脫:全字元掃描(.replace('.',) 只跳第一個點——前綴含第二個 . 即壞)。 */
|
|
49
|
+
function escapeReLiteral(s: string): string {
|
|
50
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
|
|
54
|
+
* never-match 守衛(t_580f9c54 N-7,opt-in 鐵律面;run 574 r2 實測校正):空字首組態會
|
|
55
|
+
* 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
|
|
56
|
+
* (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
|
|
57
|
+
* 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
|
|
58
|
+
* (probe 實證 /tmp/t580f9c54-r2/n7-corrected-probe.mjs:`[]` 對純 b64 樣本 NON-NULL——
|
|
59
|
+
* 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
|
|
60
|
+
function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
|
|
61
|
+
const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
|
|
62
|
+
if (usable.length === 0) return /^(?=a)b/; // never-match(lookahead 錨死矛盾面)——空字串/任意串皆不匹配
|
|
63
|
+
const escaped = usable.map(p => escapeReLiteral(p));
|
|
41
64
|
return new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
/**
|
|
45
68
|
* 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
|
|
46
|
-
*
|
|
69
|
+
* cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
|
|
70
|
+
* 截到 cipherMax 入庫(r2 MINOR-5:JSDoc 幽靈參數 plainMax 摘除)。
|
|
47
71
|
*/
|
|
48
72
|
export function makeInboundCipher(c: CipherFormats) {
|
|
49
|
-
const RE =
|
|
73
|
+
const RE = cipherPrefixRe(c.cipherPrefixes);
|
|
50
74
|
return function inboundCipher(raw: unknown): string | null {
|
|
51
75
|
if (typeof raw !== 'string') return null;
|
|
52
76
|
const text = raw.trim();
|
|
@@ -65,13 +89,17 @@ export function makeInboundCipher(c: CipherFormats) {
|
|
|
65
89
|
|
|
66
90
|
export function isCipherFor(text: string | null | undefined, c: CipherFormats): boolean {
|
|
67
91
|
if (typeof text !== 'string') return false;
|
|
68
|
-
|
|
92
|
+
// 空字首組態同面守衛(t_580f9c54 N-7 範圍增補,run 574 r2):'' 態 startsWith('') 恆真=
|
|
93
|
+
// 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
|
|
94
|
+
// 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
|
|
95
|
+
// opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
|
|
96
|
+
// 「空集 if 檢+保留 startsWith(tuple) 原式」兩行形對混合形不設防(空槽仍經 startsWith('') 放行)。
|
|
97
|
+
return c.cipherPrefixes.some(p => typeof p === 'string' && p.length > 0 && text.startsWith(p));
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
export function validWrappedKey(v: unknown, wrapPrefix: string | string[]): string | null {
|
|
72
101
|
const prefixes = Array.isArray(wrapPrefix) ? wrapPrefix : [wrapPrefix];
|
|
73
|
-
const
|
|
74
|
-
const re = new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
102
|
+
const re = cipherPrefixRe(prefixes);
|
|
75
103
|
return typeof v === 'string' && v.length <= 200 && re.test(v) ? v : null;
|
|
76
104
|
}
|
|
77
105
|
|
|
@@ -100,11 +128,22 @@ export interface KeyPackage {
|
|
|
100
128
|
export interface AuthStore {
|
|
101
129
|
/** 以 PH2 查身份(PH2 UNIQUE;tacet:隨機 account_id 在此建立)。 */
|
|
102
130
|
findByIdentityQuery(ph2: string): Promise<AuthRow | null>;
|
|
103
|
-
/** 建立(identity
|
|
131
|
+
/** 建立(identity 生成由各 fork 自決——見檔頭「呼叫端職責」;PH2 UNIQUE 衝突亦是 store 責任)。 */
|
|
104
132
|
createUser(ph2: string): Promise<string>;
|
|
105
133
|
getByUserKey(key: string): Promise<AuthRow | null>;
|
|
106
134
|
getByRecHash(recHash: string): Promise<AuthRow | null>;
|
|
107
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>;
|
|
108
147
|
revokeAllSessions(userKey: string): Promise<void>;
|
|
109
148
|
insertSession(tokenHash: string, userKey: string, expiresAt: number): Promise<void>;
|
|
110
149
|
}
|
package/src/server/cors.ts
CHANGED
|
@@ -5,6 +5,10 @@ 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 快取(卡C t_7361b68c:缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
|
|
9
|
+
// 86400 落業界 600-86400 慣例上緣;瀏覽器端自行 clamp(Chromium 2h/WebKit 10m、Firefox
|
|
10
|
+
// 照走)——快取命中不影響伺服器語意;消費端 inline OPTIONS 分流照 corsResponse 放行。
|
|
11
|
+
'Access-Control-Max-Age': '86400',
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
export function corsResponse(res: Response): Response {
|