@vellumai/credential-executor 0.4.56 → 0.5.0

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.
@@ -15,10 +15,11 @@ export type RpcError = z.infer<typeof RpcErrorSchema>;
15
15
 
16
16
  /**
17
17
  * Error returned when a local_static credential handle is used in managed
18
- * mode. The encrypted key store uses PBKDF2 key derivation from user
19
- * identity (username, homedir), but the assistant container runs as root
20
- * while CES runs as ces different derived keys make decryption silently
21
- * fail. Managed deployments must use platform_oauth handles exclusively.
18
+ * mode. v2 stores use a UID-independent `store.key` file that removes the
19
+ * technical barrier (legacy v1 relied on PBKDF2 key derivation from user
20
+ * identity, which broke across container users). The restriction is now a
21
+ * policy choice: managed deployments use platform_oauth handles exclusively
22
+ * for simpler lifecycle and centralized token management.
22
23
  */
23
24
  export const MANAGED_LOCAL_STATIC_REJECTION_ERROR =
24
25
  "local_static credential handles are not supported in managed mode. " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/credential-executor",
3
- "version": "0.4.56",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -133,14 +133,16 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef): RpcHan
133
133
 
134
134
  // -- Build handler registry ------------------------------------------------
135
135
  // NOTE: local_static credential handles are NOT supported in managed mode.
136
- // The encrypted key store uses PBKDF2 key derivation from user identity
137
- // (username, homedir), but the assistant container runs as root while CES
138
- // runs as ces different derived keys make decryption silently fail.
139
- // Managed deployments must use platform_oauth handles exclusively.
136
+ // v2 stores use a UID-independent `store.key` file that removes the
137
+ // technical barrier (legacy v1 stores relied on PBKDF2 key derivation
138
+ // from user identity, which broke across container users). The managed-
139
+ // mode restriction is now a policy choice: managed deployments use
140
+ // platform_oauth handles exclusively for simpler lifecycle and
141
+ // centralized token management.
140
142
  //
141
143
  // We provide error-returning stubs for localMaterialiser/localSubjectDeps
142
144
  // so the HTTP handler compiles but any local_static request gets a clear
143
- // error message rather than a silent decryption failure.
145
+ // rejection message.
144
146
 
