@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.
@@ -0,0 +1,104 @@
1
+ import { MoxxyError } from '@moxxy/sdk';
2
+ import type { VaultStore } from './store.js';
3
+
4
+ const PLACEHOLDER_RE = /\$\{vault:([A-Za-z0-9_.-]+)\}/g;
5
+
6
+ // Bound recursion over caller-supplied objects: a pathologically deep config
7
+ // (or an in-memory reference cycle, which is legal for JS objects even though
8
+ // JSON can't express one) would otherwise overflow the stack and take down the
9
+ // process. 64 levels is far deeper than any real config tree.
10
+ const MAX_DEPTH = 64;
11
+
12
+ function tooDeepError(): MoxxyError {
13
+ return new MoxxyError({
14
+ code: 'CONFIG_INVALID',
15
+ message: `vault: value nested too deeply (> ${MAX_DEPTH} levels) — possible reference cycle`,
16
+ hint: 'Flatten the config or remove the cyclic reference before resolving vault placeholders.',
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Resolve every `${vault:NAME}` placeholder in a string against the vault. If
22
+ * any referenced key is missing, throws — secret refs are not optional.
23
+ */
24
+ export async function resolveString(input: string, vault: VaultStore): Promise<string> {
25
+ PLACEHOLDER_RE.lastIndex = 0;
26
+ if (!PLACEHOLDER_RE.test(input)) return input;
27
+ PLACEHOLDER_RE.lastIndex = 0;
28
+ const names = new Set<string>();
29
+ let m: RegExpExecArray | null;
30
+ while ((m = PLACEHOLDER_RE.exec(input))) names.add(m[1]!);
31
+
32
+ const values = new Map<string, string>();
33
+ for (const name of names) {
34
+ const value = await vault.get(name);
35
+ if (value === null) {
36
+ throw new MoxxyError({
37
+ code: 'CONFIG_INVALID',
38
+ message: `vault: missing required entry '${name}' referenced in config`,
39
+ hint: `Add it with \`/vault set ${name} <value>\` (or the \`vault_set\` tool), then retry.`,
40
+ context: { name },
41
+ });
42
+ }
43
+ values.set(name, value);
44
+ }
45
+ return input.replace(PLACEHOLDER_RE, (_match, name: string) => values.get(name) ?? '');
46
+ }
47
+
48
+ /** Walk an arbitrary value, resolving all vault placeholders in nested strings. */
49
+ export async function resolveValue(value: unknown, vault: VaultStore): Promise<unknown> {
50
+ return resolveValueInner(value, vault, new Set());
51
+ }
52
+
53
+ // `ancestors` is the chain of objects on the path from the root to (but not
54
+ // including) the current node, so a node that appears as its own ancestor is a
55
+ // true cycle — while a shared object reachable via two *sibling* paths (a legal
56
+ // DAG) is resolved on each path rather than falsely flagged. The chain length
57
+ // is the nesting depth, so its size doubles as the depth bound.
58
+ async function resolveValueInner(
59
+ value: unknown,
60
+ vault: VaultStore,
61
+ ancestors: Set<object>,
62
+ ): Promise<unknown> {
63
+ if (typeof value === 'string') return await resolveString(value, vault);
64
+ if (value && typeof value === 'object') {
65
+ if (ancestors.has(value)) throw tooDeepError(); // reference cycle
66
+ if (ancestors.size >= MAX_DEPTH) throw tooDeepError();
67
+ const nextAncestors = new Set(ancestors).add(value);
68
+ if (Array.isArray(value)) {
69
+ return Promise.all(value.map((v) => resolveValueInner(v, vault, nextAncestors)));
70
+ }
71
+ // Resolve object properties concurrently (mirrors the array branch); each
72
+ // leaf may await vault.get(), so serializing them needlessly serializes I/O.
73
+ const pairs = await Promise.all(
74
+ Object.entries(value as Record<string, unknown>).map(
75
+ async ([k, v]) => [k, await resolveValueInner(v, vault, nextAncestors)] as const,
76
+ ),
77
+ );
78
+ return Object.fromEntries(pairs);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ export function containsPlaceholder(value: unknown): boolean {
84
+ return containsPlaceholderInner(value, new Set());
85
+ }
86
+
87
+ function containsPlaceholderInner(value: unknown, ancestors: Set<object>): boolean {
88
+ if (typeof value === 'string') {
89
+ PLACEHOLDER_RE.lastIndex = 0;
90
+ return PLACEHOLDER_RE.test(value);
91
+ }
92
+ if (value && typeof value === 'object') {
93
+ if (ancestors.has(value)) return false; // cycle — this subtree already inspected on the path
94
+ if (ancestors.size >= MAX_DEPTH) throw tooDeepError();
95
+ const nextAncestors = new Set(ancestors).add(value);
96
+ if (Array.isArray(value)) {
97
+ return value.some((v) => containsPlaceholderInner(v, nextAncestors));
98
+ }
99
+ return Object.values(value as Record<string, unknown>).some((v) =>
100
+ containsPlaceholderInner(v, nextAncestors),
101
+ );
102
+ }
103
+ return false;
104
+ }
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { VaultStore, VaultPassphraseError } from './store.js';
6
+ import { createStaticKeySource } from './keysource.js';
7
+ import { deriveKey, encrypt, generateSalt } from './crypto.js';
8
+
9
+ let tmp: string;
10
+ let filePath: string;
11
+
12
+ const keyA = deriveKey('passphrase-A', generateSalt());
13
+ const keyB = deriveKey('passphrase-B', generateSalt());
14
+
15
+ beforeEach(async () => {
16
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-vault-canary-'));
17
+ filePath = path.join(tmp, 'vault.json');
18
+ });
19
+ afterEach(async () => {
20
+ await fs.rm(tmp, { recursive: true, force: true });
21
+ });
22
+
23
+ const storeWith = (key: Buffer) =>
24
+ new VaultStore({ filePath, keySource: createStaticKeySource(key) });
25
+
26
+ describe('VaultStore canary verification', () => {
27
+ it('writes a canary into a freshly created vault', async () => {
28
+ const a = storeWith(keyA);
29
+ await a.set('first', 'value'); // triggers new-vault creation + canary write
30
+
31
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
32
+ expect(raw.canary).toMatchObject({
33
+ iv: expect.any(String),
34
+ tag: expect.any(String),
35
+ data: expect.any(String),
36
+ });
37
+ // The canary plaintext is fixed and must not appear in ciphertext.
38
+ expect(JSON.stringify(raw.canary)).not.toContain('moxxy:vault:v1');
39
+ });
40
+
41
+ it('rejects open()/get() with VaultPassphraseError + recovery hint on wrong key', async () => {
42
+ const a = storeWith(keyA);
43
+ await a.set('secret', 'hunter2');
44
+
45
+ // Reopen the same on-disk vault with a DIFFERENT key.
46
+ const b = storeWith(keyB);
47
+ const err = await b.open().then(
48
+ () => null,
49
+ (e) => e as Error,
50
+ );
51
+ expect(err).toBeInstanceOf(VaultPassphraseError);
52
+ expect((err as VaultPassphraseError).message).toContain('Wrong vault passphrase');
53
+ // Recovery hint surfaces the exact file + the wipe instructions.
54
+ expect((err as VaultPassphraseError).message).toContain(filePath);
55
+ expect((err as VaultPassphraseError).message).toContain('moxxy init');
56
+ expect((err as VaultPassphraseError).message).toContain('~/.moxxy/vault.key');
57
+ expect((err as VaultPassphraseError).message).toContain('MOXXY_VAULT_PASSPHRASE');
58
+
59
+ // A fresh store opened lazily through get() must reject the same way —
60
+ // the canary check runs on the implicit open(), not just explicit open().
61
+ const c = storeWith(keyB);
62
+ await expect(c.get('secret')).rejects.toBeInstanceOf(VaultPassphraseError);
63
+ });
64
+
65
+ it('backfills a canary on first open of a legacy (canary-less) vault and still verifies', async () => {
66
+ // Build a legacy VaultFile by hand: version 1, one real entry, NO canary.
67
+ const salt = generateSalt();
68
+ const legacyKey = deriveKey('legacy-pass', salt);
69
+ const now = new Date().toISOString();
70
+ const entryBlob = encrypt('legacy-secret', legacyKey);
71
+ const legacyFile = {
72
+ version: 1,
73
+ kdf: 'scrypt',
74
+ salt: salt.toString('base64'),
75
+ entries: {
76
+ token: { ...entryBlob, createdAt: now, updatedAt: now },
77
+ },
78
+ // no `canary` field
79
+ };
80
+ await fs.writeFile(filePath, JSON.stringify(legacyFile, null, 2), { mode: 0o600 });
81
+ expect(JSON.parse(await fs.readFile(filePath, 'utf8')).canary).toBeUndefined();
82
+
83
+ // Open with the correct legacy key: verifyPassphrase probes the first
84
+ // entry (no canary present) and must succeed, then backfill a canary.
85
+ const store = new VaultStore({ filePath, keySource: createStaticKeySource(legacyKey) });
86
+ await store.open();
87
+ expect(await store.get('token')).toBe('legacy-secret');
88
+
89
+ // The canary was backfilled and persisted to disk.
90
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
91
+ expect(raw.canary).toMatchObject({ iv: expect.any(String), tag: expect.any(String) });
92
+ // Existing entry preserved untouched.
93
+ expect(raw.entries.token).toBeDefined();
94
+
95
+ // Reopen a fresh instance: now the backfilled canary is what gets verified.
96
+ const reopened = new VaultStore({
97
+ filePath,
98
+ keySource: createStaticKeySource(legacyKey),
99
+ });
100
+ expect(await reopened.get('token')).toBe('legacy-secret');
101
+ });
102
+
103
+ it('rejects a legacy vault opened with the wrong key (entry-probe path)', async () => {
104
+ // Legacy file (no canary) with one entry, encrypted under keyA.
105
+ const salt = generateSalt();
106
+ const realKey = deriveKey('right', salt);
107
+ const now = new Date().toISOString();
108
+ const legacyFile = {
109
+ version: 1,
110
+ kdf: 'scrypt',
111
+ salt: salt.toString('base64'),
112
+ entries: { e: { ...encrypt('v', realKey), createdAt: now, updatedAt: now } },
113
+ };
114
+ await fs.writeFile(filePath, JSON.stringify(legacyFile, null, 2), { mode: 0o600 });
115
+
116
+ const wrongKey = deriveKey('wrong', salt);
117
+ const store = new VaultStore({ filePath, keySource: createStaticKeySource(wrongKey) });
118
+ // decrypt of the probe entry fails the AES-GCM auth tag -> friendly error.
119
+ await expect(store.open()).rejects.toBeInstanceOf(VaultPassphraseError);
120
+ });
121
+
122
+ it('verifyPassphrase is a no-op for an empty legacy vault (no canary, no entries)', async () => {
123
+ const salt = generateSalt();
124
+ const legacyFile = {
125
+ version: 1,
126
+ kdf: 'scrypt',
127
+ salt: salt.toString('base64'),
128
+ entries: {},
129
+ // no canary, no entries -> nothing to verify
130
+ };
131
+ await fs.writeFile(filePath, JSON.stringify(legacyFile, null, 2), { mode: 0o600 });
132
+
133
+ // ANY key opens an empty legacy vault without error (nothing to probe).
134
+ const anyKey = deriveKey('whatever', generateSalt());
135
+ const store = new VaultStore({ filePath, keySource: createStaticKeySource(anyKey) });
136
+ await expect(store.open()).resolves.toBeUndefined();
137
+ // And it backfills a canary so subsequent opens are verifiable.
138
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
139
+ expect(raw.canary).toMatchObject({ iv: expect.any(String) });
140
+ });
141
+
142
+ it('rejects an unsupported vault file version/kdf', async () => {
143
+ await fs.writeFile(
144
+ filePath,
145
+ JSON.stringify({ version: 2, kdf: 'scrypt', salt: 'x', entries: {} }),
146
+ { mode: 0o600 },
147
+ );
148
+ const store = storeWith(keyA);
149
+ await expect(store.open()).rejects.toThrow(/Unsupported vault file/);
150
+ });
151
+ });
@@ -0,0 +1,283 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { MoxxyError } from '@moxxy/sdk';
6
+ import { VaultStore } from './store.js';
7
+ import { createStaticKeySource } from './keysource.js';
8
+ import { deriveKey, generateSalt } from './crypto.js';
9
+
10
+ let tmp: string;
11
+ let filePath: string;
12
+ const stableKey = deriveKey('test-passphrase', generateSalt());
13
+
14
+ const newStore = () =>
15
+ new VaultStore({ filePath, keySource: createStaticKeySource(stableKey) });
16
+
17
+ beforeEach(async () => {
18
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-vault-'));
19
+ filePath = path.join(tmp, 'vault.json');
20
+ });
21
+ afterEach(async () => {
22
+ await fs.rm(tmp, { recursive: true, force: true });
23
+ });
24
+
25
+ describe('VaultStore', () => {
26
+ it('creates a new vault file on first set', async () => {
27
+ const store = newStore();
28
+ await store.set('hello', 'world');
29
+ expect(await store.get('hello')).toBe('world');
30
+
31
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
32
+ expect(raw.version).toBe(1);
33
+ expect(raw.kdf).toBe('scrypt');
34
+ expect(raw.entries.hello).toMatchObject({ iv: expect.any(String), tag: expect.any(String) });
35
+ // Ciphertext should never contain the plaintext
36
+ expect(raw.entries.hello.data).not.toContain('world');
37
+ });
38
+
39
+ it('round-trips multiple entries across instances', async () => {
40
+ const a = newStore();
41
+ await a.set('foo', 'one');
42
+ await a.set('bar', 'two', ['ops']);
43
+ const b = newStore();
44
+ expect(await b.get('foo')).toBe('one');
45
+ expect(await b.get('bar')).toBe('two');
46
+ const listed = await b.list();
47
+ expect(listed.map((e) => e.name).sort()).toEqual(['bar', 'foo']);
48
+ const bar = listed.find((e) => e.name === 'bar');
49
+ expect(bar?.tags).toEqual(['ops']);
50
+ });
51
+
52
+ it('returns null on missing key', async () => {
53
+ const store = newStore();
54
+ expect(await store.get('absent')).toBeNull();
55
+ expect(await store.has('absent')).toBe(false);
56
+ });
57
+
58
+ it('overwrites updates updatedAt but preserves createdAt', async () => {
59
+ const store = newStore();
60
+ await store.set('x', 'a');
61
+ const first = (await store.list())[0]!;
62
+ await new Promise((r) => setTimeout(r, 10));
63
+ await store.set('x', 'b');
64
+ const second = (await store.list())[0]!;
65
+ expect(second.createdAt).toBe(first.createdAt);
66
+ expect(second.updatedAt).not.toBe(first.updatedAt);
67
+ expect(await store.get('x')).toBe('b');
68
+ });
69
+
70
+ it('delete removes the entry', async () => {
71
+ const store = newStore();
72
+ await store.set('x', '1');
73
+ expect(await store.delete('x')).toBe(true);
74
+ expect(await store.delete('x')).toBe(false);
75
+ expect(await store.get('x')).toBeNull();
76
+ });
77
+
78
+ it('fails to decrypt with a different key', async () => {
79
+ const a = newStore();
80
+ await a.set('x', 'secret');
81
+ const otherKey = deriveKey('different', generateSalt());
82
+ const b = new VaultStore({ filePath, keySource: createStaticKeySource(otherKey) });
83
+ await expect(b.get('x')).rejects.toThrow();
84
+ });
85
+
86
+ it('serializes concurrent set() calls — no lost updates', async () => {
87
+ const store = newStore();
88
+ await Promise.all([
89
+ store.set('a', '1'),
90
+ store.set('b', '2'),
91
+ store.set('c', '3'),
92
+ store.set('d', '4'),
93
+ ]);
94
+ expect(await store.get('a')).toBe('1');
95
+ expect(await store.get('b')).toBe('2');
96
+ expect(await store.get('c')).toBe('3');
97
+ expect(await store.get('d')).toBe('4');
98
+ const listed = await store.list();
99
+ expect(listed.map((e) => e.name).sort()).toEqual(['a', 'b', 'c', 'd']);
100
+ });
101
+
102
+ it('serializes concurrent delete() calls', async () => {
103
+ const store = newStore();
104
+ await store.set('a', '1');
105
+ await store.set('b', '2');
106
+ const [r1, r2] = await Promise.all([store.delete('a'), store.delete('b')]);
107
+ expect(r1).toBe(true);
108
+ expect(r2).toBe(true);
109
+ expect(await store.list()).toEqual([]);
110
+ });
111
+
112
+ it('writes atomically: never leaves a truncated vault file behind', async () => {
113
+ const store = newStore();
114
+ await store.set('a', '1');
115
+ // After persist completes, the tmp sibling should NOT exist; only the final file.
116
+ const dir = path.dirname(filePath);
117
+ const after = await fs.readdir(dir);
118
+ const tmps = after.filter((f) => f.startsWith('vault.json.tmp.'));
119
+ expect(tmps).toEqual([]);
120
+ // And the persisted file is well-formed.
121
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
122
+ expect(raw.entries.a).toBeDefined();
123
+ });
124
+
125
+ it("merges another writer's keys on write — no whole-file last-writer-wins clobber", async () => {
126
+ // Two stores (≈ two processes) over the same file, the second holding an
127
+ // in-memory snapshot from BEFORE the first one's later writes. Its next
128
+ // whole-file persist used to silently drop those writes.
129
+ const a = newStore();
130
+ await a.set('seed', 's');
131
+ const b = newStore();
132
+ await b.open(); // b's snapshot: { seed }
133
+ await a.set('rotated_token', 'fresh'); // e.g. a rotated OAuth refresh token
134
+ await b.set('other_key', 'B'); // must MERGE, not clobber
135
+
136
+ const c = newStore();
137
+ expect(await c.get('rotated_token')).toBe('fresh');
138
+ expect(await c.get('other_key')).toBe('B');
139
+ expect(await c.get('seed')).toBe('s');
140
+ });
141
+
142
+ it("reads see another writer's newer value for the same key", async () => {
143
+ const a = newStore();
144
+ await a.set('token', 'old');
145
+ const b = newStore();
146
+ await b.set('token', 'new');
147
+ // a still holds 'old' in memory; get() must fold the on-disk update in.
148
+ expect(await a.get('token')).toBe('new');
149
+ });
150
+
151
+ it("reads observe another writer's delete (no resurrection from memory)", async () => {
152
+ const a = newStore();
153
+ await a.set('gone', 'x');
154
+ const b = newStore();
155
+ expect(await b.delete('gone')).toBe(true);
156
+ expect(await a.get('gone')).toBeNull();
157
+ // …and a's next persist must not write it back.
158
+ await a.set('unrelated', 'y');
159
+ const c = newStore();
160
+ expect(await c.get('gone')).toBeNull();
161
+ expect(await c.get('unrelated')).toBe('y');
162
+ });
163
+
164
+ it('ignores an on-disk file with a different salt (wiped/recreated vault)', async () => {
165
+ const a = newStore();
166
+ await a.set('mine', '1');
167
+ // Recreate the vault out-of-band with a different salt: a's master key
168
+ // can't decrypt those entries, so a must keep serving its own state.
169
+ await fs.rm(filePath);
170
+ const other = new VaultStore({
171
+ filePath,
172
+ keySource: createStaticKeySource(deriveKey('other-pass', generateSalt())),
173
+ });
174
+ await other.set('theirs', '2');
175
+ expect(await a.get('mine')).toBe('1');
176
+ expect(await a.get('theirs')).toBeNull();
177
+ });
178
+
179
+ it('rejects an unsupported vault file with a VAULT_CORRUPT MoxxyError', async () => {
180
+ await fs.writeFile(filePath, JSON.stringify({ version: 99, kdf: 'argon2', salt: 'x', entries: {} }), 'utf8');
181
+ const store = newStore();
182
+ const err = await store.get('anything').catch((e) => e);
183
+ expect(MoxxyError.isMoxxyError(err)).toBe(true);
184
+ expect((err as MoxxyError).code).toBe('VAULT_CORRUPT');
185
+ });
186
+
187
+ it('rejects malformed-but-valid-JSON vault files with VAULT_CORRUPT, not a raw TypeError', async () => {
188
+ // Each of these is valid JSON that passes the version/kdf gate (or fails
189
+ // it gracefully) but would crash verifyPassphrase()/list() with a raw
190
+ // TypeError if the shape weren't validated. All must surface VAULT_CORRUPT.
191
+ const cases: unknown[] = [
192
+ { version: 1, kdf: 'scrypt', salt: 'x', entries: null }, // entries null
193
+ { version: 1, kdf: 'scrypt', salt: 'x' }, // entries missing
194
+ { version: 1, kdf: 'scrypt', salt: 123, entries: {} }, // salt not a string
195
+ { version: 1, kdf: 'scrypt', salt: 'x', entries: { e: { iv: 1, tag: 2, data: 3 } } }, // blob fields not strings
196
+ {
197
+ version: 1,
198
+ kdf: 'scrypt',
199
+ salt: 'x',
200
+ entries: {},
201
+ canary: { iv: 'a', tag: 'b' }, // canary missing data
202
+ },
203
+ '"a bare json string"',
204
+ '12345',
205
+ '[]',
206
+ '{ not valid json',
207
+ ];
208
+ for (const c of cases) {
209
+ await fs.writeFile(filePath, typeof c === 'string' ? c : JSON.stringify(c), 'utf8');
210
+ const store = newStore();
211
+ const err = await store.get('anything').catch((e) => e);
212
+ expect(MoxxyError.isMoxxyError(err), `case=${JSON.stringify(c)}`).toBe(true);
213
+ expect((err as MoxxyError).code, `case=${JSON.stringify(c)}`).toBe('VAULT_CORRUPT');
214
+ }
215
+ });
216
+
217
+ it('get() reads a consistent snapshot under a concurrent set() (no torn read)', async () => {
218
+ const store = newStore();
219
+ await store.set('k', 'v0');
220
+ // Kick off a get and a set on the same tick. The get must run in its own
221
+ // mutex turn against a coherent snapshot — never a partially-updated file
222
+ // — so it returns either the old or the new value, never undefined/garbage.
223
+ const [got] = await Promise.all([store.get('k'), store.set('k', 'v1')]);
224
+ expect(['v0', 'v1']).toContain(got);
225
+ expect(await store.get('k')).toBe('v1');
226
+ });
227
+
228
+ it('get() of a structurally-valid-but-corrupt entry yields VAULT_CORRUPT, not a raw crypto error', async () => {
229
+ // A vault whose canary is fine (passphrase verifies on open) but ONE entry
230
+ // has a malformed blob: an empty `iv` and a truncated `tag`. Both are
231
+ // strings, so validateVaultFile passes, but Node's AES-GCM throws a raw
232
+ // ERR_CRYPTO_INVALID_IV / ERR_CRYPTO_INVALID_AUTH_TAG when decrypting it.
233
+ // get() must convert that into a friendly VAULT_CORRUPT MoxxyError naming
234
+ // the entry — degrade, never crash, on partial corruption.
235
+ const store = newStore();
236
+ await store.set('good', 'value'); // creates the vault + canary
237
+ const onDisk = JSON.parse(await fs.readFile(filePath, 'utf8'));
238
+ const now = new Date().toISOString();
239
+ for (const bad of [
240
+ { iv: '', tag: 'AA==', data: '', createdAt: now, updatedAt: now }, // empty IV
241
+ { iv: 'AAAAAAAAAAAA', tag: '', data: '', createdAt: now, updatedAt: now }, // empty tag
242
+ { iv: 'AAAAAAAAAAAA', tag: 'AA==', data: '', createdAt: now, updatedAt: now }, // short tag
243
+ { iv: 'AAAAAAAAAAAAAAAA', tag: 'AAAAAAAAAAAAAAAAAAAAAA==', data: 'AAAA', createdAt: now, updatedAt: now }, // wrong key/auth mismatch
244
+ ]) {
245
+ onDisk.entries.broken = bad;
246
+ await fs.writeFile(filePath, JSON.stringify(onDisk, null, 2), 'utf8');
247
+ const fresh = newStore();
248
+ const err = await fresh.get('broken').catch((e) => e);
249
+ expect(MoxxyError.isMoxxyError(err), `blob=${JSON.stringify(bad)}`).toBe(true);
250
+ expect((err as MoxxyError).code, `blob=${JSON.stringify(bad)}`).toBe('VAULT_CORRUPT');
251
+ expect((err as MoxxyError).context, `blob=${JSON.stringify(bad)}`).toMatchObject({ name: 'broken' });
252
+ // A good sibling entry on the same file still decrypts (one bad entry
253
+ // doesn't lock the whole vault).
254
+ expect(await fresh.get('good')).toBe('value');
255
+ }
256
+ });
257
+
258
+ it('close() zeroes the master key and can be reopened', async () => {
259
+ const key = deriveKey('close-test', generateSalt());
260
+ const store = new VaultStore({ filePath, keySource: createStaticKeySource(key) });
261
+ await store.set('s', 'v');
262
+ store.close();
263
+ // Reopening re-derives the (same static) key and still decrypts.
264
+ const reopened = new VaultStore({ filePath, keySource: createStaticKeySource(key) });
265
+ expect(await reopened.get('s')).toBe('v');
266
+ });
267
+
268
+ it('keysource error during persist does not poison subsequent calls', async () => {
269
+ // Even if a single mutation fails, the chain should keep running.
270
+ const store = newStore();
271
+ await store.set('a', '1');
272
+ // First successful set already initialized vault. Now run a failing
273
+ // operation back-to-back with a normal one.
274
+ const failing = store.set('b', ''.repeat(1)).then(
275
+ () => 'ok' as const,
276
+ () => 'failed' as const,
277
+ );
278
+ const ok = await store.set('c', '3').then(() => 'ok' as const);
279
+ expect(ok).toBe('ok');
280
+ await failing; // resolves either way; the chain is still alive
281
+ expect(await store.get('c')).toBe('3');
282
+ });
283
+ });