@vellumai/credential-executor 0.8.12 → 0.9.0-dev.202606162243.4268db3
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/package.json
CHANGED
|
@@ -12,7 +12,10 @@ import { randomBytes } from "node:crypto";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createLocalSecureKeyBackend,
|
|
17
|
+
StoreUnavailableError,
|
|
18
|
+
} from "../materializers/local-secure-key-backend.js";
|
|
16
19
|
|
|
17
20
|
// ---------------------------------------------------------------------------
|
|
18
21
|
// Helpers
|
|
@@ -153,4 +156,49 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
|
153
156
|
const result = await backend.delete("anything");
|
|
154
157
|
expect(result).toBe("error");
|
|
155
158
|
});
|
|
159
|
+
|
|
160
|
+
// -------------------------------------------------------------------------
|
|
161
|
+
// UNAVAILABLE (store exists but cannot be read / decrypted) must be distinct
|
|
162
|
+
// from ABSENT (no store, or the store reads cleanly but lacks the key). The
|
|
163
|
+
// former throws so the RPC layer reports `unreachable`; the latter returns
|
|
164
|
+
// undefined/[]. This is the cold-start fix: a transiently-unreadable store or
|
|
165
|
+
// missing key material must never masquerade as "credential not found".
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
test("get() THROWS (not undefined) when the store file exists but is unreadable", async () => {
|
|
169
|
+
const { securityDir, vellumRoot } = setup();
|
|
170
|
+
// A store file that exists but cannot be parsed (e.g. a partial/corrupt
|
|
171
|
+
// read) — distinct from no store at all.
|
|
172
|
+
writeFileSync(join(securityDir, "keys.enc"), "{ not valid json", {
|
|
173
|
+
mode: 0o600,
|
|
174
|
+
});
|
|
175
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
176
|
+
await expect(backend.get("anything")).rejects.toThrow(StoreUnavailableError);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("get() returns undefined when the store reads cleanly but the key is absent", async () => {
|
|
180
|
+
const { vellumRoot } = setup();
|
|
181
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
182
|
+
await backend.set("present/key", "v"); // valid store with one entry
|
|
183
|
+
// A genuinely missing key in a readable store is ABSENT, not unavailable.
|
|
184
|
+
expect(await backend.get("absent/key")).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("get() THROWS when a v2 entry exists but store.key is missing (cold-start key-material race)", async () => {
|
|
188
|
+
const { securityDir, vellumRoot } = setup();
|
|
189
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
190
|
+
await backend.set("k", "v"); // creates the v2 store + store.key
|
|
191
|
+
expect(await backend.get("k")).toBe("v"); // sanity: warm read works
|
|
192
|
+
// Simulate the cold-start window where the key material is transiently
|
|
193
|
+
// unavailable: the entry still exists, but it cannot be decrypted yet.
|
|
194
|
+
rmSync(join(securityDir, "store.key"));
|
|
195
|
+
await expect(backend.get("k")).rejects.toThrow(StoreUnavailableError);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("list() THROWS (not []) when the store file exists but is unreadable", async () => {
|
|
199
|
+
const { securityDir, vellumRoot } = setup();
|
|
200
|
+
writeFileSync(join(securityDir, "keys.enc"), "{ corrupt", { mode: 0o600 });
|
|
201
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
202
|
+
await expect(backend.list()).rejects.toThrow(StoreUnavailableError);
|
|
203
|
+
});
|
|
156
204
|
});
|
|
@@ -65,7 +65,7 @@ function rowToRecord(row: OAuthConnectionRow): OAuthConnectionRecord {
|
|
|
65
65
|
* Create a read-only OAuth connection lookup backed by the assistant's
|
|
66
66
|
* SQLite database.
|
|
67
67
|
*
|
|
68
|
-
* @param workspaceDir - The workspace directory (e.g.
|
|
68
|
+
* @param workspaceDir - The workspace directory (e.g. `$VELLUM_WORKSPACE_DIR`).
|
|
69
69
|
*/
|
|
70
70
|
export function createLocalOAuthLookup(
|
|
71
71
|
workspaceDir: string,
|
|
@@ -278,6 +278,34 @@ function readStore(storePath: string): StoreFile | null {
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Errors
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Thrown by `get()` / `list()` when the credential store is UNAVAILABLE — the
|
|
287
|
+
* store file exists but cannot be read, the v2 `store.key` (or v1 machine
|
|
288
|
+
* entropy) key material is missing, or an entry fails to decrypt. This is
|
|
289
|
+
* deliberately DISTINCT from a credential being ABSENT (no store file yet, or
|
|
290
|
+
* the store reads cleanly but lacks the requested key), which returns
|
|
291
|
+
* `undefined` / `[]`.
|
|
292
|
+
*
|
|
293
|
+
* The distinction matters on CES cold start: for a brief window after a
|
|
294
|
+
* (re)start the key material can be transiently unreadable (see the
|
|
295
|
+
* `entropyGetter` note on {@link createLocalSecureKeyBackend} — "the file may
|
|
296
|
+
* not exist at construction time but appears later"). Returning `undefined`
|
|
297
|
+
* there reports a credential that EXISTS as "not found"; throwing instead
|
|
298
|
+
* surfaces the true state to the RPC layer (a `HANDLER_ERROR` the daemon maps
|
|
299
|
+
* to `unreachable`), so a transient read failure is never mistaken for a
|
|
300
|
+
* missing credential.
|
|
301
|
+
*/
|
|
302
|
+
export class StoreUnavailableError extends Error {
|
|
303
|
+
constructor(message: string) {
|
|
304
|
+
super(message);
|
|
305
|
+
this.name = "StoreUnavailableError";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
281
309
|
// ---------------------------------------------------------------------------
|
|
282
310
|
// Backend implementation
|
|
283
311
|
// ---------------------------------------------------------------------------
|
|
@@ -310,29 +338,50 @@ export function createLocalSecureKeyBackend(
|
|
|
310
338
|
|
|
311
339
|
return {
|
|
312
340
|
async get(key: string): Promise<string | undefined> {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const storeKey = readStoreKey(vellumRoot);
|
|
323
|
-
if (!storeKey) return undefined;
|
|
324
|
-
aesKey = storeKey;
|
|
325
|
-
} else {
|
|
326
|
-
// v1: derive key from machine entropy via PBKDF2
|
|
327
|
-
const entropy = entropyGetter?.() ?? staticEntropy;
|
|
328
|
-
const salt = Buffer.from(store.salt, "hex");
|
|
329
|
-
aesKey = deriveKey(salt, entropy);
|
|
341
|
+
const store = readStore(storePath);
|
|
342
|
+
if (!store) {
|
|
343
|
+
// No store file at all → the credential is genuinely ABSENT. A store
|
|
344
|
+
// file that EXISTS but could not be read is UNAVAILABLE, not absent —
|
|
345
|
+
// surface it rather than reporting a false "not found".
|
|
346
|
+
if (existsSync(storePath)) {
|
|
347
|
+
throw new StoreUnavailableError(
|
|
348
|
+
"credential store exists but could not be read",
|
|
349
|
+
);
|
|
330
350
|
}
|
|
331
|
-
|
|
332
|
-
return decrypt(entry, aesKey);
|
|
333
|
-
} catch {
|
|
334
351
|
return undefined;
|
|
335
352
|
}
|
|
353
|
+
|
|
354
|
+
const entry = store.entries[key];
|
|
355
|
+
// The store read cleanly and has no such entry → genuinely not found.
|
|
356
|
+
if (!entry) return undefined;
|
|
357
|
+
|
|
358
|
+
let aesKey: Buffer;
|
|
359
|
+
if (store.version === 2) {
|
|
360
|
+
const storeKey = readStoreKey(vellumRoot);
|
|
361
|
+
if (!storeKey) {
|
|
362
|
+
// The entry exists but the key material to decrypt it is unavailable
|
|
363
|
+
// (the cold-start race). Unavailable, NOT not-found.
|
|
364
|
+
throw new StoreUnavailableError(
|
|
365
|
+
"v2 store entry present but store.key is unavailable",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
aesKey = storeKey;
|
|
369
|
+
} else {
|
|
370
|
+
// v1: derive key from machine entropy via PBKDF2. `entropy` is an
|
|
371
|
+
// OPTIONAL override (managed mode); when undefined, deriveKey falls
|
|
372
|
+
// back to local machine entropy — the normal local-mode path, so an
|
|
373
|
+
// undefined value here is NOT "unavailable". A v1 cold-start race (the
|
|
374
|
+
// override entropy file not yet present) yields the wrong key and
|
|
375
|
+
// surfaces below as a decrypt failure → unavailable.
|
|
376
|
+
const entropy = entropyGetter?.() ?? staticEntropy;
|
|
377
|
+
const salt = Buffer.from(store.salt, "hex");
|
|
378
|
+
aesKey = deriveKey(salt, entropy);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// A decrypt failure means corrupt data or wrong key material — the
|
|
382
|
+
// credential exists but we cannot produce its value. Let it propagate as
|
|
383
|
+
// unavailable rather than swallowing it into a false "not found".
|
|
384
|
+
return decrypt(entry, aesKey);
|
|
336
385
|
},
|
|
337
386
|
|
|
338
387
|
// NOTE: read-modify-write without file locking. The atomic rename
|
|
@@ -387,13 +436,18 @@ export function createLocalSecureKeyBackend(
|
|
|
387
436
|
},
|
|
388
437
|
|
|
389
438
|
async list(): Promise<string[]> {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
439
|
+
const store = readStore(storePath);
|
|
440
|
+
if (!store) {
|
|
441
|
+
// Mirror get(): a store file that exists but is unreadable is
|
|
442
|
+
// UNAVAILABLE (surface it), not an empty credential set.
|
|
443
|
+
if (existsSync(storePath)) {
|
|
444
|
+
throw new StoreUnavailableError(
|
|
445
|
+
"credential store exists but could not be read",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
395
448
|
return [];
|
|
396
449
|
}
|
|
450
|
+
return Object.keys(store.entries);
|
|
397
451
|
},
|
|
398
452
|
};
|
|
399
453
|
}
|