145
147
  const localMaterialiserStub = {
146
148
  materialise: async () => ({
@@ -12,16 +12,24 @@
12
12
  * encrypted store so subsequent reads (by both CES and the assistant)
13
13
  * see the updated value.
14
14
  *
15
- * The encrypted store uses AES-256-GCM with a key derived from machine-
16
- * specific entropy via PBKDF2. The derivation includes `userInfo().username`
17
- * and `userInfo().homedir`, so the key is only correct when CES runs as the
18
- * same OS user as the assistant.
15
+ * Two store formats are supported:
19
16
  *
20
- * **This backend must NOT be used in managed mode.** In managed (sidecar)
21
- * deployments, the assistant container runs as `root` while the CES
22
- * container runs as `ces` (uid 1001). The different user identity produces
23
- * a different PBKDF2-derived key, causing silent decryption failures.
24
- * Managed deployments must use `platform_oauth` handles exclusively.
17
+ * - **v2 (primary):** AES-256-GCM with a random 32-byte key stored at
18
+ * `<vellumRoot>/protected/store.key`. The key is machine-independent
19
+ * any process that can read the key file can decrypt the store.
20
+ *
21
+ * - **v1 (legacy):** AES-256-GCM with a key derived from machine-specific
22
+ * entropy via PBKDF2. The derivation includes `userInfo().username` and
23
+ * `userInfo().homedir`, so the key is only correct when CES runs as the
24
+ * same OS user as the assistant.
25
+ *
26
+ * **Managed-mode restriction (v1 only):** For legacy v1 stores, the
27
+ * different container user identity produces a different PBKDF2-derived
28
+ * key, causing silent decryption failures. v2 stores use a
29
+ * UID-independent `store.key` file that can be shared via volume mount,
30
+ * removing this technical barrier. Managed deployments currently use
31
+ * `platform_oauth` handles exclusively as a policy choice (simpler
32
+ * lifecycle, centralized token management).
25
33
  */
26
34
 
27
35
  import {
@@ -30,7 +38,7 @@ import {
30
38
  pbkdf2Sync,
31
39
  randomBytes,
32
40
  } from "node:crypto";
33
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
41
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
34
42
  import { hostname, userInfo } from "node:os";
35
43
  import { dirname, join } from "node:path";
36
44
 
@@ -60,12 +68,42 @@ interface EncryptedEntry {
60
68
  data: string;
61
69
  }
62
70
 
63
- interface StoreFile {
71
+ interface StoreFileV1 {
64
72
  version: 1;
65
73
  salt: string;
66
74
  entries: Record<string, EncryptedEntry>;
67
75
  }
68
76
 
77
+ interface StoreFileV2 {
78
+ version: 2;
79
+ entries: Record<string, EncryptedEntry>;
80
+ }
81
+
82
+ type StoreFile = StoreFileV1 | StoreFileV2;
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Store key file (v2 format)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const STORE_KEY_FILENAME = "store.key";
89
+
90
+ /**
91
+ * Read the v2 store key file from `<vellumRoot>/protected/store.key`.
92
+ * Returns the raw 32-byte key buffer, or null if the file is missing,
93
+ * wrong size, or unreadable.
94
+ */
95
+ function readStoreKey(vellumRoot: string): Buffer | null {
96
+ try {
97
+ const keyPath = join(vellumRoot, "protected", STORE_KEY_FILENAME);
98
+ if (!existsSync(keyPath)) return null;
99
+ const buf = readFileSync(keyPath);
100
+ if (buf.length !== KEY_LENGTH) return null;
101
+ return buf;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
69
107
  // ---------------------------------------------------------------------------
70
108
  // Machine entropy (must match assistant/src/security/encrypted-store.ts)
71
109
  // ---------------------------------------------------------------------------
@@ -152,14 +190,15 @@ function readStore(storePath: string): StoreFile | null {
152
190
  try {
153
191
  const raw = readFileSync(storePath, "utf-8");
154
192
  const parsed = JSON.parse(raw);
155
- if (
156
- parsed.version !== 1 ||
157
- typeof parsed.salt !== "string" ||
158
- typeof parsed.entries !== "object"
159
- ) {
160
- return null;
193
+ if (typeof parsed.entries !== "object") return null;
194
+
195
+ if (parsed.version === 1 && typeof parsed.salt === "string") {
196
+ return parsed as StoreFileV1;
197
+ }
198
+ if (parsed.version === 2) {
199
+ return parsed as StoreFileV2;
161
200
  }
162
- return parsed as StoreFile;
201
+ return null;
163
202
  } catch {
164
203
  return null;
165
204
  }
@@ -204,12 +243,19 @@ export function createLocalSecureKeyBackend(
204
243
  const entry = store.entries[key];
205
244
  if (!entry) return undefined;
206
245
 
207
- // Lazy re-read: prefer getter (handles startup race) over static value.
208
- const entropy = entropyGetter?.() ?? staticEntropy;
246
+ let aesKey: Buffer;
247
+ if (store.version === 2) {
248
+ const storeKey = readStoreKey(vellumRoot);
249
+ if (!storeKey) return undefined;
250
+ aesKey = storeKey;
251
+ } else {
252
+ // v1: derive key from machine entropy via PBKDF2
253
+ const entropy = entropyGetter?.() ?? staticEntropy;
254
+ const salt = Buffer.from(store.salt, "hex");
255
+ aesKey = deriveKey(salt, entropy);
256
+ }
209
257
 
210
- const salt = Buffer.from(store.salt, "hex");
211
- const derivedKey = deriveKey(salt, entropy);
212
- return decrypt(entry, derivedKey);
258
+ return decrypt(entry, aesKey);
213
259
  } catch {
214
260
  return undefined;
215
261
  }
@@ -225,10 +271,19 @@ export function createLocalSecureKeyBackend(
225
271
  const store = readStore(storePath);
226
272
  if (!store) return false;
227
273
 
228
- const entropy = entropyGetter?.() ?? staticEntropy;
229
- const salt = Buffer.from(store.salt, "hex");
230
- const derivedKey = deriveKey(salt, entropy);
231
- store.entries[key] = encrypt(value, derivedKey);
274
+ let aesKey: Buffer;
275
+ if (store.version === 2) {
276
+ const storeKey = readStoreKey(vellumRoot);
277
+ if (!storeKey) return false;
278
+ aesKey = storeKey;
279
+ } else {
280
+ // v1: derive key from machine entropy via PBKDF2
281
+ const entropy = entropyGetter?.() ?? staticEntropy;
282
+ const salt = Buffer.from(store.salt, "hex");
283
+ aesKey = deriveKey(salt, entropy);
284
+ }
285
+
286
+ store.entries[key] = encrypt(value, aesKey);
232
287
  writeStore(store, storePath);
233
288
  return true;
234
289
  } catch {