@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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * argon2.ts — Argon2id 包裹原語(jr3w./jr3d.,安全路線第 2 步,2026-09-14)。
3
+ *
4
+ * 動機(安全路線定案):離線 oracle 的戰利品是 wrapped(/auth/login 回應帶 wrapped+salt,
5
+ * 拿到一次回應即可永久離線猜)。PBKDF2-SHA256 600k 對 GPU 友善;Argon2id 的記憶體困難
6
+ * 讓每猜成本上升一至兩個數量級,oracle 實際價值大幅中和。OPAQUE 降級為後續精益求精。
7
+ *
8
+ * 帶內版本化契約(不變量 5):升級 KDF = 換新前綴,禁原地改語意。
9
+ * jr3w. 單因子:結構與 jr1w. 同構(salt 獨立存 users.salt;wrapped = prefix + b64(iv[12] ‖ ct),
10
+ * ct = AES-GCM(KEK, hex(noteKey), aad='notekey')),只換 KEK 派生:
11
+ * KEK = Argon2id(pass, salt, m=64MiB, t=3, p=1, tag=32B) → AES-GCM key。
12
+ * jr3d. 雙因子(jr2w. 後繼):payload 同 jr2w.(pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2')),
13
+ * KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, pinSalt3Prefix‖hex(pinSalt))[32],
14
+ * salt = pinSalt, info = 'journal-kek2-v1:' + wrapDual3, L = 32 )。
15
+ * 兩 payload 長度相同(皆 108B):客戶端按前綴分流(jr3w.→單因子、jr3d.→雙因子),形狀互斥由
16
+ * AAD 與長度把關,跨前綴呼叫一律回 null(unwrap 有前綴守衛,驗證閘有跨協議斷言)。
17
+ *
18
+ * 雙載體(同一 spec 兩端一致,交叉驗證逐位元一致):
19
+ * - node(驗證閘/prod smoke):node:crypto.argon2(Node 26 原生,26.8.1 實測)
20
+ * - 瀏覽器:hash-wasm argon2id(純 wasm 內嵌 base64,零網路請求,4.12.0 實測 64MiB t=3 ≈ 180ms)
21
+ * 標準向量:RFC 9106 無 secret/associated-data Argon2id(t=3, m=32, p=4, pwd=32B 0x01, salt=16B 0x00)
22
+ * = 72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503(兩載體實測 MATCH)。
23
+ *
24
+ * 遷移契約(禁強制重置):既有 jr1w./jr2w. 帳戶在下次成功 unwrap 後由客戶端靜默
25
+ * re-wrap 推 jr3w./jr3d.(手上有 noteKey、同一 noteKey 重包,資料零換鑰);舊前綴照解。
26
+ *
27
+ * ⚠️ 鐵律:unwrap 輸出 noteKey 一律 extractable=true(要能再包裹);KEK/KEK2 nonextractable。
28
+ * ⚠️ deriveArgon2id 禁 fallback(零知識):無載體即 throw,禁降級 PBKDF2 或 zeros 兜底。
29
+ * ⚠️ wrappedRec(復原套件)維持 jr1w.:recToken 是 256-bit 實體因子,KDF 強度無意義,不動。
30
+ */
31
+
32
+ import { IV_LEN, normalizePin } from './note-crypto.ts';
33
+ import {
34
+ b64, unb64, toHex, hexToBytes, encryptWithKey, decryptWithKey, importAesGcm,
35
+ } from './note-crypto.ts';
36
+
37
+ /** RFC 9106 無 secret/ad 標準向量(Argon2id v=0x13, t=3, m=32, p=4, T=32, pwd=32B 0x01, salt=16B 0x00)。 */
38
+ export const ARGON_RFC9106_EXPECTED =
39
+ '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
40
+
41
+ /**
42
+ * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
43
+ *
44
+ * 固定鹽是被迫設計:per-user 鹽會摧毀 PH2 UNIQUE(同一密語必須恒生同一 ph2,
45
+ * 幽靈帳號機制與「同密語不可開第二帳戶」都靠它)。帶內版本化:改參數 = 換鹽尾碼
46
+ * (v2/v3…)重遷移,禁原地改語意。實長 12B(RFC 9106 鹽最小 8B;Task 0 探針實證)。
47
+ */
48
+ export const PH1_V2_SALT = 'tacet-ph1-v1';
49
+
50
+ /**
51
+ * PH1 v2:登入憑證 = Argon2id(pass, 固定域鹽, m=64MiB, t=3, p=1) → hex64。
52
+ *
53
+ * 動機:DB 全洩後 ph2 = sha256(ph1) 是離線爆破讀日記的最後一個快雜湊面(候選密語
54
+ * 重算 sha256(sha256(g)) 對 ph2 命中即 g 是密語)。ph1 改 Argon2id 派生後每猜成本
55
+ * ×10⁴-10⁶。參數沿 jr3w. 同一組常數(單一碼路);無載體即 throw(禁 fallback 鐵律)。
56
+ */
57
+ export async function derivePh1Argon(passphrase: string): Promise<string> {
58
+ return toHex(await deriveArgon2id(
59
+ passphrase,
60
+ new TextEncoder().encode(PH1_V2_SALT),
61
+ ARGON_MEMORY_KIB,
62
+ ARGON_ITERATIONS,
63
+ ARGON_PARALLELISM,
64
+ ));
65
+ }
66
+
67
+ /** Argon2id 參數(jr3w./jr3d. pass 段;前綴即版本,參數寫死本模組)。 */
68
+ export const ARGON_MEMORY_KIB = 65536; // 64 MiB(手機實測基準;瀏覽器 wasm ~180ms)
69
+ export const ARGON_ITERATIONS = 3; // t
70
+ export const ARGON_PARALLELISM = 1; // p(瀏覽器 wasm 無平行收益,定 1 兩端一致)
71
+ export const ARGON_TAG_LEN = 32; // 256-bit AES key
72
+
73
+ /** 雙因子 pin 段 Argon2id 參數(PIN 弱 → 記憶體補償;與 pass 段同級)。 */
74
+ export const ARGON_PIN_MEMORY_KIB = 65536;
75
+ export const ARGON_PIN_ITERATIONS = 3;
76
+ export const ARGON_PIN_PARALLELISM = 1;
77
+
78
+ const SALT_LEN = 16;
79
+ const DUAL_SALT_LEN = 16;
80
+ const DUAL_IV_LEN = 12;
81
+
82
+ /** jr3w 家族前綴(各 fork config 注入;未配置 = jr3w API 拒絕,兄弟 fork 行為不變)。 */
83
+ export interface Argon3Config {
84
+ /** 單因子包裹前綴,如 'jr3w.'。未配置 = 拒絕。 */
85
+ wrap3?: string;
86
+ /** 雙因子包裹前綴,如 'jr3d.'。未配置 = 拒絕。 */
87
+ wrapDual3?: string;
88
+ /** 雙因子 pin 段 Argon2id salt 前綴,如 'tacet-note-pin3:'(拼接 hex(pinSalt) 後整串當 salt)。 */
89
+ pinSalt3Prefix?: string;
90
+ /** 分享包裹前綴(jr3s.,單篇分享連結 Argon2id 版)。未配置 = 分享 API 拒絕(兄弟 fork 行為不變)。 */
91
+ wrapShare3?: string;
92
+ }
93
+
94
+ interface HashWasmArgon2id {
95
+ (params: {
96
+ password: string | Uint8Array;
97
+ salt: Uint8Array;
98
+ iterations: number;
99
+ parallelism: number;
100
+ memorySize: number;
101
+ hashLength: number;
102
+ outputType: 'binary' | 'hex' | 'encoded';
103
+ }): Promise<Uint8Array | string>;
104
+ }
105
+
106
+ type ArgonLoader = () => Promise<{ argon2id: HashWasmArgon2id }>;
107
+
108
+ let injectedLoader: ArgonLoader | null = null;
109
+
110
+ /** 載體注入(瀏覽器端由接線層指定 hash-wasm;node 端不注入走 node:crypto 原生)。 */
111
+ export function setArgonLoader(loader: ArgonLoader | null): void {
112
+ injectedLoader = loader;
113
+ }
114
+
115
+ /**
116
+ * Argon2id 派生 32B。雙載體:injectedLoader(瀏覽器 hash-wasm)優先;
117
+ * 無注入時走 node:crypto 原生(僅 node 執行環境可達)。皆不可得即 throw(禁 fallback)。
118
+ */
119
+ async function deriveArgon2id(
120
+ password: string | Uint8Array,
121
+ salt: Uint8Array,
122
+ memoryKib: number,
123
+ passes: number,
124
+ parallelism: number,
125
+ ): Promise<Uint8Array> {
126
+ if (injectedLoader) {
127
+ const mod = await injectedLoader();
128
+ const out = await mod.argon2id({
129
+ password,
130
+ salt,
131
+ iterations: passes,
132
+ parallelism,
133
+ memorySize: memoryKib,
134
+ hashLength: ARGON_TAG_LEN,
135
+ outputType: 'binary',
136
+ });
137
+ const bytes = typeof out === 'string' ? hexToBytes(out) : new Uint8Array(out);
138
+ if (bytes.length !== ARGON_TAG_LEN) throw new Error('ERR_ARGON2_TAGLEN');
139
+ return bytes;
140
+ }
141
+ const nodeCrypto = (globalThis as unknown as {
142
+ process?: { getBuiltinModule?: (id: string) => { argon2?: NodeArgonFn } | undefined };
143
+ }).process?.getBuiltinModule?.('node:crypto');
144
+ if (!nodeCrypto?.argon2) throw new Error('ERR_ARGON2_UNAVAILABLE');
145
+ return new Promise<Uint8Array>((resolve, reject) => {
146
+ nodeCrypto.argon2!(
147
+ 'argon2id',
148
+ {
149
+ message: password,
150
+ nonce: salt,
151
+ memory: memoryKib,
152
+ passes,
153
+ parallelism,
154
+ tagLength: ARGON_TAG_LEN,
155
+ maxmem: 2 * 1024 * 1024 * 1024, // 2GiB:node 原生預設 1GiB 上限對 m+128*p*8 記帳過緊
156
+ },
157
+ (err: Error | null, key: Uint8Array) => (err ? reject(err) : resolve(new Uint8Array(key))),
158
+ );
159
+ });
160
+ }
161
+
162
+ type NodeArgonFn = (
163
+ algorithm: 'argon2d' | 'argon2i' | 'argon2id',
164
+ parameters: {
165
+ message: string | Uint8Array;
166
+ nonce: Uint8Array;
167
+ memory: number;
168
+ passes: number;
169
+ parallelism: number;
170
+ tagLength: number;
171
+ maxmem?: number;
172
+ },
173
+ callback: (err: Error | null, key: Uint8Array) => void,
174
+ ) => void;
175
+
176
+ /** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
177
+ export async function verifyArgonKat(): Promise<boolean> {
178
+ const raw = await deriveArgon2id(new Uint8Array(32).fill(1), new Uint8Array(16), 32, 3, 4);
179
+ return toHex(raw) === ARGON_RFC9106_EXPECTED;
180
+ }
181
+
182
+ // ── jr3w. 單因子包裹 ──────────────────────────────────────────────────────────
183
+
184
+ async function argonKekFromRaw(raw: Uint8Array): Promise<CryptoKey> {
185
+ return crypto.subtle.importKey('raw', raw as BufferSource, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
186
+ }
187
+
188
+ /** KEK = Argon2id(pass, salt, m=64MiB, t=3, p=1) → AES-GCM key(nonextractable)。 */
189
+ export async function deriveKekArgon(password: string, salt: Uint8Array): Promise<CryptoKey> {
190
+ return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
191
+ }
192
+
193
+ /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
194
+ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
195
+ if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
196
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
197
+ const kek = await deriveKekArgon(passphrase, salt);
198
+ const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
199
+ const wrapped = cfg.wrap3 + await encryptWithKey(kek, rawHex, 'notekey');
200
+ return { wrapped, salt: toHex(salt) };
201
+ }
202
+
203
+ /** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。 */
204
+ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
205
+ try {
206
+ if (!cfg.wrap3 || !wrapped.startsWith(cfg.wrap3)) return null;
207
+ const salt = hexToBytes(saltHex);
208
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
209
+ const payload = unb64(wrapped.slice(cfg.wrap3.length));
210
+ // 嚴格長度:iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 92B 固定(salt 在 users.salt 不內嵌)
211
+ if (payload.length !== IV_LEN + 80) return null;
212
+ const kek = await deriveKekArgon(passphrase, salt);
213
+ const rawHex = await decryptWithKey(kek, payload, 'notekey');
214
+ if (!rawHex) return null;
215
+ return importAesGcm(hexToBytes(rawHex), true);
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ // ── jr3d. 雙因子合鑰(PIN 第二因子,jr2w. 的 Argon2id 版) ─────────────────────
222
+ //
223
+ // KEK2 = HKDF-SHA256( ikm = Argon2id(pass, salt1)[32] ‖ Argon2id(pinNorm, prefix‖hex(pinSalt))[32],
224
+ // salt = pinSalt, info = 'journal-kek2-v1:' + wrapDual3, L = 32 )
225
+ // info 字串與 jr2w. 家族同名(journal-kek2-v1):HKDF 合成步驟同構,兩家族 KEK2 值
226
+ // 因 KDF bits 不同而天然互斥(驗證閘有跨家族隔離斷言)。
227
+ // 兩段 Argon2 bits 用完即棄;KEK2 import 當下 nonextractable;輸出 noteKey extractable=true。
228
+
229
+ async function deriveKek2Argon(
230
+ cfg: Argon3Config,
231
+ passphrase: string,
232
+ pinNorm: string,
233
+ salt1: Uint8Array,
234
+ pinSalt: Uint8Array,
235
+ ): Promise<CryptoKey> {
236
+ if (!cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
237
+ const passBits = await deriveArgon2id(passphrase, salt1, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM);
238
+ const pinBits = await deriveArgon2id(
239
+ pinNorm,
240
+ new TextEncoder().encode(cfg.pinSalt3Prefix + toHex(pinSalt)),
241
+ ARGON_PIN_MEMORY_KIB,
242
+ ARGON_PIN_ITERATIONS,
243
+ ARGON_PIN_PARALLELISM,
244
+ );
245
+ const ikm = new Uint8Array(passBits.byteLength + pinBits.byteLength);
246
+ ikm.set(passBits, 0);
247
+ ikm.set(pinBits, passBits.byteLength);
248
+ const hkdfBase = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveKey']);
249
+ return crypto.subtle.deriveKey(
250
+ { name: 'HKDF', hash: 'SHA-256', salt: pinSalt as BufferSource, info: new TextEncoder().encode('journal-kek2-v1:' + cfg.wrapDual3) as BufferSource },
251
+ hkdfBase,
252
+ { name: 'AES-GCM', length: 256 },
253
+ false,
254
+ ['encrypt', 'decrypt'],
255
+ );
256
+ }
257
+
258
+ /** jr3d. 包裹:payload = pinSalt[16] ‖ iv[12] ‖ GCM(KEK2, hex(noteKey), aad='notekey2');salt1 由呼叫端存 users.salt。 */
259
+ export async function wrapNoteKeyDual3(
260
+ cfg: Argon3Config,
261
+ noteKey: CryptoKey,
262
+ passphrase: string,
263
+ pin: string,
264
+ ): Promise<{ wrapped: string; salt: string }> {
265
+ if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix) throw new Error('ERR_JR3W_NOT_CONFIGURED');
266
+ const pinNorm = normalizePin(pin);
267
+ if (!pinNorm) throw new Error('ERR_JR3W_NOT_CONFIGURED');
268
+ const salt1 = crypto.getRandomValues(new Uint8Array(SALT_LEN));
269
+ const pinSalt = crypto.getRandomValues(new Uint8Array(DUAL_SALT_LEN));
270
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
271
+ const iv = crypto.getRandomValues(new Uint8Array(DUAL_IV_LEN));
272
+ const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
273
+ const ct = new Uint8Array(await crypto.subtle.encrypt(
274
+ { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode('notekey2') as BufferSource },
275
+ kek2,
276
+ new TextEncoder().encode(rawHex) as BufferSource,
277
+ ));
278
+ const payload = new Uint8Array(DUAL_SALT_LEN + DUAL_IV_LEN + ct.byteLength);
279
+ payload.set(pinSalt, 0);
280
+ payload.set(iv, DUAL_SALT_LEN);
281
+ payload.set(ct, DUAL_SALT_LEN + DUAL_IV_LEN);
282
+ return { wrapped: cfg.wrapDual3 + b64(payload), salt: toHex(salt1) };
283
+ }
284
+
285
+ /** jr3d. 解包:pinSalt 內嵌自描述,salt1 取自 login 回應;任何不符回 null,不拋。 */
286
+ export async function unwrapNoteKeyDual3(
287
+ cfg: Argon3Config,
288
+ wrapped: string,
289
+ passphrase: string,
290
+ pin: string,
291
+ salt1Hex: string,
292
+ ): Promise<CryptoKey | null> {
293
+ try {
294
+ if (!cfg.wrapDual3 || !cfg.pinSalt3Prefix) return null;
295
+ if (!wrapped.startsWith(cfg.wrapDual3)) return null;
296
+ const salt1 = hexToBytes(salt1Hex);
297
+ if (salt1.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(salt1Hex)) return null;
298
+ const payload = unb64(wrapped.slice(cfg.wrapDual3.length));
299
+ // 嚴格長度:pinSalt(16) + iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 108B 固定
300
+ if (payload.length !== DUAL_SALT_LEN + DUAL_IV_LEN + 80) return null;
301
+ const pinSalt = payload.slice(0, DUAL_SALT_LEN);
302
+ const ivPrefixedCt = payload.slice(DUAL_SALT_LEN); // decryptWithKey 契約:payload = iv[12] ‖ ct
303
+ const pinNorm = normalizePin(pin);
304
+ if (!pinNorm) return null;
305
+ const kek2 = await deriveKek2Argon(cfg, passphrase, pinNorm, salt1, pinSalt);
306
+ const rawHex = await decryptWithKey(kek2, ivPrefixedCt, 'notekey2');
307
+ if (!rawHex) return null;
308
+ return importAesGcm(hexToBytes(rawHex), true); // extractable=true:要能再包裹(鐵律)
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ // ── jr3s. 分享包裹(分享連結 V2,安全路線商用前清單;jrsw. 的 Argon2id 版) ───
315
+ //
316
+ // KEK_share = Argon2id(sharePass, salt, m=64MiB, t=3, p=1, tag=32B)(與 jr3w. 同級參數),
317
+ // payload = iv[12] ‖ GCM(KEK_share, hex(noteKey), aad='notekey-share')(與 jrsw. 同構只換 KDF),
318
+ // wrapped = 'jr3s.' + b64(payload),嚴格 92B;salt 由呼叫端存 shares.salt(與 jr1w./jr3w. 同形)。
319
+ // AAD 沿用 'notekey-share':兩代分享包裹結構同構,跨代誤用由 KDF 差異與前綴守衛雙層把關。
320
+ // 帶內版本化:share KDF 升級 = 換新前綴(jrsw. → jr3s.),舊前綴語意不動;讀取端兩代並行,
321
+ // 建立端只收 jr3s.(關閉弱 KDF 建立面)。
322
+ // ⚠️ unwrap 輸出 noteKey 一律 extractable=true(要能解日記密文;鐵律 4)。
323
+
324
+ /** jr3s. 包裹:salt 隨機 16 bytes(hex 由呼叫端存 shares.salt)。 */
325
+ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, sharePass: string): Promise<{ wrapped: string; salt: string }> {
326
+ if (!cfg.wrapShare3) throw new Error('ERR_JR3S_NOT_CONFIGURED');
327
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
328
+ const kek = await deriveKekArgon(sharePass, salt);
329
+ const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
330
+ const wrapped = cfg.wrapShare3 + await encryptWithKey(kek, rawHex, 'notekey-share');
331
+ return { wrapped, salt: toHex(salt) };
332
+ }
333
+
334
+ /** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。 */
335
+ export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
336
+ try {
337
+ if (!cfg.wrapShare3 || !wrapped.startsWith(cfg.wrapShare3)) return null;
338
+ const salt = hexToBytes(saltHex);
339
+ if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
340
+ const payload = unb64(wrapped.slice(cfg.wrapShare3.length));
341
+ // 嚴格長度:iv(12) + ct(hex(noteKey) 64B + GCM tag 16B) = 92B 固定(salt 在 shares.salt 不內嵌)
342
+ if (payload.length !== IV_LEN + 80) return null;
343
+ const kek = await deriveKekArgon(sharePass, salt);
344
+ const rawHex = await decryptWithKey(kek, payload, 'notekey-share');
345
+ if (!rawHex) return null;
346
+ return importAesGcm(hexToBytes(rawHex), true); // extractable=true:解出後要能解日記密文(鐵律)
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * bip39.ts — 復原套件轉寫層(BIP39 英文 24 詞 ⇄ entropy 32B ⇄ recToken hex64)。
3
+ *
4
+ * 契約裁定(2026-09-10 復原套件三件):
5
+ * - **轉寫層,非 KDF 升級**:24 詞 ⇄ hex64 可逆互轉;線上契約(rec_hash = SHA-256(hex64)、
6
+ * wrappedRec = jr1w. + GCM(PBKDF2(recToken, ...)))完全不變,伺服器零改動。
7
+ * 熵等價:24 詞 = 256-bit entropy + 8-bit checksum(BIP39 標準);與舊 hex64 同熵。
8
+ * - **checksum 是轉寫品質閘**:單詞打錯 255/256 機率當場被抓(64 hex 轉寫無此偵測);
9
+ * 解析失敗一律回 null(不洩漏哪類錯:字數/詞表外/checksum 統一,UI 統一訊息)。
10
+ * - **只收 24 詞**:12 詞是降級禁用(security-roadmap 裁定);15/18/21 不收(契約面單一)。
11
+ * - **相容層永久**:舊帳戶 hex64 紙本照走現行鏈;recTokenToWords 供既有套件顯示用。
12
+ * - **零依賴**:SHA-256 用 WebCrypto(全 async API);bits 打包自製(閘 [9] 以
13
+ * @scure/bip39 參照實作 200 組雙向對照守證)。禁用 @scure 的 mnemonicToSeed
14
+ * (PBKDF2-SHA512 seed 派生與 Tacet 契約無關,不可混入)。
15
+ */
16
+ import { wordlist } from './wordlist.ts';
17
+
18
+ const HEX64_RE = /^[0-9a-f]{64}$/;
19
+
20
+ async function sha256(data: Uint8Array): Promise<Uint8Array> {
21
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', data as BufferSource));
22
+ }
23
+
24
+ function hexToBytes(hexStr: string): Uint8Array {
25
+ const out = new Uint8Array(hexStr.length / 2);
26
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16);
27
+ return out;
28
+ }
29
+
30
+ function bytesToHex(bytes: Uint8Array): string {
31
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
32
+ }
33
+
34
+ /** 輸入正規化:NFKC → 換行/全形空白/多空白收斂單半形空格 → 小寫(wordlist 全小寫,輸入容錯)。 */
35
+ function normalizeWords(input: string): string[] {
36
+ return input.normalize('NFKC').trim().toLowerCase().split(/\s+/).filter(w => w.length > 0);
37
+ }
38
+
39
+ /** 11-bit 打包(BIP39 標準位元累積器;carry 恆 < 19 bits,索引 = 低位遮罩 0x7ff)。 */
40
+ function entropyToIndexes(entropy: Uint8Array, checksum: number): number[] {
41
+ const bytes = new Uint8Array(entropy.length + 1);
42
+ bytes.set(entropy);
43
+ bytes[entropy.length] = checksum; // 32B entropy → checksum 8 bits = SHA-256 首位元組全值
44
+ const indexes: number[] = [];
45
+ let carry = 0;
46
+ let bits = 0;
47
+ for (const byte of bytes) {
48
+ carry = (carry << 8) | byte;
49
+ bits += 8;
50
+ if (bits >= 11) {
51
+ bits -= 11;
52
+ indexes.push((carry >>> bits) & 0x7ff);
53
+ carry &= (1 << bits) - 1;
54
+ }
55
+ }
56
+ return indexes;
57
+ }
58
+
59
+ /** 11-bit 逆打包:24 個索引 → 33 bytes(entropy 32B + checksum 1B)。 */
60
+ function indexesToBytes(indexes: number[]): Uint8Array {
61
+ const out = new Uint8Array(33);
62
+ let carry = 0;
63
+ let bits = 0;
64
+ let pos = 0;
65
+ for (const idx of indexes) {
66
+ carry = (carry << 11) | idx;
67
+ bits += 11;
68
+ while (bits >= 8) {
69
+ bits -= 8;
70
+ out[pos++] = (carry >>> bits) & 0xff;
71
+ carry &= (1 << bits) - 1;
72
+ }
73
+ }
74
+ return out; // 24 詞 = 264 bits = 33 bytes 整除,無餘位(11-bit 打包自洽)
75
+ }
76
+
77
+ /** 產生 24 詞套件(entropy 32B 隨機 → +8-bit checksum → 24 詞)。 */
78
+ export async function generateBip39Words(): Promise<string[]> {
79
+ const entropy = crypto.getRandomValues(new Uint8Array(32));
80
+ const checksum = (await sha256(entropy))[0];
81
+ return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
82
+ }
83
+
84
+ /** 24 詞 → recToken hex64。任何不符(字數/詞表外/checksum)回 null,不洩漏哪類錯。 */
85
+ export async function wordsToRecToken(input: string | string[]): Promise<string | null> {
86
+ const words = typeof input === 'string' ? normalizeWords(input) : input.map(w => w.normalize('NFKC').trim().toLowerCase());
87
+ if (words.length !== 24) return null;
88
+ const indexes: number[] = [];
89
+ for (const w of words) {
90
+ const idx = wordlist.indexOf(w);
91
+ if (idx < 0) return null;
92
+ indexes.push(idx);
93
+ }
94
+ const bytes = indexesToBytes(indexes);
95
+ const entropy = bytes.slice(0, 32);
96
+ const checksum = bytes[32];
97
+ const expected = (await sha256(entropy))[0];
98
+ if (checksum !== expected) return null;
99
+ return bytesToHex(entropy);
100
+ }
101
+
102
+ /** recToken hex64 → 24 詞(既有套件顯示需求;非 hex64 或格式不符回 null)。 */
103
+ export async function recTokenToWords(recToken: string): Promise<string[] | null> {
104
+ if (!HEX64_RE.test(recToken)) return null;
105
+ const entropy = hexToBytes(recToken);
106
+ const checksum = (await sha256(entropy))[0];
107
+ return entropyToIndexes(entropy, checksum).map(idx => wordlist[idx]);
108
+ }
109
+
110
+ /** 抄寫抽驗位置:24 詞中抽 4 個不重複位置(0-23),排序回傳(顯示順序穩定)。 */
111
+ export function spotCheckIndexes(): number[] {
112
+ const pool = Array.from({ length: 24 }, (_, i) => i);
113
+ for (let i = pool.length - 1; i > 0; i--) {
114
+ const j = crypto.getRandomValues(new Uint32Array(1))[0] % (i + 1);
115
+ [pool[i], pool[j]] = [pool[j], pool[i]];
116
+ }
117
+ return pool.slice(0, 4).sort((a, b) => a - b);
118
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * keys.ts — storage key 命名空間。各產品以 config 指定前綴(如 journal 的 <brand>_)。
3
+ * 理由:fork diff 證實 noteCrypto 差異一半在 localStorage key 前綴——參數化收進 core。
4
+ */
5
+
6
+ export interface KeyStoreConfig {
7
+ /** 產品前綴,如 'tacet'(不含底線)。 */
8
+ brand: string;
9
+ }
10
+
11
+ export function makeKeyStore(config: KeyStoreConfig) {
12
+ const wrapKey = (soulKey: string) => `${config.brand}_notekey:${soulKey}`;
13
+ return {
14
+ get: (k: string): string | null => {
15
+ try { return localStorage.getItem(k); } catch { return null; }
16
+ },
17
+ set: (k: string, v: string): void => {
18
+ try { localStorage.setItem(k, v); } catch { /* private mode:不阻擋主流程 */ }
19
+ },
20
+ remove: (k: string): void => {
21
+ try { localStorage.removeItem(k); } catch { /* noop */ }
22
+ },
23
+ noteKeyWrap: wrapKey,
24
+ };
25
+ }
26
+
27
+ export type KeyStore = ReturnType<typeof makeKeyStore>;