@tacet-ink/journal-core 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +40 -0
- package/scripts/verify-core-crypto.ts +414 -0
- package/src/client/argon2.ts +350 -0
- package/src/client/bip39.ts +118 -0
- package/src/client/keys.ts +27 -0
- package/src/client/note-crypto.ts +433 -0
- package/src/client/pinlock.ts +105 -0
- package/src/client/wordlist.ts +264 -0
- package/src/index.ts +83 -0
- package/src/server/auth.ts +146 -0
- package/src/server/cors.ts +18 -0
- package/src/server/env.ts +6 -0
- package/src/server/hash.ts +28 -0
- package/src/server/ratelimit.ts +60 -0
|
@@ -0,0 +1,433 @@
|
|
|
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
|
+
*
|
|
15
|
+
* 前綴全部可配置(帶內版本化:升級 KDF 參數 = 換新前綴)。AAD 由呼叫端傳入——
|
|
16
|
+
* 例如綁 `noteId`(跨列搬移必解密失敗)、`lifeId:day`(逐日日記)或留言綁 `tst:<lifeId>`。
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ extractable 鐵律:所有要被 exportKey/wrap 的 key,
|
|
19
|
+
* import 當下就必須 extractable=true。noteKey 全部產生路徑(generate/unwrap×3)
|
|
20
|
+
* 一律顯式 true;KEK/guest key 保持 nonextractable(只加解密、永不 export)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── 配置 ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface NoteCryptoConfig {
|
|
26
|
+
/** guest 時代 KDF 前綴,如 'journal-note-u1'。 */
|
|
27
|
+
guestKdfPrefix: string;
|
|
28
|
+
/** 復原包裹 KDF salt 前綴,如 'journal-note-rec1:'。 */
|
|
29
|
+
recSaltPrefix: string;
|
|
30
|
+
/** 密文前綴:guest 時代,如 'jr1g.'。 */
|
|
31
|
+
cipherGuest: string;
|
|
32
|
+
/** 密文前綴:綁定時代,如 'jr1b.'。 */
|
|
33
|
+
cipherBound: string;
|
|
34
|
+
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
35
|
+
wrap: string;
|
|
36
|
+
/** 雙因子合鑰包裹前綴(jr2w.,PIN 第二因子)。未配置 = dual API 拒絕(未配置行為不變)。 */
|
|
37
|
+
wrapDual?: string;
|
|
38
|
+
/** 雙因子 pin 段 PBKDF2 salt 前綴,如 'journal-note-pin1:'(pinSalt 內嵌 payload 後拼接)。 */
|
|
39
|
+
pinSaltPrefix?: string;
|
|
40
|
+
/** 分享包裹前綴(jrsw.,單篇分享連結 V1)。未配置 = share API 拒絕(未配置行為不變)。 */
|
|
41
|
+
wrapShare?: string;
|
|
42
|
+
/** localStorage key store(品牌前綴由 keys.ts 管理)。 */
|
|
43
|
+
store: import('./keys').KeyStore;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 建議值
|
|
47
|
+
export const PIN_PBKDF2_ITERATIONS = 2_000_000; // 雙因子 pin 段(設計 v2:6 位數字也要扛離線爆破)
|
|
48
|
+
export const IV_LEN = 12;
|
|
49
|
+
|
|
50
|
+
const HEX32_RE = /^[0-9a-f]{32}$/; // 16-byte salt hex
|
|
51
|
+
|
|
52
|
+
// ── 基礎工具(export 供 argon2.ts 等同檔模組複用;語意不變) ─────────────────
|
|
53
|
+
|
|
54
|
+
export function b64(bytes: Uint8Array): string {
|
|
55
|
+
let binary = '';
|
|
56
|
+
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
|
57
|
+
return btoa(binary);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function unb64(text: string): Uint8Array {
|
|
61
|
+
const binary = atob(text);
|
|
62
|
+
const bytes = new Uint8Array(binary.length);
|
|
63
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
64
|
+
return bytes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function toHex(bytes: Uint8Array): string {
|
|
68
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
72
|
+
return new Uint8Array((hex.match(/.{2}/g) ?? []).map(h => parseInt(h, 16)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function sha256Hex(text: string): Promise<string> {
|
|
76
|
+
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
77
|
+
return toHex(new Uint8Array(d));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** SHA-256 hex(分享定位鍵雜湊等呼叫端雜湊用;與伺服器端 sha256Hex 同構)。 */
|
|
81
|
+
export function sha256HexExport(text: string): Promise<string> {
|
|
82
|
+
return sha256Hex(text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function importAesGcm(raw: Uint8Array, extractable = false): Promise<CryptoKey> {
|
|
86
|
+
// extractable 預設 false(最小權限);唯一需要 true 的是 noteKey。
|
|
87
|
+
return crypto.subtle.importKey('raw', raw as BufferSource, { name: 'AES-GCM' }, extractable, ['encrypt', 'decrypt']);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function deriveGuestKey(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey> {
|
|
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
|
+
|
|
96
|
+
async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
|
|
97
|
+
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveKey']);
|
|
98
|
+
return crypto.subtle.deriveKey(
|
|
99
|
+
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
100
|
+
keyMat,
|
|
101
|
+
{ name: 'AES-GCM', length: 256 },
|
|
102
|
+
false,
|
|
103
|
+
['encrypt', 'decrypt'],
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── 雙因子合鑰(jr2w.,PIN 第二因子;2026-09-09 定案 v2) ────────────────────
|
|
108
|
+
//
|
|
109
|
+
// KEK2 = HKDF-SHA256( ikm = PBKDF2(pass, salt1, 600k)[32B] ‖ PBKDF2(pin, pinSaltPrefix‖pinSalt, 2M)[32B],
|
|
110
|
+
// salt = pinSalt(同 16B,公開非秘密), info = 'journal-kek2-v1:' + wrapDual, L=32 )
|
|
111
|
+
// payload = pinSalt[16B] ‖ iv[12B] ‖ AES-GCM(KEK2, hex(noteKey), aad='notekey2')
|
|
112
|
+
// wrapped2 = 'jr2w.' + b64(payload) —— pinSalt 內嵌自描述:unwrap 不需 identity/salt 參數。
|
|
113
|
+
// 兩段 PBKDF2 bits 不落地(用完即棄);KEK2 import 當下 nonextractable;
|
|
114
|
+
// 輸出 noteKey 一律 extractable=true(鐵律 4)。
|
|
115
|
+
|
|
116
|
+
/** PIN 正規化契約:NFKC → trim → lowercase(不分大小寫;正規化後內含空白由強度檢拒絕)。 */
|
|
117
|
+
export function normalizePin(pin: string): string {
|
|
118
|
+
return pin.normalize('NFKC').trim().toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function derivePbkdf2Bits(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
|
122
|
+
const keyMat = await crypto.subtle.importKey('raw', new TextEncoder().encode(password) as BufferSource, 'PBKDF2', false, ['deriveBits']);
|
|
123
|
+
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: salt as BufferSource, iterations, hash: 'SHA-256' }, keyMat, 256);
|
|
124
|
+
return new Uint8Array(bits);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function deriveKek2(cfg: NoteCryptoConfig, passphrase: string, pin: string, salt1: Uint8Array, pinSalt: Uint8Array): Promise<CryptoKey> {
|
|
128
|
+
const passBits = await derivePbkdf2Bits(passphrase, salt1, PBKDF2_ITERATIONS);
|
|
129
|
+
const pinBits = await derivePbkdf2Bits(pin, new TextEncoder().encode(cfg.pinSaltPrefix + toHex(pinSalt)), PIN_PBKDF2_ITERATIONS);
|
|
130
|
+
const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
|
|
131
|
+
ikm.set(passBits, 0);
|
|
132
|
+
ikm.set(pinBits, passBits.byteLength);
|
|
133
|
+
const hkdfBase = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveKey']);
|
|
134
|
+
return crypto.subtle.deriveKey(
|
|
135
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode('journal-kek2-v1:' + cfg.wrapDual) as BufferSource },
|
|
136
|
+
hkdfBase,
|
|
137
|
+
{ name: 'AES-GCM', length: 256 },
|
|
138
|
+
false,
|
|
139
|
+
['encrypt', 'decrypt'],
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const DUAL_SALT_LEN = 16;
|
|
144
|
+
const DUAL_IV_LEN = 12;
|
|
145
|
+
|
|
146
|
+
/** 雙因子包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey));salt1 隨機由呼叫端存 users.salt。 */
|
|
147
|
+
export async function wrapNoteKeyDual(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string, pin: string): Promise<{ wrapped: string; salt: string }> {
|
|
148
|
+
if (!cfg.wrapDual || !cfg.pinSaltPrefix) throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
149
|
+
const pinNorm = normalizePin(pin);
|
|
150
|
+
if (!pinNorm) throw new Error('ERR_DUAL_NOT_CONFIGURED');
|
|
151
|
+
const salt1 = crypto.getRandomValues(new Uint8Array(16));
|
|
152
|
+
const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
|
|
153
|
+
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
154
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
155
|
+
const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
156
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
157
|
+
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
|
|
158
|
+
kek2,
|
|
159
|
+
new TextEncoder().encode(rawHex) as BufferSource,
|
|
160
|
+
));
|
|
161
|
+
const payload = new Uint8Array(DUAL_SALT_LEN + iv.byteLength + ct.byteLength);
|
|
162
|
+
payload.set(pinSalt, 0);
|
|
163
|
+
payload.set(iv, DUAL_SALT_LEN);
|
|
164
|
+
payload.set(ct, DUAL_SALT_LEN + iv.byteLength);
|
|
165
|
+
return { wrapped: cfg.wrapDual + b64(payload), salt: toHex(salt1) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 雙因子解包:pinSalt 內嵌自描述,salt1 取自 login 回應(與 jr1w 同形);任何不符回 null,不拋。 */
|
|
169
|
+
export async function unwrapNoteKeyDual(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, pin: string, salt1Hex: string): Promise<CryptoKey | null> {
|
|
170
|
+
try {
|
|
171
|
+
if (!cfg.wrapDual || !cfg.pinSaltPrefix) return null;
|
|
172
|
+
if (!wrapped.startsWith(cfg.wrapDual)) return null;
|
|
173
|
+
const salt1 = hexToBytes(salt1Hex);
|
|
174
|
+
if (salt1.length !== 16) return null;
|
|
175
|
+
const payload = unb64(wrapped.slice(cfg.wrapDual.length));
|
|
176
|
+
// 嚴格長度:pinSalt(16) + iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 108B 固定
|
|
177
|
+
if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
|
|
178
|
+
const pinSalt = payload.slice(0, DUAL_SALT_LEN);
|
|
179
|
+
const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
180
|
+
const pinNorm = normalizePin(pin);
|
|
181
|
+
if (!pinNorm) return null;
|
|
182
|
+
const kek2 = await deriveKek2(cfg, passphrase, pinNorm, salt1, pinSalt);
|
|
183
|
+
const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
|
|
184
|
+
if (!rawHex) return null;
|
|
185
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── 對稱核心:encrypt / decrypt(AAD 由呼叫端指定) ──────────────────────────
|
|
192
|
+
|
|
193
|
+
export async function encryptWithKey(key: CryptoKey, plaintext: string, aad: string): Promise<string> {
|
|
194
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
195
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
196
|
+
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(aad) as BufferSource },
|
|
197
|
+
key,
|
|
198
|
+
new TextEncoder().encode(plaintext) as BufferSource,
|
|
199
|
+
));
|
|
200
|
+
const out = new Uint8Array(IV_LEN + ct.byteLength);
|
|
201
|
+
out.set(iv, 0);
|
|
202
|
+
out.set(ct, IV_LEN);
|
|
203
|
+
return b64(out);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function decryptWithKey(key: CryptoKey, payload: Uint8Array, aad: string): Promise<string | null> {
|
|
207
|
+
try {
|
|
208
|
+
const iv = payload.slice(0, IV_LEN);
|
|
209
|
+
const ct = payload.slice(IV_LEN);
|
|
210
|
+
const plain = await crypto.subtle.decrypt(
|
|
211
|
+
{ name: 'AES-GCM', iv: iv as BufferSource, additionalData: new TextEncoder().encode(aad) as BufferSource },
|
|
212
|
+
key,
|
|
213
|
+
ct as BufferSource,
|
|
214
|
+
);
|
|
215
|
+
return new TextDecoder().decode(plain);
|
|
216
|
+
} catch {
|
|
217
|
+
return null; // 錯誤金鑰 / AAD 不符 / 密文損壞 → 呼叫端回落降級句
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── noteKey(時代 2)─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/** 綁定當下生成:random 256-bit AES-GCM key。必須 extractable(包裹靠 exportKey)。 */
|
|
224
|
+
export async function generateNoteKey(): Promise<CryptoKey> {
|
|
225
|
+
const raw = crypto.getRandomValues(new Uint8Array(32));
|
|
226
|
+
return importAesGcm(raw, true);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 登入憑證:PH1 = SHA-256(pass)——pass 明文從此不過線(伺服器存 PH2)。 */
|
|
230
|
+
export function ph1Of(passphrase: string): Promise<string> {
|
|
231
|
+
return sha256Hex(passphrase);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 復原種子:32 bytes hex,只顯示一次;伺服器只收 SHA-256。 */
|
|
235
|
+
export function generateRecToken(): string {
|
|
236
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
237
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
238
|
+
.join('');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function recTokenHash(recToken: string): Promise<string> {
|
|
242
|
+
return sha256Hex(recToken);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── 金鑰包裹 ────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/** passphrase 包裹:salt 隨機 16 bytes(hex 存於 users.salt)。 */
|
|
248
|
+
export async function wrapNoteKey(cfg: NoteCryptoConfig, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
249
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
250
|
+
const kek = await deriveKek(passphrase, salt);
|
|
251
|
+
const payload = new Uint8Array(await crypto.subtle.exportKey('raw', noteKey));
|
|
252
|
+
const wrapped = cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
253
|
+
return { wrapped, salt: toHex(salt) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function unwrapNoteKey(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
257
|
+
try {
|
|
258
|
+
if (!wrapped.startsWith(cfg.wrap)) return null;
|
|
259
|
+
const salt = hexToBytes(saltHex);
|
|
260
|
+
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
261
|
+
const kek = await deriveKek(passphrase, salt);
|
|
262
|
+
const rawHex = await decryptWithKey(kek, unb64(wrapped.slice(cfg.wrap.length)), 'notekey');
|
|
263
|
+
if (!rawHex) return null;
|
|
264
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── 分享包裹(jrsw.,單篇分享連結 V1)────────────────────────────────────────
|
|
271
|
+
//
|
|
272
|
+
// KEK_share = PBKDF2(sharePass, salt 16B 隨機, 600k, SHA-256)(與 jr1w 同構),
|
|
273
|
+
// payload = iv[12] ‖ AES-GCM(KEK_share, hex(noteKey), aad='notekey-share'),
|
|
274
|
+
// wrapped = 'jrsw.' + b64(payload)。salt 由呼叫端存 D1(shares.salt);
|
|
275
|
+
// 分享密語永不過線(與 passphrase 同律)。AAD 用獨立字串,兩種包裹結構不可互換。
|
|
276
|
+
|
|
277
|
+
/** 分享包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
|
|
278
|
+
export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey, sharePass: string): Promise<{ wrapped: string; salt: string }> {
|
|
279
|
+
if (!cfg.wrapShare) throw new Error('ERR_SHARE_NOT_CONFIGURED');
|
|
280
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
281
|
+
const kek = await deriveKek(sharePass, salt);
|
|
282
|
+
const payload = new Uint8Array(await crypto.subtle.exportKey('raw', noteKey));
|
|
283
|
+
const wrapped = cfg.wrapShare + await encryptWithKey(kek, toHex(payload), 'notekey-share');
|
|
284
|
+
return { wrapped, salt: toHex(salt) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 分享解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。 */
|
|
288
|
+
export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
289
|
+
try {
|
|
290
|
+
if (!cfg.wrapShare) return null;
|
|
291
|
+
if (!wrapped.startsWith(cfg.wrapShare)) return null;
|
|
292
|
+
const salt = hexToBytes(saltHex);
|
|
293
|
+
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
294
|
+
const kek = await deriveKek(sharePass, salt);
|
|
295
|
+
const rawHex = await decryptWithKey(kek, unb64(wrapped.slice(cfg.wrapShare.length)), 'notekey-share');
|
|
296
|
+
if (!rawHex) return null;
|
|
297
|
+
return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 復原套件包裹:KEK_rec = PBKDF2(recToken, salt = recSaltPrefix ‖ identity)。 */
|
|
304
|
+
async function deriveRecKek(cfg: NoteCryptoConfig, recToken: string, identity: string): Promise<CryptoKey> {
|
|
305
|
+
return deriveKek(recToken, new TextEncoder().encode(cfg.recSaltPrefix + identity));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function wrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, noteKey: CryptoKey, recToken: string, identity: string): Promise<string> {
|
|
309
|
+
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
310
|
+
const payload = new Uint8Array(await crypto.subtle.exportKey('raw', noteKey));
|
|
311
|
+
return cfg.wrap + await encryptWithKey(kek, toHex(payload), 'notekey');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped: string, recToken: string, identity: string): Promise<CryptoKey | null> {
|
|
315
|
+
try {
|
|
316
|
+
if (!wrapped.startsWith(cfg.wrap)) return null;
|
|
317
|
+
const kek = await deriveRecKek(cfg, recToken, identity);
|
|
318
|
+
const rawHex = await decryptWithKey(kek, unb64(wrapped.slice(cfg.wrap.length)), 'notekey');
|
|
319
|
+
if (!rawHex) return null;
|
|
320
|
+
return importAesGcm(hexToBytes(rawHex), true); // 復原後要能重新包裹
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 綁定請求的金鑰包裹三件套(bind 用)+ recovery_token_hash。 */
|
|
327
|
+
export async function buildBindPayload(
|
|
328
|
+
cfg: NoteCryptoConfig,
|
|
329
|
+
noteKey: CryptoKey,
|
|
330
|
+
passphrase: string,
|
|
331
|
+
recToken: string,
|
|
332
|
+
identity: string,
|
|
333
|
+
): Promise<{ wrapped: string; salt: string; wrappedRec: string; recTokenHash: string }> {
|
|
334
|
+
const { wrapped, salt } = await wrapNoteKey(cfg, noteKey, passphrase);
|
|
335
|
+
const wrappedRec = await wrapNoteKeyWithRecToken(cfg, noteKey, recToken, identity);
|
|
336
|
+
return {
|
|
337
|
+
wrapped,
|
|
338
|
+
salt,
|
|
339
|
+
wrappedRec,
|
|
340
|
+
recTokenHash: await recTokenHash(recToken),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── 本機包裹(PWA session 期免重打密語) ─────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
export async function storeLocalWrap(cfg: NoteCryptoConfig, identity: string, noteKey: CryptoKey): Promise<void> {
|
|
347
|
+
try {
|
|
348
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
349
|
+
const raw = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
350
|
+
cfg.store.set(cfg.store.noteKeyWrap(identity), cfg.wrap + await encryptWithKey(guest, raw, 'notekey-local'));
|
|
351
|
+
} catch { /* private mode:不阻擋主流程 */ }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function loadLocalWrap(cfg: NoteCryptoConfig, identity: string): Promise<CryptoKey | null> {
|
|
355
|
+
try {
|
|
356
|
+
const stored = cfg.store.get(cfg.store.noteKeyWrap(identity));
|
|
357
|
+
if (!stored || !stored.startsWith(cfg.wrap)) return null;
|
|
358
|
+
const guest = await deriveGuestKey(cfg, identity);
|
|
359
|
+
const rawHex = await decryptWithKey(guest, unb64(stored.slice(cfg.wrap.length)), 'notekey-local');
|
|
360
|
+
if (!rawHex) return null;
|
|
361
|
+
return importAesGcm(hexToBytes(rawHex), true);
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function clearLocalWrap(cfg: NoteCryptoConfig, identity: string): void {
|
|
368
|
+
cfg.store.remove(cfg.store.noteKeyWrap(identity));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── 日記密文入口(held key 狀態機) ─────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
export interface HeldKey {
|
|
374
|
+
get(): CryptoKey | null;
|
|
375
|
+
set(key: CryptoKey | null): void;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function makeHeldKey(): HeldKey {
|
|
379
|
+
let held: CryptoKey | null = null;
|
|
380
|
+
return { get: () => held, set: (k) => { held = k; } };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface IdentityProvider {
|
|
384
|
+
/** 當前身份字串(guest 派生與本機包裹的命名空間,如 account_id)。 */
|
|
385
|
+
current(): string;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 加密一則:綁定時代持有 noteKey → cipherBound;否則 guest key → cipherGuest。
|
|
389
|
+
* held 為空時先試本機包裹(reload / PWA 續存期免重打密語)。 */
|
|
390
|
+
export async function encryptNote(
|
|
391
|
+
cfg: NoteCryptoConfig,
|
|
392
|
+
held: HeldKey,
|
|
393
|
+
plaintext: string,
|
|
394
|
+
aad: string,
|
|
395
|
+
idp: IdentityProvider,
|
|
396
|
+
): Promise<string> {
|
|
397
|
+
if (!held.get()) {
|
|
398
|
+
const identity = idp.current();
|
|
399
|
+
if (identity) held.set(await loadLocalWrap(cfg, identity));
|
|
400
|
+
}
|
|
401
|
+
if (held.get()) {
|
|
402
|
+
return cfg.cipherBound + await encryptWithKey(held.get()!, plaintext, aad);
|
|
403
|
+
}
|
|
404
|
+
const guest = await deriveGuestKey(cfg, idp.current());
|
|
405
|
+
return cfg.cipherGuest + await encryptWithKey(guest, plaintext, aad);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 解密一則:按前綴選鑰匙;無前綴 = 舊版明文(相容層)原樣返回;失敗回 null。 */
|
|
409
|
+
export async function decryptNote(
|
|
410
|
+
cfg: NoteCryptoConfig,
|
|
411
|
+
held: HeldKey,
|
|
412
|
+
cipher: string,
|
|
413
|
+
aad: string,
|
|
414
|
+
idp: IdentityProvider,
|
|
415
|
+
): Promise<string | null> {
|
|
416
|
+
try {
|
|
417
|
+
const isGuest = cipher.startsWith(cfg.cipherGuest);
|
|
418
|
+
const isBound = cipher.startsWith(cfg.cipherBound);
|
|
419
|
+
if (!isGuest && !isBound) return cipher; // 舊版明文(相容層):原樣顯示
|
|
420
|
+
if (isGuest) {
|
|
421
|
+
const guest = await deriveGuestKey(cfg, idp.current());
|
|
422
|
+
return await decryptWithKey(guest, unb64(cipher.slice(cfg.cipherGuest.length)), aad);
|
|
423
|
+
}
|
|
424
|
+
if (!held.get()) {
|
|
425
|
+
const identity = idp.current();
|
|
426
|
+
held.set(identity ? await loadLocalWrap(cfg, identity) : null);
|
|
427
|
+
}
|
|
428
|
+
if (!held.get()) return null;
|
|
429
|
+
return await decryptWithKey(held.get()!, unb64(cipher.slice(cfg.cipherBound.length)), aad);
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
|
|
18
|
+
import {
|
|
19
|
+
b64, unb64, toHex, hexToBytes, decryptWithKey, importAesGcm, normalizePin,
|
|
20
|
+
} from './note-crypto.ts';
|
|
21
|
+
|
|
22
|
+
/** 雙因子 pin 段等級(2M)過重:鎖定閘要即時性,600k 與 jr1w. 同級。 */
|
|
23
|
+
export const PINLOCK_ITERATIONS = 600_000;
|
|
24
|
+
|
|
25
|
+
const SALT_LEN = 16;
|
|
26
|
+
const IV_LEN = 12;
|
|
27
|
+
/** pinSalt(16) + iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 108B 固定。 */
|
|
28
|
+
const PAYLOAD_LEN = SALT_LEN + IV_LEN + 80;
|
|
29
|
+
|
|
30
|
+
/** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。 */
|
|
31
|
+
export interface PinLockConfig {
|
|
32
|
+
/** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
|
|
33
|
+
pinLock?: string;
|
|
34
|
+
/** KDF salt 前綴,如 'tacet-pinlock-v1:'(拼接 hex(pinSalt) 後整串當 salt)。 */
|
|
35
|
+
pinLockSaltPrefix?: string;
|
|
36
|
+
/** AAD(鎖定包裹專用,與登入第二因子/分享包裹互斥)。未配置 = 拒絕。 */
|
|
37
|
+
pinLockAad?: string;
|
|
38
|
+
/** 解包輸出的 noteKey(要能再包裹/匯出,鐵律 1)。 */
|
|
39
|
+
noteKeyExtractable: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** KEK = PBKDF2-SHA256(pinNorm, pinLockSaltPrefix + hex(pinSalt), 600k) → AES-GCM key(nonextractable)。 */
|
|
43
|
+
async function deriveLockKek(pinNorm: string, pinSalt: Uint8Array, cfg: PinLockConfig): Promise<CryptoKey> {
|
|
44
|
+
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
|
|
45
|
+
const keyMat = await crypto.subtle.importKey(
|
|
46
|
+
'raw',
|
|
47
|
+
new TextEncoder().encode(pinNorm) as BufferSource,
|
|
48
|
+
'PBKDF2',
|
|
49
|
+
false,
|
|
50
|
+
['deriveKey'],
|
|
51
|
+
);
|
|
52
|
+
return crypto.subtle.deriveKey(
|
|
53
|
+
{
|
|
54
|
+
name: 'PBKDF2',
|
|
55
|
+
hash: 'SHA-256',
|
|
56
|
+
salt: new TextEncoder().encode(cfg.pinLockSaltPrefix + toHex(pinSalt)) as BufferSource,
|
|
57
|
+
iterations: PINLOCK_ITERATIONS,
|
|
58
|
+
},
|
|
59
|
+
keyMat,
|
|
60
|
+
{ name: 'AES-GCM', length: 256 },
|
|
61
|
+
false, // KEK nonextractable
|
|
62
|
+
['encrypt', 'decrypt'],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** jr1p. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad);全自描述(salt 內嵌)。 */
|
|
67
|
+
export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string> {
|
|
68
|
+
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
|
|
69
|
+
const pinNorm = normalizePin(pin);
|
|
70
|
+
if (!pinNorm) throw new Error('ERR_PINLOCK_EMPTY');
|
|
71
|
+
const pinSalt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
72
|
+
const kek = await deriveLockKek(pinNorm, pinSalt, cfg);
|
|
73
|
+
const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
|
|
74
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
75
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
76
|
+
{ name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(cfg.pinLockAad) as BufferSource },
|
|
77
|
+
kek,
|
|
78
|
+
new TextEncoder().encode(rawHex) as BufferSource,
|
|
79
|
+
));
|
|
80
|
+
const payload = new Uint8Array(PAYLOAD_LEN);
|
|
81
|
+
payload.set(pinSalt, 0);
|
|
82
|
+
payload.set(iv, SALT_LEN);
|
|
83
|
+
payload.set(ct, SALT_LEN + IV_LEN);
|
|
84
|
+
return cfg.pinLock + b64(payload);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。 */
|
|
88
|
+
export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null> {
|
|
89
|
+
try {
|
|
90
|
+
if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) return null;
|
|
91
|
+
if (!wrapped.startsWith(cfg.pinLock)) return null;
|
|
92
|
+
const payload = unb64(wrapped.slice(cfg.pinLock.length));
|
|
93
|
+
if (payload.length !== PAYLOAD_LEN) return null;
|
|
94
|
+
const pinSalt = payload.slice(0, SALT_LEN);
|
|
95
|
+
const ivPrefixedCt = payload.slice(SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
|
|
96
|
+
const pinNorm = normalizePin(pin);
|
|
97
|
+
if (!pinNorm) return null;
|
|
98
|
+
const kek = await deriveLockKek(pinNorm, pinSalt, cfg);
|
|
99
|
+
const rawHex = await decryptWithKey(kek, ivPrefixedCt, cfg.pinLockAad);
|
|
100
|
+
if (!rawHex) return null;
|
|
101
|
+
return importAesGcm(hexToBytes(rawHex), true);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|