@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/LICENSE +21 -0
- package/dist/crypto.d.ts +30 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +87 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +212 -0
- package/dist/index.js.map +1 -0
- package/dist/keysource.d.ts +50 -0
- package/dist/keysource.d.ts.map +1 -0
- package/dist/keysource.js +145 -0
- package/dist/keysource.js.map +1 -0
- package/dist/placeholder.d.ts +10 -0
- package/dist/placeholder.d.ts.map +1 -0
- package/dist/placeholder.js +92 -0
- package/dist/placeholder.js.map +1 -0
- package/dist/store.d.ts +106 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +415 -0
- package/dist/store.js.map +1 -0
- package/package.json +65 -0
- package/src/command.test.ts +101 -0
- package/src/crypto.test.ts +66 -0
- package/src/crypto.ts +106 -0
- package/src/index.ts +269 -0
- package/src/keysource.test.ts +285 -0
- package/src/keysource.ts +198 -0
- package/src/placeholder.test.ts +142 -0
- package/src/placeholder.ts +104 -0
- package/src/store.canary.test.ts +151 -0
- package/src/store.test.ts +283 -0
- package/src/store.ts +457 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } 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 { createCombinedKeySource, createStaticKeySource } from './keysource.js';
|
|
6
|
+
import { generateSalt } from './crypto.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Fake @napi-rs/keyring.
|
|
10
|
+
//
|
|
11
|
+
// keysource.ts reaches the OS keychain via a *dynamic* `import('@napi-rs/keyring')`
|
|
12
|
+
// and its synchronous `Entry` class. We replace that module with an in-memory
|
|
13
|
+
// store so the tests never touch the real macOS/Linux/Windows keychain. Like
|
|
14
|
+
// the real library, `getPassword()` THROWS when no entry exists (rather than
|
|
15
|
+
// returning null). The mock factory must not close over outer mutable bindings
|
|
16
|
+
// directly (vi.mock is hoisted above them), so the backing store lives on a
|
|
17
|
+
// stable object the class methods read at call time.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const keytarState: { store: Map<string, string>; failGet: boolean; failSet: boolean } = {
|
|
20
|
+
store: new Map(),
|
|
21
|
+
failGet: false,
|
|
22
|
+
failSet: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
vi.mock('@napi-rs/keyring', () => ({
|
|
26
|
+
Entry: class {
|
|
27
|
+
private readonly mapKey: string;
|
|
28
|
+
constructor(service: string, account: string) {
|
|
29
|
+
this.mapKey = `${service}:${account}`;
|
|
30
|
+
}
|
|
31
|
+
getPassword(): string {
|
|
32
|
+
if (keytarState.failGet) throw new Error('keychain locked');
|
|
33
|
+
const v = keytarState.store.get(this.mapKey);
|
|
34
|
+
if (v == null) throw new Error('No matching entry found in secure storage');
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
setPassword(password: string): void {
|
|
38
|
+
if (keytarState.failSet) throw new Error('keychain refused');
|
|
39
|
+
keytarState.store.set(this.mapKey, password);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const KEYTAR_KEY = 'moxxy:vault-master-key';
|
|
45
|
+
|
|
46
|
+
let tmp: string;
|
|
47
|
+
let diskKeyPath: string;
|
|
48
|
+
const ENV_VAR = 'TEST_VAULT_PASSPHRASE_XYZ';
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-keysrc-'));
|
|
52
|
+
diskKeyPath = path.join(tmp, 'vault.key');
|
|
53
|
+
keytarState.store = new Map();
|
|
54
|
+
keytarState.failGet = false;
|
|
55
|
+
keytarState.failSet = false;
|
|
56
|
+
delete process.env[ENV_VAR];
|
|
57
|
+
delete process.env.MOXXY_VAULT_PASSPHRASE;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
62
|
+
delete process.env[ENV_VAR];
|
|
63
|
+
delete process.env.MOXXY_VAULT_PASSPHRASE;
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Poll the filesystem until the backfill writes `expected`. We compare against
|
|
69
|
+
* the expected value (not "any value") because the disk file may already hold
|
|
70
|
+
* a stale value that the fire-and-forget backfill is about to overwrite.
|
|
71
|
+
*/
|
|
72
|
+
async function waitForFile(filePath: string, expected: string, timeoutMs = 1000): Promise<string> {
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
let last = '<unwritten>';
|
|
75
|
+
for (;;) {
|
|
76
|
+
try {
|
|
77
|
+
last = (await fs.readFile(filePath, 'utf8')).trim();
|
|
78
|
+
if (last === expected) return last;
|
|
79
|
+
} catch {
|
|
80
|
+
// not written yet
|
|
81
|
+
}
|
|
82
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${filePath}; last=${last}`);
|
|
83
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Poll the fake keychain until the backfill writes `expected`. */
|
|
88
|
+
async function waitForKeytar(key: string, expected: string, timeoutMs = 1000): Promise<string> {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
for (;;) {
|
|
91
|
+
const v = keytarState.store.get(key);
|
|
92
|
+
if (v === expected) return v;
|
|
93
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for keytar ${key}; last=${v ?? '<unset>'}`);
|
|
94
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Let any not-yet-awaited backfill promises settle, then assert absence. */
|
|
99
|
+
async function flushMicrotasks(): Promise<void> {
|
|
100
|
+
// A couple of macrotask hops covers the dynamic import + fs round-trip the
|
|
101
|
+
// fire-and-forget backfills perform.
|
|
102
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('createCombinedKeySource', () => {
|
|
106
|
+
it('env var wins and is NOT persisted to keytar or disk', async () => {
|
|
107
|
+
process.env[ENV_VAR] = 'from-env';
|
|
108
|
+
// Seed both lower-priority sources to prove the env branch short-circuits
|
|
109
|
+
// before they're consulted AND before any persistence happens.
|
|
110
|
+
const src = createCombinedKeySource({
|
|
111
|
+
passphrasePrompt: async () => {
|
|
112
|
+
throw new Error('prompt should not be called when env var is set');
|
|
113
|
+
},
|
|
114
|
+
envVar: ENV_VAR,
|
|
115
|
+
diskKeyPath,
|
|
116
|
+
});
|
|
117
|
+
const salt = generateSalt();
|
|
118
|
+
const key = await src.obtain(salt);
|
|
119
|
+
|
|
120
|
+
expect(key).toBeInstanceOf(Buffer);
|
|
121
|
+
expect(key.length).toBe(32);
|
|
122
|
+
expect(src.name).toBe(`env:${ENV_VAR}`);
|
|
123
|
+
|
|
124
|
+
// Give any (incorrect) async persistence a chance to fire, then prove
|
|
125
|
+
// neither backend was written.
|
|
126
|
+
await flushMicrotasks();
|
|
127
|
+
expect(keytarState.store.size).toBe(0);
|
|
128
|
+
await expect(fs.readFile(diskKeyPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keytar value wins over disk, and backfills disk', async () => {
|
|
132
|
+
const keytarVal = Buffer.from('k'.repeat(32)).toString('base64');
|
|
133
|
+
const diskVal = Buffer.from('d'.repeat(32)).toString('base64');
|
|
134
|
+
keytarState.store.set(KEYTAR_KEY, keytarVal);
|
|
135
|
+
await fs.writeFile(diskKeyPath, diskVal + '\n', { mode: 0o600 });
|
|
136
|
+
|
|
137
|
+
const src = createCombinedKeySource({
|
|
138
|
+
passphrasePrompt: async () => {
|
|
139
|
+
throw new Error('prompt should not be called');
|
|
140
|
+
},
|
|
141
|
+
diskKeyPath,
|
|
142
|
+
});
|
|
143
|
+
const key = await src.obtain(generateSalt());
|
|
144
|
+
|
|
145
|
+
expect(key.toString('base64')).toBe(keytarVal);
|
|
146
|
+
expect(src.name).toBe('keychain');
|
|
147
|
+
|
|
148
|
+
// Disk is backfilled with the keytar value (overwriting the stale disk val).
|
|
149
|
+
const onDisk = await waitForFile(diskKeyPath, keytarVal);
|
|
150
|
+
expect(onDisk).toBe(keytarVal);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('disk value used when keytar absent, and backfills keytar', async () => {
|
|
154
|
+
const diskVal = Buffer.from('d'.repeat(32)).toString('base64');
|
|
155
|
+
await fs.writeFile(diskKeyPath, diskVal + '\n', { mode: 0o600 });
|
|
156
|
+
// keytar empty.
|
|
157
|
+
|
|
158
|
+
const src = createCombinedKeySource({
|
|
159
|
+
passphrasePrompt: async () => {
|
|
160
|
+
throw new Error('prompt should not be called');
|
|
161
|
+
},
|
|
162
|
+
diskKeyPath,
|
|
163
|
+
});
|
|
164
|
+
const key = await src.obtain(generateSalt());
|
|
165
|
+
|
|
166
|
+
expect(key.toString('base64')).toBe(diskVal);
|
|
167
|
+
expect(src.name).toBe(`file:${diskKeyPath}`);
|
|
168
|
+
|
|
169
|
+
// keytar is backfilled with the disk value.
|
|
170
|
+
const inKeytar = await waitForKeytar(KEYTAR_KEY, diskVal);
|
|
171
|
+
expect(inKeytar).toBe(diskVal);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('interactive prompt is the last resort and persists to BOTH keytar and disk', async () => {
|
|
175
|
+
let prompts = 0;
|
|
176
|
+
const src = createCombinedKeySource({
|
|
177
|
+
passphrasePrompt: async () => {
|
|
178
|
+
prompts += 1;
|
|
179
|
+
return 'my-passphrase';
|
|
180
|
+
},
|
|
181
|
+
diskKeyPath,
|
|
182
|
+
});
|
|
183
|
+
const salt = generateSalt();
|
|
184
|
+
const key = await src.obtain(salt);
|
|
185
|
+
|
|
186
|
+
expect(prompts).toBe(1);
|
|
187
|
+
expect(src.name).toBe('passphrase');
|
|
188
|
+
expect(key.length).toBe(32);
|
|
189
|
+
|
|
190
|
+
// The prompt branch awaits persistKey(), so both backends are written
|
|
191
|
+
// synchronously before obtain() resolves.
|
|
192
|
+
const expected = key.toString('base64');
|
|
193
|
+
expect(keytarState.store.get(KEYTAR_KEY)).toBe(expected);
|
|
194
|
+
const onDisk = (await fs.readFile(diskKeyPath, 'utf8')).trim();
|
|
195
|
+
expect(onDisk).toBe(expected);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('disableKeytar skips the keychain entirely (disk-only)', async () => {
|
|
199
|
+
const src = createCombinedKeySource({
|
|
200
|
+
passphrasePrompt: async () => 'pw',
|
|
201
|
+
disableKeytar: true,
|
|
202
|
+
diskKeyPath,
|
|
203
|
+
});
|
|
204
|
+
const key = await src.obtain(generateSalt());
|
|
205
|
+
expect(src.name).toBe('passphrase');
|
|
206
|
+
|
|
207
|
+
// Disk written, keytar untouched.
|
|
208
|
+
const onDisk = (await fs.readFile(diskKeyPath, 'utf8')).trim();
|
|
209
|
+
expect(onDisk).toBe(key.toString('base64'));
|
|
210
|
+
await flushMicrotasks();
|
|
211
|
+
expect(keytarState.store.size).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('diskKeyPath:false disables the disk cache (keytar-only persistence)', async () => {
|
|
215
|
+
const src = createCombinedKeySource({
|
|
216
|
+
passphrasePrompt: async () => 'pw',
|
|
217
|
+
diskKeyPath: false,
|
|
218
|
+
});
|
|
219
|
+
const key = await src.obtain(generateSalt());
|
|
220
|
+
expect(src.name).toBe('passphrase');
|
|
221
|
+
// Persisted to keytar only; no file anywhere under tmp.
|
|
222
|
+
expect(keytarState.store.get(KEYTAR_KEY)).toBe(key.toString('base64'));
|
|
223
|
+
const entries = await fs.readdir(tmp);
|
|
224
|
+
expect(entries).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('writes the disk key file with mode 0o600', async () => {
|
|
228
|
+
const src = createCombinedKeySource({
|
|
229
|
+
passphrasePrompt: async () => 'pw',
|
|
230
|
+
diskKeyPath,
|
|
231
|
+
});
|
|
232
|
+
await src.obtain(generateSalt());
|
|
233
|
+
const stat = await fs.stat(diskKeyPath);
|
|
234
|
+
// Mask to the permission bits; expect owner read/write only.
|
|
235
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('name starts as "unknown" before obtain() is called', () => {
|
|
239
|
+
const src = createCombinedKeySource({
|
|
240
|
+
passphrasePrompt: async () => 'pw',
|
|
241
|
+
diskKeyPath,
|
|
242
|
+
});
|
|
243
|
+
expect(src.name).toBe('unknown');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('persist() writes the supplied key to both backends', async () => {
|
|
247
|
+
const src = createCombinedKeySource({
|
|
248
|
+
passphrasePrompt: async () => 'pw',
|
|
249
|
+
diskKeyPath,
|
|
250
|
+
});
|
|
251
|
+
const key = Buffer.from('p'.repeat(32));
|
|
252
|
+
await src.persist?.(key, generateSalt());
|
|
253
|
+
expect(keytarState.store.get(KEYTAR_KEY)).toBe(key.toString('base64'));
|
|
254
|
+
const onDisk = (await fs.readFile(diskKeyPath, 'utf8')).trim();
|
|
255
|
+
expect(onDisk).toBe(key.toString('base64'));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('falls through to the prompt when keytar throws and disk is empty', async () => {
|
|
259
|
+
keytarState.failGet = true; // keychain unavailable
|
|
260
|
+
let prompted = false;
|
|
261
|
+
const src = createCombinedKeySource({
|
|
262
|
+
passphrasePrompt: async () => {
|
|
263
|
+
prompted = true;
|
|
264
|
+
return 'pw';
|
|
265
|
+
},
|
|
266
|
+
diskKeyPath,
|
|
267
|
+
});
|
|
268
|
+
await src.obtain(generateSalt());
|
|
269
|
+
expect(prompted).toBe(true);
|
|
270
|
+
expect(src.name).toBe('passphrase');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('createStaticKeySource', () => {
|
|
275
|
+
it('always returns the provided key and reports name "static"', async () => {
|
|
276
|
+
const key = Buffer.from('s'.repeat(32));
|
|
277
|
+
const src = createStaticKeySource(key);
|
|
278
|
+
expect(src.name).toBe('static');
|
|
279
|
+
// Ignores the salt and returns the exact same buffer regardless.
|
|
280
|
+
expect(await src.obtain(generateSalt())).toBe(key);
|
|
281
|
+
expect(await src.obtain(generateSalt())).toBe(key);
|
|
282
|
+
// No persist hook on the static source.
|
|
283
|
+
expect(src.persist).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
});
|
package/src/keysource.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { writeFileAtomic, moxxyPath } from '@moxxy/sdk/server';
|
|
3
|
+
import { deriveKeyAsync } from './crypto.js';
|
|
4
|
+
|
|
5
|
+
const KEYCHAIN_SERVICE = 'moxxy';
|
|
6
|
+
const KEYCHAIN_ACCOUNT = 'vault-master-key';
|
|
7
|
+
|
|
8
|
+
export interface MasterKeySource {
|
|
9
|
+
/** Returns the raw 32-byte AES key. May open a keychain or prompt the user. */
|
|
10
|
+
obtain(salt: Buffer): Promise<Buffer>;
|
|
11
|
+
/**
|
|
12
|
+
* Force-persist a master key obtained out-of-band for future sessions.
|
|
13
|
+
*
|
|
14
|
+
* NB: `VaultStore` does NOT call this — `obtain()` already persists the key
|
|
15
|
+
* as a side effect on the passphrase-prompt path, so the running system
|
|
16
|
+
* never needs it. It exists as a forced-rewrite hook for callers that hold a
|
|
17
|
+
* key themselves (e.g. a hypothetical `moxxy doctor --reseed`). Optional, so
|
|
18
|
+
* sources that can't persist (env/static) simply omit it.
|
|
19
|
+
*/
|
|
20
|
+
persist?(key: Buffer, salt: Buffer): Promise<void>;
|
|
21
|
+
readonly name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CombinedKeySourceOptions {
|
|
25
|
+
readonly passphrasePrompt: () => Promise<string>;
|
|
26
|
+
readonly envVar?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Skip the OS keychain (`@napi-rs/keyring`) entirely, using only the disk
|
|
29
|
+
* cache + passphrase. Named `disableKeytar` for backwards compatibility —
|
|
30
|
+
* the underlying keychain library is now `@napi-rs/keyring`, not keytar.
|
|
31
|
+
*/
|
|
32
|
+
readonly disableKeytar?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Disk fallback for the master key, used when the OS keychain isn't
|
|
35
|
+
* available (no native binary, or it refuses to bind — common on headless
|
|
36
|
+
* Linux). Stored as base64 at this path with mode 0o600 — less secure than
|
|
37
|
+
* the OS keychain, but means the user types their passphrase ONCE instead
|
|
38
|
+
* of every run. Set to false to disable.
|
|
39
|
+
*
|
|
40
|
+
* Default: `~/.moxxy/vault.key`.
|
|
41
|
+
*/
|
|
42
|
+
readonly diskKeyPath?: string | false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the vault master key in priority order:
|
|
47
|
+
* 1. `MOXXY_VAULT_PASSPHRASE` env var (derive on each call — no persistence).
|
|
48
|
+
* 2. OS keychain via `@napi-rs/keyring`.
|
|
49
|
+
* 3. On-disk cached key at `~/.moxxy/vault.key` (mode 0600).
|
|
50
|
+
* 4. Interactive passphrase prompt.
|
|
51
|
+
*
|
|
52
|
+
* The first successful prompt persists the derived key to BOTH the OS keychain
|
|
53
|
+
* (if available) and the disk cache so subsequent runs are silent. The chosen
|
|
54
|
+
* source's name is exposed via `.name` so `moxxy doctor` can surface it
|
|
55
|
+
* ("vault unlocked via keychain" / "via ~/.moxxy/vault.key").
|
|
56
|
+
*/
|
|
57
|
+
export function createCombinedKeySource(opts: CombinedKeySourceOptions): MasterKeySource {
|
|
58
|
+
let resolvedName = 'unknown';
|
|
59
|
+
const diskPath = resolveDiskPath(opts.diskKeyPath);
|
|
60
|
+
// Memoize the env-path scrypt derivation. obtain() is called on every
|
|
61
|
+
// VaultStore.open(), and scryptSync (N=16384) is a CPU-bound stall of the
|
|
62
|
+
// single-threaded event loop; without this cache an env-configured process
|
|
63
|
+
// re-pays the full KDF cost every time a fresh VaultStore opens. Keyed by
|
|
64
|
+
// (passphrase, salt) so a salt change still re-derives. Single entry — the
|
|
65
|
+
// env passphrase and the on-disk salt are stable for a process lifetime.
|
|
66
|
+
let envCache: { passphrase: string; saltB64: string; key: Buffer } | null = null;
|
|
67
|
+
const deriveEnvKey = async (passphrase: string, salt: Buffer): Promise<Buffer> => {
|
|
68
|
+
const saltB64 = salt.toString('base64');
|
|
69
|
+
if (envCache && envCache.passphrase === passphrase && envCache.saltB64 === saltB64) {
|
|
70
|
+
return envCache.key;
|
|
71
|
+
}
|
|
72
|
+
const key = await deriveKeyAsync(passphrase, salt);
|
|
73
|
+
envCache = { passphrase, saltB64, key };
|
|
74
|
+
return key;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const persistKey = async (keyB64: string): Promise<void> => {
|
|
78
|
+
if (!opts.disableKeytar) await tryKeychainSet(keyB64);
|
|
79
|
+
if (diskPath) await tryDiskSet(diskPath, keyB64);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
get name() {
|
|
84
|
+
return resolvedName;
|
|
85
|
+
},
|
|
86
|
+
async obtain(salt) {
|
|
87
|
+
const envName = opts.envVar ?? 'MOXXY_VAULT_PASSPHRASE';
|
|
88
|
+
const envValue = process.env[envName];
|
|
89
|
+
if (envValue) {
|
|
90
|
+
resolvedName = `env:${envName}`;
|
|
91
|
+
return await deriveEnvKey(envValue, salt);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!opts.disableKeytar) {
|
|
95
|
+
const fromKeychain = await tryKeychainGet();
|
|
96
|
+
if (fromKeychain) {
|
|
97
|
+
resolvedName = 'keychain';
|
|
98
|
+
// Backfill the disk cache so a future keychain outage doesn't
|
|
99
|
+
// suddenly force a passphrase prompt. Awaited (both helpers swallow
|
|
100
|
+
// their own errors) so the "subsequent runs are silent" guarantee
|
|
101
|
+
// actually holds before obtain() resolves, and a process that exits
|
|
102
|
+
// immediately after first open() still persisted the backfill.
|
|
103
|
+
if (diskPath) await tryDiskSet(diskPath, fromKeychain);
|
|
104
|
+
return Buffer.from(fromKeychain, 'base64');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (diskPath) {
|
|
109
|
+
const fromDisk = await tryDiskGet(diskPath);
|
|
110
|
+
if (fromDisk) {
|
|
111
|
+
resolvedName = `file:${diskPath}`;
|
|
112
|
+
// Backfill the keychain if it became available since the file was written.
|
|
113
|
+
if (!opts.disableKeytar) await tryKeychainSet(fromDisk);
|
|
114
|
+
return Buffer.from(fromDisk, 'base64');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const passphrase = await opts.passphrasePrompt();
|
|
119
|
+
resolvedName = 'passphrase';
|
|
120
|
+
const key = await deriveKeyAsync(passphrase, salt);
|
|
121
|
+
await persistKey(key.toString('base64'));
|
|
122
|
+
return key;
|
|
123
|
+
},
|
|
124
|
+
async persist(key) {
|
|
125
|
+
await persistKey(key.toString('base64'));
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveDiskPath(supplied: string | false | undefined): string | null {
|
|
131
|
+
if (supplied === false) return null;
|
|
132
|
+
if (typeof supplied === 'string') return supplied;
|
|
133
|
+
return moxxyPath('vault.key');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function tryDiskGet(filePath: string): Promise<string | null> {
|
|
137
|
+
try {
|
|
138
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
139
|
+
return raw.trim() || null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function tryDiskSet(filePath: string, value: string): Promise<void> {
|
|
146
|
+
try {
|
|
147
|
+
// Crash-atomic, owner-only (0o600) — this is the cached master key.
|
|
148
|
+
await writeFileAtomic(filePath, value + '\n', { mode: 0o600 });
|
|
149
|
+
} catch {
|
|
150
|
+
// Best-effort; if we can't write, the next run will just re-prompt.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Minimal shape of the `@napi-rs/keyring` `Entry` we rely on. The library is
|
|
156
|
+
* a dynamic, optional import: it ships prebuilt native binaries per platform,
|
|
157
|
+
* but if it isn't installed (or its binary is missing) we fall back to the
|
|
158
|
+
* disk cache / passphrase rather than failing. Unlike keytar, `getPassword()`
|
|
159
|
+
* is synchronous and THROWS when no entry exists — both are handled by the
|
|
160
|
+
* surrounding try/catch.
|
|
161
|
+
*/
|
|
162
|
+
interface KeyringEntry {
|
|
163
|
+
getPassword(): string;
|
|
164
|
+
setPassword(password: string): void;
|
|
165
|
+
}
|
|
166
|
+
type KeyringModule = {
|
|
167
|
+
Entry?: new (service: string, account: string) => KeyringEntry;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
async function tryKeychainGet(): Promise<string | null> {
|
|
171
|
+
try {
|
|
172
|
+
const mod = (await import('@napi-rs/keyring')) as KeyringModule;
|
|
173
|
+
if (!mod.Entry) return null;
|
|
174
|
+
return new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).getPassword() || null;
|
|
175
|
+
} catch {
|
|
176
|
+
// Not installed, no stored entry, or keychain locked — fall back.
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function tryKeychainSet(value: string): Promise<void> {
|
|
182
|
+
try {
|
|
183
|
+
const mod = (await import('@napi-rs/keyring')) as KeyringModule;
|
|
184
|
+
if (!mod.Entry) return;
|
|
185
|
+
new mod.Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).setPassword(value);
|
|
186
|
+
} catch {
|
|
187
|
+
// Best-effort; keychain failures must not break the vault.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createStaticKeySource(key: Buffer): MasterKeySource {
|
|
192
|
+
return {
|
|
193
|
+
name: 'static',
|
|
194
|
+
async obtain() {
|
|
195
|
+
return key;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
import { containsPlaceholder, resolveString, resolveValue } from './placeholder.js';
|
|
10
|
+
|
|
11
|
+
let tmp: string;
|
|
12
|
+
let vault: VaultStore;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-vault-ph-'));
|
|
16
|
+
vault = new VaultStore({
|
|
17
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
18
|
+
keySource: createStaticKeySource(deriveKey('pw', generateSalt())),
|
|
19
|
+
});
|
|
20
|
+
await vault.set('API_KEY', 'sk-xyz');
|
|
21
|
+
await vault.set('CHAT_ID', '42');
|
|
22
|
+
});
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('resolveString', () => {
|
|
28
|
+
it('returns input unchanged when no placeholder', async () => {
|
|
29
|
+
expect(await resolveString('plain', vault)).toBe('plain');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('substitutes a single placeholder', async () => {
|
|
33
|
+
expect(await resolveString('Bearer ${vault:API_KEY}', vault)).toBe('Bearer sk-xyz');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('substitutes multiple placeholders', async () => {
|
|
37
|
+
const out = await resolveString('${vault:API_KEY}-${vault:CHAT_ID}', vault);
|
|
38
|
+
expect(out).toBe('sk-xyz-42');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws a CONFIG_INVALID MoxxyError on missing required entries', async () => {
|
|
42
|
+
await expect(resolveString('${vault:MISSING}', vault)).rejects.toThrow(/missing required entry/);
|
|
43
|
+
const err = await resolveString('${vault:MISSING}', vault).catch((e) => e);
|
|
44
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
45
|
+
expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
|
|
46
|
+
expect((err as MoxxyError).context).toMatchObject({ name: 'MISSING' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('resolveValue', () => {
|
|
51
|
+
it('walks nested objects and arrays', async () => {
|
|
52
|
+
const result = await resolveValue(
|
|
53
|
+
{
|
|
54
|
+
provider: {
|
|
55
|
+
config: { apiKey: '${vault:API_KEY}' },
|
|
56
|
+
tags: ['${vault:CHAT_ID}', 'literal'],
|
|
57
|
+
},
|
|
58
|
+
depth: 3,
|
|
59
|
+
flag: true,
|
|
60
|
+
},
|
|
61
|
+
vault,
|
|
62
|
+
);
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
provider: { config: { apiKey: 'sk-xyz' }, tags: ['42', 'literal'] },
|
|
65
|
+
depth: 3,
|
|
66
|
+
flag: true,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves object key insertion order', async () => {
|
|
71
|
+
const result = (await resolveValue(
|
|
72
|
+
{ z: '${vault:API_KEY}', a: 'lit', m: '${vault:CHAT_ID}' },
|
|
73
|
+
vault,
|
|
74
|
+
)) as Record<string, unknown>;
|
|
75
|
+
expect(Object.keys(result)).toEqual(['z', 'a', 'm']);
|
|
76
|
+
expect(result).toEqual({ z: 'sk-xyz', a: 'lit', m: '42' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('resolves object properties concurrently (overlapped awaits)', async () => {
|
|
80
|
+
// Wrap vault.get with a small delay + concurrency counter; if object
|
|
81
|
+
// properties resolved sequentially, max concurrency would be 1.
|
|
82
|
+
let active = 0;
|
|
83
|
+
let maxActive = 0;
|
|
84
|
+
const realGet = vault.get.bind(vault);
|
|
85
|
+
vault.get = async (name: string) => {
|
|
86
|
+
active++;
|
|
87
|
+
maxActive = Math.max(maxActive, active);
|
|
88
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
89
|
+
try {
|
|
90
|
+
return await realGet(name);
|
|
91
|
+
} finally {
|
|
92
|
+
active--;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const result = (await resolveValue(
|
|
96
|
+
{ a: '${vault:API_KEY}', b: '${vault:CHAT_ID}', c: '${vault:API_KEY}' },
|
|
97
|
+
vault,
|
|
98
|
+
)) as Record<string, unknown>;
|
|
99
|
+
expect(result).toEqual({ a: 'sk-xyz', b: '42', c: 'sk-xyz' });
|
|
100
|
+
expect(maxActive).toBeGreaterThan(1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('containsPlaceholder', () => {
|
|
105
|
+
it('finds placeholders at any depth', () => {
|
|
106
|
+
expect(containsPlaceholder('${vault:X}')).toBe(true);
|
|
107
|
+
expect(containsPlaceholder('plain')).toBe(false);
|
|
108
|
+
expect(containsPlaceholder({ a: { b: ['${vault:X}'] } })).toBe(true);
|
|
109
|
+
expect(containsPlaceholder({ a: { b: ['plain'] } })).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not stack-overflow on a reference cycle', () => {
|
|
113
|
+
const cyclic: Record<string, unknown> = { a: 'plain' };
|
|
114
|
+
cyclic.self = cyclic; // legal in-memory cycle
|
|
115
|
+
expect(() => containsPlaceholder(cyclic)).not.toThrow();
|
|
116
|
+
expect(containsPlaceholder(cyclic)).toBe(false);
|
|
117
|
+
cyclic.secret = '${vault:X}';
|
|
118
|
+
expect(containsPlaceholder(cyclic)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws (rather than overflowing) on pathologically deep input', () => {
|
|
122
|
+
let node: Record<string, unknown> = { leaf: 'plain' };
|
|
123
|
+
for (let i = 0; i < 200; i++) node = { nested: node };
|
|
124
|
+
expect(() => containsPlaceholder(node)).toThrow(/nested too deeply/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('resolveValue worst-case guards', () => {
|
|
129
|
+
it('throws a CONFIG_INVALID MoxxyError on a reference cycle instead of overflowing', async () => {
|
|
130
|
+
const cyclic: Record<string, unknown> = { apiKey: '${vault:API_KEY}' };
|
|
131
|
+
cyclic.self = cyclic;
|
|
132
|
+
const err = await resolveValue(cyclic, vault).catch((e) => e);
|
|
133
|
+
expect(MoxxyError.isMoxxyError(err)).toBe(true);
|
|
134
|
+
expect((err as MoxxyError).code).toBe('CONFIG_INVALID');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('throws on pathologically deep input rather than blowing the stack', async () => {
|
|
138
|
+
let node: Record<string, unknown> = { leaf: '${vault:API_KEY}' };
|
|
139
|
+
for (let i = 0; i < 200; i++) node = { nested: node };
|
|
140
|
+
await expect(resolveValue(node, vault)).rejects.toThrow(/nested too deeply/);
|
|
141
|
+
});
|
|
142
|
+
});
|