@moxxy/plugin-vault 0.0.33

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/src/store.ts ADDED
@@ -0,0 +1,457 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createMutex, MoxxyError, type Mutex } from '@moxxy/sdk';
3
+ import { writeFileAtomic } from '@moxxy/sdk/server';
4
+ import { decrypt, encrypt, generateSalt, type EncryptedBlob } from './crypto.js';
5
+ import type { MasterKeySource } from './keysource.js';
6
+
7
+ interface VaultFile {
8
+ readonly version: 1;
9
+ readonly kdf: 'scrypt';
10
+ readonly salt: string;
11
+ readonly entries: Record<string, VaultEntry>;
12
+ /**
13
+ * Known-plaintext probe encrypted with the master key. On open(), we
14
+ * decrypt this to verify the user-supplied passphrase matches the one
15
+ * used to create the file — otherwise the next `get()` would throw a
16
+ * cryptic AES-GCM auth-tag error.
17
+ *
18
+ * Optional for backward compatibility: a vault written by an older
19
+ * version of this code won't have it, in which case we fall back to
20
+ * probing the first real entry (if any).
21
+ */
22
+ readonly canary?: EncryptedBlob;
23
+ }
24
+
25
+ const CANARY_PLAINTEXT = 'moxxy:vault:v1';
26
+
27
+ // Upper bound on filesystem mtime granularity (HFS+/old ext are ~1-2s). Within
28
+ // this window of "now" an unchanged (mtime,size) fingerprint can't be trusted
29
+ // to mean "no other writer", so syncFromDisk re-reads instead of fast-pathing.
30
+ const MTIME_GRANULARITY_MS = 2000;
31
+
32
+ /**
33
+ * Thrown when the supplied passphrase doesn't match the stored vault.
34
+ * Surfaced to the user with a recovery hint instead of the raw AES-GCM
35
+ * "Unsupported state or unable to authenticate data" error.
36
+ */
37
+ export class VaultPassphraseError extends Error {
38
+ constructor(public readonly filePath: string) {
39
+ super(
40
+ `Wrong vault passphrase for ${filePath}.\n` +
41
+ ` If you've forgotten it, wipe the vault and key cache, then re-run \`moxxy init\`:\n` +
42
+ ` rm ${filePath} ~/.moxxy/vault.key\n` +
43
+ ` (If the OS keychain holds a cached key, also clear it: \`security delete-generic-password -s moxxy\` on macOS.)\n` +
44
+ ` Or set MOXXY_VAULT_PASSPHRASE to a known value to skip the prompt entirely.`,
45
+ );
46
+ this.name = 'VaultPassphraseError';
47
+ }
48
+ }
49
+
50
+ export interface VaultEntry extends EncryptedBlob {
51
+ readonly createdAt: string;
52
+ readonly updatedAt: string;
53
+ readonly tags?: ReadonlyArray<string>;
54
+ }
55
+
56
+ export interface VaultEntryInfo {
57
+ readonly name: string;
58
+ readonly createdAt: string;
59
+ readonly updatedAt: string;
60
+ readonly tags?: ReadonlyArray<string>;
61
+ }
62
+
63
+ export interface VaultStoreOptions {
64
+ readonly filePath: string;
65
+ readonly keySource: MasterKeySource;
66
+ }
67
+
68
+ export class VaultStore {
69
+ private readonly filePath: string;
70
+ private readonly keySource: MasterKeySource;
71
+ private file: VaultFile | null = null;
72
+ private masterKey: Buffer | null = null;
73
+ // Serializes every mutator (set/delete) and persist() so concurrent
74
+ // writers don't clobber each other through the read-modify-write +
75
+ // whole-file rewrite. open() is also chained through this so two
76
+ // parallel `open()` calls never both derive a fresh salt.
77
+ private readonly mutex: Mutex = createMutex();
78
+ // stat() fingerprint of the file as of our last load/sync/persist; lets
79
+ // syncFromDisk() skip the read+parse when nothing else has written.
80
+ private lastSynced: { mtimeMs: number; size: number } | null = null;
81
+
82
+ constructor(opts: VaultStoreOptions) {
83
+ this.filePath = opts.filePath;
84
+ this.keySource = opts.keySource;
85
+ }
86
+
87
+ async open(): Promise<void> {
88
+ if (this.file && this.masterKey) return;
89
+ return this.mutex.run(async () => {
90
+ if (this.file && this.masterKey) return;
91
+ await this.load();
92
+ });
93
+ }
94
+
95
+ get sourceName(): string {
96
+ return this.keySource.name;
97
+ }
98
+
99
+ private async load(): Promise<void> {
100
+ let raw: string | null = null;
101
+ try {
102
+ raw = await fs.readFile(this.filePath, 'utf8');
103
+ } catch (err) {
104
+ if (!isEnoent(err)) throw err;
105
+ }
106
+ if (raw === null) {
107
+ const salt = generateSalt();
108
+ this.masterKey = await this.obtainOwnedKey(salt);
109
+ this.file = {
110
+ version: 1,
111
+ kdf: 'scrypt',
112
+ salt: salt.toString('base64'),
113
+ entries: {},
114
+ canary: encrypt(CANARY_PLAINTEXT, this.masterKey),
115
+ };
116
+ await this.persist();
117
+ return;
118
+ }
119
+ let parsedRaw: unknown;
120
+ try {
121
+ parsedRaw = JSON.parse(raw);
122
+ } catch {
123
+ throw this.corruptError('Vault file is not valid JSON');
124
+ }
125
+ if (!isPlainObject(parsedRaw)) {
126
+ throw this.corruptError('Vault file is not a JSON object');
127
+ }
128
+ const parsed = parsedRaw as Partial<VaultFile>;
129
+ if (parsed.version !== 1 || parsed.kdf !== 'scrypt') {
130
+ throw new MoxxyError({
131
+ code: 'VAULT_CORRUPT',
132
+ message: `Unsupported vault file: version=${String(parsed.version)} kdf=${String(parsed.kdf)}`,
133
+ hint: `This vault was written by an incompatible version. Back up and remove ${this.filePath} to re-initialize.`,
134
+ context: { filePath: this.filePath },
135
+ });
136
+ }
137
+ if (!validateVaultFile(parsed)) {
138
+ throw this.corruptError('Vault file is malformed (bad salt/entries/canary shape)');
139
+ }
140
+ this.file = parsed;
141
+ const salt = Buffer.from(parsed.salt, 'base64');
142
+ this.masterKey = await this.obtainOwnedKey(salt);
143
+ this.verifyPassphrase();
144
+ // Backfill the canary on the first successful open of a legacy vault.
145
+ if (!this.file.canary) {
146
+ this.file = { ...this.file, canary: encrypt(CANARY_PLAINTEXT, this.masterKey) };
147
+ try {
148
+ await this.persist();
149
+ } catch {
150
+ // Best-effort — failing to backfill doesn't break the open.
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Obtain the master key and return a copy the store solely owns. Key sources
157
+ * may hand back a buffer they (or the caller) keep a reference to — copying
158
+ * lets `close()` zero our buffer without corrupting that shared one.
159
+ */
160
+ private async obtainOwnedKey(salt: Buffer): Promise<Buffer> {
161
+ return Buffer.from(await this.keySource.obtain(salt));
162
+ }
163
+
164
+ private corruptError(message: string): MoxxyError {
165
+ return new MoxxyError({
166
+ code: 'VAULT_CORRUPT',
167
+ message,
168
+ hint: `Back up and remove ${this.filePath} to re-initialize the vault.`,
169
+ context: { filePath: this.filePath },
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Confirm the master key decrypts the file's canary (or, for legacy
175
+ * vaults without a canary, the first stored entry). On mismatch throw
176
+ * a friendly `VaultPassphraseError` rather than letting the cryptic
177
+ * "Unsupported state or unable to authenticate data" error from
178
+ * Node's AES-GCM bubble up later.
179
+ */
180
+ private verifyPassphrase(): void {
181
+ if (!this.file || !this.masterKey) return;
182
+ const probe = this.file.canary ?? firstEntry(this.file.entries);
183
+ if (!probe) return; // empty legacy vault — nothing to verify yet.
184
+ try {
185
+ const plaintext = decrypt(probe, this.masterKey);
186
+ if (this.file.canary && plaintext !== CANARY_PLAINTEXT) {
187
+ throw new VaultPassphraseError(this.filePath);
188
+ }
189
+ } catch (err) {
190
+ if (err instanceof VaultPassphraseError) throw err;
191
+ throw new VaultPassphraseError(this.filePath);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Fold other writers' entries in from the on-disk file. Historically the
197
+ * vault persisted a whole-file snapshot of THIS instance's memory, so a
198
+ * write from any other `VaultStore` (another moxxy process, or a second
199
+ * instance in this one) was silently clobbered by our next persist —
200
+ * last-writer-wins, fatal for single-use rotated OAuth refresh tokens.
201
+ * Now every read and every mutation first re-reads the file (mtime/size
202
+ * gated, so the common no-other-writer case costs one `stat`) and merges:
203
+ * - key on both sides → newer `updatedAt` wins (ISO timestamps);
204
+ * - key only on disk → adopt it (another writer added it);
205
+ * - key only in memory → drop it (every mutation persists before
206
+ * returning, so a key missing on disk was deleted by another writer).
207
+ * Skipped when the on-disk salt differs (vault wiped/recreated — those
208
+ * entries are undecryptable under our master key) or the file is
209
+ * unreadable/corrupt (keep memory; the next persist restores a good file).
210
+ *
211
+ * Must be called while holding `this.mutex`.
212
+ */
213
+ private async syncFromDisk(): Promise<void> {
214
+ if (!this.file) return;
215
+ let st: { mtimeMs: number; size: number };
216
+ try {
217
+ st = await fs.stat(this.filePath);
218
+ } catch (err) {
219
+ if (isEnoent(err)) return; // deleted out from under us — keep memory
220
+ throw err;
221
+ }
222
+ // Fast path: skip the read+merge only when the stat fingerprint is
223
+ // unchanged AND it has aged past the filesystem's mtime granularity. On
224
+ // coarse-mtime filesystems (HFS+, older ext) two writes inside the same
225
+ // ~1-2s tick that also produce an identical byte length share a
226
+ // (mtime,size) fingerprint, so a recent match might hide a sibling write.
227
+ // The merge is idempotent and cheap, so when in doubt we re-read.
228
+ if (
229
+ this.lastSynced &&
230
+ st.mtimeMs === this.lastSynced.mtimeMs &&
231
+ st.size === this.lastSynced.size &&
232
+ Date.now() - st.mtimeMs > MTIME_GRANULARITY_MS
233
+ ) {
234
+ return;
235
+ }
236
+ let parsedRaw: unknown;
237
+ try {
238
+ parsedRaw = JSON.parse(await fs.readFile(this.filePath, 'utf8'));
239
+ } catch {
240
+ return;
241
+ }
242
+ if (!isPlainObject(parsedRaw)) return;
243
+ const parsed = parsedRaw as Partial<VaultFile>;
244
+ if (
245
+ parsed.version !== 1 ||
246
+ parsed.kdf !== 'scrypt' ||
247
+ parsed.salt !== this.file.salt ||
248
+ !validateVaultFile(parsed)
249
+ ) {
250
+ return;
251
+ }
252
+ this.file = { ...this.file, entries: mergeEntries(this.file.entries, parsed.entries) };
253
+ // Fingerprint from BEFORE the read: if a write landed in between, the
254
+ // next sync simply re-reads — never the other way around.
255
+ this.lastSynced = { mtimeMs: st.mtimeMs, size: st.size };
256
+ }
257
+
258
+ /**
259
+ * Crash-atomic write: serialize to a sibling tmp file, then rename. POSIX
260
+ * rename is atomic, so a crash mid-write leaves the previous vault intact
261
+ * rather than truncated. mode 0o600 keeps the secret store owner-only.
262
+ */
263
+ private async persist(): Promise<void> {
264
+ if (!this.file) return;
265
+ await writeFileAtomic(this.filePath, JSON.stringify(this.file, null, 2), { mode: 0o600 });
266
+ // Force the next syncFromDisk() to re-read rather than recording a stat
267
+ // here: between our rename and a post-write fs.stat another process may
268
+ // have renamed ITS version in, so the fingerprint we'd capture could be
269
+ // a foreign file's — which would then make us skip reading the very
270
+ // update we're out of sync with. Dropping the fingerprint costs one
271
+ // extra (idempotent, merge-only) read on the next access.
272
+ this.lastSynced = null;
273
+ }
274
+
275
+ async set(name: string, value: string, tags?: ReadonlyArray<string>): Promise<void> {
276
+ await this.open();
277
+ return this.mutex.run(async () => {
278
+ if (!this.file || !this.masterKey) throw new Error('vault not open');
279
+ await this.syncFromDisk();
280
+ const now = new Date().toISOString();
281
+ const existing = this.file.entries[name];
282
+ const blob = encrypt(value, this.masterKey);
283
+ this.file = {
284
+ ...this.file,
285
+ entries: {
286
+ ...this.file.entries,
287
+ [name]: {
288
+ ...blob,
289
+ createdAt: existing?.createdAt ?? now,
290
+ updatedAt: now,
291
+ tags: tags ?? existing?.tags,
292
+ },
293
+ },
294
+ };
295
+ await this.persist();
296
+ });
297
+ }
298
+
299
+ async get(name: string): Promise<string | null> {
300
+ await this.open();
301
+ // Read the entry inside the same mutex turn as syncFromDisk so a
302
+ // concurrent set()/delete() (which replaces this.file wholesale) can't
303
+ // swap the snapshot out between the sync and the decrypt.
304
+ return this.mutex.run(async () => {
305
+ await this.syncFromDisk();
306
+ if (!this.file || !this.masterKey) throw new Error('vault not open');
307
+ const entry = this.file.entries[name];
308
+ if (!entry) return null;
309
+ return this.decryptEntry(name, entry);
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Decrypt a single stored blob, converting any low-level crypto failure into
315
+ * a friendly `VAULT_CORRUPT` error. `validateVaultFile` only guarantees the
316
+ * blob's iv/tag/data are *strings*, not that they base64-decode to the right
317
+ * lengths — a partially-corrupted entry (e.g. a truncated `iv` or `tag`, or a
318
+ * value re-keyed out-of-band) passes structural validation yet makes Node's
319
+ * AES-GCM throw a raw `ERR_CRYPTO_INVALID_IV` / `ERR_CRYPTO_INVALID_AUTH_TAG`
320
+ * / auth-tag-mismatch error. The vault's passphrase is already verified
321
+ * against the canary on open(), so a per-entry decrypt failure means THAT
322
+ * entry is corrupt, not that the whole vault is locked — surface a targeted
323
+ * recovery hint instead of crashing the caller with a cryptic crypto error.
324
+ */
325
+ private decryptEntry(name: string, entry: VaultEntry): string {
326
+ try {
327
+ return decrypt(entry, this.masterKey!);
328
+ } catch (err) {
329
+ throw new MoxxyError({
330
+ code: 'VAULT_CORRUPT',
331
+ message: `vault: entry '${name}' could not be decrypted (corrupt or re-keyed)`,
332
+ hint: `Re-store it with \`/vault set ${name} <value>\`, or back up and remove ${this.filePath} to re-initialize.`,
333
+ context: { filePath: this.filePath, name },
334
+ cause: err,
335
+ });
336
+ }
337
+ }
338
+
339
+ async has(name: string): Promise<boolean> {
340
+ await this.open();
341
+ return this.mutex.run(async () => {
342
+ await this.syncFromDisk();
343
+ return Boolean(this.file?.entries[name]);
344
+ });
345
+ }
346
+
347
+ async delete(name: string): Promise<boolean> {
348
+ await this.open();
349
+ return this.mutex.run(async () => {
350
+ if (!this.file) return false;
351
+ await this.syncFromDisk();
352
+ if (!(name in this.file.entries)) return false;
353
+ const { [name]: _removed, ...rest } = this.file.entries;
354
+ void _removed;
355
+ this.file = { ...this.file, entries: rest };
356
+ await this.persist();
357
+ return true;
358
+ });
359
+ }
360
+
361
+ async list(): Promise<ReadonlyArray<VaultEntryInfo>> {
362
+ await this.open();
363
+ return this.mutex.run(async () => {
364
+ await this.syncFromDisk();
365
+ if (!this.file) return [];
366
+ return Object.entries(this.file.entries).map(([name, e]) => ({
367
+ name,
368
+ createdAt: e.createdAt,
369
+ updatedAt: e.updatedAt,
370
+ tags: e.tags,
371
+ }));
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Wipe the in-memory master key and cached file so the AES key is no longer
377
+ * recoverable from a heap/core dump or swap after the store is done. The
378
+ * store can be reopened — `open()` re-derives the key. Returned plaintext
379
+ * strings from `get()` cannot be wiped (JS strings are immutable); only the
380
+ * long-lived key Buffer is zeroed here.
381
+ */
382
+ close(): void {
383
+ this.masterKey?.fill(0);
384
+ this.masterKey = null;
385
+ this.file = null;
386
+ this.lastSynced = null;
387
+ }
388
+
389
+ [Symbol.dispose](): void {
390
+ this.close();
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Merge our in-memory entries with another writer's on-disk entries. Disk is
396
+ * the base (all current writers merge-before-persist, so disk is the union of
397
+ * everyone's persisted state); when both sides hold a key, the newer
398
+ * `updatedAt` wins so a stale whole-file write from an older moxxy can't roll
399
+ * back a fresher value (e.g. a just-rotated OAuth refresh token).
400
+ */
401
+ function mergeEntries(
402
+ memory: Record<string, VaultEntry>,
403
+ disk: Record<string, VaultEntry>,
404
+ ): Record<string, VaultEntry> {
405
+ const merged: Record<string, VaultEntry> = { ...disk };
406
+ for (const [name, mine] of Object.entries(memory)) {
407
+ const theirs = merged[name];
408
+ if (theirs && theirs.updatedAt >= mine.updatedAt) continue;
409
+ if (!theirs) continue; // absent on disk → deleted by another writer
410
+ merged[name] = mine;
411
+ }
412
+ return merged;
413
+ }
414
+
415
+ function isEnoent(err: unknown): boolean {
416
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
417
+ }
418
+
419
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
420
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
421
+ }
422
+
423
+ function isEncryptedBlob(v: unknown): v is EncryptedBlob {
424
+ return (
425
+ isPlainObject(v) &&
426
+ typeof v.iv === 'string' &&
427
+ typeof v.tag === 'string' &&
428
+ typeof v.data === 'string'
429
+ );
430
+ }
431
+
432
+ /**
433
+ * Structural validation of a parsed vault file beyond the version/kdf gate:
434
+ * `salt` is a string, `entries` is a plain object whose every value is a
435
+ * well-formed encrypted blob (with createdAt/updatedAt strings), and `canary`
436
+ * (when present) is a blob. Guards against partial writes / manual edits that
437
+ * are valid JSON but would otherwise throw a raw TypeError in
438
+ * verifyPassphrase()/list() instead of a friendly VAULT_CORRUPT error.
439
+ */
440
+ function validateVaultFile(parsed: Partial<VaultFile>): parsed is VaultFile {
441
+ if (typeof parsed.salt !== 'string') return false;
442
+ if (!isPlainObject(parsed.entries)) return false;
443
+ for (const entry of Object.values(parsed.entries)) {
444
+ if (!isEncryptedBlob(entry)) return false;
445
+ const e = entry as Partial<VaultEntry>;
446
+ if (typeof e.createdAt !== 'string' || typeof e.updatedAt !== 'string') return false;
447
+ }
448
+ if (parsed.canary !== undefined && !isEncryptedBlob(parsed.canary)) return false;
449
+ return true;
450
+ }
451
+
452
+ function firstEntry(entries: Record<string, VaultEntry>): VaultEntry | undefined {
453
+ for (const key of Object.keys(entries)) {
454
+ return entries[key];
455
+ }
456
+ return undefined;
457
+ }