@vellumai/credential-executor 0.11.2-dev.202608060103.4220693 → 0.11.2-dev.202608060508.a7ab7f7
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
|
@@ -6,23 +6,38 @@
|
|
|
6
6
|
* tests in `local-materializers.test.ts`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
-
import
|
|
9
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
} from "node:fs";
|
|
11
19
|
import { randomBytes } from "node:crypto";
|
|
12
20
|
import { join } from "node:path";
|
|
13
21
|
import { tmpdir } from "node:os";
|
|
14
22
|
|
|
23
|
+
import { CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
|
|
24
|
+
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
25
|
+
|
|
15
26
|
import {
|
|
16
27
|
createLocalSecureKeyBackend,
|
|
17
28
|
StoreUnavailableError,
|
|
18
29
|
} from "../materializers/local-secure-key-backend.js";
|
|
30
|
+
import { buildCrudHandlers } from "../main.js";
|
|
19
31
|
|
|
20
32
|
// ---------------------------------------------------------------------------
|
|
21
33
|
// Helpers
|
|
22
34
|
// ---------------------------------------------------------------------------
|
|
23
35
|
|
|
24
36
|
function makeTmpDir(): string {
|
|
25
|
-
const dir = join(
|
|
37
|
+
const dir = join(
|
|
38
|
+
tmpdir(),
|
|
39
|
+
`ces-backend-test-${randomBytes(8).toString("hex")}`,
|
|
40
|
+
);
|
|
26
41
|
mkdirSync(dir, { recursive: true });
|
|
27
42
|
return dir;
|
|
28
43
|
}
|
|
@@ -100,7 +115,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
|
100
115
|
expect(Buffer.compare(keyAfterFirst, keyAfterSecond)).toBe(0);
|
|
101
116
|
|
|
102
117
|
// keys.enc has exactly 2 entries
|
|
103
|
-
const store = JSON.parse(
|
|
118
|
+
const store = JSON.parse(
|
|
119
|
+
readFileSync(join(securityDir, "keys.enc"), "utf-8"),
|
|
120
|
+
);
|
|
104
121
|
expect(Object.keys(store.entries).length).toBe(2);
|
|
105
122
|
|
|
106
123
|
// Both values round-trip
|
|
@@ -114,16 +131,22 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
|
114
131
|
// Manually write a v1 store
|
|
115
132
|
const salt = randomBytes(32).toString("hex");
|
|
116
133
|
const v1Store = { version: 1, salt, entries: {} };
|
|
117
|
-
writeFileSync(
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(securityDir, "keys.enc"),
|
|
136
|
+
JSON.stringify(v1Store, null, 2),
|
|
137
|
+
{
|
|
138
|
+
mode: 0o600,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
120
141
|
|
|
121
142
|
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
122
143
|
const result = await backend.set("v1-key", "v1-value");
|
|
123
144
|
expect(result).toBe(true);
|
|
124
145
|
|
|
125
146
|
// Read back and verify format preserved
|
|
126
|
-
const storeAfter = JSON.parse(
|
|
147
|
+
const storeAfter = JSON.parse(
|
|
148
|
+
readFileSync(join(securityDir, "keys.enc"), "utf-8"),
|
|
149
|
+
);
|
|
127
150
|
expect(storeAfter.version).toBe(1);
|
|
128
151
|
expect(storeAfter.salt).toBe(salt);
|
|
129
152
|
expect(Object.keys(storeAfter.entries)).toContain("v1-key");
|
|
@@ -173,7 +196,9 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
|
173
196
|
mode: 0o600,
|
|
174
197
|
});
|
|
175
198
|
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
176
|
-
await expect(backend.get("anything")).rejects.toThrow(
|
|
199
|
+
await expect(backend.get("anything")).rejects.toThrow(
|
|
200
|
+
StoreUnavailableError,
|
|
201
|
+
);
|
|
177
202
|
});
|
|
178
203
|
|
|
179
204
|
test("get() returns undefined when the store reads cleanly but the key is absent", async () => {
|
|
@@ -201,4 +226,119 @@ describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
|
201
226
|
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
202
227
|
await expect(backend.list()).rejects.toThrow(StoreUnavailableError);
|
|
203
228
|
});
|
|
229
|
+
|
|
230
|
+
// -------------------------------------------------------------------------
|
|
231
|
+
// Store-write durability: the store is written to a temp file with
|
|
232
|
+
// `flush: true` so the ack the assistant receives follows an fsync.
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
test("writeStore fsyncs the temp file (flush: true) so the ack follows a durable write", async () => {
|
|
236
|
+
const { securityDir, vellumRoot } = setup();
|
|
237
|
+
const spy = spyOn(fs, "writeFileSync");
|
|
238
|
+
try {
|
|
239
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
240
|
+
await backend.set("durable/key", "v");
|
|
241
|
+
|
|
242
|
+
const keysEncTmpPrefix = join(securityDir, "keys.enc") + ".tmp.";
|
|
243
|
+
const flushedStoreWrite = spy.mock.calls.some((call) => {
|
|
244
|
+
const [path, , opts] = call as [
|
|
245
|
+
unknown,
|
|
246
|
+
unknown,
|
|
247
|
+
{ flush?: boolean } | undefined,
|
|
248
|
+
];
|
|
249
|
+
return (
|
|
250
|
+
typeof path === "string" &&
|
|
251
|
+
path.startsWith(keysEncTmpPrefix) &&
|
|
252
|
+
!!opts &&
|
|
253
|
+
typeof opts === "object" &&
|
|
254
|
+
opts.flush === true
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
expect(flushedStoreWrite).toBe(true);
|
|
258
|
+
} finally {
|
|
259
|
+
spy.mockRestore();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("writeStore fsyncs the parent directory so the rename is durable", async () => {
|
|
264
|
+
const { vellumRoot } = setup();
|
|
265
|
+
const fsyncSpy = spyOn(fs, "fsyncSync");
|
|
266
|
+
try {
|
|
267
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
268
|
+
await backend.set("durable/key", "v");
|
|
269
|
+
// The directory fsync runs after the rename; at least one fsyncSync call
|
|
270
|
+
// must have happened for the store write.
|
|
271
|
+
expect(fsyncSpy.mock.calls.length).toBeGreaterThan(0);
|
|
272
|
+
} finally {
|
|
273
|
+
fsyncSpy.mockRestore();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// CES CRUD audit logging (buildCrudHandlers)
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
describe("buildCrudHandlers audit logging", () => {
|
|
283
|
+
function fakeBackend(over: Partial<SecureKeyBackend> = {}): SecureKeyBackend {
|
|
284
|
+
return {
|
|
285
|
+
get: async () => undefined,
|
|
286
|
+
set: async () => true,
|
|
287
|
+
delete: async () => "deleted",
|
|
288
|
+
list: async () => [],
|
|
289
|
+
...over,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const ctx = { sessionId: "test-session" };
|
|
294
|
+
|
|
295
|
+
test("SetCredential emits an audit line with account + outcome, never the value", async () => {
|
|
296
|
+
const calls: Array<{ obj: unknown; msg: string }> = [];
|
|
297
|
+
const audit = {
|
|
298
|
+
info: (obj: unknown, msg: string) => {
|
|
299
|
+
calls.push({ obj, msg });
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
const handlers = buildCrudHandlers(
|
|
303
|
+
fakeBackend(),
|
|
304
|
+
audit as unknown as Parameters<typeof buildCrudHandlers>[1],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const res = await handlers[CesRpcMethod.SetCredential]!(
|
|
308
|
+
{ account: "vellum:assistant_api_key", value: "super-secret-value" },
|
|
309
|
+
ctx,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(res).toEqual({ ok: true });
|
|
313
|
+
expect(calls).toContainEqual({
|
|
314
|
+
obj: { account: "vellum:assistant_api_key", ok: true },
|
|
315
|
+
msg: "CES credential set",
|
|
316
|
+
});
|
|
317
|
+
// The audit line must never carry the credential value.
|
|
318
|
+
expect(JSON.stringify(calls)).not.toContain("super-secret-value");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("DeleteCredential emits an audit line with account + result", async () => {
|
|
322
|
+
const calls: Array<{ obj: unknown; msg: string }> = [];
|
|
323
|
+
const audit = {
|
|
324
|
+
info: (obj: unknown, msg: string) => {
|
|
325
|
+
calls.push({ obj, msg });
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const handlers = buildCrudHandlers(
|
|
329
|
+
fakeBackend({ delete: async () => "deleted" }),
|
|
330
|
+
audit as unknown as Parameters<typeof buildCrudHandlers>[1],
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const res = await handlers[CesRpcMethod.DeleteCredential]!(
|
|
334
|
+
{ account: "github:api_token" },
|
|
335
|
+
ctx,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(res).toEqual({ result: "deleted" });
|
|
339
|
+
expect(calls).toContainEqual({
|
|
340
|
+
obj: { account: "github:api_token", result: "deleted" },
|
|
341
|
+
msg: "CES credential delete",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
204
344
|
});
|
package/src/main.ts
CHANGED
|
@@ -75,8 +75,9 @@ function ensureDataDirs(mode: CesMode): void {
|
|
|
75
75
|
* share the same registry — they differ only in where the secure key backend
|
|
76
76
|
* reads from and whether the health server is started.
|
|
77
77
|
*/
|
|
78
|
-
function buildCrudHandlers(
|
|
78
|
+
export function buildCrudHandlers(
|
|
79
79
|
secureKeyBackend: SecureKeyBackend,
|
|
80
|
+
audit: Pick<ReturnType<typeof getLogger>, "info"> = log,
|
|
80
81
|
): RpcHandlerRegistry {
|
|
81
82
|
const handlers: RpcHandlerRegistry = {};
|
|
82
83
|
|
|
@@ -90,6 +91,8 @@ function buildCrudHandlers(
|
|
|
90
91
|
value: string;
|
|
91
92
|
}) => {
|
|
92
93
|
const ok = await secureKeyBackend.set(req.account, req.value);
|
|
94
|
+
// Audit the mutation: account name and outcome only, never the value.
|
|
95
|
+
audit.info({ account: req.account, ok }, "CES credential set");
|
|
93
96
|
return { ok };
|
|
94
97
|
}) as (typeof handlers)[string];
|
|
95
98
|
|
|
@@ -97,6 +100,7 @@ function buildCrudHandlers(
|
|
|
97
100
|
account: string;
|
|
98
101
|
}) => {
|
|
99
102
|
const result = await secureKeyBackend.delete(req.account);
|
|
103
|
+
audit.info({ account: req.account, result }, "CES credential delete");
|
|
100
104
|
return { result };
|
|
101
105
|
}) as (typeof handlers)[string];
|
|
102
106
|
|
|
@@ -111,6 +115,7 @@ function buildCrudHandlers(
|
|
|
111
115
|
const results = [];
|
|
112
116
|
for (const { account, value } of req.credentials) {
|
|
113
117
|
const ok = await secureKeyBackend.set(account, value);
|
|
118
|
+
audit.info({ account, ok }, "CES credential set (bulk)");
|
|
114
119
|
results.push({ account, ok });
|
|
115
120
|
}
|
|
116
121
|
return { results };
|
|
@@ -169,6 +174,7 @@ function serveStandaloneSocket(opts: {
|
|
|
169
174
|
|
|
170
175
|
netServer.on("connection", (socket: Socket) => {
|
|
171
176
|
connectionCount++;
|
|
177
|
+
log.info({ connections: connectionCount }, "CES client connected");
|
|
172
178
|
const readable = new Readable({ read() {} });
|
|
173
179
|
const writable = new Writable({
|
|
174
180
|
write(chunk, _encoding, callback) {
|
|
@@ -205,6 +211,7 @@ function serveStandaloneSocket(opts: {
|
|
|
205
211
|
})
|
|
206
212
|
.then(() => {
|
|
207
213
|
connectionCount = Math.max(0, connectionCount - 1);
|
|
214
|
+
log.info({ connections: connectionCount }, "CES client disconnected");
|
|
208
215
|
});
|
|
209
216
|
});
|
|
210
217
|
|
|
@@ -392,11 +399,19 @@ async function main(): Promise<void> {
|
|
|
392
399
|
log,
|
|
393
400
|
onApiKeyUpdate:
|
|
394
401
|
mode === "managed"
|
|
395
|
-
? (
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
402
|
+
? (newKey: string, newAssistantId?: string) => {
|
|
403
|
+
// Ack-only. CES reads the durable assistant API key from the shared
|
|
404
|
+
// credential store via the CRUD handlers; this RPC just acknowledges
|
|
405
|
+
// the assistant's notification that the key rotated. The pushed
|
|
406
|
+
// value is intentionally not persisted here (length logged, never
|
|
407
|
+
// the value).
|
|
408
|
+
log.info(
|
|
409
|
+
{
|
|
410
|
+
keyBytes: newKey.length,
|
|
411
|
+
assistantIdUpdated: Boolean(newAssistantId),
|
|
412
|
+
},
|
|
413
|
+
"Acknowledged assistant API key update notification (ack only; value not stored by CES)",
|
|
414
|
+
);
|
|
400
415
|
}
|
|
401
416
|
: undefined,
|
|
402
417
|
});
|
|
@@ -413,11 +428,13 @@ async function main(): Promise<void> {
|
|
|
413
428
|
log.info("Server stopped.");
|
|
414
429
|
}
|
|
415
430
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
431
|
+
if (import.meta.main) {
|
|
432
|
+
main().catch((err) => {
|
|
433
|
+
try {
|
|
434
|
+
getLogger("main").fatal({ err }, "Fatal error");
|
|
435
|
+
} catch {
|
|
436
|
+
process.stderr.write(`[ces-${getCesMode()}] Fatal: ${err}\n`);
|
|
437
|
+
}
|
|
438
|
+
process.exit(1);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
@@ -38,7 +38,17 @@ import {
|
|
|
38
38
|
pbkdf2Sync,
|
|
39
39
|
randomBytes,
|
|
40
40
|
} from "node:crypto";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
chmodSync,
|
|
43
|
+
closeSync,
|
|
44
|
+
existsSync,
|
|
45
|
+
fsyncSync,
|
|
46
|
+
mkdirSync,
|
|
47
|
+
openSync,
|
|
48
|
+
readFileSync,
|
|
49
|
+
renameSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from "node:fs";
|
|
42
52
|
import { hostname, userInfo } from "node:os";
|
|
43
53
|
import { dirname, join } from "node:path";
|
|
44
54
|
|
|
@@ -55,8 +65,7 @@ const ALGORITHM = "aes-256-gcm";
|
|
|
55
65
|
const KEY_LENGTH = 32; // bytes (256 bits)
|
|
56
66
|
const IV_LENGTH = 16; // bytes (128 bits)
|
|
57
67
|
const AUTH_TAG_LENGTH = 16; // bytes
|
|
58
|
-
const PBKDF2_ITERATIONS =
|
|
59
|
-
process.env.BUN_TEST === "1" ? 1 : 100_000;
|
|
68
|
+
const PBKDF2_ITERATIONS = process.env.BUN_TEST === "1" ? 1 : 100_000;
|
|
60
69
|
|
|
61
70
|
// ---------------------------------------------------------------------------
|
|
62
71
|
// On-disk format (must match assistant/src/security/encrypted-store.ts)
|
|
@@ -250,10 +259,30 @@ function writeStore(store: StoreFile, storePath: string): void {
|
|
|
250
259
|
const protectedDir = dirname(storePath);
|
|
251
260
|
mkdirSync(protectedDir, { recursive: true });
|
|
252
261
|
// Atomic write: write to temp file then rename to avoid partial/corrupt writes.
|
|
262
|
+
// `flush: true` fsyncs the temp file before we return, so the ack the caller
|
|
263
|
+
// receives follows a durable write and survives a crash within the window.
|
|
253
264
|
const tmpPath = storePath + `.tmp.${process.pid}`;
|
|
254
|
-
writeFileSync(tmpPath, JSON.stringify(store, null, 2), {
|
|
265
|
+
writeFileSync(tmpPath, JSON.stringify(store, null, 2), {
|
|
266
|
+
mode: 0o600,
|
|
267
|
+
flush: true,
|
|
268
|
+
});
|
|
255
269
|
chmodSync(tmpPath, 0o600);
|
|
256
270
|
renameSync(tmpPath, storePath);
|
|
271
|
+
|
|
272
|
+
// Fsync the parent directory so the rename itself is durable. `{flush:true}`
|
|
273
|
+
// syncs the temp file's contents, not the directory entry the rename updates,
|
|
274
|
+
// so without this a host-level crash could still expose the previous keys.enc.
|
|
275
|
+
// Best-effort: a directory-fsync failure must not fail the write.
|
|
276
|
+
try {
|
|
277
|
+
const dirFd = openSync(protectedDir, "r");
|
|
278
|
+
try {
|
|
279
|
+
fsyncSync(dirFd);
|
|
280
|
+
} finally {
|
|
281
|
+
closeSync(dirFd);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Directory fsync is a durability nicety, not required for correctness.
|
|
285
|
+
}
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
// ---------------------------------------------------------------------------
|
|
@@ -330,7 +359,10 @@ export class StoreUnavailableError extends Error {
|
|
|
330
359
|
*/
|
|
331
360
|
export function createLocalSecureKeyBackend(
|
|
332
361
|
vellumRoot: string,
|
|
333
|
-
options?: {
|
|
362
|
+
options?: {
|
|
363
|
+
entropyOverride?: string;
|
|
364
|
+
entropyGetter?: () => string | undefined;
|
|
365
|
+
},
|
|
334
366
|
): SecureKeyBackend {
|
|
335
367
|
const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME);
|
|
336
368
|
const staticEntropy = options?.entropyOverride;
|