@tacet-ink/journal-core 0.3.0 → 0.3.2

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,224 @@
1
+ /**
2
+ * argon-auto.ts — Argon2id 載體平台預設。
3
+ *
4
+ * 優先序(向後相容鐵律:setArgonLoader 照舊優先):
5
+ * 1. 呼叫端已 setArgonLoader 注入(非本模組自裝)='injected'——本模組零介入。
6
+ * 2. 回退鏈 chain(可覆寫;預設 ['node','worker','inline'])逐項取第一個可用者:
7
+ * - 'node':node:crypto.argon2 在場(argon2.ts 原生腿;本模組不注入)。
8
+ * - 'worker':Web Worker(argon-worker.ts 內嵌 blob 形);執行期失效=若 chain 於其後含
9
+ * 'inline' 則回退 inline(onFallback 通知+currentArgonCarrier 翻 'inline'=行為顯形),否則 throw。
10
+ * - 'inline':同執行緒 vendor hash-wasm(WebAssembly 在場即可)。
11
+ * 3. 皆不可用=ERR_VAULT_KDF_UNSUPPORTED(zero-fallback:禁 PBKDF2/zeros 兜底)。
12
+ * 裝載即 KAT:每個載體首用前跑 RFC 9106 KAT(verifyArgonKat)——錯值載體=拒用,不只是缺席才拒。
13
+ *
14
+ * 'worker'/'inline' 經 setArgonLoader 全域注入(argon2.ts 派生本體唯一入口):同頁其他 argon2.ts
15
+ * 呼叫端共享此載體;呼叫端之後自行 setArgonLoader 即覆寫(下次 ensure 判 'injected')。
16
+ * resetArgonCarrier() 撤除本模組自裝載體(不動呼叫端注入)並終止 Worker。
17
+ */
18
+
19
+ import { getArgonLoader, setArgonLoader, verifyArgonKat, type ArgonLoader, type HashWasmArgon2id } from './argon2.ts';
20
+ import { makeWorkerArgon2id, createBlobArgonWorker, type ArgonWorkerLike } from './argon-worker.ts';
21
+ import { hashWasmArgon2Factory } from './vendor/hash-wasm-argon2.ts';
22
+ import { VaultError } from './vault-error.ts';
23
+
24
+ export type ArgonCarrierKind = 'injected' | 'node' | 'worker' | 'inline';
25
+ export type ArgonCarrierChoice = 'node' | 'worker' | 'inline';
26
+ export type { ArgonWorkerLike };
27
+
28
+ export interface ArgonFallbackEvent {
29
+ from: 'worker';
30
+ to: 'inline';
31
+ error: unknown;
32
+ }
33
+
34
+ export interface ArgonCarrierOptions {
35
+ /** 回退鏈(預設 ['node','worker','inline'])。例:['worker']=只准 Worker(失效即拒,不回退)。 */
36
+ chain?: readonly ArgonCarrierChoice[];
37
+ /** Worker 工廠覆寫(預設瀏覽器 blob Worker;測試/特殊宿主注入轉接層)。 */
38
+ createWorker?: () => ArgonWorkerLike;
39
+ /** Worker → inline 回退通知(行為顯形;不提供=靜默回退但 currentArgonCarrier 仍翻 'inline')。 */
40
+ onFallback?: (ev: ArgonFallbackEvent) => void;
41
+ }
42
+
43
+ export const DEFAULT_ARGON_CHAIN: readonly ArgonCarrierChoice[] = ['node', 'worker', 'inline'];
44
+
45
+ function nodeArgonAvailable(): boolean {
46
+ const nodeCrypto = (globalThis as unknown as {
47
+ process?: { getBuiltinModule?: (id: string) => { argon2?: unknown } | undefined };
48
+ }).process?.getBuiltinModule?.('node:crypto');
49
+ return typeof nodeCrypto?.argon2 === 'function';
50
+ }
51
+
52
+ function workerAvailable(opts: ArgonCarrierOptions): boolean {
53
+ if (opts.createWorker) return true;
54
+ const g = globalThis as unknown as { Worker?: unknown; Blob?: unknown; URL?: { createObjectURL?: unknown } };
55
+ return typeof g.Worker === 'function' && typeof g.Blob === 'function' && typeof g.URL?.createObjectURL === 'function';
56
+ }
57
+
58
+ function wasmAvailable(): boolean {
59
+ return typeof (globalThis as unknown as { WebAssembly?: unknown }).WebAssembly === 'object';
60
+ }
61
+
62
+ // ── 狀態 ────────────────────────────────────────────────────────────────────
63
+
64
+ interface AutoState {
65
+ loader: ArgonLoader;
66
+ kind: 'worker' | 'inline';
67
+ chainKey: string;
68
+ createWorker: ArgonCarrierOptions['createWorker'];
69
+ onFallback: ArgonCarrierOptions['onFallback'];
70
+ dispose(): void;
71
+ }
72
+
73
+ let auto: AutoState | null = null;
74
+ let nodeVerified = false;
75
+ const verified = new WeakSet<ArgonLoader>();
76
+ let failures = 0;
77
+
78
+ /** 載體終端失效累計(拋出派生本體的失效才計;回退成功不計)。vault 以前後差分辨
79
+ * 「解包 null=密語錯」與「載體失效」(低階 unwrap 吞錯回 null,邊界還原真因)。 */
80
+ export function argonCarrierFailures(): number {
81
+ return failures;
82
+ }
83
+
84
+ /** 現行載體(未 ensure 過且無注入、非 node=null)。 */
85
+ export function currentArgonCarrier(): ArgonCarrierKind | null {
86
+ const cur = getArgonLoader();
87
+ if (cur) return auto && cur === auto.loader ? auto.kind : 'injected';
88
+ return nodeArgonAvailable() ? 'node' : null;
89
+ }
90
+
91
+ /** 撤除本模組自裝載體(呼叫端 setArgonLoader 注入不動)+終止 Worker。 */
92
+ export function resetArgonCarrier(): void {
93
+ if (!auto) return;
94
+ const a = auto;
95
+ auto = null;
96
+ if (getArgonLoader() === a.loader) setArgonLoader(null);
97
+ a.dispose();
98
+ }
99
+
100
+ // ── inline 載體(同執行緒 vendor hash-wasm;串行) ────────────────────────────
101
+
102
+ function makeInlineArgon2id(): HashWasmArgon2id {
103
+ let mod: { argon2id: HashWasmArgon2id } | null = null;
104
+ let queue: Promise<unknown> = Promise.resolve();
105
+ return (params) => {
106
+ const job = queue.then(() => {
107
+ if (!mod) mod = hashWasmArgon2Factory();
108
+ return mod.argon2id(params);
109
+ });
110
+ queue = job.catch(() => undefined);
111
+ return job;
112
+ };
113
+ }
114
+
115
+ function terminal(e: unknown, what: string): VaultError {
116
+ failures++;
117
+ return new VaultError('ERR_VAULT_KDF_UNSUPPORTED', what, { cause: e });
118
+ }
119
+
120
+ function installInline(chainKey: string, opts: ArgonCarrierOptions): AutoState {
121
+ const inline = makeInlineArgon2id();
122
+ const argon2id: HashWasmArgon2id = async (params) => {
123
+ try { return await inline(params); } catch (e) { throw terminal(e, 'inline carrier failed'); }
124
+ };
125
+ const mod = { argon2id };
126
+ const st: AutoState = {
127
+ loader: () => Promise.resolve(mod),
128
+ kind: 'inline',
129
+ chainKey,
130
+ createWorker: opts.createWorker,
131
+ onFallback: opts.onFallback,
132
+ dispose: () => {},
133
+ };
134
+ return st;
135
+ }
136
+
137
+ function installWorker(chainKey: string, opts: ArgonCarrierOptions, fallbackInline: boolean): AutoState {
138
+ const w = makeWorkerArgon2id(opts.createWorker ?? createBlobArgonWorker);
139
+ let inline: HashWasmArgon2id | null = null;
140
+ const st: AutoState = {
141
+ loader: () => Promise.resolve(mod),
142
+ kind: 'worker',
143
+ chainKey,
144
+ createWorker: opts.createWorker,
145
+ onFallback: opts.onFallback,
146
+ dispose: () => w.terminate(),
147
+ };
148
+ const argon2id: HashWasmArgon2id = async (params) => {
149
+ if (st.kind === 'worker') {
150
+ try {
151
+ return await w.argon2id(params);
152
+ } catch (e) {
153
+ if (!fallbackInline) throw terminal(e, 'worker carrier failed (chain has no inline fallback)');
154
+ // 回退:整載體翻 inline(不再試 Worker)——顯形:kind 翻面+onFallback 通知。
155
+ if (st.kind === 'worker') {
156
+ st.kind = 'inline';
157
+ w.terminate();
158
+ try { opts.onFallback?.({ from: 'worker', to: 'inline', error: e }); } catch { /* 通知面不影響派生 */ }
159
+ }
160
+ }
161
+ }
162
+ inline ??= makeInlineArgon2id();
163
+ try { return await inline(params); } catch (e) { throw terminal(e, 'inline carrier failed'); }
164
+ };
165
+ const mod = { argon2id };
166
+ return st;
167
+ }
168
+
169
+ async function katOrThrow(): Promise<void> {
170
+ let ok = false;
171
+ try { ok = await verifyArgonKat(); } catch (e) {
172
+ if (e instanceof VaultError) throw e;
173
+ throw terminal(e, 'carrier KAT threw');
174
+ }
175
+ if (!ok) throw terminal(null, 'carrier KAT mismatch (RFC 9106)');
176
+ }
177
+
178
+ /**
179
+ * 確保 Argon2id 載體可用並回報種類。冪等:同選項重呼沿用既裝載體(Worker 不重建);
180
+ * 選項(chain/createWorker/onFallback)改變=撤除重裝。首用載體必過 RFC 9106 KAT。
181
+ */
182
+ export async function ensureArgonCarrier(opts: ArgonCarrierOptions = {}): Promise<ArgonCarrierKind> {
183
+ const cur = getArgonLoader();
184
+ if (cur && (!auto || cur !== auto.loader)) {
185
+ // 呼叫端覆寫優先:自裝殘留(若有)撤除但不碰全域注入。
186
+ if (auto) { const a = auto; auto = null; a.dispose(); }
187
+ if (!verified.has(cur)) { await katOrThrow(); verified.add(cur); }
188
+ return 'injected';
189
+ }
190
+ const chain = opts.chain ?? DEFAULT_ARGON_CHAIN;
191
+ if (!Array.isArray(chain) || chain.length === 0 || !chain.every((c) => c === 'node' || c === 'worker' || c === 'inline')) {
192
+ throw new VaultError('ERR_VAULT_CONFIG', 'carrier.chain');
193
+ }
194
+ const chainKey = chain.join(',');
195
+ if (auto && cur === auto.loader && auto.chainKey === chainKey && auto.createWorker === opts.createWorker && auto.onFallback === opts.onFallback) {
196
+ return auto.kind;
197
+ }
198
+ resetArgonCarrier();
199
+ for (let i = 0; i < chain.length; i++) {
200
+ const choice = chain[i];
201
+ if (choice === 'node') {
202
+ if (!nodeArgonAvailable()) continue;
203
+ if (!nodeVerified) { await katOrThrow(); nodeVerified = true; }
204
+ return 'node';
205
+ }
206
+ if (choice === 'worker') {
207
+ if (!workerAvailable(opts)) continue;
208
+ const st = installWorker(chainKey, opts, chain.slice(i + 1).includes('inline') && wasmAvailable());
209
+ auto = st;
210
+ setArgonLoader(st.loader);
211
+ try { await katOrThrow(); } catch (e) { resetArgonCarrier(); throw e; }
212
+ return st.kind;
213
+ }
214
+ if (choice === 'inline') {
215
+ if (!wasmAvailable()) continue;
216
+ const st = installInline(chainKey, opts);
217
+ auto = st;
218
+ setArgonLoader(st.loader);
219
+ try { await katOrThrow(); } catch (e) { resetArgonCarrier(); throw e; }
220
+ return st.kind;
221
+ }
222
+ }
223
+ throw new VaultError('ERR_VAULT_KDF_UNSUPPORTED', 'no Argon2id carrier in chain [' + chainKey + ']');
224
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * argon-worker.ts — Argon2id Web Worker 載體:登入不卡畫面。
3
+ *
4
+ * Worker 本體=內嵌 blob 形(免 bundle 步):vendor 工廠 hashWasmArgon2Factory 的原文
5
+ *(Function#toString)+訊息膠水組成 Worker 源碼字串 → Blob → object URL → new Worker。
6
+ * 協定:main → { id, password, salt, iterations, parallelism, memorySize, hashLength };
7
+ * worker → { id, ok: true, out: Uint8Array } | { id, ok: false, error }。
8
+ * 串行:main 端單一佇列(hash-wasm Argon2 禁並行——argon2.ts deriveKek2Argon 註同理),
9
+ * worker 端亦以 promise 鏈串行(雙防線)。
10
+ * 失敗態:建構拋出/error 事件/ok:false 回應=本載體失效(dead),佇列內與後續請求一律 reject——
11
+ * 回退與否由 argon-auto.ts 回退鏈決定(本檔不自帶回退,行為顯形)。
12
+ */
13
+
14
+ import type { HashWasmArgon2id } from './argon2.ts';
15
+ import { hashWasmArgon2Factory } from './vendor/hash-wasm-argon2.ts';
16
+
17
+ /** Worker 最小介面(瀏覽器 Worker 直接相容;node worker_threads 等可包轉接層注入)。 */
18
+ export interface ArgonWorkerLike {
19
+ postMessage(message: unknown, transfer?: Transferable[]): void;
20
+ terminate(): void;
21
+ onmessage: ((ev: { data: unknown }) => void) | null;
22
+ onerror: ((ev: unknown) => void) | null;
23
+ }
24
+
25
+ /** Worker 源碼字串(self.onmessage 形;瀏覽器 DedicatedWorkerGlobalScope 語意)。 */
26
+ export function argonWorkerSource(): string {
27
+ return [
28
+ "'use strict';",
29
+ 'const factory = (' + hashWasmArgon2Factory.toString() + ');',
30
+ 'let mod = null;',
31
+ 'let q = Promise.resolve();',
32
+ 'self.onmessage = (ev) => {',
33
+ ' const m = ev.data;',
34
+ ' q = q.then(async () => {',
35
+ ' try {',
36
+ ' if (!mod) mod = factory();',
37
+ " const r = await mod.argon2id({ password: m.password, salt: m.salt, iterations: m.iterations, parallelism: m.parallelism, memorySize: m.memorySize, hashLength: m.hashLength, outputType: 'binary' });",
38
+ ' const out = new Uint8Array(r);',
39
+ ' self.postMessage({ id: m.id, ok: true, out }, [out.buffer]);',
40
+ ' } catch (e) {',
41
+ ' self.postMessage({ id: m.id, ok: false, error: String((e && e.message) || e) });',
42
+ ' }',
43
+ ' });',
44
+ '};',
45
+ '',
46
+ ].join('\n');
47
+ }
48
+
49
+ /** 瀏覽器預設 Worker 工廠:blob URL(CSP 需 worker-src blob:;不允許時建構拋出或 error 事件→回退鏈接手)。 */
50
+ export function createBlobArgonWorker(): ArgonWorkerLike {
51
+ const url = URL.createObjectURL(new Blob([argonWorkerSource()], { type: 'text/javascript' }));
52
+ let w: Worker;
53
+ try {
54
+ w = new Worker(url);
55
+ } catch (e) {
56
+ URL.revokeObjectURL(url);
57
+ throw e;
58
+ }
59
+ const like: ArgonWorkerLike = {
60
+ postMessage: (m, t) => w.postMessage(m, t ?? []),
61
+ terminate: () => { w.terminate(); URL.revokeObjectURL(url); },
62
+ onmessage: null,
63
+ onerror: null,
64
+ };
65
+ w.onmessage = (ev) => like.onmessage?.({ data: ev.data });
66
+ w.onerror = (ev) => like.onerror?.(ev);
67
+ w.onmessageerror = (ev) => like.onerror?.(ev);
68
+ return like;
69
+ }
70
+
71
+ interface Pending {
72
+ resolve: (v: Uint8Array) => void;
73
+ reject: (e: unknown) => void;
74
+ }
75
+
76
+ /** Worker 載體:回 hash-wasm 同形 argon2id(argon2.ts ArgonLoader 契約)+terminate。 */
77
+ export function makeWorkerArgon2id(createWorker: () => ArgonWorkerLike): { argon2id: HashWasmArgon2id; terminate(): void } {
78
+ let worker: ArgonWorkerLike | null = null;
79
+ let dead: unknown = null;
80
+ let seq = 0;
81
+ const pending = new Map<number, Pending>();
82
+ let queue: Promise<unknown> = Promise.resolve();
83
+
84
+ const die = (e: unknown): void => {
85
+ if (!dead) dead = e ?? new Error('ERR_ARGON_WORKER_DEAD');
86
+ for (const p of pending.values()) p.reject(dead);
87
+ pending.clear();
88
+ if (worker) { try { worker.terminate(); } catch { /* 已終止 */ } }
89
+ worker = null;
90
+ };
91
+
92
+ const boot = (): ArgonWorkerLike => {
93
+ if (worker) return worker;
94
+ const w = createWorker();
95
+ w.onmessage = (ev) => {
96
+ const d = ev.data as { id?: number; ok?: boolean; out?: Uint8Array; error?: string } | null;
97
+ const p = d && typeof d.id === 'number' ? pending.get(d.id) : undefined;
98
+ if (!p || !d) return;
99
+ pending.delete(d.id!);
100
+ if (d.ok && d.out instanceof Uint8Array) p.resolve(d.out);
101
+ else {
102
+ const err = new Error('ERR_ARGON_WORKER: ' + (d.error ?? 'bad response'));
103
+ p.reject(err);
104
+ die(err); // ok:false=worker 內 wasm 失效(CSP wasm-eval/記憶體)——整載體 dead,交回退鏈
105
+ }
106
+ };
107
+ w.onerror = (ev) => die(new Error('ERR_ARGON_WORKER_ERROR', { cause: ev }));
108
+ worker = w;
109
+ return w;
110
+ };
111
+
112
+ const run = (params: Parameters<HashWasmArgon2id>[0]): Promise<Uint8Array> =>
113
+ new Promise<Uint8Array>((resolve, reject) => {
114
+ if (dead) { reject(dead); return; }
115
+ let w: ArgonWorkerLike;
116
+ try { w = boot(); } catch (e) { die(e); reject(dead); return; }
117
+ const id = ++seq;
118
+ pending.set(id, { resolve, reject });
119
+ try {
120
+ w.postMessage({
121
+ id,
122
+ password: params.password,
123
+ salt: params.salt,
124
+ iterations: params.iterations,
125
+ parallelism: params.parallelism,
126
+ memorySize: params.memorySize,
127
+ hashLength: params.hashLength,
128
+ });
129
+ } catch (e) {
130
+ pending.delete(id);
131
+ die(e);
132
+ reject(dead);
133
+ }
134
+ });
135
+
136
+ const argon2id: HashWasmArgon2id = (params) => {
137
+ const job = queue.then(() => run(params));
138
+ queue = job.catch(() => undefined);
139
+ return job.then((out) => {
140
+ if (params.outputType === 'binary') return out;
141
+ if (params.outputType === 'hex') return Array.from(out).map((b) => b.toString(16).padStart(2, '0')).join('');
142
+ throw new Error('ERR_ARGON_WORKER_OUTPUT_TYPE');
143
+ });
144
+ };
145
+
146
+ return { argon2id, terminate: () => die(new Error('ERR_ARGON_WORKER_TERMINATED')) };
147
+ }
@@ -52,6 +52,12 @@ function deriveInput(passphrase: string): string {
52
52
  export const ARGON_RFC9106_EXPECTED =
53
53
  '72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
54
54
 
55
+ /** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
56
+ * tagLen=派生本體固定輸出長(ARGON_TAG_LEN);pwd=passwordLen 位元組 passwordByte、salt=saltLen 位元組 0x00。 */
57
+ export const ARGON_RFC9106_PARAMS = Object.freeze({
58
+ memoryKib: 32, iterations: 3, parallelism: 4, tagLen: 32, passwordByte: 0x01, passwordLen: 32, saltLen: 16,
59
+ } as const);
60
+
55
61
  /**
56
62
  * PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
57
63
  *
@@ -140,7 +146,7 @@ export interface Argon3Config {
140
146
  wrapDual4?: string;
141
147
  }
142
148
 
143
- interface HashWasmArgon2id {
149
+ export interface HashWasmArgon2id {
144
150
  (params: {
145
151
  password: string | Uint8Array;
146
152
  salt: Uint8Array;
@@ -152,7 +158,7 @@ interface HashWasmArgon2id {
152
158
  }): Promise<Uint8Array | string>;
153
159
  }
154
160
 
155
- type ArgonLoader = () => Promise<{ argon2id: HashWasmArgon2id }>;
161
+ export type ArgonLoader = () => Promise<{ argon2id: HashWasmArgon2id }>;
156
162
 
157
163
  let injectedLoader: ArgonLoader | null = null;
158
164
 
@@ -161,6 +167,11 @@ export function setArgonLoader(loader: ArgonLoader | null): void {
161
167
  injectedLoader = loader;
162
168
  }
163
169
 
170
+ /** 現行注入載體(唯讀查詢;未注入=null)。argon-auto.ts 據此判「呼叫端覆寫優先」——0.3.1 新增 export。 */
171
+ export function getArgonLoader(): ArgonLoader | null {
172
+ return injectedLoader;
173
+ }
174
+
164
175
  /**
165
176
  * Argon2id 派生 32B。雙載體:injectedLoader(瀏覽器 hash-wasm)優先;
166
177
  * 無注入時走 node:crypto 原生(僅 node 執行環境可達)。皆不可得即 throw(禁 fallback)。
@@ -224,7 +235,8 @@ type NodeArgonFn = (
224
235
 
225
236
  /** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
226
237
  export async function verifyArgonKat(): Promise<boolean> {
227
- const raw = await deriveArgon2id(new Uint8Array(32).fill(1), new Uint8Array(16), 32, 3, 4);
238
+ const k = ARGON_RFC9106_PARAMS;
239
+ const raw = await deriveArgon2id(new Uint8Array(k.passwordLen).fill(k.passwordByte), new Uint8Array(k.saltLen), k.memoryKib, k.iterations, k.parallelism);
228
240
  return toHex(raw) === ARGON_RFC9106_EXPECTED;
229
241
  }
230
242
 
@@ -239,7 +251,7 @@ export async function deriveKekArgon(password: string, salt: Uint8Array): Promis
239
251
  return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
240
252
  }
241
253
 
242
- /** jr3w. 包裹:payload = salt[16] ‖ iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey');salt 由呼叫端存 users.salt。 */
254
+ /** jr3w. 包裹:payload = iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,由呼叫端存 users.salt。 */
243
255
  export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
244
256
  if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
245
257
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
@@ -274,7 +286,7 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
274
286
  // 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
275
287
  // pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
276
288
 
277
- /** jr4w. 包裹(v3 密語):payload = salt[16] ‖ iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey');salt 呼叫端存 users.salt。 */
289
+ /** jr4w. 包裹(v3 密語):payload = iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,呼叫端存 users.salt。 */
278
290
  export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
279
291
  if (!cfg.wrap4) throw new Error('ERR_JR4W_NOT_CONFIGURED');
280
292
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
@@ -27,6 +27,8 @@
27
27
 
28
28
  // ── 配置 ────────────────────────────────────────────────────────────────────
29
29
 
30
+ import type { KeyStore } from './keys.js';
31
+
30
32
  export interface NoteCryptoConfig {
31
33
  /** guest 時代 KDF 前綴,如 'journal-note-u1'。 */
32
34
  guestKdfPrefix: string;
@@ -75,7 +77,7 @@ export interface NoteCryptoConfig {
75
77
  /** 本機 IDB 密文前綴(jr1d.,notes store stored 形)。未配置 = local API 拒絕(未配置行為不變)。 */
76
78
  cipherLocal?: string;
77
79
  /** localStorage key store(品牌前綴由 keys.ts 管理)。 */
78
- store: import('./keys').KeyStore;
80
+ store: KeyStore;
79
81
  }
80
82
 
81
83
  export const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 建議值
@@ -0,0 +1,51 @@
1
+ /**
2
+ * vault-error.ts — 高階 Vault API 的型別化錯誤家族。
3
+ *
4
+ * 低階原語面不動(throw 'ERR_*' 字串錯誤+unwrap 恆 null 的混態照舊)——只在 vault.ts/
5
+ * argon-auto.ts 邊界轉譯:高階 API 一律 throw VaultError,呼叫端以 `code` 分流,不比對 message。
6
+ * 失敗面刻意不細分「密語錯」與「密文竄改」(同一 GCM 失敗——細分=oracle)。
7
+ */
8
+
9
+ export type VaultErrorCode =
10
+ /** 配置缺欄/畸形(app、cipher、argon.wrap4、recovery 兩欄一體…)——opt-in 未配置即拒。 */
11
+ | 'ERR_VAULT_CONFIG'
12
+ /** serverRecord 缺 wrapped/salt 欄或形不符(不猜)。 */
13
+ | 'ERR_VAULT_BAD_RECORD'
14
+ /** wrapped 前綴不屬任何已配置家族(未知世代/他產品前綴)。 */
15
+ | 'ERR_VAULT_WRAP_UNKNOWN'
16
+ /** 家族辨識成功但解包失敗:密語/PIN 錯或包裹竄改(刻意不分)。 */
17
+ | 'ERR_VAULT_WRAP_MISMATCH'
18
+ /** 雙因子家族(jr2w./jr3d./jr4d.)缺 PIN。 */
19
+ | 'ERR_VAULT_PIN_REQUIRED'
20
+ /** 密語空值/非字串。 */
21
+ | 'ERR_VAULT_BAD_PASSPHRASE'
22
+ /** PIN 正規化後為空。 */
23
+ | 'ERR_VAULT_BAD_PIN'
24
+ /** Argon2id 載體缺席或失效(zero-fallback:禁 PBKDF2/zeros 兜底)。 */
25
+ | 'ERR_VAULT_KDF_UNSUPPORTED'
26
+ /** recordId 非字串或空。 */
27
+ | 'ERR_VAULT_BAD_RECORD_ID'
28
+ /** encrypt 資料形不支援(undefined、BigInt、循環 JSON、非 Uint8Array 的 ArrayBuffer 族…)。 */
29
+ | 'ERR_VAULT_BAD_DATA'
30
+ /** 密文 blob 形不符(前綴/base64/長度/型別標記)。 */
31
+ | 'ERR_VAULT_BAD_BLOB'
32
+ /** 密文解密失敗:recordId(AAD)不符、金鑰不符或密文竄改(刻意不分)。 */
33
+ | 'ERR_VAULT_DECRYPT'
34
+ /** 復原套件形不符(recToken 非 hex64、wrappedRec 前綴不符)或解包失敗。 */
35
+ | 'ERR_VAULT_RECOVERY'
36
+ /** 未預期的低階錯誤(cause 帶原錯)。 */
37
+ | 'ERR_VAULT_INTERNAL';
38
+
39
+ export class VaultError extends Error {
40
+ readonly code: VaultErrorCode;
41
+ constructor(code: VaultErrorCode, message?: string, options?: { cause?: unknown }) {
42
+ super(message ? code + ': ' + message : code, options);
43
+ this.name = 'VaultError';
44
+ this.code = code;
45
+ }
46
+ }
47
+
48
+ /** 型別守衛:`isVaultError(e)` 或指定碼 `isVaultError(e, 'ERR_VAULT_DECRYPT')`。 */
49
+ export function isVaultError(e: unknown, code?: VaultErrorCode): e is VaultError {
50
+ return e instanceof VaultError && (code === undefined || e.code === code);
51
+ }