@tacet-ink/journal-core 0.2.4 → 0.3.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 +37 -15
- package/dist/client/argon2.d.ts +166 -0
- package/dist/client/argon2.js +406 -0
- package/dist/client/bip39.d.ts +11 -0
- package/dist/client/bip39.js +139 -0
- package/dist/client/keys.d.ts +15 -0
- package/dist/client/keys.js +30 -0
- package/dist/client/note-crypto.d.ts +203 -0
- package/dist/client/note-crypto.js +600 -0
- package/dist/client/pinlock.d.ts +38 -0
- package/dist/client/pinlock.js +84 -0
- package/dist/client/wordlist.d.ts +7 -0
- package/dist/client/wordlist.js +264 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/server/auth.d.ts +99 -0
- package/dist/server/auth.js +158 -0
- package/dist/server/cors.d.ts +10 -0
- package/dist/server/cors.js +21 -0
- package/dist/server/env.d.ts +6 -0
- package/dist/server/env.js +1 -0
- package/dist/server/hash.d.ts +11 -0
- package/dist/server/hash.js +26 -0
- package/dist/server/ladder.d.ts +49 -0
- package/dist/server/ladder.js +50 -0
- package/dist/server/ratelimit.d.ts +32 -0
- package/dist/server/ratelimit.js +63 -0
- package/docs/format-spec.md +369 -0
- package/docs/vectors/bip39.json +1063 -0
- package/docs/vectors/families.json +594 -0
- package/docs/vectors/kat.json +145 -0
- package/package.json +60 -16
- package/scripts/verify-core-crypto.ts +303 -73
- package/src/server/auth.ts +3 -2
- package/src/server/ladder.ts +3 -2
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* note-crypto.ts — 自由書寫 e2e 加密,品牌參數化核心。
|
|
3
|
+
*
|
|
4
|
+
* 血統:抽取自姊妹專案的日記加密模組(37 斷言驗證閘母型);
|
|
5
|
+
* fork diff 已證實:除品牌前綴(guest KDF 前綴、密文前綴家族、KDF salt 前綴)
|
|
6
|
+
* 與產品附加機制外,密碼學本體在多個產品間完全一致——本檔承載該本體。
|
|
7
|
+
*
|
|
8
|
+
* 兩時代金鑰模型(不變量,勿破壞):
|
|
9
|
+
* 時代 1(未綁定 guest):K_u = SHA-256(guestKdfPrefix ‖ identity) — 純客戶端派生,混淆級
|
|
10
|
+
* 時代 2(綁定後):隨機 256-bit noteKey;日常加解密只用 noteKey,passphrase 以
|
|
11
|
+
* KEK = PBKDF2(pass, salt, 600000, SHA-256) 包裹成 wrapped 上傳;復原套件 wrappedRec
|
|
12
|
+
* = 同款包裹在 KEK_rec = PBKDF2(recToken, salt = recSaltPrefix ‖ identity) 下。
|
|
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
|
+
* 時點=遷移率裁定非日曆)。
|
|
19
|
+
*
|
|
20
|
+
* 前綴全部可配置(帶內版本化:升級 KDF 參數 = 換新前綴)。AAD 由呼叫端傳入——
|
|
21
|
+
* 例如綁 `noteId`(跨列搬移必解密失敗)、`lifeId:day`(逐日日記)或留言綁 `tst:<lifeId>`。
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ extractable 鐵律:所有要被 exportKey/wrap 的 key,
|
|
24
|
+
* import 當下就必須 extractable=true。noteKey 全部產生路徑(generate/unwrap×3)
|
|
25
|
+
* 一律顯式 true;KEK/guest key 保持 nonextractable(只加解密、永不 export)。
|
|
26
|
+
*/
|
|
27
|
+
export const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 建議值
|
|
28
|
+
export const PIN_PBKDF2_ITERATIONS = 2_000_000; // 雙因子 pin 段(設計 v2:6 位數字也要扛離線爆破)
|
|
29
|
+
export const IV_LEN = 12;
|
|
30
|
+
const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
|
|
31
|
+
/**
|
|
32
|
+
* hex 合法性(fail-closed):大小寫正規化後長度恰 2n(n bytes=2 hex digits×n)+
|
|
33
|
+
* 每雙位元組真 hex。
|
|
34
|
+
*/
|
|
35
|
+
const HEX_RE = /^([0-9a-f][0-9a-f])+$/;
|
|
36
|
+
// ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
|
|
37
|
+
export function b64(bytes) {
|
|
38
|
+
// 分塊 String.fromCharCode.apply(瀏覽器實測 4MiB 222ms→27ms;
|
|
39
|
+
// node 端分塊形 132-181ms→18ms,與閘執行帳同源——Node-only toBase64 的 1ms 帳是
|
|
40
|
+
// 不同形,非本體帳)。瀏覽器端恆走本形——core 是 isomorphic TS
|
|
41
|
+
// 源碼發行,不引入 Node-only
|
|
42
|
+
// toBase64 分支(評估後否決的路線)。apply 參數上限安全窗 8192;輸出 byte 等價
|
|
43
|
+
// (驗證閘 [14] 有 0/1/7/8191/8192/8193/65539 邊界逐位元組對照+unb64 roundtrip)。
|
|
44
|
+
let binary = '';
|
|
45
|
+
for (let i = 0; i < bytes.byteLength; i += 8192) {
|
|
46
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
|
|
47
|
+
}
|
|
48
|
+
return btoa(binary);
|
|
49
|
+
}
|
|
50
|
+
export function unb64(text) {
|
|
51
|
+
const binary = atob(text);
|
|
52
|
+
const bytes = new Uint8Array(binary.length);
|
|
53
|
+
for (let i = 0; i < binary.length; i++)
|
|
54
|
+
bytes[i] = binary.charCodeAt(i);
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
export function toHex(bytes) {
|
|
58
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
59
|
+
}
|
|
60
|
+
export function hexToBytes(hex) {
|
|
61
|
+
// fail-closed:非法字元靜默歸零是 unwrap 家族的假金鑰生產器——
|
|
62
|
+
// 拋 ERR_BAD_HEX(呼叫端 unwrap 全部 try/catch 回 null;非 catch 端僅 bip39
|
|
63
|
+
// HEX64_RE 前置自守)。奇數長度、非 hex、空字串皆拒;大小寫/全形 NFKC 收容。
|
|
64
|
+
// 測與 parse 同一(正規化後)真相——形檢吃正規化串、
|
|
65
|
+
// parseInt 吃原始串=全形 hex digit NaN 歸零殘形(README 例外契約自穿透),收口殲滅。
|
|
66
|
+
// NFKC 寬容=設計面刻意:'⑩'→'10' 等相容字元收容後是真 hex
|
|
67
|
+
// 值(⑩→[16] 實帳),非缺陷形(NaN 歸零已殲滅=輸出恆數學等值);rawHex 檢查同式(HEX_RE 同行後置
|
|
68
|
+
// =rawHex 前置守衛);鹽欄同為同行後置(note-crypto/argon2 各 unwrap 面鹽欄形檢——行號帳不寫死,
|
|
69
|
+
// 新增段推移行號=註解行號帳漂移,同病禁再犯)。
|
|
70
|
+
// 行為恆 null(純註解);正式輸入面形檢在場,公開原語只保證「正規化後真 hex 恆等值、垃圾恆拒」。
|
|
71
|
+
// 若要 ASCII-only 收緊=帶內版本化(換前綴),禁原地改語意。
|
|
72
|
+
const norm = hex.normalize('NFKC').toLowerCase();
|
|
73
|
+
if (!HEX_RE.test(norm)) {
|
|
74
|
+
throw new Error('ERR_BAD_HEX');
|
|
75
|
+
}
|
|
76
|
+
return new Uint8Array((norm.match(/.{2}/g) ?? []).map(h => parseInt(h, 16)));
|
|
77
|
+
}
|
|
78
|
+
async function sha256Hex(text) {
|
|
79
|
+
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
80
|
+
return toHex(new Uint8Array(d));
|
|
81
|
+
}
|
|
82
|
+
/** SHA-256 hex(分享定位鍵雜湊等呼叫端雜湊用;與伺服器端 sha256Hex 同構)。 */
|
|
83
|
+
export function sha256HexExport(text) {
|
|
84
|
+
return sha256Hex(text);
|
|
85
|
+
}
|
|
86
|
+
export async function importAesGcm(raw, extractable = false) {
|
|
87
|
+
// extractable 預設 false(最小權限);唯一需要 true 的是 noteKey。
|
|
88
|
+
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, extractable, ['encrypt', 'decrypt']);
|
|
89
|
+
}
|
|
90
|
+
export async function deriveGuestKey(cfg, identity) {
|
|
91
|
+
const material = new TextEncoder().encode(cfg.guestKdfPrefix + identity);
|
|
92
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', material));
|
|
93
|
+
return importAesGcm(digest);
|
|
94
|
+
}
|
|
95
|
+
async function deriveKek(password, salt) {
|
|
96
|
+
// 這裡的 importKey 是 deriveKey 用途集——與 derivePbkdf2Bits 的 deriveBits 面不是同一形,
|
|
97
|
+
// 禁共用 importKeyRawForKdf(usage 不符=InvalidAccessError;probe 實證)。
|
|
98
|
+
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
|
|
99
|
+
return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, keyMat, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
100
|
+
}
|
|
101
|
+
/** raw → PBKDF2 import(僅供 deriveBits 用途;與 deriveKek 的 deriveKey 用途分行)。 */
|
|
102
|
+
async function importKeyRawForKdf(password) {
|
|
103
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
|
|
104
|
+
}
|
|
105
|
+
// ── 雙因子合鑰(jr2w.,PIN 第二因子;2026-09-09 定案 v2) ────────────────────
|
|
106
|
+
//
|
|
107
|
+
// KEK2 = HKDF-SHA256( ikm = PBKDF2(pass, salt1, 600k)[32B] ‖ PBKDF2(pin, pinSaltPrefix‖pinSalt, 2M)[32B],
|
|
108
|
+
// salt = pinSalt(同 16B,公開非秘密), info = 'journal-kek2-v1:' + wrapDual, L=32 )
|
|
109
|
+
// payload = pinSalt[16B] ‖ iv[12B] ‖ AES-GCM(KEK2, hex(noteKey), aad='notekey2')
|
|
110
|
+
// wrapped2 = 'jr2w.' + b64(payload) —— pinSalt 內嵌自描述:unwrap 不需 identity/salt 參數。
|
|
111
|
+
// 兩段 PBKDF2 bits 不落地(用完即棄);KEK2 import 當下 nonextractable;
|
|
112
|
+
// 輸出 noteKey 一律 extractable=true(鐵律 4)。
|
|
113
|
+
/** PIN 正規化契約:NFKC → trim → lowercase(不分大小寫;正規化後內含空白由強度檢拒絕)。 */
|
|
114
|
+
export function normalizePin(pin) {
|
|
115
|
+
return pin.normalize('NFKC').trim().toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 密語正規化契約 v3(NFKC-only 裁定):NFKC——**大小寫摺疊禁絕:區分大小寫
|
|
119
|
+
* (大小寫差=不同 KEK/ph2)+首尾空白保留(不 trim)**。
|
|
120
|
+
* 消費面只有 v3 世代:jr4w./jr4d. 包裹家族與 PH1 v3(derivePh1ArgonV3)——
|
|
121
|
+
* 舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw pass 派生,帶內版本化=
|
|
122
|
+
* KEK 輸入契約改變=換新前綴,禁原地改語意),禁把本函式接進舊家族入口。
|
|
123
|
+
* 與 normalizePin 契約並存不混(NFKC→trim→lowercase,pinlock/dual 面不動):
|
|
124
|
+
* 兩函式是正交契約,閘有「同輸入不同輸出」行為向量承載分離。
|
|
125
|
+
* 三面單一真相:本體(此處)+README 家族表+驗證閘 [15] 行為向量——改 NFKC 語意=帶內版本化(換世代),禁原地改。
|
|
126
|
+
*/
|
|
127
|
+
export function normalizePassphrase(passphrase) {
|
|
128
|
+
return passphrase.normalize('NFKC');
|
|
129
|
+
}
|
|
130
|
+
async function derivePbkdf2Bits(password, salt, iterations) {
|
|
131
|
+
const km = await importKeyRawForKdf(password);
|
|
132
|
+
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt, iterations, hash: 'SHA-256' }, km, 256);
|
|
133
|
+
return new Uint8Array(bits);
|
|
134
|
+
}
|
|
135
|
+
async function deriveKek2(cfg, passphrase, pin, salt1, pinSalt) {
|
|
136
|
+
// pass 段與 pin 段 PBKDF2 相互獨立(不同 salt/密碼不同鹽源)=可並行。
|
|
137
|
+
// ⚠️ 瀏覽器 hash-wasm Argon2(jr3d. 對應形 deriveKek2Argon)禁並行——純 wasm Argon2id
|
|
138
|
+
// 共享記憶體池、兩實例並行在部分引擎靜態直 throw(設計已明示);並行只收 PBKDF2 家族。
|
|
139
|
+
// 兩段各自 importKeyRawForKdf(keyMat 參數化退場——pass/pin 段絕不共享 PBKDF2 基材)。
|
|
140
|
+
// 輸出序=ikm 組裝序=passBits@0 ‖ pinBits@32(與原串行逐位一致);閘錨綁左手側 destructure,
|
|
141
|
+
// 對調滑接=既有 jr2w. 帳戶全不可解(一手實證)。
|
|
142
|
+
const [passBits, pinBits] = await Promise.all([
|
|
143
|
+
derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS),
|
|
144
|
+
derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS),
|
|
145
|
+
]);
|
|
146
|
+
const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
|
|
147
|
+
ikm.set(passBits, 0);
|
|
148
|
+
ikm.set(pinBits, passBits.byteLength);
|
|
149
|
+
const hkdfBase = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveKey']);
|
|
150
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: pinSalt, info: new TextEncoder().encode('journal-kek2-v1:' + cfg.wrapDual) }, hkdfBase, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
151
|
+
}
|
|
152
|
+
const DUAL_SALT_LEN = 16;
|
|
153
|
+
const DUAL_IV_LEN = 12;
|
|
154
|
+
/** 雙因子包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey));salt1 隨機由呼叫端存 users.salt。 */
|
|
155
|
+
export async function wrapNoteKeyDual(cfg, noteKey, passphrase, pin) {
|
|
156
|
+
if (!cfg.wrapDual || !cfg.pinSaltPrefix)
|
|
157
|
+
throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
158
|
+
const pinNorm = normalizePin(pin);
|
|
159
|
+
if (!pinNorm)
|
|
160
|
+
throw new Error('ERR_PIN_EMPTY'); // 空 PIN ≠ 未配置(語意分離)
|
|
161
|
+
const salt1 = crypto.getRandomValues(new Uint8Array(16));
|
|
162
|
+
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
163
|
+
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
164
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
165
|
+
const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
166
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') }, kek2, new TextEncoder().encode(rawHex)));
|
|
167
|
+
const payload = new Uint8Array(DUAL_SALT_LEN + iv.byteLength + ct.byteLength);
|
|
168
|
+
payload.set(pinSalt, 0);
|
|
169
|
+
payload.set(iv, DUAL_SALT_LEN);
|
|
170
|
+
payload.set(ct, DUAL_SALT_LEN + iv.byteLength);
|
|
171
|
+
return { wrapped: cfg.wrapDual + b64(payload), salt: toHex(salt1) };
|
|
172
|
+
}
|
|
173
|
+
/** 雙因子解包:pinSalt 內嵌自描述,salt1 取自 login 回應(與 jr1w 同形);任何不符回 null,不拋。 */
|
|
174
|
+
export async function unwrapNoteKeyDual(cfg, wrapped, passphrase, pin, salt1Hex) {
|
|
175
|
+
try {
|
|
176
|
+
if (!cfg.wrapDual || !cfg.pinSaltPrefix)
|
|
177
|
+
return null;
|
|
178
|
+
if (!wrapped.startsWith(cfg.wrapDual))
|
|
179
|
+
return null;
|
|
180
|
+
const salt1 = hexToBytes(salt1Hex);
|
|
181
|
+
if (salt1.length !== 16 || !HEX32_RE.test(salt1Hex))
|
|
182
|
+
return null;
|
|
183
|
+
const payload = unb64(wrapped.slice(cfg.wrapDual.length));
|
|
184
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex 字串 64B + GCM tag 16B) = 108B 固定;rawHex hex 形為本函式自有檢查(鹽內嵌族不經 openNoteKey 本體)
|
|
185
|
+
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80)
|
|
186
|
+
return null;
|
|
187
|
+
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
188
|
+
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
189
|
+
const pinNorm = normalizePin(pin);
|
|
190
|
+
if (!pinNorm)
|
|
191
|
+
return null;
|
|
192
|
+
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
193
|
+
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
194
|
+
if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex))
|
|
195
|
+
return null;
|
|
196
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ── 對稱核心:encrypt / decrypt(AAD 由呼叫端指定) ──────────────────────────
|
|
203
|
+
export async function encryptWithKey(key, plaintext, aad) {
|
|
204
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
205
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(aad) }, key, new TextEncoder().encode(plaintext)));
|
|
206
|
+
const out = new Uint8Array(IV_LEN + ct.byteLength);
|
|
207
|
+
out.set(iv, 0);
|
|
208
|
+
out.set(ct, IV_LEN);
|
|
209
|
+
return b64(out);
|
|
210
|
+
}
|
|
211
|
+
export async function decryptWithKey(key, payload, aad) {
|
|
212
|
+
try {
|
|
213
|
+
const iv = payload.slice(0, IV_LEN);
|
|
214
|
+
const ct = payload.slice(IV_LEN);
|
|
215
|
+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv, additionalData: new TextEncoder().encode(aad) }, key, ct);
|
|
216
|
+
return new TextDecoder().decode(plain);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null; // 錯誤金鑰 / AAD 不符 / 密文損壞 → 呼叫端回落降級句
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ── 附件密文(jr1c.,image attachments;金鑰由呼叫端決定) ────────────────────
|
|
223
|
+
//
|
|
224
|
+
// 附件用既有 noteKey(綁定後)或 guest key(Era 0 純本地),零新金鑰管理;
|
|
225
|
+
// AAD 帶 'jr1a:<note_id>:<attachment_id>'(綁篇+綁附件,跨列搬移必失敗)。
|
|
226
|
+
// 前綴 cfg opt-in(未配置即拒):兄弟 fork 不配置=API 拒絕,行為不變。
|
|
227
|
+
export async function encryptAttach(cfg, key, plaintext, aad) {
|
|
228
|
+
if (!cfg.cipherAttach)
|
|
229
|
+
throw new Error('ERR_ATTACH_NOT_CONFIGURED');
|
|
230
|
+
return cfg.cipherAttach + await encryptWithKey(key, plaintext, aad);
|
|
231
|
+
}
|
|
232
|
+
export async function decryptAttach(cfg, key, cipher, aad) {
|
|
233
|
+
try {
|
|
234
|
+
if (!cfg.cipherAttach)
|
|
235
|
+
return null;
|
|
236
|
+
if (!cipher.startsWith(cfg.cipherAttach))
|
|
237
|
+
return null;
|
|
238
|
+
return await decryptWithKey(key, unb64(cipher.slice(cfg.cipherAttach.length)), aad);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ── 本機 IDB 密文(jr1d.,notes store stored 形;金鑰由呼叫端決定) ──────────
|
|
245
|
+
//
|
|
246
|
+
// IDB 持久形密文:bound 態=held noteKey、Era 0=guest key(兩者皆由呼叫端注入;
|
|
247
|
+
// 鑰匙狀態機在呼叫端 store 層,core 只承載前綴與對稱本體)。payload 自帶 {v:1,...}
|
|
248
|
+
// 版本欄(呼叫端 JSON 面),前綴即可辨識;AAD 綁 note_id(呼叫端契約,跨列搬移必失敗)。
|
|
249
|
+
// 前綴 cfg opt-in(未配置即拒):兄弟 fork 不配置=API 拒絕,行為不變。
|
|
250
|
+
export async function encryptLocal(cfg, key, plaintext, aad) {
|
|
251
|
+
if (!cfg.cipherLocal)
|
|
252
|
+
throw new Error('ERR_LOCAL_NOT_CONFIGURED');
|
|
253
|
+
return cfg.cipherLocal + await encryptWithKey(key, plaintext, aad);
|
|
254
|
+
}
|
|
255
|
+
export async function decryptLocal(cfg, key, cipher, aad) {
|
|
256
|
+
try {
|
|
257
|
+
if (!cfg.cipherLocal)
|
|
258
|
+
return null;
|
|
259
|
+
if (!cipher.startsWith(cfg.cipherLocal))
|
|
260
|
+
return null;
|
|
261
|
+
return await decryptWithKey(key, unb64(cipher.slice(cfg.cipherLocal.length)), aad);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// ── noteKey(時代 2)─────────────────────────────────────────────────────────
|
|
268
|
+
/** 綁定當下生成:random 256-bit AES-GCM key。必須 extractable(包裹靠 exportKey)。 */
|
|
269
|
+
export async function generateNoteKey() {
|
|
270
|
+
const raw = crypto.getRandomValues(new Uint8Array(32));
|
|
271
|
+
return importAesGcm(raw, true);
|
|
272
|
+
}
|
|
273
|
+
/** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。 */
|
|
274
|
+
export function ph1Of(passphrase) {
|
|
275
|
+
return sha256Hex(passphrase);
|
|
276
|
+
}
|
|
277
|
+
/** 復原種子:32 bytes hex,只顯示一次;伺服器只收 SHA-256。 */
|
|
278
|
+
export function generateRecToken() {
|
|
279
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
280
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
281
|
+
.join('');
|
|
282
|
+
}
|
|
283
|
+
export function recTokenHash(recToken) {
|
|
284
|
+
return sha256Hex(recToken);
|
|
285
|
+
}
|
|
286
|
+
// ── 金鑰包裹 ────────────────────────────────────────────────────────────────
|
|
287
|
+
/** noteKey raw hex 長度(hex(noteKey) 64B = payload ct 面 80B 契約的一半)。 */
|
|
288
|
+
const NOTEKEY_HEX_LEN = 64;
|
|
289
|
+
/**
|
|
290
|
+
* 共用包裹核心(收口):prefix + b64(iv[12] ‖ GCM(KEK, hex(noteKey), aad))。
|
|
291
|
+
* 鹽外置家族字首家系清點(七呼叫端):jr1w.(wrapNoteKey)/jrsw.
|
|
292
|
+
*(wrapNoteKeyShare)/jr3w.(wrapNoteKey3)/jr4w.(wrapNoteKey4)/jr3s.
|
|
293
|
+
*(wrapNoteKeyShare3)/jr1l.(storeLocalWrap)/jr1r.+jr1w.(wrapNoteKeyWithRecToken——
|
|
294
|
+
* HKDF 世代專用前綴+AAD 'notekey-rec'、未配置世代 legacy 面=cfg.wrap+AAD 'notekey')
|
|
295
|
+
* 同一本體;KDF 差異在呼叫端 KEK、payload 形狀(salt 外置 vs 內嵌)由各家族維持。
|
|
296
|
+
* prefix null/undefined=未配置拒絕(呼叫端亦可先行拒絕,帶自家錯誤碼形;雙防線)。
|
|
297
|
+
*/
|
|
298
|
+
export async function sealNoteKey(prefix, noteKey, kek, aad) {
|
|
299
|
+
if (!prefix)
|
|
300
|
+
throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
301
|
+
const raw = await crypto.subtle.exportKey('raw', noteKey);
|
|
302
|
+
if (raw.byteLength !== 32)
|
|
303
|
+
throw new Error('ERR_WRAPTAMPER_LEN32');
|
|
304
|
+
return prefix + await encryptWithKey(kek, toHex(new Uint8Array(raw)), aad);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 共用解包核心(收口):任何不符——前綴、base64 形、payload 長度、
|
|
308
|
+
* GCM(金鑰/AAD/密文損壞)、rawHex 長度/hex 形——恆回 null 不拋。
|
|
309
|
+
* 修正紀錄:atob 對非法字元會拋、原 docstring「不拋」與實作不符——
|
|
310
|
+
* 本體吞收 null(公開原語誠實契約;呼叫端 unwrap 家族 try/catch 是雙防線非依賴面)。
|
|
311
|
+
* 家族嚴格度收口實況(校正+殘餘校正;
|
|
312
|
+
* 七呼叫端帳同步——鹽外置家族字首家系清點,6 點帳已過期):
|
|
313
|
+
* 本體嚴格 92B 終結七呼叫端=note-crypto 四點(unwrapNoteKey=jr1w/
|
|
314
|
+
* unwrapNoteKeyShare=jrsw/unwrapNoteKeyWithRecToken(legacy 面 jr1w./HKDF 面 jr1r.)
|
|
315
|
+
* /loadLocalWrap=jr1l.)
|
|
316
|
+
* +argon2 三點(unwrapNoteKey3=jr3w/unwrapNoteKey4=jr4w/unwrapNoteKeyShare3=jr3s;
|
|
317
|
+
* own 92B 檢已隨收口摘除);
|
|
318
|
+
* 鹽內嵌 3 點(jr2w/jr3d/jr1p)own 108B+rawHex 形檢——與本體同嚴格度、刻意不經本體
|
|
319
|
+
* (pinSalt 前綴不在鹽外置形契約內)。維護本體時鹽內嵌三族不隨行。
|
|
320
|
+
*/
|
|
321
|
+
export async function openNoteKey(wrapped, kek, aad, prefix) {
|
|
322
|
+
if (!wrapped.startsWith(prefix))
|
|
323
|
+
return null;
|
|
324
|
+
let payload;
|
|
325
|
+
try {
|
|
326
|
+
payload = unb64(wrapped.slice(prefix.length)); // atob 非法字元拋點吞收 null(公開原語全 null 契約)
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null; // 壞 base64=任何不符面之一(行為閘有壞 b64 向量承載)
|
|
330
|
+
}
|
|
331
|
+
if (payload.length !== IV_LEN + 80)
|
|
332
|
+
return null; // 嚴格 92B:iv 12+ct 80(hex64 字串編碼 64B+tag 16)
|
|
333
|
+
const rawHex = await decryptWithKey(kek, payload, aad);
|
|
334
|
+
if (!rawHex || rawHex.length !== NOTEKEY_HEX_LEN || !HEX_RE.test(rawHex))
|
|
335
|
+
return null;
|
|
336
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹/解密(鐵律)
|
|
337
|
+
}
|
|
338
|
+
/** passphrase 包裹:salt 隨機 16 bytes(hex 存於 users.salt)。 */
|
|
339
|
+
export async function wrapNoteKey(cfg, noteKey, passphrase) {
|
|
340
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
341
|
+
if (!cfg.wrap)
|
|
342
|
+
throw new Error('ERR_WRAP_NOT_CONFIGURED');
|
|
343
|
+
const kek = await deriveKek(passphrase, salt);
|
|
344
|
+
const wrapped = await sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
345
|
+
return { wrapped, salt: toHex(salt) };
|
|
346
|
+
}
|
|
347
|
+
export async function unwrapNoteKey(cfg, wrapped, passphrase, saltHex) {
|
|
348
|
+
try {
|
|
349
|
+
if (!cfg.wrap)
|
|
350
|
+
return null;
|
|
351
|
+
if (!wrapped.startsWith(cfg.wrap))
|
|
352
|
+
return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 600k PBKDF2 成本
|
|
353
|
+
const salt = hexToBytes(saltHex);
|
|
354
|
+
if (salt.length !== 16 || !HEX32_RE.test(saltHex))
|
|
355
|
+
return null;
|
|
356
|
+
const kek = await deriveKek(passphrase, salt);
|
|
357
|
+
return await openNoteKey(wrapped, kek, 'notekey', cfg.wrap);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// ── 分享包裹(jrsw.,單篇分享連結 V1)────────────────────────────────────────
|
|
364
|
+
//
|
|
365
|
+
// KEK_share = PBKDF2(sharePass, salt 16B 隨機, 600k, SHA-256)(與 jr1w 同構),
|
|
366
|
+
// payload = iv[12] ‖ AES-GCM(KEK_share, hex(noteKey), aad='notekey-share'),
|
|
367
|
+
// wrapped = 'jrsw.' + b64(payload)。salt 由呼叫端存 D1(shares.salt);
|
|
368
|
+
// 分享密語永不過線(與 passphrase 同律)。AAD 用獨立字串,兩種包裹結構不可互換。
|
|
369
|
+
/** 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
|
|
370
|
+
export async function wrapNoteKeyShare(cfg, noteKey, sharePass) {
|
|
371
|
+
if (!cfg.wrapShare)
|
|
372
|
+
throw new Error('ERR_SHARE_NOT_CONFIGURED');
|
|
373
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
374
|
+
const kek = await deriveKek(sharePass, salt);
|
|
375
|
+
const wrapped = await sealNoteKey(cfg.wrapShare, noteKey, kek, 'notekey-share');
|
|
376
|
+
return { wrapped, salt: toHex(salt) };
|
|
377
|
+
}
|
|
378
|
+
/** 分享解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。 */
|
|
379
|
+
export async function unwrapNoteKeyShare(cfg, wrapped, sharePass, saltHex) {
|
|
380
|
+
try {
|
|
381
|
+
if (!cfg.wrapShare)
|
|
382
|
+
return null;
|
|
383
|
+
if (!wrapped.startsWith(cfg.wrapShare))
|
|
384
|
+
return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 600k PBKDF2 成本
|
|
385
|
+
const salt = hexToBytes(saltHex);
|
|
386
|
+
if (salt.length !== 16 || !HEX32_RE.test(saltHex))
|
|
387
|
+
return null;
|
|
388
|
+
const kek = await deriveKek(sharePass, salt);
|
|
389
|
+
return await openNoteKey(wrapped, kek, 'notekey-share', cfg.wrapShare);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** 復原套件 KEK 派生:HKDF 專責(本體只承載 recKekHkdf 配置世代——
|
|
396
|
+
* 呼叫端須先過世代配對門)。KEK_rec = HKDF-SHA256(
|
|
397
|
+
* ikm = recToken(TextEncoder UTF-8;recToken 恆 hex64 256-bit 實體因子),
|
|
398
|
+
* salt = recKekSalt?(16B hex → 16 raw bytes;未配置=零鹽),
|
|
399
|
+
* info = 'journal-kek-rec-v1:' + recKekHkdf, L = 32 )——輸出 AES-GCM 256 nonextractable。
|
|
400
|
+
* HKDF 世代 identity 不入 KEK(identity-free by design——256-bit 實體因子足綁;
|
|
401
|
+
* identity 綁域由舊契約面 recSaltPrefix‖identity 專屬承載,兩世代綁域刻意分離)。
|
|
402
|
+
* 強度語意:recToken 256-bit 實體因子,KDF 強度無意義——收的是
|
|
403
|
+
* 派生域(HKDF info)+前綴契約面(jr1r.)的世代分離;非防爆破強化帳。
|
|
404
|
+
* 鹽域二分(鹽家族):HKDF salt 屬「派生面內嵌 cfg、payload 零內嵌」——
|
|
405
|
+
* 92B 帳純粹來自 hex(noteKey) 64B+iv 12+tag 16(pinSalt 內嵌 payload 是 jr2w./jr3d.
|
|
406
|
+
* 鹽內嵌族的事,不入此帳)。PBKDF2 舊世代派生=deriveRecLegacyKek(單一本體,檢查對齊
|
|
407
|
+
* 收口:deriveRecKek 禁 PBKDF2 fallback——派生源雙模=C2 缺口形)。 */
|
|
408
|
+
async function deriveRecKek(cfg, recToken) {
|
|
409
|
+
const hkdfTail = cfg.recKekHkdf;
|
|
410
|
+
const info = 'journal-kek-rec-v1:' + hkdfTail;
|
|
411
|
+
if (cfg.recKekSalt && !HEX32_RE.test(cfg.recKekSalt))
|
|
412
|
+
throw new Error('ERR_REC_KEK_SALT');
|
|
413
|
+
const salt = cfg.recKekSalt ? hexToBytes(cfg.recKekSalt) : new Uint8Array(0);
|
|
414
|
+
const hkdfBase = await crypto.subtle.importKey('raw', new TextEncoder().encode(recToken), 'HKDF', false, ['deriveKey']);
|
|
415
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: salt, info: new TextEncoder().encode(info) }, hkdfBase, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
416
|
+
}
|
|
417
|
+
export async function wrapNoteKeyWithRecToken(cfg, noteKey, recToken, identity) {
|
|
418
|
+
// 世代配對門(檢查對齊 C1/C2 收口):一前綴一份契約補完。
|
|
419
|
+
// 禁寫面=「前綴面 jr1r.×派生面 PBKDF2」(wrapRec-only,C1:寫得出讀不回)與
|
|
420
|
+
// 「前綴面 jr1w.×派生面 HKDF」(HKDF-only,C2:借 jr1w. 前綴 carrying 第二派生、
|
|
421
|
+
// 升級即失資料)——兩面都由部分配置自然接線產生,門 fail-closed 拒寫。
|
|
422
|
+
if (cfg.wrapRec || cfg.recKekHkdf) {
|
|
423
|
+
if (!cfg.wrapRec || !cfg.recKekHkdf)
|
|
424
|
+
throw new Error('ERR_REC_CFG_PARTIAL');
|
|
425
|
+
const kek = await deriveRecKek(cfg, recToken);
|
|
426
|
+
return sealNoteKey(cfg.wrapRec, noteKey, kek, 'notekey-rec');
|
|
427
|
+
}
|
|
428
|
+
// 未配置世代(兩欄皆缺席=legacy 契約面永不變):cfg.wrap+PBKDF2+AAD 'notekey'
|
|
429
|
+
//(既有向量 byte 不變;cfg.wrap 缺席由 sealNoteKey 守衛承載——雙防線)。
|
|
430
|
+
const kek = await deriveRecLegacyKek(cfg, recToken, identity);
|
|
431
|
+
return sealNoteKey(cfg.wrap, noteKey, kek, 'notekey');
|
|
432
|
+
}
|
|
433
|
+
export async function unwrapNoteKeyWithRecToken(cfg, wrapped, recToken, identity) {
|
|
434
|
+
try {
|
|
435
|
+
// 次序契約:jr1r. 面只在兩欄齊備時試(HKDF 專責派生——cfg.wrap 面零 HKDF 腿,
|
|
436
|
+
// jr1w. 前綴只 carrying passphrase-PBKDF2 派生:「一個前綴一份契約」第二違例收口)。
|
|
437
|
+
// 快速前綴快檢(t_87ef62dd):jr1r. 腿接線=條件面(錯前綴跳過 HKDF 腿)——
|
|
438
|
+
// 禁 return null 形(會短路「新世代讀舊寫新」的舊面回落雙試=[20] 過渡保護契約);
|
|
439
|
+
// 舊面是終面(回落後無再退)=return null 快檢形恰當(錯前綴免付 600k PBKDF2)。
|
|
440
|
+
if (cfg.wrapRec && cfg.recKekHkdf && wrapped.startsWith(cfg.wrapRec)) {
|
|
441
|
+
const recKek = await deriveRecKek(cfg, recToken);
|
|
442
|
+
const fresh = await openNoteKey(wrapped, recKek, 'notekey-rec', cfg.wrapRec);
|
|
443
|
+
if (fresh)
|
|
444
|
+
return fresh;
|
|
445
|
+
}
|
|
446
|
+
// 舊面回落(過渡保護):cfg.wrap 在場即試(部分配置讀面照走——寫面已被配對門
|
|
447
|
+
// 拒,讀面寬容=舊 blob 永遠可解的承載面)。
|
|
448
|
+
if (!cfg.wrap)
|
|
449
|
+
return null;
|
|
450
|
+
if (!wrapped.startsWith(cfg.wrap))
|
|
451
|
+
return null; // 快速前綴快檢:錯前綴 junk 免付 600k PBKDF2 成本
|
|
452
|
+
const legacyKek = await deriveRecLegacyKek(cfg, recToken, identity);
|
|
453
|
+
return await openNoteKey(wrapped, legacyKek, 'notekey', cfg.wrap);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** 舊契約派生本體(legacy 派生全案單一本體):PBKDF2(recToken,
|
|
460
|
+
* salt = recSaltPrefix ‖ identity)——永不變(配置形正確時既有 wrappedRec
|
|
461
|
+
* blob 逐位可解)。寫面未配置世代與讀面回落同源呼叫本體;deriveRecKek 改 HKDF 專責
|
|
462
|
+
* (檢查對齊 C2 收口:HKDF 派生禁借 cfg.wrap 前綴面,派生源不再雙模)。 */
|
|
463
|
+
async function deriveRecLegacyKek(cfg, recToken, identity) {
|
|
464
|
+
return deriveKek(recToken, new TextEncoder().encode(cfg.recSaltPrefix + identity));
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。
|
|
468
|
+
* 兩段 600k PBKDF2 相互獨立(pass 鹽隨機、rec 鹽=recSaltPrefix‖identity 定值)=並行
|
|
469
|
+
*(node 實測 133-152ms→68-84ms,與閘執行帳同源)。
|
|
470
|
+
* AES-GCM derive 佔 600k 迴圈 99%,兩段 wrapped 各自隨機 iv(pass 段另隨機鹽 16B;rec 腿
|
|
471
|
+
* KEK 鹽定值、回傳 salt 來自 pass 腿 w1)——逐位等值帳由
|
|
472
|
+
* 「兩段各自與 wrapNoteKey 直呼相等」承載。
|
|
473
|
+
*/
|
|
474
|
+
export async function buildBindPayload(cfg, noteKey, passphrase, recToken, identity) {
|
|
475
|
+
const [w1, wrappedRec] = await Promise.all([
|
|
476
|
+
wrapNoteKey(cfg, noteKey, passphrase),
|
|
477
|
+
wrapNoteKeyWithRecToken(cfg, noteKey, recToken, identity),
|
|
478
|
+
]);
|
|
479
|
+
return {
|
|
480
|
+
wrapped: w1.wrapped,
|
|
481
|
+
salt: w1.salt,
|
|
482
|
+
wrappedRec,
|
|
483
|
+
recTokenHash: await recTokenHash(recToken),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
// ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
|
|
487
|
+
/**
|
|
488
|
+
* 本機包裹:KEK = deriveGuestKey(identity 派生、passphrase-free——
|
|
489
|
+
* session 期免重打密語的機制原樣),payload 改走專用前綴 cfg.wrapLocal(jr1l.)。
|
|
490
|
+
* 舊實作借用 cfg.wrap(jr1w. passphrase 包裹前綴)寫本機包裹=「一個前綴一份契約」
|
|
491
|
+
* 的第二違例(wrappedRec 專用前綴歸 v0.2.0 世代收口):本機包裹的 KDF 與 caller 不同、
|
|
492
|
+
* 儲存面(localStorage)不同、生命週期不同(clearLocalWrap 隨時摘除)——共前綴讓
|
|
493
|
+
* 「本機字串是否可能誤入 server 包裹欄」對帳失真。新前綴=讀舊寫新自癒:
|
|
494
|
+
* loadLocalWrap 先試 jr1l.(本體嚴格面),命中舊形(jr1w.)回落解密後即重包
|
|
495
|
+
* cfg.wrapLocal 回寫(自癒一次),寫入恆 jr1l.。
|
|
496
|
+
*/
|
|
497
|
+
export async function storeLocalWrap(cfg, identity, noteKey) {
|
|
498
|
+
try {
|
|
499
|
+
// 寫入恆專用前綴 cfg.wrapLocal(jr1l.;未配置=無寫入退場,讀取面照走舊形回落——
|
|
500
|
+
// 行為不變態)。單一真相:loadLocalWrap 自癒腿經本函式回寫(前綴契約不二寫)。
|
|
501
|
+
if (!cfg.wrapLocal)
|
|
502
|
+
return;
|
|
503
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
504
|
+
const wrapped = await sealNoteKey(cfg.wrapLocal, noteKey, guest, 'notekey-local');
|
|
505
|
+
cfg.store.set(cfg.store.noteKeyWrap(identity), wrapped);
|
|
506
|
+
}
|
|
507
|
+
catch { /* private mode:不阻擋主流程 */ }
|
|
508
|
+
}
|
|
509
|
+
async function loadLocalWrap(cfg, identity) {
|
|
510
|
+
try {
|
|
511
|
+
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
512
|
+
if (!stored)
|
|
513
|
+
return null;
|
|
514
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
515
|
+
// 次序契約:新形必先於舊形試——新形 blob 在舊前綴面恆拒(family isolation);先新後舊
|
|
516
|
+
// 仍是要件(新形命中即不跑舊解包),但兩前綴互斥 startsWith 下單一三元只有一腿成立,
|
|
517
|
+
// 實際次序不可觀察。
|
|
518
|
+
const fresh = cfg.wrapLocal
|
|
519
|
+
? await openNoteKey(stored, guest, 'notekey-local', cfg.wrapLocal) // 本體嚴格面(92B+rawHex hex 形)
|
|
520
|
+
: null;
|
|
521
|
+
if (fresh)
|
|
522
|
+
return fresh;
|
|
523
|
+
const legacy = cfg.wrap
|
|
524
|
+
? await openNoteKey(stored, guest, 'notekey-local', cfg.wrap)
|
|
525
|
+
: null;
|
|
526
|
+
if (legacy) {
|
|
527
|
+
await storeLocalWrap(cfg, identity, legacy);
|
|
528
|
+
return legacy;
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
export function clearLocalWrap(cfg, identity) {
|
|
537
|
+
cfg.store.remove(cfg.store.noteKeyWrap(identity));
|
|
538
|
+
}
|
|
539
|
+
export function makeHeldKey() {
|
|
540
|
+
let held = null;
|
|
541
|
+
return { get: () => held, set: (k) => { held = k; } };
|
|
542
|
+
}
|
|
543
|
+
/** 加密一則:綁定時代持有 noteKey → cipherBound;否則 guest key → cipherGuest
|
|
544
|
+
* (guest 前綴選配:未配置=ERR_GUEST_NOT_CONFIGURED,bound 路徑不受牽連)。
|
|
545
|
+
* held 為空時先試本機包裹(reload / PWA 續存期免重打密語)。 */
|
|
546
|
+
export async function encryptNote(cfg, held, plaintext, aad, idp) {
|
|
547
|
+
if (!held.get()) {
|
|
548
|
+
const identity = idp.current();
|
|
549
|
+
if (identity)
|
|
550
|
+
held.set(await loadLocalWrap(cfg, identity));
|
|
551
|
+
}
|
|
552
|
+
if (held.get()) {
|
|
553
|
+
return cfg.cipherBound + await encryptWithKey(held.get(), plaintext, aad);
|
|
554
|
+
}
|
|
555
|
+
if (!cfg.cipherGuest)
|
|
556
|
+
throw new Error('ERR_GUEST_NOT_CONFIGURED'); // guest 路徑選配:未配置即拒(bound 路徑不受牽連)
|
|
557
|
+
// 空 identity 拒絕:K_u = SHA-256(prefix ‖ '') 仍可派生(sha256 不拋),
|
|
558
|
+
// 靜默產出「空帳號金鑰」=可解同樣空 identity 的任何 guest 密文(對稱面誤配)。fail-closed。
|
|
559
|
+
const identity = idp.current();
|
|
560
|
+
if (!identity)
|
|
561
|
+
throw new Error('ERR_NO_IDENTITY');
|
|
562
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
563
|
+
return cfg.cipherGuest + await encryptWithKey(guest, plaintext, aad);
|
|
564
|
+
}
|
|
565
|
+
/** 解密一則:按前綴選鑰匙;無前綴 = 舊版明文(相容層)原樣返回(guest 前綴未配置時
|
|
566
|
+
* 不明字串一律 null=guest 家族整面拒絕,跨前綴鐵律不降級);失敗回 null。 */
|
|
567
|
+
export async function decryptNote(cfg, held, cipher, aad, idp) {
|
|
568
|
+
try {
|
|
569
|
+
// guest 前綴正規化:畸形配置 ''(空字串)會讓
|
|
570
|
+
// startsWith('') 恆真=bound 列誤導 guest 分支回 null(解密面整列滅失)。
|
|
571
|
+
// falsy 一律收斂為 undefined,與加密面「未配置即拒」truthiness 對稱。
|
|
572
|
+
const guestPrefix = cfg.cipherGuest || undefined;
|
|
573
|
+
const isGuest = guestPrefix !== undefined && cipher.startsWith(guestPrefix);
|
|
574
|
+
const isBound = cipher.startsWith(cfg.cipherBound);
|
|
575
|
+
if (!isGuest && !isBound) {
|
|
576
|
+
// 舊版明文(相容層)原樣返回;guest 前綴未配置=guest 家族整面拒絕(不明字串
|
|
577
|
+
// 一律 null——跨前綴鐵律「回 null 不降級」,避免他家族殘列被當明文顯示)。
|
|
578
|
+
return guestPrefix === undefined ? null : cipher;
|
|
579
|
+
}
|
|
580
|
+
if (isGuest) {
|
|
581
|
+
// 空 identity 拒絕(對稱面):guest 密文在 identity 缺席時必 null
|
|
582
|
+
//(K_u 仍可派生但「空帳號金鑰」不是該密文作者的 intent;誤配下兩態皆 fail-closed)。
|
|
583
|
+
const identity = idp.current();
|
|
584
|
+
if (!identity)
|
|
585
|
+
return null;
|
|
586
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
587
|
+
return await decryptWithKey(guest, unb64(cipher.slice(guestPrefix.length)), aad);
|
|
588
|
+
}
|
|
589
|
+
if (!held.get()) {
|
|
590
|
+
const identity = idp.current();
|
|
591
|
+
held.set(identity ? await loadLocalWrap(cfg, identity) : null);
|
|
592
|
+
}
|
|
593
|
+
if (!held.get())
|
|
594
|
+
return null;
|
|
595
|
+
return await decryptWithKey(held.get(), unb64(cipher.slice(cfg.cipherBound.length)), aad);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pinlock.ts — 本機 PIN 鎖定包裹原語(jr1p.,「開啟時鎖定」,2026-09-24 動工)。
|
|
3
|
+
*
|
|
4
|
+
* 威脅模型:裝置被順手翻看/拔走。raw noteKey 摘除、以 PIN 派生 KEK 包裹,
|
|
5
|
+
* 鎖定期間 localStorage 無 raw key(鐵律 8 誠實姿態的實質升級)。
|
|
6
|
+
* jr1p. 刻意用 PBKDF2-SHA256 600k,不用 Argon2id:解鎖每次啟動都要經歷,
|
|
7
|
+
* 鎖定閘要即時性+省電;鎖定閘非登入 oracle 戰場(離線爆破 6 位數字 ≈ 單 GPU 數天=門檻不是牆)。
|
|
8
|
+
* KDF 升級走帶內版本化(新前綴),禁原地改語意。
|
|
9
|
+
*
|
|
10
|
+
* payload(pinSalt 內嵌自描述,與 jr2w./jr3d. 同構):
|
|
11
|
+
* jr1p. + b64( pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey-pinlock') ),嚴格 108B
|
|
12
|
+
* KEK = PBKDF2-SHA256(pinNorm, 'tacet-pinlock-v1:' + hex(pinSalt), 600k) → AES-GCM key(nonextractable)
|
|
13
|
+
* ⚠️ unwrap 輸出 noteKey 一律 extractable=true(鐵律 1:要能再包裹/匯出);KEK nonextractable。
|
|
14
|
+
* ⚠️ cfg 未配置 = 全拒(wrapDual/wrapShare3 同款 opt-in 律,兄弟 fork 零影響)。
|
|
15
|
+
* ⚠️ 語意分離:jr1p. 是本機裝置閘,與登入第二因子(jr2w./jr3d.)契約、payload、威脅模型全分離。
|
|
16
|
+
*/
|
|
17
|
+
/** 雙因子 pin 段等級(2M)過重:鎖定閘要即時性,600k 與 jr1w. 同級。 */
|
|
18
|
+
export declare const PINLOCK_ITERATIONS = 600000;
|
|
19
|
+
/** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
|
|
20
|
+
* noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
|
|
21
|
+
* 要能再包裹/匯出)不是可調選項;tacet 消費端 TACET_PINLOCK 同步摘欄。 */
|
|
22
|
+
export interface PinLockConfig {
|
|
23
|
+
/** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
|
|
24
|
+
pinLock?: string;
|
|
25
|
+
/** KDF salt 前綴,如 'tacet-pinlock-v1:'(拼接 hex(pinSalt) 後整串當 salt)。 */
|
|
26
|
+
pinLockSaltPrefix?: string;
|
|
27
|
+
/** AAD(鎖定包裹專用,與登入第二因子/分享包裹互斥)。未配置 = 拒絕。 */
|
|
28
|
+
pinLockAad?: string;
|
|
29
|
+
}
|
|
30
|
+
/** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。
|
|
31
|
+
* 鹽內嵌家族(jr2w./jr3d./jr1p.)自帶三段組裝——sealNoteKey 是鹽外置家族形(iv‖ct),
|
|
32
|
+
* 內嵌鹽的 pinSalt 前綴不在其契約內(毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
|
|
33
|
+
export declare function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string>;
|
|
34
|
+
/** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
|
|
35
|
+
* 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
|
|
36
|
+
* hexToBytes fail-closed 由 try/catch 承接=行為不變)——不經 openNoteKey 本體
|
|
37
|
+
* (openNoteKey 是鹽外置家族 92B 形,pinSalt 前綴不在其契約內)。 */
|
|
38
|
+
export declare function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null>;
|