@vellumai/credential-executor 0.5.6 → 0.5.7
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/node_modules/@vellumai/ces-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +119 -0
- package/node_modules/@vellumai/credential-storage/package.json +1 -0
- package/node_modules/@vellumai/egress-proxy/package.json +1 -0
- package/package.json +2 -1
- package/src/__tests__/local-secure-key-backend.test.ts +156 -0
- package/src/__tests__/managed-integration.test.ts +3 -5
- package/src/main.ts +21 -0
- package/src/managed-main.ts +5 -5
- package/src/materializers/local-secure-key-backend.ts +73 -12
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
*
|
|
26
26
|
* **Lifecycle**
|
|
27
27
|
* - `update_managed_credential` — Push an updated API key to CES after hatch
|
|
28
|
+
*
|
|
29
|
+
* **Credential CRUD**
|
|
30
|
+
* - `get_credential` — Retrieve a credential by account name
|
|
31
|
+
* - `set_credential` — Store or update a credential
|
|
32
|
+
* - `delete_credential` — Delete a credential by account name
|
|
33
|
+
* - `list_credentials` — List all credential account names
|
|
28
34
|
*/
|
|
29
35
|
|
|
30
36
|
import { z } from "zod/v4";
|
|
@@ -51,6 +57,14 @@ export const CesRpcMethod = {
|
|
|
51
57
|
ListAuditRecords: "list_audit_records",
|
|
52
58
|
/** Push an updated assistant credential to CES after post-hatch provisioning. */
|
|
53
59
|
UpdateManagedCredential: "update_managed_credential",
|
|
60
|
+
/** Retrieve a single credential by account name. */
|
|
61
|
+
GetCredential: "get_credential",
|
|
62
|
+
/** Store or update a credential by account name. */
|
|
63
|
+
SetCredential: "set_credential",
|
|
64
|
+
/** Delete a credential by account name. */
|
|
65
|
+
DeleteCredential: "delete_credential",
|
|
66
|
+
/** List all credential account names. */
|
|
67
|
+
ListCredentials: "list_credentials",
|
|
54
68
|
} as const;
|
|
55
69
|
|
|
56
70
|
export type CesRpcMethod =
|
|
@@ -421,6 +435,79 @@ export type UpdateManagedCredentialResponse = z.infer<
|
|
|
421
435
|
typeof UpdateManagedCredentialResponseSchema
|
|
422
436
|
>;
|
|
423
437
|
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// get_credential
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
export const GetCredentialSchema = z.object({
|
|
443
|
+
/** The account name to look up. */
|
|
444
|
+
account: z.string(),
|
|
445
|
+
});
|
|
446
|
+
export type GetCredential = z.infer<typeof GetCredentialSchema>;
|
|
447
|
+
|
|
448
|
+
export const GetCredentialResponseSchema = z.object({
|
|
449
|
+
/** Whether the credential was found. */
|
|
450
|
+
found: z.boolean(),
|
|
451
|
+
/** The credential value (present only when found). */
|
|
452
|
+
value: z.string().optional(),
|
|
453
|
+
});
|
|
454
|
+
export type GetCredentialResponse = z.infer<
|
|
455
|
+
typeof GetCredentialResponseSchema
|
|
456
|
+
>;
|
|
457
|
+
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// set_credential
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
export const SetCredentialSchema = z.object({
|
|
463
|
+
/** The account name to store the credential under. */
|
|
464
|
+
account: z.string(),
|
|
465
|
+
/** The credential value to store. */
|
|
466
|
+
value: z.string(),
|
|
467
|
+
});
|
|
468
|
+
export type SetCredential = z.infer<typeof SetCredentialSchema>;
|
|
469
|
+
|
|
470
|
+
export const SetCredentialResponseSchema = z.object({
|
|
471
|
+
/** Whether the credential was successfully stored. */
|
|
472
|
+
ok: z.boolean(),
|
|
473
|
+
});
|
|
474
|
+
export type SetCredentialResponse = z.infer<
|
|
475
|
+
typeof SetCredentialResponseSchema
|
|
476
|
+
>;
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// delete_credential
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
export const DeleteCredentialSchema = z.object({
|
|
483
|
+
/** The account name to delete. */
|
|
484
|
+
account: z.string(),
|
|
485
|
+
});
|
|
486
|
+
export type DeleteCredential = z.infer<typeof DeleteCredentialSchema>;
|
|
487
|
+
|
|
488
|
+
export const DeleteCredentialResponseSchema = z.object({
|
|
489
|
+
/** The result of the delete operation. */
|
|
490
|
+
result: z.enum(["deleted", "not-found", "error"]),
|
|
491
|
+
});
|
|
492
|
+
export type DeleteCredentialResponse = z.infer<
|
|
493
|
+
typeof DeleteCredentialResponseSchema
|
|
494
|
+
>;
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// list_credentials
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
export const ListCredentialsSchema = z.object({});
|
|
501
|
+
export type ListCredentials = z.infer<typeof ListCredentialsSchema>;
|
|
502
|
+
|
|
503
|
+
export const ListCredentialsResponseSchema = z.object({
|
|
504
|
+
/** The account names of all stored credentials. */
|
|
505
|
+
accounts: z.array(z.string()),
|
|
506
|
+
});
|
|
507
|
+
export type ListCredentialsResponse = z.infer<
|
|
508
|
+
typeof ListCredentialsResponseSchema
|
|
509
|
+
>;
|
|
510
|
+
|
|
424
511
|
// ---------------------------------------------------------------------------
|
|
425
512
|
// Full RPC contract type map
|
|
426
513
|
// ---------------------------------------------------------------------------
|
|
@@ -466,6 +553,22 @@ export interface CesRpcContract {
|
|
|
466
553
|
request: UpdateManagedCredential;
|
|
467
554
|
response: UpdateManagedCredentialResponse;
|
|
468
555
|
};
|
|
556
|
+
[CesRpcMethod.GetCredential]: {
|
|
557
|
+
request: GetCredential;
|
|
558
|
+
response: GetCredentialResponse;
|
|
559
|
+
};
|
|
560
|
+
[CesRpcMethod.SetCredential]: {
|
|
561
|
+
request: SetCredential;
|
|
562
|
+
response: SetCredentialResponse;
|
|
563
|
+
};
|
|
564
|
+
[CesRpcMethod.DeleteCredential]: {
|
|
565
|
+
request: DeleteCredential;
|
|
566
|
+
response: DeleteCredentialResponse;
|
|
567
|
+
};
|
|
568
|
+
[CesRpcMethod.ListCredentials]: {
|
|
569
|
+
request: ListCredentials;
|
|
570
|
+
response: ListCredentialsResponse;
|
|
571
|
+
};
|
|
469
572
|
}
|
|
470
573
|
|
|
471
574
|
/**
|
|
@@ -508,4 +611,20 @@ export const CesRpcSchemas = {
|
|
|
508
611
|
request: UpdateManagedCredentialSchema,
|
|
509
612
|
response: UpdateManagedCredentialResponseSchema,
|
|
510
613
|
},
|
|
614
|
+
[CesRpcMethod.GetCredential]: {
|
|
615
|
+
request: GetCredentialSchema,
|
|
616
|
+
response: GetCredentialResponseSchema,
|
|
617
|
+
},
|
|
618
|
+
[CesRpcMethod.SetCredential]: {
|
|
619
|
+
request: SetCredentialSchema,
|
|
620
|
+
response: SetCredentialResponseSchema,
|
|
621
|
+
},
|
|
622
|
+
[CesRpcMethod.DeleteCredential]: {
|
|
623
|
+
request: DeleteCredentialSchema,
|
|
624
|
+
response: DeleteCredentialResponseSchema,
|
|
625
|
+
},
|
|
626
|
+
[CesRpcMethod.ListCredentials]: {
|
|
627
|
+
request: ListCredentialsSchema,
|
|
628
|
+
response: ListCredentialsResponseSchema,
|
|
629
|
+
},
|
|
511
630
|
} as const;
|
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem-level tests for the CES local SecureKeyBackend.
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise `createLocalSecureKeyBackend` with real files and
|
|
5
|
+
* env-var overrides, separate from the in-memory resolver/materialiser
|
|
6
|
+
* tests in `local-materializers.test.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
import { createLocalSecureKeyBackend } from "../materializers/local-secure-key-backend.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function makeTmpDir(): string {
|
|
22
|
+
const dir = join(tmpdir(), `ces-backend-test-${randomBytes(8).toString("hex")}`);
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tests
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe("createLocalSecureKeyBackend — filesystem", () => {
|
|
32
|
+
let tmpDir: string | undefined;
|
|
33
|
+
let savedSecurityDir: string | undefined;
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
if (savedSecurityDir !== undefined) {
|
|
37
|
+
process.env.CREDENTIAL_SECURITY_DIR = savedSecurityDir;
|
|
38
|
+
} else {
|
|
39
|
+
delete process.env.CREDENTIAL_SECURITY_DIR;
|
|
40
|
+
}
|
|
41
|
+
if (tmpDir && existsSync(tmpDir)) {
|
|
42
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
tmpDir = undefined;
|
|
45
|
+
savedSecurityDir = undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function setup(): { securityDir: string; vellumRoot: string } {
|
|
49
|
+
tmpDir = makeTmpDir();
|
|
50
|
+
const securityDir = join(tmpDir, "security");
|
|
51
|
+
mkdirSync(securityDir, { recursive: true });
|
|
52
|
+
savedSecurityDir = process.env.CREDENTIAL_SECURITY_DIR;
|
|
53
|
+
process.env.CREDENTIAL_SECURITY_DIR = securityDir;
|
|
54
|
+
// vellumRoot is unused when CREDENTIAL_SECURITY_DIR is set,
|
|
55
|
+
// but we pass tmpDir for consistency
|
|
56
|
+
return { securityDir, vellumRoot: tmpDir };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("set() on a fresh security directory creates store.key and keys.enc", async () => {
|
|
60
|
+
const { securityDir, vellumRoot } = setup();
|
|
61
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
62
|
+
|
|
63
|
+
const result = await backend.set("test/key", "secret-value");
|
|
64
|
+
expect(result).toBe(true);
|
|
65
|
+
|
|
66
|
+
// store.key exists, is 32 bytes, mode 0o600
|
|
67
|
+
const keyPath = join(securityDir, "store.key");
|
|
68
|
+
expect(existsSync(keyPath)).toBe(true);
|
|
69
|
+
const keyBuf = readFileSync(keyPath);
|
|
70
|
+
expect(keyBuf.length).toBe(32);
|
|
71
|
+
const mode = statSync(keyPath).mode & 0o777;
|
|
72
|
+
expect(mode).toBe(0o600);
|
|
73
|
+
|
|
74
|
+
// keys.enc exists and is valid v2 JSON
|
|
75
|
+
const encPath = join(securityDir, "keys.enc");
|
|
76
|
+
expect(existsSync(encPath)).toBe(true);
|
|
77
|
+
const store = JSON.parse(readFileSync(encPath, "utf-8"));
|
|
78
|
+
expect(store.version).toBe(2);
|
|
79
|
+
expect(typeof store.entries).toBe("object");
|
|
80
|
+
|
|
81
|
+
// Round-trip
|
|
82
|
+
const value = await backend.get("test/key");
|
|
83
|
+
expect(value).toBe("secret-value");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("subsequent set() calls append to the existing store without recreating files", async () => {
|
|
87
|
+
const { securityDir, vellumRoot } = setup();
|
|
88
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
89
|
+
|
|
90
|
+
await backend.set("key1", "val1");
|
|
91
|
+
const keyAfterFirst = readFileSync(join(securityDir, "store.key"));
|
|
92
|
+
|
|
93
|
+
await backend.set("key2", "val2");
|
|
94
|
+
const keyAfterSecond = readFileSync(join(securityDir, "store.key"));
|
|
95
|
+
|
|
96
|
+
// store.key was not regenerated
|
|
97
|
+
expect(Buffer.compare(keyAfterFirst, keyAfterSecond)).toBe(0);
|
|
98
|
+
|
|
99
|
+
// keys.enc has exactly 2 entries
|
|
100
|
+
const store = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
|
|
101
|
+
expect(Object.keys(store.entries).length).toBe(2);
|
|
102
|
+
|
|
103
|
+
// Both values round-trip
|
|
104
|
+
expect(await backend.get("key1")).toBe("val1");
|
|
105
|
+
expect(await backend.get("key2")).toBe("val2");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("set() against a pre-existing v1 store preserves format and round-trips", async () => {
|
|
109
|
+
const { securityDir, vellumRoot } = setup();
|
|
110
|
+
|
|
111
|
+
// Manually write a v1 store
|
|
112
|
+
const salt = randomBytes(32).toString("hex");
|
|
113
|
+
const v1Store = { version: 1, salt, entries: {} };
|
|
114
|
+
writeFileSync(join(securityDir, "keys.enc"), JSON.stringify(v1Store, null, 2), {
|
|
115
|
+
mode: 0o600,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
119
|
+
const result = await backend.set("v1-key", "v1-value");
|
|
120
|
+
expect(result).toBe(true);
|
|
121
|
+
|
|
122
|
+
// Read back and verify format preserved
|
|
123
|
+
const storeAfter = JSON.parse(readFileSync(join(securityDir, "keys.enc"), "utf-8"));
|
|
124
|
+
expect(storeAfter.version).toBe(1);
|
|
125
|
+
expect(storeAfter.salt).toBe(salt);
|
|
126
|
+
expect(Object.keys(storeAfter.entries)).toContain("v1-key");
|
|
127
|
+
|
|
128
|
+
// store.key was NOT created (v1 stores don't use it)
|
|
129
|
+
expect(existsSync(join(securityDir, "store.key"))).toBe(false);
|
|
130
|
+
|
|
131
|
+
// Round-trip
|
|
132
|
+
const value = await backend.get("v1-key");
|
|
133
|
+
expect(value).toBe("v1-value");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("get() returns undefined when no store exists", async () => {
|
|
137
|
+
const { vellumRoot } = setup();
|
|
138
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
139
|
+
const value = await backend.get("anything");
|
|
140
|
+
expect(value).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("list() returns empty array when no store exists", async () => {
|
|
144
|
+
const { vellumRoot } = setup();
|
|
145
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
146
|
+
const keys = await backend.list();
|
|
147
|
+
expect(keys).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("delete() returns error when no store exists", async () => {
|
|
151
|
+
const { vellumRoot } = setup();
|
|
152
|
+
const backend = createLocalSecureKeyBackend(vellumRoot);
|
|
153
|
+
const result = await backend.delete("anything");
|
|
154
|
+
expect(result).toBe("error");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -351,13 +351,13 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
351
351
|
const url = new URL(req.url);
|
|
352
352
|
if (url.pathname === "/healthz") {
|
|
353
353
|
return new Response(
|
|
354
|
-
JSON.stringify({ status: "ok"
|
|
354
|
+
JSON.stringify({ status: "ok" }),
|
|
355
355
|
{ headers: { "Content-Type": "application/json" } },
|
|
356
356
|
);
|
|
357
357
|
}
|
|
358
358
|
if (url.pathname === "/readyz") {
|
|
359
359
|
return new Response(
|
|
360
|
-
JSON.stringify({
|
|
360
|
+
JSON.stringify({ status: "ok", rpcConnected: false }),
|
|
361
361
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
362
362
|
);
|
|
363
363
|
}
|
|
@@ -466,13 +466,11 @@ describe("managed CES integration (real Unix socket)", () => {
|
|
|
466
466
|
expect(healthzResp.status).toBe(200);
|
|
467
467
|
const healthzBody = await healthzResp.json();
|
|
468
468
|
expect(healthzBody.status).toBe("ok");
|
|
469
|
-
expect(healthzBody.version).toBe(CES_PROTOCOL_VERSION);
|
|
470
469
|
|
|
471
470
|
const readyzResp = await fetch(`http://localhost:${healthPort}/readyz`);
|
|
472
471
|
expect(readyzResp.status).toBe(200);
|
|
473
472
|
const readyzBody = await readyzResp.json();
|
|
474
|
-
expect(readyzBody.
|
|
475
|
-
expect(readyzBody.version).toBe(CES_PROTOCOL_VERSION);
|
|
473
|
+
expect(readyzBody.status).toBe("ok");
|
|
476
474
|
|
|
477
475
|
// -- Step 5: Unknown method returns METHOD_NOT_FOUND -----------------------
|
|
478
476
|
const unknownRpcId = "rpc-3";
|
package/src/main.ts
CHANGED
|
@@ -261,6 +261,27 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
|
|
|
261
261
|
auditStore,
|
|
262
262
|
}) as typeof handlers[string];
|
|
263
263
|
|
|
264
|
+
// Register credential CRUD handlers
|
|
265
|
+
handlers[CesRpcMethod.GetCredential] = (async (req: { account: string }) => {
|
|
266
|
+
const value = await secureKeyBackend.get(req.account);
|
|
267
|
+
return { found: value !== undefined, value };
|
|
268
|
+
}) as typeof handlers[string];
|
|
269
|
+
|
|
270
|
+
handlers[CesRpcMethod.SetCredential] = (async (req: { account: string; value: string }) => {
|
|
271
|
+
const ok = await secureKeyBackend.set(req.account, req.value);
|
|
272
|
+
return { ok };
|
|
273
|
+
}) as typeof handlers[string];
|
|
274
|
+
|
|
275
|
+
handlers[CesRpcMethod.DeleteCredential] = (async (req: { account: string }) => {
|
|
276
|
+
const result = await secureKeyBackend.delete(req.account);
|
|
277
|
+
return { result };
|
|
278
|
+
}) as typeof handlers[string];
|
|
279
|
+
|
|
280
|
+
handlers[CesRpcMethod.ListCredentials] = (async () => {
|
|
281
|
+
const accounts = await secureKeyBackend.list();
|
|
282
|
+
return { accounts };
|
|
283
|
+
}) as typeof handlers[string];
|
|
284
|
+
|
|
264
285
|
return handlers;
|
|
265
286
|
}
|
|
266
287
|
|
package/src/managed-main.ts
CHANGED
|
@@ -110,7 +110,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
|
|
|
110
110
|
// from the bootstrap handshake (the assistant forwards it after hatch).
|
|
111
111
|
// We use a lazy getter so the handshake-provided key takes effect even
|
|
112
112
|
// though handlers are built before the handshake completes.
|
|
113
|
-
const platformBaseUrl = process.env["
|
|
113
|
+
const platformBaseUrl = process.env["VELLUM_PLATFORM_URL"] ?? "";
|
|
114
114
|
const assistantId = process.env["PLATFORM_ASSISTANT_ID"] ?? "";
|
|
115
115
|
|
|
116
116
|
const { getAssistantApiKey, getManagedSubjectOptions, getManagedMaterializerOptions } =
|
|
@@ -123,7 +123,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
|
|
|
123
123
|
|
|
124
124
|
if (!platformBaseUrl || !assistantId) {
|
|
125
125
|
warn(
|
|
126
|
-
"
|
|
126
|
+
"VELLUM_PLATFORM_URL and/or PLATFORM_ASSISTANT_ID not set. " +
|
|
127
127
|
"Managed credential materialisation will depend on the handshake-provided API key.",
|
|
128
128
|
);
|
|
129
129
|
}
|
|
@@ -207,7 +207,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
|
|
|
207
207
|
return {
|
|
208
208
|
ok: false as const,
|
|
209
209
|
error:
|
|
210
|
-
"
|
|
210
|
+
"VELLUM_PLATFORM_URL and/or ASSISTANT_API_KEY not set. " +
|
|
211
211
|
"Managed credential materialisation is not available.",
|
|
212
212
|
};
|
|
213
213
|
}
|
|
@@ -342,7 +342,7 @@ function startHealthServer(
|
|
|
342
342
|
const url = new URL(req.url);
|
|
343
343
|
if (url.pathname === "/healthz") {
|
|
344
344
|
return new Response(
|
|
345
|
-
JSON.stringify({ status: "ok"
|
|
345
|
+
JSON.stringify({ status: "ok" }),
|
|
346
346
|
{ headers: { "Content-Type": "application/json" } },
|
|
347
347
|
);
|
|
348
348
|
}
|
|
@@ -354,7 +354,7 @@ function startHealthServer(
|
|
|
354
354
|
// without a connection anyway, so readiness is purely about the
|
|
355
355
|
// process being up and able to accept a future connection.
|
|
356
356
|
return new Response(
|
|
357
|
-
JSON.stringify({
|
|
357
|
+
JSON.stringify({ status: "ok", rpcConnected }),
|
|
358
358
|
{
|
|
359
359
|
status: 200,
|
|
360
360
|
headers: { "Content-Type": "application/json" },
|
|
@@ -123,6 +123,61 @@ function readStoreKey(vellumRoot: string): Buffer | null {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Auto-initialization helpers
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read or generate the v2 store key. If the key file does not exist,
|
|
132
|
+
* creates a new 32-byte random key and writes it atomically.
|
|
133
|
+
*/
|
|
134
|
+
function getOrReadStoreKey(vellumRoot: string): Buffer {
|
|
135
|
+
const existing = readStoreKey(vellumRoot);
|
|
136
|
+
if (existing) return existing;
|
|
137
|
+
|
|
138
|
+
const securityDir = resolveSecurityDir(vellumRoot);
|
|
139
|
+
mkdirSync(securityDir, { recursive: true });
|
|
140
|
+
|
|
141
|
+
const key = randomBytes(KEY_LENGTH);
|
|
142
|
+
const keyPath = join(securityDir, STORE_KEY_FILENAME);
|
|
143
|
+
const tmpPath = keyPath + `.tmp.${process.pid}`;
|
|
144
|
+
writeFileSync(tmpPath, key, { mode: 0o600 });
|
|
145
|
+
chmodSync(tmpPath, 0o600);
|
|
146
|
+
renameSync(tmpPath, keyPath);
|
|
147
|
+
|
|
148
|
+
return key;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read an existing store or create a new empty v2 store. Returns the store
|
|
153
|
+
* and the AES key needed to encrypt/decrypt entries.
|
|
154
|
+
*
|
|
155
|
+
* For existing v1 stores, throws so the caller can fall back to the legacy
|
|
156
|
+
* PBKDF2 path (v1 stores cannot be auto-initialized with a store key).
|
|
157
|
+
*/
|
|
158
|
+
function getOrCreateStore(
|
|
159
|
+
storePath: string,
|
|
160
|
+
vellumRoot: string,
|
|
161
|
+
): { store: StoreFile; aesKey: Buffer } {
|
|
162
|
+
const existing = readStore(storePath);
|
|
163
|
+
if (existing) {
|
|
164
|
+
if (existing.version === 1) {
|
|
165
|
+
throw new Error("v1 store cannot be auto-initialized");
|
|
166
|
+
}
|
|
167
|
+
const storeKey = readStoreKey(vellumRoot);
|
|
168
|
+
if (!storeKey) {
|
|
169
|
+
throw new Error("v2 store exists but store.key is missing or corrupt");
|
|
170
|
+
}
|
|
171
|
+
return { store: existing, aesKey: storeKey };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No store exists — create a new empty v2 store
|
|
175
|
+
const aesKey = getOrReadStoreKey(vellumRoot);
|
|
176
|
+
const store: StoreFileV2 = { version: 2, entries: {} };
|
|
177
|
+
writeStore(store, storePath);
|
|
178
|
+
return { store, aesKey };
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
// ---------------------------------------------------------------------------
|
|
127
182
|
// Machine entropy (must match assistant/src/security/encrypted-store.ts)
|
|
128
183
|
// ---------------------------------------------------------------------------
|
|
@@ -287,19 +342,25 @@ export function createLocalSecureKeyBackend(
|
|
|
287
342
|
// future improvement.
|
|
288
343
|
async set(key: string, value: string): Promise<boolean> {
|
|
289
344
|
try {
|
|
290
|
-
|
|
291
|
-
if (!store) return false;
|
|
292
|
-
|
|
345
|
+
let store: StoreFile;
|
|
293
346
|
let aesKey: Buffer;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
const result = getOrCreateStore(storePath, vellumRoot);
|
|
350
|
+
store = result.store;
|
|
351
|
+
aesKey = result.aesKey;
|
|
352
|
+
} catch {
|
|
353
|
+
// Fallback: v1 store or other error — try legacy PBKDF2 path
|
|
354
|
+
const existing = readStore(storePath);
|
|
355
|
+
if (!existing) return false;
|
|
356
|
+
store = existing;
|
|
357
|
+
if (store.version === 1) {
|
|
358
|
+
const entropy = entropyGetter?.() ?? staticEntropy;
|
|
359
|
+
const salt = Buffer.from(store.salt, "hex");
|
|
360
|
+
aesKey = deriveKey(salt, entropy);
|
|
361
|
+
} else {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
303
364
|
}
|
|
304
365
|
|
|
305
366
|
store.entries[key] = encrypt(value, aesKey);
|