@tacet-ink/journal-core 0.3.2 → 0.3.4

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.
@@ -1,110 +0,0 @@
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 字串 64B + GCM tag 16B) = 108B 固定。 */
28
- const PAYLOAD_LEN = SALT_LEN + IV_LEN + 80;
29
-
30
- /** 本機鎖定 cfg(各 fork config 注入;未配置 = API 拒絕,兄弟 fork 行為不變)。
31
- * noteKeyExtractable 必填死欄移除——解包輸出恆 extractable(鐵律 1,
32
- * 要能再包裹/匯出)不是可調選項;tacet 消費端 TACET_PINLOCK 同步摘欄。 */
33
- export interface PinLockConfig {
34
- /** 本機鎖定包裹前綴,如 'jr1p.'. 未配置 = 拒絕。 */
35
- pinLock?: string;
36
- /** KDF salt 前綴,如 'tacet-pinlock-v1:'(拼接 hex(pinSalt) 後整串當 salt)。 */
37
- pinLockSaltPrefix?: string;
38
- /** AAD(鎖定包裹專用,與登入第二因子/分享包裹互斥)。未配置 = 拒絕。 */
39
- pinLockAad?: string;
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
- * 鹽內嵌家族(jr2w./jr3d./jr1p.)自帶三段組裝——sealNoteKey 是鹽外置家族形(iv‖ct),
68
- * 內嵌鹽的 pinSalt 前綴不在其契約內(毒化輪回歸實證:亂收口=unwrap 恆 null)。 */
69
- export async function wrapNoteKeyPinLock(cfg: PinLockConfig, noteKey: CryptoKey, pin: string): Promise<string> {
70
- if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) throw new Error('ERR_PINLOCK_NOT_CONFIGURED');
71
- const pinNorm = normalizePin(pin);
72
- if (!pinNorm) throw new Error('ERR_PINLOCK_EMPTY');
73
- const pinSalt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
74
- const kek = await deriveLockKek(pinNorm, pinSalt, cfg);
75
- const rawHex = toHex(new Uint8Array(await crypto.subtle.exportKey('raw', noteKey)));
76
- const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
77
- const ct = new Uint8Array(await crypto.subtle.encrypt(
78
- { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(cfg.pinLockAad) as BufferSource },
79
- kek,
80
- new TextEncoder().encode(rawHex) as BufferSource,
81
- ));
82
- const payload = new Uint8Array(PAYLOAD_LEN);
83
- payload.set(pinSalt, 0);
84
- payload.set(iv, SALT_LEN);
85
- payload.set(ct, SALT_LEN + IV_LEN);
86
- return cfg.pinLock + b64(payload);
87
- }
88
-
89
- /** jr1p. 解包:全自描述;任何不符(前綴/長度/PIN 空/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 1)。
90
- * 嚴格檢查為鹽內嵌族自有(payload 嚴格 108B+rawHex hex 形檢查;
91
- * hexToBytes fail-closed 由 try/catch 承接=行為不變)——不經 openNoteKey 本體
92
- * (openNoteKey 是鹽外置家族 92B 形,pinSalt 前綴不在其契約內)。 */
93
- export async function unwrapNoteKeyPinLock(cfg: PinLockConfig, wrapped: string, pin: string): Promise<CryptoKey | null> {
94
- try {
95
- if (!cfg.pinLock || !cfg.pinLockSaltPrefix || !cfg.pinLockAad) return null;
96
- if (!wrapped.startsWith(cfg.pinLock)) return null;
97
- const payload = unb64(wrapped.slice(cfg.pinLock.length));
98
- if (payload.length !== PAYLOAD_LEN) return null;
99
- const pinSalt = payload.slice(0, SALT_LEN);
100
- const ivPrefixedCt = payload.slice(SALT_LEN); // 鹽內嵌家族:payload = pinSalt[16] ‖ iv[12] ‖ ct
101
- const pinNorm = normalizePin(pin);
102
- if (!pinNorm) return null;
103
- const kek = await deriveLockKek(pinNorm, pinSalt, cfg);
104
- const rawHex = await decryptWithKey(kek, ivPrefixedCt, cfg.pinLockAad);
105
- if (!rawHex || rawHex.length !== 64 || !/^[0-9a-f]+$/.test(rawHex)) return null;
106
- return importAesGcm(hexToBytes(rawHex), true);
107
- } catch {
108
- return null;
109
- }
110
- }
@@ -1,51 +0,0 @@
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
- }
@@ -1,486 +0,0 @@
1
- /**
2
- * vault.ts — 高階 Vault API:createVault/unlockVault/recoverVault → VaultHandle。
3
- *
4
- * 定位:低階原語(note-crypto.ts/argon2.ts)的**呼叫端**——additive-only,零改動原語語意。
5
- * 一個 vault=一把隨機 256-bit noteKey(generateNoteKey)+伺服器存的包裹欄(wrapped+salt)
6
- * +選配復原套件(jr1r. 家族 wrappedRec)。本模組無儲存面(pure object):storage 域
7
- *(keys.ts 品牌命名空間)是上層責任,serverRecord 由呼叫端上傳/覆存。
8
- *
9
- * 鐵律承襲(逐條落點):
10
- * - extractable:noteKey 恆來自 generateNoteKey/unwrap 家族(皆 extractable=true)——本檔不自 import 金鑰。
11
- * - AAD 由呼叫端指定:本檔就是呼叫端——記錄密文 AAD=`<app>:<recordId>`(app 由配置定、
12
- * recordId 由 API 參數帶入;低階原語簽名不動)。
13
- * - 帶內版本化:寫面恆最新家族(jr4w./PIN 時 jr4d.);upgrade=新前綴包同一 noteKey,舊前綴語意不動。
14
- * - zero-fallback:Argon2id 載體缺席=ERR_VAULT_KDF_UNSUPPORTED,禁 PBKDF2/zeros 兜底
15
- * (jr1w./jr2w. 舊世代讀面本就是 PBKDF2 契約——那是家族語意,不是兜底)。
16
- * - opt-in 未配置即拒:配置缺欄/serverRecord 缺欄=ERR_VAULT_*,不猜。
17
- *
18
- * 記錄密文形(本 API 自有契約,前綴由配置 `cipher` 定——一個前綴一份契約,禁借 cipherBound):
19
- * blob = cipher + b64( iv[12] ‖ AES-GCM-256(noteKey, tag[1] ‖ body, aad = UTF8(app + ':' + recordId)) )
20
- * tag:0x73 's'=字串(body=UTF-8)/0x6a 'j'=JSON(body=UTF-8(JSON.stringify))/0x62 'b'=位元組(body=原樣)。
21
- */
22
-
23
- import {
24
- generateNoteKey,
25
- generateRecToken,
26
- recTokenHash,
27
- wrapNoteKeyWithRecToken,
28
- unwrapNoteKeyWithRecToken,
29
- unwrapNoteKey,
30
- unwrapNoteKeyDual,
31
- normalizePin,
32
- b64,
33
- unb64,
34
- IV_LEN,
35
- type NoteCryptoConfig,
36
- } from './note-crypto.ts';
37
- import {
38
- wrapNoteKey4,
39
- unwrapNoteKey4,
40
- wrapNoteKeyDual4,
41
- unwrapNoteKeyDual4,
42
- unwrapNoteKey3,
43
- unwrapNoteKeyDual3,
44
- type Argon3Config,
45
- } from './argon2.ts';
46
- import type { KeyStore } from './keys.ts';
47
- import { VaultError, isVaultError, type VaultErrorCode } from './vault-error.ts';
48
- import { ensureArgonCarrier, argonCarrierFailures, type ArgonCarrierKind, type ArgonCarrierOptions } from './argon-auto.ts';
49
-
50
- export { VaultError, isVaultError, type VaultErrorCode };
51
- export {
52
- ensureArgonCarrier,
53
- currentArgonCarrier,
54
- resetArgonCarrier,
55
- DEFAULT_ARGON_CHAIN,
56
- type ArgonCarrierKind,
57
- type ArgonCarrierChoice,
58
- type ArgonCarrierOptions,
59
- type ArgonFallbackEvent,
60
- } from './argon-auto.ts';
61
- export { argonWorkerSource, createBlobArgonWorker, type ArgonWorkerLike } from './argon-worker.ts';
62
-
63
- // ── 型別 ────────────────────────────────────────────────────────────────────
64
-
65
- export interface VaultConfig {
66
- /** 應用域(AAD 前段):記錄密文 AAD=`<app>:<recordId>`。必填非空。 */
67
- app: string;
68
- /** 記錄密文前綴(如 'jr1v.')。必填非空,且與所有包裹前綴互不為前綴。 */
69
- cipher: string;
70
- /** Argon2id 家族前綴:`wrap4` 必填(寫面);`wrapDual4`+`pinSalt3Prefix`=PIN 寫面;
71
- * `wrap3`/`wrapDual3`=舊世代讀面(unlock+upgrade 來源)。`wrapShare3` 本 API 不用。 */
72
- argon: Argon3Config;
73
- /** PBKDF2 舊世代讀面(jr1w.=`wrap`;jr2w.=`wrapDual`+`pinSaltPrefix`)。只讀不寫。 */
74
- legacy?: { wrap?: string; wrapDual?: string; pinSaltPrefix?: string };
75
- /** 復原套件(jr1r. HKDF 世代;兩欄一體)。未配置=createVault 不產復原套件、recoverVault 拒。 */
76
- recovery?: { wrapRec: string; recKekHkdf: string; recKekSalt?: string };
77
- }
78
-
79
- export interface VaultOptions extends VaultConfig {
80
- /** PIN 第二因子:create=寫 jr4d.;unlock=雙因子家族必帶。 */
81
- pin?: string;
82
- /** Argon2id 載體選項(回退鏈/Worker 工廠/回退通知)。setArgonLoader 注入恆優先。 */
83
- carrier?: ArgonCarrierOptions;
84
- }
85
-
86
- /** 伺服器存的包裹欄(login 回應帶回;兩欄成對)。 */
87
- export interface VaultServerRecord {
88
- wrapped: string;
89
- salt: string;
90
- }
91
-
92
- /** 復原套件:recToken 只顯示一次(可經 bip39.ts 轉 24 詞);伺服器存 wrappedRec+recTokenHash。 */
93
- export interface VaultRecoveryKit {
94
- recToken: string;
95
- wrappedRec: string;
96
- recTokenHash: string;
97
- }
98
-
99
- /** 包裹家族(以配置欄名識別——前綴值由各產品配置)。 */
100
- export type VaultWrapFamily = 'wrap4' | 'wrapDual4' | 'wrap3' | 'wrapDual3' | 'wrap' | 'wrapDual';
101
-
102
- export type VaultJson = string | number | boolean | null | VaultJson[] | { [k: string]: VaultJson };
103
- /** encrypt 資料形:字串/JSON 值(物件、陣列、數、布林、null)/Uint8Array。decrypt 還原同形。 */
104
- export type VaultData = string | Uint8Array | VaultJson;
105
-
106
- export interface VaultHandle {
107
- /** 現行包裹家族(recoverVault 後未 changePassphrase=null)。 */
108
- readonly family: VaultWrapFamily | null;
109
- /** 現行伺服器包裹欄副本(recoverVault 後未 changePassphrase=null)。 */
110
- readonly serverRecord: VaultServerRecord | null;
111
- /** 最近一次 Argon2id 派生所用載體(未用過 Argon=null,例:jr1w. 解鎖、復原)。 */
112
- readonly carrier: ArgonCarrierKind | null;
113
- /** 加密一則:AAD=`<app>:<recordId>`。 */
114
- encrypt(recordId: string, data: VaultData): Promise<string>;
115
- /** 解密一則:recordId 不符(搬列)/竄改=ERR_VAULT_DECRYPT。 */
116
- decrypt<T extends VaultData = VaultData>(recordId: string, blob: string): Promise<T>;
117
- /** 換密語:同一 noteKey 以最新家族重包(資料零換鑰)。pin:字串=jr4d.;null=改單因子;
118
- * 省略=沿現行(雙因子 vault 省略=ERR_VAULT_PIN_REQUIRED——本 API 不保留 PIN)。回新包裹欄供覆存。 */
119
- changePassphrase(newPassphrase: string, opts?: { pin?: string | null }): Promise<VaultServerRecord>;
120
- /** 包裹家族非最新世代(jr1w./jr2w./jr3w./jr3d. → jr4 家族)。 */
121
- needsUpgrade(): boolean;
122
- /** 同一 noteKey 重包最新家族(同因子形)。先以 passphrase(+pin)解現行包裹驗證同鑰,
123
- * 不符=ERR_VAULT_WRAP_MISMATCH(防錯密語重包鎖死帳戶)。已最新=原包裹欄原樣回(無寫入)。 */
124
- upgrade(passphrase: string, opts?: { pin?: string }): Promise<VaultServerRecord>;
125
- }
126
-
127
- // ── 配置驗證 ────────────────────────────────────────────────────────────────
128
-
129
- const HEX32_RE = /^[0-9a-f]{32}$/;
130
- const HEX64_RE = /^[0-9a-f]{64}$/;
131
-
132
- const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
133
-
134
- interface FamilySpec {
135
- family: VaultWrapFamily;
136
- dual: boolean;
137
- current: boolean;
138
- argon: boolean;
139
- prefix: (c: VaultConfig) => string | undefined;
140
- }
141
-
142
- /** 讀面次序(新→舊):jr4 家族優先,舊世代依序。 */
143
- const FAMILIES: readonly FamilySpec[] = [
144
- { family: 'wrap4', dual: false, current: true, argon: true, prefix: (c) => c.argon.wrap4 },
145
- { family: 'wrapDual4', dual: true, current: true, argon: true, prefix: (c) => (c.argon.pinSalt3Prefix ? c.argon.wrapDual4 : undefined) },
146
- { family: 'wrap3', dual: false, current: false, argon: true, prefix: (c) => c.argon.wrap3 },
147
- { family: 'wrapDual3', dual: true, current: false, argon: true, prefix: (c) => (c.argon.pinSalt3Prefix ? c.argon.wrapDual3 : undefined) },
148
- { family: 'wrap', dual: false, current: false, argon: false, prefix: (c) => c.legacy?.wrap },
149
- { family: 'wrapDual', dual: true, current: false, argon: false, prefix: (c) => (c.legacy?.pinSaltPrefix ? c.legacy?.wrapDual : undefined) },
150
- ];
151
-
152
- function specOf(family: VaultWrapFamily): FamilySpec {
153
- return FAMILIES.find((f) => f.family === family)!;
154
- }
155
-
156
- function validateConfig(c: VaultConfig): void {
157
- if (!c || typeof c !== 'object') throw new VaultError('ERR_VAULT_CONFIG', 'config missing');
158
- if (!nonEmpty(c.app)) throw new VaultError('ERR_VAULT_CONFIG', 'app');
159
- if (!nonEmpty(c.cipher)) throw new VaultError('ERR_VAULT_CONFIG', 'cipher');
160
- if (!c.argon || !nonEmpty(c.argon.wrap4)) throw new VaultError('ERR_VAULT_CONFIG', 'argon.wrap4');
161
- const prefixes: string[] = [c.cipher];
162
- for (const f of FAMILIES) {
163
- const p = f.prefix(c);
164
- if (p !== undefined && p !== '') prefixes.push(p);
165
- }
166
- if (c.recovery !== undefined) {
167
- const r = c.recovery;
168
- // 兩欄一體(ERR_REC_CFG_PARTIAL 母型):部分配置拒——禁寫出跨契約 blob。
169
- if (!r || !nonEmpty(r.wrapRec) || !nonEmpty(r.recKekHkdf)) throw new VaultError('ERR_VAULT_CONFIG', 'recovery.wrapRec+recKekHkdf');
170
- if (r.recKekSalt !== undefined && !HEX32_RE.test(r.recKekSalt)) throw new VaultError('ERR_VAULT_CONFIG', 'recovery.recKekSalt');
171
- prefixes.push(r.wrapRec);
172
- }
173
- // 前綴互斥:任兩前綴互不為前綴(startsWith 家族辨識的唯一性前提——歧義配置不猜)。
174
- for (let i = 0; i < prefixes.length; i++) {
175
- for (let j = 0; j < prefixes.length; j++) {
176
- if (i !== j && prefixes[i].startsWith(prefixes[j])) throw new VaultError('ERR_VAULT_CONFIG', 'prefix overlap: ' + prefixes[i] + ' / ' + prefixes[j]);
177
- }
178
- }
179
- }
180
-
181
- function checkPassphrase(p: unknown): asserts p is string {
182
- if (!nonEmpty(p)) throw new VaultError('ERR_VAULT_BAD_PASSPHRASE');
183
- }
184
-
185
- function checkPin(pin: string): void {
186
- if (typeof pin !== 'string' || !normalizePin(pin)) throw new VaultError('ERR_VAULT_BAD_PIN');
187
- }
188
-
189
- function checkDualWritable(c: VaultConfig): void {
190
- if (!nonEmpty(c.argon.wrapDual4) || !nonEmpty(c.argon.pinSalt3Prefix)) {
191
- throw new VaultError('ERR_VAULT_CONFIG', 'argon.wrapDual4+pinSalt3Prefix (PIN write)');
192
- }
193
- }
194
-
195
- /** 低階原語只吃完整 NoteCryptoConfig;本 API 用到的欄位只有 wrap/wrapDual/pinSaltPrefix/復原面——
196
- * store 面不被觸及(vault 無儲存),以惰性空殼滿足型別。 */
197
- const NO_STORE: KeyStore = { get: () => null, set: () => {}, remove: () => {}, noteKeyWrap: (k: string) => k };
198
-
199
- function legacyCfg(c: VaultConfig): NoteCryptoConfig {
200
- return {
201
- guestKdfPrefix: '',
202
- recSaltPrefix: '',
203
- cipherBound: c.cipher,
204
- wrap: c.legacy?.wrap ?? '',
205
- wrapDual: c.legacy?.wrapDual,
206
- pinSaltPrefix: c.legacy?.pinSaltPrefix,
207
- store: NO_STORE,
208
- };
209
- }
210
-
211
- /** 復原面專用 cfg:wrap='' =舊契約回落腿關閉(identity 綁域的 jr1w. 復原套件不屬本 API)。 */
212
- function recoveryCfg(c: VaultConfig): NoteCryptoConfig {
213
- const r = c.recovery!;
214
- return {
215
- guestKdfPrefix: '',
216
- recSaltPrefix: '',
217
- cipherBound: c.cipher,
218
- wrap: '',
219
- wrapRec: r.wrapRec,
220
- recKekHkdf: r.recKekHkdf,
221
- recKekSalt: r.recKekSalt,
222
- store: NO_STORE,
223
- };
224
- }
225
-
226
- // ── 邊界轉譯 ────────────────────────────────────────────────────────────────
227
-
228
- /** 低階 throw(字串錯誤碼)→ VaultError;VaultError 原樣上拋。 */
229
- async function translate<T>(fn: () => Promise<T>): Promise<T> {
230
- try {
231
- return await fn();
232
- } catch (e) {
233
- if (isVaultError(e)) throw e;
234
- const msg = e instanceof Error ? e.message : String(e);
235
- if (/ARGON2/.test(msg)) throw new VaultError('ERR_VAULT_KDF_UNSUPPORTED', msg, { cause: e });
236
- if (/NOT_CONFIGURED/.test(msg)) throw new VaultError('ERR_VAULT_CONFIG', msg, { cause: e });
237
- if (msg === 'ERR_PIN_EMPTY') throw new VaultError('ERR_VAULT_BAD_PIN', msg, { cause: e });
238
- throw new VaultError('ERR_VAULT_INTERNAL', msg, { cause: e });
239
- }
240
- }
241
-
242
- // ── 包裹分派 ────────────────────────────────────────────────────────────────
243
-
244
- function detectFamily(c: VaultConfig, wrapped: string): FamilySpec | null {
245
- for (const f of FAMILIES) {
246
- const p = f.prefix(c);
247
- if (p && wrapped.startsWith(p)) return f;
248
- }
249
- return null;
250
- }
251
-
252
- async function unwrapBy(f: FamilySpec, c: VaultConfig, rec: VaultServerRecord, pass: string, pin: string): Promise<CryptoKey | null> {
253
- switch (f.family) {
254
- case 'wrap4': return unwrapNoteKey4(c.argon, rec.wrapped, pass, rec.salt);
255
- case 'wrapDual4': return unwrapNoteKeyDual4(c.argon, rec.wrapped, pass, pin, rec.salt);
256
- case 'wrap3': return unwrapNoteKey3(c.argon, rec.wrapped, pass, rec.salt);
257
- case 'wrapDual3': return unwrapNoteKeyDual3(c.argon, rec.wrapped, pass, pin, rec.salt);
258
- case 'wrap': return unwrapNoteKey(legacyCfg(c), rec.wrapped, pass, rec.salt);
259
- case 'wrapDual': return unwrapNoteKeyDual(legacyCfg(c), rec.wrapped, pass, pin, rec.salt);
260
- }
261
- }
262
-
263
- async function wrapCurrent(c: VaultConfig, noteKey: CryptoKey, pass: string, pin: string | null): Promise<{ rec: VaultServerRecord; family: VaultWrapFamily }> {
264
- if (pin === null) return { rec: await wrapNoteKey4(c.argon, noteKey, pass), family: 'wrap4' };
265
- return { rec: await wrapNoteKeyDual4(c.argon, noteKey, pass, pin), family: 'wrapDual4' };
266
- }
267
-
268
- /** 解包 null 的歸因:期間載體失效計數有增=KDF_UNSUPPORTED(低階 unwrap 吞錯回 null,邊界還原真因)。 */
269
- async function unwrapOrThrow(f: FamilySpec, c: VaultConfig, rec: VaultServerRecord, pass: string, pin: string): Promise<CryptoKey> {
270
- const before = argonCarrierFailures();
271
- const key = await unwrapBy(f, c, rec, pass, pin);
272
- if (key) return key;
273
- if (argonCarrierFailures() !== before) throw new VaultError('ERR_VAULT_KDF_UNSUPPORTED', 'carrier failed during unwrap');
274
- throw new VaultError('ERR_VAULT_WRAP_MISMATCH');
275
- }
276
-
277
- async function sameKey(a: CryptoKey, b: CryptoKey): Promise<boolean> {
278
- const [ra, rb] = await Promise.all([crypto.subtle.exportKey('raw', a), crypto.subtle.exportKey('raw', b)]);
279
- const x = new Uint8Array(ra);
280
- const y = new Uint8Array(rb);
281
- if (x.length !== y.length) return false;
282
- let d = 0;
283
- for (let i = 0; i < x.length; i++) d |= x[i] ^ y[i];
284
- return d === 0;
285
- }
286
-
287
- // ── 記錄密文 ────────────────────────────────────────────────────────────────
288
-
289
- const TAG_STRING = 0x73; // 's'
290
- const TAG_JSON = 0x6a; // 'j'
291
- const TAG_BYTES = 0x62; // 'b'
292
- const GCM_TAG_LEN = 16;
293
-
294
- function aadOf(app: string, recordId: unknown): Uint8Array {
295
- if (!nonEmpty(recordId)) throw new VaultError('ERR_VAULT_BAD_RECORD_ID');
296
- return new TextEncoder().encode(app + ':' + recordId);
297
- }
298
-
299
- function encodeData(data: VaultData): Uint8Array {
300
- let tag: number;
301
- let body: Uint8Array;
302
- if (typeof data === 'string') {
303
- tag = TAG_STRING;
304
- body = new TextEncoder().encode(data);
305
- } else if (data instanceof Uint8Array) {
306
- tag = TAG_BYTES;
307
- body = data;
308
- } else {
309
- // ArrayBuffer/其他 view:JSON.stringify 會靜默變 {}(資料滅失)——明拒,不猜。
310
- if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) throw new VaultError('ERR_VAULT_BAD_DATA', 'binary must be Uint8Array');
311
- let json: string | undefined;
312
- try { json = JSON.stringify(data); } catch (e) { throw new VaultError('ERR_VAULT_BAD_DATA', 'JSON', { cause: e }); }
313
- if (json === undefined) throw new VaultError('ERR_VAULT_BAD_DATA', 'not JSON-serializable');
314
- tag = TAG_JSON;
315
- body = new TextEncoder().encode(json);
316
- }
317
- const out = new Uint8Array(1 + body.byteLength);
318
- out[0] = tag;
319
- out.set(body, 1);
320
- return out;
321
- }
322
-
323
- function decodeData(plain: Uint8Array): VaultData {
324
- if (plain.length < 1) throw new VaultError('ERR_VAULT_BAD_BLOB', 'empty plaintext');
325
- const body = plain.subarray(1);
326
- try {
327
- switch (plain[0]) {
328
- case TAG_STRING: return new TextDecoder('utf-8', { fatal: true }).decode(body);
329
- case TAG_JSON: return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)) as VaultJson;
330
- case TAG_BYTES: return body.slice();
331
- }
332
- } catch (e) {
333
- throw new VaultError('ERR_VAULT_BAD_BLOB', 'body decode', { cause: e });
334
- }
335
- throw new VaultError('ERR_VAULT_BAD_BLOB', 'type tag');
336
- }
337
-
338
- async function sealRecord(c: VaultConfig, noteKey: CryptoKey, recordId: string, data: VaultData): Promise<string> {
339
- const aad = aadOf(c.app, recordId);
340
- const plain = encodeData(data);
341
- const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
342
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: aad as BufferSource }, noteKey, plain as BufferSource));
343
- const out = new Uint8Array(IV_LEN + ct.byteLength);
344
- out.set(iv, 0);
345
- out.set(ct, IV_LEN);
346
- return c.cipher + b64(out);
347
- }
348
-
349
- async function openRecord(c: VaultConfig, noteKey: CryptoKey, recordId: string, blob: string): Promise<VaultData> {
350
- const aad = aadOf(c.app, recordId);
351
- if (typeof blob !== 'string' || !blob.startsWith(c.cipher)) throw new VaultError('ERR_VAULT_BAD_BLOB', 'prefix');
352
- let payload: Uint8Array;
353
- try { payload = unb64(blob.slice(c.cipher.length)); } catch (e) { throw new VaultError('ERR_VAULT_BAD_BLOB', 'base64', { cause: e }); }
354
- if (payload.length < IV_LEN + GCM_TAG_LEN + 1) throw new VaultError('ERR_VAULT_BAD_BLOB', 'length');
355
- let plain: Uint8Array;
356
- try {
357
- plain = new Uint8Array(await crypto.subtle.decrypt(
358
- { name: 'AES-GCM', iv: payload.subarray(0, IV_LEN) as BufferSource, additionalData: aad as BufferSource },
359
- noteKey,
360
- payload.subarray(IV_LEN) as BufferSource,
361
- ));
362
- } catch (e) {
363
- throw new VaultError('ERR_VAULT_DECRYPT', undefined, { cause: e });
364
- }
365
- return decodeData(plain);
366
- }
367
-
368
- // ── handle ──────────────────────────────────────────────────────────────────
369
-
370
- interface HandleState {
371
- rec: VaultServerRecord | null;
372
- family: VaultWrapFamily | null;
373
- carrier: ArgonCarrierKind | null;
374
- }
375
-
376
- function makeHandle(c: VaultConfig, noteKey: CryptoKey, st: HandleState, carrierOpts: ArgonCarrierOptions | undefined): VaultHandle {
377
- const ensure = async (): Promise<void> => { st.carrier = await ensureArgonCarrier(carrierOpts); };
378
- return {
379
- get family() { return st.family; },
380
- get serverRecord() { return st.rec ? { ...st.rec } : null; },
381
- get carrier() { return st.carrier; },
382
- encrypt: (recordId, data) => translate(() => sealRecord(c, noteKey, recordId, data)),
383
- decrypt: <T extends VaultData = VaultData>(recordId: string, blob: string) =>
384
- translate(() => openRecord(c, noteKey, recordId, blob)) as Promise<T>,
385
- changePassphrase: (newPassphrase, opts) => translate(async () => {
386
- checkPassphrase(newPassphrase);
387
- let pin: string | null;
388
- if (opts?.pin === undefined) {
389
- if (st.family && specOf(st.family).dual) throw new VaultError('ERR_VAULT_PIN_REQUIRED');
390
- pin = null;
391
- } else {
392
- pin = opts.pin;
393
- }
394
- if (pin !== null) { checkPin(pin); checkDualWritable(c); }
395
- await ensure();
396
- const w = await wrapCurrent(c, noteKey, newPassphrase, pin);
397
- st.rec = w.rec;
398
- st.family = w.family;
399
- return { ...w.rec };
400
- }),
401
- needsUpgrade: () => st.family !== null && !specOf(st.family).current,
402
- upgrade: (passphrase, opts) => translate(async () => {
403
- if (!st.rec || !st.family) throw new VaultError('ERR_VAULT_BAD_RECORD', 'no server record (recovered vault: use changePassphrase)');
404
- if (specOf(st.family).current) return { ...st.rec };
405
- checkPassphrase(passphrase);
406
- const f = specOf(st.family);
407
- const pin = f.dual ? opts?.pin : undefined;
408
- if (f.dual) {
409
- if (pin === undefined) throw new VaultError('ERR_VAULT_PIN_REQUIRED');
410
- checkPin(pin);
411
- checkDualWritable(c);
412
- }
413
- await ensure(); // 寫面恆 Argon(jr4);讀驗證面 jr3 亦 Argon
414
- const check = await unwrapOrThrow(f, c, st.rec, passphrase, pin ?? '');
415
- if (!(await sameKey(check, noteKey))) throw new VaultError('ERR_VAULT_WRAP_MISMATCH', 'different noteKey');
416
- const w = await wrapCurrent(c, noteKey, passphrase, f.dual ? pin! : null);
417
- st.rec = w.rec;
418
- st.family = w.family;
419
- return { ...w.rec };
420
- }),
421
- };
422
- }
423
-
424
- // ── 入口 ────────────────────────────────────────────────────────────────────
425
-
426
- /** 新 vault:隨機 noteKey+最新家族包裹(pin=jr4d.,否則 jr4w.)+選配復原套件(jr1r.)。 */
427
- export function createVault(passphrase: string, opts: VaultOptions): Promise<{
428
- vault: VaultHandle;
429
- serverRecord: VaultServerRecord;
430
- recovery: VaultRecoveryKit | null;
431
- }> {
432
- return translate(async () => {
433
- validateConfig(opts);
434
- checkPassphrase(passphrase);
435
- const pin = opts.pin === undefined ? null : opts.pin;
436
- if (pin !== null) { checkPin(pin); checkDualWritable(opts); }
437
- const carrier = await ensureArgonCarrier(opts.carrier);
438
- const noteKey = await generateNoteKey();
439
- const w = await wrapCurrent(opts, noteKey, passphrase, pin);
440
- let recovery: VaultRecoveryKit | null = null;
441
- if (opts.recovery) {
442
- const recToken = generateRecToken();
443
- // identity 不入 HKDF 世代 KEK(identity-free by design)——空字串僅滿足簽名。
444
- const wrappedRec = await wrapNoteKeyWithRecToken(recoveryCfg(opts), noteKey, recToken, '');
445
- recovery = { recToken, wrappedRec, recTokenHash: await recTokenHash(recToken) };
446
- }
447
- const vault = makeHandle(opts, noteKey, { rec: w.rec, family: w.family, carrier }, opts.carrier);
448
- return { vault, serverRecord: { ...w.rec }, recovery };
449
- });
450
- }
451
-
452
- /** 解鎖:serverRecord(wrapped+salt 成對)按前綴辨識家族(新→舊次序),舊世代照解;
453
- * needsUpgrade() 為真時呼叫端可於同一動線 upgrade(passphrase) 靜默重包(Argon 遷移契約同構)。 */
454
- export function unlockVault(passphrase: string, serverRecord: VaultServerRecord, opts: VaultOptions): Promise<VaultHandle> {
455
- return translate(async () => {
456
- validateConfig(opts);
457
- const r = serverRecord as Partial<VaultServerRecord> | null | undefined;
458
- if (!r || typeof r !== 'object' || !nonEmpty(r.wrapped) || typeof r.salt !== 'string' || !HEX32_RE.test(r.salt)) {
459
- throw new VaultError('ERR_VAULT_BAD_RECORD');
460
- }
461
- checkPassphrase(passphrase);
462
- const rec: VaultServerRecord = { wrapped: r.wrapped, salt: r.salt };
463
- const f = detectFamily(opts, rec.wrapped);
464
- if (!f) throw new VaultError('ERR_VAULT_WRAP_UNKNOWN');
465
- if (f.dual) {
466
- if (opts.pin === undefined) throw new VaultError('ERR_VAULT_PIN_REQUIRED');
467
- checkPin(opts.pin);
468
- }
469
- const carrier = f.argon ? await ensureArgonCarrier(opts.carrier) : null;
470
- const noteKey = await unwrapOrThrow(f, opts, rec, passphrase, opts.pin ?? '');
471
- return makeHandle(opts, noteKey, { rec, family: f.family, carrier }, opts.carrier);
472
- });
473
- }
474
-
475
- /** 復原:recToken(hex64)+wrappedRec(jr1r.)→ handle(無包裹欄——呼叫端隨即 changePassphrase 設新密語)。 */
476
- export function recoverVault(recToken: string, wrappedRec: string, opts: VaultOptions): Promise<VaultHandle> {
477
- return translate(async () => {
478
- validateConfig(opts);
479
- if (!opts.recovery) throw new VaultError('ERR_VAULT_CONFIG', 'recovery');
480
- if (typeof recToken !== 'string' || !HEX64_RE.test(recToken)) throw new VaultError('ERR_VAULT_RECOVERY', 'recToken');
481
- if (typeof wrappedRec !== 'string' || !wrappedRec.startsWith(opts.recovery.wrapRec)) throw new VaultError('ERR_VAULT_RECOVERY', 'wrappedRec prefix');
482
- const noteKey = await unwrapNoteKeyWithRecToken(recoveryCfg(opts), wrappedRec, recToken, '');
483
- if (!noteKey) throw new VaultError('ERR_VAULT_RECOVERY');
484
- return makeHandle(opts, noteKey, { rec: null, family: null, carrier: null }, opts.carrier);
485
- });
486
- }