@foldrun/core 6.0.0 → 7.0.1
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/dist/src/secrets.d.ts +22 -0
- package/dist/src/secrets.js +174 -25
- package/dist/src/store.js +67 -2
- package/package.json +1 -1
- package/src/secrets.ts +189 -26
- package/src/store.ts +64 -2
package/dist/src/secrets.d.ts
CHANGED
|
@@ -31,8 +31,12 @@ interface StoredSecret {
|
|
|
31
31
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
32
|
* beside the ciphertext and survives the value being set again. */
|
|
33
33
|
sites?: string[];
|
|
34
|
+
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
35
|
+
* key and sites (vaultAad). Absent: the older, unbound form. */
|
|
36
|
+
v?: 2;
|
|
34
37
|
}
|
|
35
38
|
type SecretsFile = Record<string, StoredSecret>;
|
|
39
|
+
declare const writeBound: () => boolean;
|
|
36
40
|
/**
|
|
37
41
|
* Change one vault file safely: `change` gets what is stored now and returns
|
|
38
42
|
* the new contents, or null to leave the file untouched. Returns what is
|
|
@@ -183,6 +187,11 @@ export declare function materializeSecrets(env: Record<string, string>,
|
|
|
183
187
|
ctx?: {
|
|
184
188
|
tenant: string;
|
|
185
189
|
workspace?: string;
|
|
190
|
+
/** A rotated refresh token goes here INSTEAD of the vault: a caller that
|
|
191
|
+
* cannot open the vault (the egress proxy without the install key)
|
|
192
|
+
* keeps it itself and has it written by a process that can. `next` is
|
|
193
|
+
* the stored value with the new refresh token. */
|
|
194
|
+
onRotate?: (name: string, usedValue: string, next: string) => unknown;
|
|
186
195
|
}): Promise<Record<string, string>>;
|
|
187
196
|
export interface ServiceAccountConfig {
|
|
188
197
|
token_url: string;
|
|
@@ -254,6 +263,19 @@ export declare function basicAuthorization(username: string, password: string):
|
|
|
254
263
|
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
255
264
|
export declare function isApiValue(value: string): boolean;
|
|
256
265
|
export declare function apiConfigOf(value: string): ApiConfig;
|
|
266
|
+
/**
|
|
267
|
+
* Seal every unbound record of an account for its place (v2). Run at boot
|
|
268
|
+
* once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
|
|
269
|
+
* scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
|
|
270
|
+
* value is opened first, and a file holding one that will not open is left
|
|
271
|
+
* exactly as it is and reported. Idempotent: bound records are skipped.
|
|
272
|
+
*/
|
|
273
|
+
export declare function sealVaultBindings(tenant: string): {
|
|
274
|
+
sealed: number;
|
|
275
|
+
unreadable: string[];
|
|
276
|
+
};
|
|
277
|
+
/** Whether this install writes bound vault records (and migrates at boot). */
|
|
278
|
+
export declare const vaultWritesBound: typeof writeBound;
|
|
257
279
|
/**
|
|
258
280
|
* Move an account's secrets from the install key onto its own key.
|
|
259
281
|
*
|
package/dist/src/secrets.js
CHANGED
|
@@ -56,6 +56,12 @@ export function installKeyMaterial() {
|
|
|
56
56
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
57
57
|
if (fromEnv)
|
|
58
58
|
return fromEnv;
|
|
59
|
+
// A process that must never hold the install key (the egress proxy) says
|
|
60
|
+
// so; without this, asking for it would quietly mint a NEW random key on
|
|
61
|
+
// the shared data volume — a second root key nobody chose.
|
|
62
|
+
if (process.env.FOLDRUN_INSTALL_KEY_ABSENT === "1") {
|
|
63
|
+
throw new Error("this process runs without the install key (FOLDRUN_INSTALL_KEY_ABSENT=1) — whatever asked for it belongs in the worker");
|
|
64
|
+
}
|
|
59
65
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
60
66
|
if (!fs.existsSync(keyFile())) {
|
|
61
67
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
@@ -76,6 +82,81 @@ function secretsFile(tenant, workspace) {
|
|
|
76
82
|
const base = fs.existsSync(legacy) ? legacy : path.join(dataRoot(), tenant, "workspaces", workspace);
|
|
77
83
|
return path.join(base, "secrets.json");
|
|
78
84
|
}
|
|
85
|
+
// ------------------------------------------------------------ binding
|
|
86
|
+
//
|
|
87
|
+
// A v2 record is sealed with WHERE it belongs as additional authenticated
|
|
88
|
+
// data: the account, the scope (account or which workspace), the name, its
|
|
89
|
+
// kind, which key sealed it, and the sites it is bound to. Before 29 Oct 2026
|
|
90
|
+
// (OWASP review) a record was bound to nothing: anyone who could write the
|
|
91
|
+
// data volume or a backup could move one secret's ciphertext under another
|
|
92
|
+
// name, scope or account, or rewrite its clear `sites` and have the egress
|
|
93
|
+
// proxy fill it for their own host. Now any of those makes it fail to open,
|
|
94
|
+
// which reads as "not set" — never as unbound.
|
|
95
|
+
//
|
|
96
|
+
// The place comes from the FILE the record is read from, never from fields
|
|
97
|
+
// inside it. Sites stay in clear beside the ciphertext (listing needs no
|
|
98
|
+
// key, and a pod from before v2 reads them there) and are authenticated by
|
|
99
|
+
// the AAD.
|
|
100
|
+
//
|
|
101
|
+
// Rolled out in steps: every release since reads v2; FOLDRUN_VAULT_WRITE_BOUND=1
|
|
102
|
+
// writes it and migrates existing records at boot; FOLDRUN_VAULT_REQUIRE_BOUND=1
|
|
103
|
+
// refuses the old form once none is left.
|
|
104
|
+
// The scope is account or workspace, not WHICH workspace: a workspace can be
|
|
105
|
+
// renamed (its folder moves with its vault), and binding the name would make
|
|
106
|
+
// every rename a re-seal racing other writers. What that leaves open — a
|
|
107
|
+
// ciphertext moved between two workspaces of the same account by someone who
|
|
108
|
+
// can already write the volume — is the residual, and stays inside one account.
|
|
109
|
+
function vaultAad(tenant, workspace, name, rec) {
|
|
110
|
+
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
111
|
+
throw new Error("malformed sites");
|
|
112
|
+
}
|
|
113
|
+
return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
|
|
114
|
+
}
|
|
115
|
+
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
116
|
+
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
117
|
+
/** One record, sealed for its place. Bound (v2) when the install writes bound
|
|
118
|
+
* records or `bound` says so; the unbound form otherwise. */
|
|
119
|
+
function sealRecord(tenant, workspace, name, value, meta, bound = writeBound()) {
|
|
120
|
+
const { key, mark } = keyFor(tenant);
|
|
121
|
+
const rec = {
|
|
122
|
+
iv: "",
|
|
123
|
+
tag: "",
|
|
124
|
+
data: "",
|
|
125
|
+
updatedAt: meta.updatedAt ?? new Date().toISOString(),
|
|
126
|
+
...(meta.kind ? { kind: meta.kind } : {}),
|
|
127
|
+
...(mark ? { k: mark } : {}),
|
|
128
|
+
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
129
|
+
};
|
|
130
|
+
const iv = crypto.randomBytes(12);
|
|
131
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
132
|
+
if (bound) {
|
|
133
|
+
cipher.setAAD(vaultAad(tenant, workspace, name, rec));
|
|
134
|
+
rec.v = 2;
|
|
135
|
+
}
|
|
136
|
+
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
137
|
+
rec.iv = iv.toString("base64");
|
|
138
|
+
rec.tag = cipher.getAuthTag().toString("base64");
|
|
139
|
+
rec.data = enc.toString("base64");
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
/** A record's value, opened for its place; null when it will not open. */
|
|
143
|
+
function openRecord(tenant, workspace, name, rec) {
|
|
144
|
+
if (!rec)
|
|
145
|
+
return null;
|
|
146
|
+
if (rec.v === 2) {
|
|
147
|
+
// Metadata someone rewrote into a shape that is not a list fails closed,
|
|
148
|
+
// like a bad tag — never as an exception up the read path.
|
|
149
|
+
let aad;
|
|
150
|
+
try {
|
|
151
|
+
aad = vaultAad(tenant, workspace, name, rec);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return decrypt(tenant, rec, aad);
|
|
157
|
+
}
|
|
158
|
+
return requireBound() ? null : decrypt(tenant, rec);
|
|
159
|
+
}
|
|
79
160
|
function read(tenant, workspace) {
|
|
80
161
|
return readFile(secretsFile(tenant, workspace));
|
|
81
162
|
}
|
|
@@ -162,18 +243,15 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
162
243
|
}
|
|
163
244
|
if (value.length > 8192)
|
|
164
245
|
throw new Error("secret too long");
|
|
165
|
-
const sealed = encryptValue(tenant, value);
|
|
166
246
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
167
247
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
168
248
|
// property of the credential's use, not of its current value, and a
|
|
169
249
|
// rotation that silently unbound it would put the new value in pods.
|
|
250
|
+
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
170
251
|
const sites = all[name]?.sites;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
...(kind ? { kind } : {}),
|
|
175
|
-
...(sites?.length ? { sites } : {}),
|
|
176
|
-
};
|
|
252
|
+
// Once bound, always bound: a re-save with the switch off must not
|
|
253
|
+
// quietly drop the record's authentication.
|
|
254
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
|
|
177
255
|
return all;
|
|
178
256
|
});
|
|
179
257
|
}
|
|
@@ -205,6 +283,16 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
205
283
|
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
206
284
|
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
207
285
|
}
|
|
286
|
+
// A bound record names its sites in its seal: open it with the old
|
|
287
|
+
// binding and seal it again with the new one. One that will not open is
|
|
288
|
+
// refused, never relabelled.
|
|
289
|
+
if (rec.v === 2) {
|
|
290
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
291
|
+
if (value === null)
|
|
292
|
+
throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
293
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
294
|
+
return all;
|
|
295
|
+
}
|
|
208
296
|
if (clean.length)
|
|
209
297
|
rec.sites = clean;
|
|
210
298
|
else
|
|
@@ -225,7 +313,11 @@ export function secretSites(tenant, names, workspace) {
|
|
|
225
313
|
const own = workspace ? read(tenant, workspace) : {};
|
|
226
314
|
const account = read(tenant);
|
|
227
315
|
for (const name of names) {
|
|
228
|
-
|
|
316
|
+
// The record getSecret would take the VALUE from — the workspace's only
|
|
317
|
+
// when it opens, else the account's. Picking by presence paired an
|
|
318
|
+
// account value with a workspace record's (unauthenticated) sites.
|
|
319
|
+
const ownRec = own[name];
|
|
320
|
+
const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
|
|
229
321
|
if (rec?.sites?.length)
|
|
230
322
|
out[name] = [...rec.sites];
|
|
231
323
|
}
|
|
@@ -260,9 +352,12 @@ export function encryptValue(tenant, value) {
|
|
|
260
352
|
export function decryptValue(tenant, rec) {
|
|
261
353
|
return decrypt(tenant, rec);
|
|
262
354
|
}
|
|
263
|
-
function decrypt(tenant, rec) {
|
|
355
|
+
function decrypt(tenant, rec, aad) {
|
|
264
356
|
if (!rec)
|
|
265
357
|
return null;
|
|
358
|
+
// A bound record opens only with its binding: one without is not a guess.
|
|
359
|
+
if (rec.v === 2 && !aad)
|
|
360
|
+
return null;
|
|
266
361
|
try {
|
|
267
362
|
// The record says which key wrote it. An unmarked one predates per-account
|
|
268
363
|
// keys and is under the install key; guessing either way would turn a
|
|
@@ -286,6 +381,8 @@ function decrypt(tenant, rec) {
|
|
|
286
381
|
}
|
|
287
382
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
|
|
288
383
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
384
|
+
if (aad)
|
|
385
|
+
decipher.setAAD(aad);
|
|
289
386
|
return Buffer.concat([
|
|
290
387
|
decipher.update(Buffer.from(rec.data, "base64")),
|
|
291
388
|
decipher.final(),
|
|
@@ -298,11 +395,11 @@ function decrypt(tenant, rec) {
|
|
|
298
395
|
/** Nearest wins: a workspace secret shadows an account one of the same name. */
|
|
299
396
|
export function getSecret(tenant, name, workspace) {
|
|
300
397
|
if (workspace) {
|
|
301
|
-
const own =
|
|
398
|
+
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
302
399
|
if (own !== null)
|
|
303
400
|
return { value: own, scope: "workspace" };
|
|
304
401
|
}
|
|
305
|
-
const shared =
|
|
402
|
+
const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
|
|
306
403
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
307
404
|
}
|
|
308
405
|
export function deleteSecret(tenant, name, workspace) {
|
|
@@ -408,7 +505,7 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
408
505
|
const newKey = crypto.createHash("sha256").update(to).digest();
|
|
409
506
|
const rewritten = [];
|
|
410
507
|
const unreadable = [];
|
|
411
|
-
for (const { file } of secretFiles(tenant)) {
|
|
508
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
412
509
|
let count = 0;
|
|
413
510
|
mutateVaultFile(file, (all) => {
|
|
414
511
|
const next = {};
|
|
@@ -423,10 +520,16 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
423
520
|
next[name] = rec;
|
|
424
521
|
continue;
|
|
425
522
|
}
|
|
523
|
+
// A bound record keeps its binding: same place, same AAD.
|
|
524
|
+
let aad = null;
|
|
426
525
|
let plain;
|
|
427
526
|
try {
|
|
527
|
+
if (rec.v === 2)
|
|
528
|
+
aad = vaultAad(tenant, workspace, name, rec);
|
|
428
529
|
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
429
530
|
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
531
|
+
if (aad)
|
|
532
|
+
d.setAAD(aad);
|
|
430
533
|
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
431
534
|
}
|
|
432
535
|
catch {
|
|
@@ -436,6 +539,8 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
436
539
|
}
|
|
437
540
|
const iv = crypto.randomBytes(12);
|
|
438
541
|
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
542
|
+
if (aad)
|
|
543
|
+
c.setAAD(aad);
|
|
439
544
|
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
440
545
|
next[name] = {
|
|
441
546
|
iv: iv.toString("base64"),
|
|
@@ -445,6 +550,10 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
445
550
|
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
446
551
|
// rotation, and a file/ssh/api secret stops being materialised.
|
|
447
552
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
553
|
+
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
554
|
+
// unbinds every bound secret puts their values into pods.
|
|
555
|
+
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
556
|
+
...(aad ? { v: 2 } : {}),
|
|
448
557
|
};
|
|
449
558
|
count++;
|
|
450
559
|
}
|
|
@@ -519,8 +628,11 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
|
|
|
519
628
|
let replaced = false;
|
|
520
629
|
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
521
630
|
const rec = all[name];
|
|
522
|
-
if (rec &&
|
|
523
|
-
|
|
631
|
+
if (rec && openRecord(tenant, scope, name, rec) === usedValue) {
|
|
632
|
+
// Built whole, not spread over the old record: a spread kept a stale
|
|
633
|
+
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
634
|
+
// record then opened with neither.
|
|
635
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
524
636
|
replaced = true;
|
|
525
637
|
}
|
|
526
638
|
return all;
|
|
@@ -641,7 +753,9 @@ ctx) {
|
|
|
641
753
|
// the next run does not refresh again.
|
|
642
754
|
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
643
755
|
try {
|
|
644
|
-
if (ctx)
|
|
756
|
+
if (ctx?.onRotate)
|
|
757
|
+
await ctx.onRotate(name, value, next);
|
|
758
|
+
else if (ctx)
|
|
645
759
|
replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
646
760
|
}
|
|
647
761
|
catch {
|
|
@@ -849,7 +963,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
849
963
|
};
|
|
850
964
|
if (!SECRET_NAME.test(name))
|
|
851
965
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
852
|
-
const
|
|
966
|
+
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
853
967
|
// Written under the vault's own lock, with the binding checked in the same
|
|
854
968
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
855
969
|
// one would present the certificate to a whole domain.
|
|
@@ -858,7 +972,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
858
972
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
859
973
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
860
974
|
}
|
|
861
|
-
all[name] =
|
|
975
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
862
976
|
return all;
|
|
863
977
|
});
|
|
864
978
|
}
|
|
@@ -944,6 +1058,46 @@ export function isApiValue(value) {
|
|
|
944
1058
|
export function apiConfigOf(value) {
|
|
945
1059
|
return JSON.parse(value.slice(API_PREFIX.length));
|
|
946
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Seal every unbound record of an account for its place (v2). Run at boot
|
|
1063
|
+
* once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
|
|
1064
|
+
* scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
|
|
1065
|
+
* value is opened first, and a file holding one that will not open is left
|
|
1066
|
+
* exactly as it is and reported. Idempotent: bound records are skipped.
|
|
1067
|
+
*/
|
|
1068
|
+
export function sealVaultBindings(tenant) {
|
|
1069
|
+
assertSafeName(tenant, "tenant");
|
|
1070
|
+
let sealed = 0;
|
|
1071
|
+
const unreadable = [];
|
|
1072
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
1073
|
+
mutateVaultFile(file, (all) => {
|
|
1074
|
+
const plain = new Map();
|
|
1075
|
+
const bad = [];
|
|
1076
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
1077
|
+
if (rec.v === 2)
|
|
1078
|
+
continue;
|
|
1079
|
+
const value = decrypt(tenant, rec);
|
|
1080
|
+
if (value === null)
|
|
1081
|
+
bad.push(workspace ? `${workspace}/${name}` : name);
|
|
1082
|
+
else
|
|
1083
|
+
plain.set(name, value);
|
|
1084
|
+
}
|
|
1085
|
+
unreadable.push(...bad);
|
|
1086
|
+
if (plain.size === 0 || bad.length)
|
|
1087
|
+
return null;
|
|
1088
|
+
const next = { ...all };
|
|
1089
|
+
for (const [name, value] of plain) {
|
|
1090
|
+
const rec = all[name];
|
|
1091
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1092
|
+
}
|
|
1093
|
+
sealed += plain.size;
|
|
1094
|
+
return next;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
return { sealed, unreadable };
|
|
1098
|
+
}
|
|
1099
|
+
/** Whether this install writes bound vault records (and migrates at boot). */
|
|
1100
|
+
export const vaultWritesBound = writeBound;
|
|
947
1101
|
/**
|
|
948
1102
|
* Move an account's secrets from the install key onto its own key.
|
|
949
1103
|
*
|
|
@@ -958,7 +1112,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
958
1112
|
return { moved: 0, unreadable: [] };
|
|
959
1113
|
let moved = 0;
|
|
960
1114
|
const unreadable = [];
|
|
961
|
-
for (const { file } of secretFiles(tenant)) {
|
|
1115
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
962
1116
|
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
963
1117
|
// between our read and our write must not be lost.
|
|
964
1118
|
mutateVaultFile(file, (all) => {
|
|
@@ -967,7 +1121,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
967
1121
|
for (const [name, rec] of Object.entries(all)) {
|
|
968
1122
|
if (rec.k === "tenant")
|
|
969
1123
|
continue; // already moved
|
|
970
|
-
const value =
|
|
1124
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
971
1125
|
if (value === null) {
|
|
972
1126
|
unreadableHere.push(name);
|
|
973
1127
|
continue;
|
|
@@ -982,12 +1136,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
982
1136
|
return null;
|
|
983
1137
|
const next = { ...all };
|
|
984
1138
|
for (const [name, value] of plain) {
|
|
985
|
-
next[name] = {
|
|
986
|
-
...encryptValue(tenant, value),
|
|
987
|
-
updatedAt: all[name].updatedAt,
|
|
988
|
-
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
989
|
-
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
990
|
-
};
|
|
1139
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
991
1140
|
}
|
|
992
1141
|
moved += plain.size;
|
|
993
1142
|
return next;
|
package/dist/src/store.js
CHANGED
|
@@ -2316,10 +2316,61 @@ export function listWorkspaceFiles(tenant, workspace) {
|
|
|
2316
2316
|
}
|
|
2317
2317
|
return out.sort();
|
|
2318
2318
|
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Where a path really lands once every symlink on the way is followed — for
|
|
2321
|
+
* one not there yet, its nearest existing ancestor's real path with the rest
|
|
2322
|
+
* appended. null when a link on the way points nowhere. `followLast: false`
|
|
2323
|
+
* stops short of the last part: the link itself is what is removed or moved.
|
|
2324
|
+
*/
|
|
2325
|
+
function realLanding(full, followLast = true) {
|
|
2326
|
+
const tail = followLast ? [] : [path.basename(full)];
|
|
2327
|
+
let probe = followLast ? full : path.dirname(full);
|
|
2328
|
+
for (;;) {
|
|
2329
|
+
try {
|
|
2330
|
+
fs.lstatSync(probe);
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
const up = path.dirname(probe);
|
|
2334
|
+
if (up === probe)
|
|
2335
|
+
return null;
|
|
2336
|
+
tail.unshift(path.basename(probe));
|
|
2337
|
+
probe = up;
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
try {
|
|
2341
|
+
return path.join(fs.realpathSync(probe), ...tail);
|
|
2342
|
+
}
|
|
2343
|
+
catch {
|
|
2344
|
+
return null; // a dangling link: where it would write is not knowable
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
/** Whether `full` stays in the workspace `dir` with symlinks followed (FOL-83).
|
|
2349
|
+
* The string checks (assertEditablePath, runOutputPath's prefix) read the
|
|
2350
|
+
* path as written; a link planted inside the workspace — `knowledge/x.md` →
|
|
2351
|
+
* /etc/passwd, or a folder → / — passed them, and reads, writes and the run
|
|
2352
|
+
* archive followed it out. */
|
|
2353
|
+
function landsInside(dir, full, followLast = true) {
|
|
2354
|
+
let root;
|
|
2355
|
+
try {
|
|
2356
|
+
root = fs.realpathSync(dir);
|
|
2357
|
+
}
|
|
2358
|
+
catch {
|
|
2359
|
+
root = path.resolve(dir);
|
|
2360
|
+
}
|
|
2361
|
+
const at = realLanding(full, followLast);
|
|
2362
|
+
return at !== null && (at === root || at.startsWith(root + path.sep));
|
|
2363
|
+
}
|
|
2364
|
+
function assertLandsInside(dir, full, rel, followLast = true) {
|
|
2365
|
+
if (!landsInside(dir, full, followLast))
|
|
2366
|
+
throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
|
|
2367
|
+
}
|
|
2319
2368
|
export function readWorkspaceFile(tenant, workspace, rel) {
|
|
2320
|
-
const
|
|
2369
|
+
const dir = workspaceDir(tenant, workspace);
|
|
2370
|
+
const p = path.join(dir, assertEditablePath(rel));
|
|
2321
2371
|
if (!fs.existsSync(p))
|
|
2322
2372
|
throw new Error(`file not found: ${rel}`);
|
|
2373
|
+
assertLandsInside(dir, p, rel);
|
|
2323
2374
|
return fs.readFileSync(p, "utf8");
|
|
2324
2375
|
}
|
|
2325
2376
|
export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
@@ -2328,7 +2379,11 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
|
2328
2379
|
if (content.length > MAX_EDITABLE_FILE)
|
|
2329
2380
|
throw new Error("file too large");
|
|
2330
2381
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
2331
|
-
const
|
|
2382
|
+
const dir = workspaceDir(tenant, workspace);
|
|
2383
|
+
const p = path.join(dir, assertEditablePath(rel));
|
|
2384
|
+
// Before anything is created: mkdir below would otherwise make folders
|
|
2385
|
+
// through a linked directory too.
|
|
2386
|
+
assertLandsInside(dir, p, rel);
|
|
2332
2387
|
const existed = fs.existsSync(p);
|
|
2333
2388
|
const before = existed ? fs.readFileSync(p, "utf8") : null;
|
|
2334
2389
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -2439,6 +2494,10 @@ export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
|
|
|
2439
2494
|
throw new Error(`${from} does not exist`);
|
|
2440
2495
|
if (fs.existsSync(dst))
|
|
2441
2496
|
throw new Error(`${to} already exists`);
|
|
2497
|
+
// The link itself may be moved; what is moved must not come from, or go
|
|
2498
|
+
// to, outside the workspace through a linked folder.
|
|
2499
|
+
assertLandsInside(dir, src, from, false);
|
|
2500
|
+
assertLandsInside(dir, dst, to);
|
|
2442
2501
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
2443
2502
|
const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
|
|
2444
2503
|
fs.renameSync(src, dst);
|
|
@@ -2509,12 +2568,16 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
|
|
|
2509
2568
|
if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
|
|
2510
2569
|
/^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
|
|
2511
2570
|
/^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
|
|
2571
|
+
// The last part may be a link — rmSync removes the link, not what it
|
|
2572
|
+
// names — but a linked folder ABOVE it would delete outside.
|
|
2573
|
+
assertLandsInside(dir, path.join(dir, norm), norm, false);
|
|
2512
2574
|
const gone = deletionsUnder(dir, norm);
|
|
2513
2575
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
2514
2576
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
2515
2577
|
return;
|
|
2516
2578
|
}
|
|
2517
2579
|
const p = path.join(dir, assertEditablePath(norm));
|
|
2580
|
+
assertLandsInside(dir, p, norm, false);
|
|
2518
2581
|
const gone = deletionsUnder(dir, norm);
|
|
2519
2582
|
fs.rmSync(p, { force: true });
|
|
2520
2583
|
recordRevision(tenant, workspace, gone, meta);
|
|
@@ -2816,6 +2879,8 @@ export function runOutputPath(tenant, workspace, runId, agent, rel) {
|
|
|
2816
2879
|
return null;
|
|
2817
2880
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile())
|
|
2818
2881
|
return null;
|
|
2882
|
+
if (!landsInside(workspaceDir(tenant, workspace), full))
|
|
2883
|
+
return null;
|
|
2819
2884
|
return full;
|
|
2820
2885
|
}
|
|
2821
2886
|
export function readRun(tenant, workspace, runId) {
|
package/package.json
CHANGED
package/src/secrets.ts
CHANGED
|
@@ -63,6 +63,12 @@ export type SecretScope = "account" | "workspace";
|
|
|
63
63
|
export function installKeyMaterial(): string {
|
|
64
64
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
65
65
|
if (fromEnv) return fromEnv;
|
|
66
|
+
// A process that must never hold the install key (the egress proxy) says
|
|
67
|
+
// so; without this, asking for it would quietly mint a NEW random key on
|
|
68
|
+
// the shared data volume — a second root key nobody chose.
|
|
69
|
+
if (process.env.FOLDRUN_INSTALL_KEY_ABSENT === "1") {
|
|
70
|
+
throw new Error("this process runs without the install key (FOLDRUN_INSTALL_KEY_ABSENT=1) — whatever asked for it belongs in the worker");
|
|
71
|
+
}
|
|
66
72
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
67
73
|
if (!fs.existsSync(keyFile())) {
|
|
68
74
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
@@ -105,10 +111,100 @@ interface StoredSecret {
|
|
|
105
111
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
106
112
|
* beside the ciphertext and survives the value being set again. */
|
|
107
113
|
sites?: string[];
|
|
114
|
+
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
115
|
+
* key and sites (vaultAad). Absent: the older, unbound form. */
|
|
116
|
+
v?: 2;
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
type SecretsFile = Record<string, StoredSecret>;
|
|
111
120
|
|
|
121
|
+
// ------------------------------------------------------------ binding
|
|
122
|
+
//
|
|
123
|
+
// A v2 record is sealed with WHERE it belongs as additional authenticated
|
|
124
|
+
// data: the account, the scope (account or which workspace), the name, its
|
|
125
|
+
// kind, which key sealed it, and the sites it is bound to. Before 29 Oct 2026
|
|
126
|
+
// (OWASP review) a record was bound to nothing: anyone who could write the
|
|
127
|
+
// data volume or a backup could move one secret's ciphertext under another
|
|
128
|
+
// name, scope or account, or rewrite its clear `sites` and have the egress
|
|
129
|
+
// proxy fill it for their own host. Now any of those makes it fail to open,
|
|
130
|
+
// which reads as "not set" — never as unbound.
|
|
131
|
+
//
|
|
132
|
+
// The place comes from the FILE the record is read from, never from fields
|
|
133
|
+
// inside it. Sites stay in clear beside the ciphertext (listing needs no
|
|
134
|
+
// key, and a pod from before v2 reads them there) and are authenticated by
|
|
135
|
+
// the AAD.
|
|
136
|
+
//
|
|
137
|
+
// Rolled out in steps: every release since reads v2; FOLDRUN_VAULT_WRITE_BOUND=1
|
|
138
|
+
// writes it and migrates existing records at boot; FOLDRUN_VAULT_REQUIRE_BOUND=1
|
|
139
|
+
// refuses the old form once none is left.
|
|
140
|
+
|
|
141
|
+
// The scope is account or workspace, not WHICH workspace: a workspace can be
|
|
142
|
+
// renamed (its folder moves with its vault), and binding the name would make
|
|
143
|
+
// every rename a re-seal racing other writers. What that leaves open — a
|
|
144
|
+
// ciphertext moved between two workspaces of the same account by someone who
|
|
145
|
+
// can already write the volume — is the residual, and stays inside one account.
|
|
146
|
+
function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites">): Buffer {
|
|
147
|
+
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
148
|
+
throw new Error("malformed sites");
|
|
149
|
+
}
|
|
150
|
+
return Buffer.from(
|
|
151
|
+
JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]),
|
|
152
|
+
"utf8",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
156
|
+
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
157
|
+
|
|
158
|
+
/** One record, sealed for its place. Bound (v2) when the install writes bound
|
|
159
|
+
* records or `bound` says so; the unbound form otherwise. */
|
|
160
|
+
function sealRecord(
|
|
161
|
+
tenant: string,
|
|
162
|
+
workspace: string | undefined,
|
|
163
|
+
name: string,
|
|
164
|
+
value: string,
|
|
165
|
+
meta: { kind?: SecretKind; sites?: string[]; updatedAt?: string },
|
|
166
|
+
bound = writeBound(),
|
|
167
|
+
): StoredSecret {
|
|
168
|
+
const { key, mark } = keyFor(tenant);
|
|
169
|
+
const rec: StoredSecret = {
|
|
170
|
+
iv: "",
|
|
171
|
+
tag: "",
|
|
172
|
+
data: "",
|
|
173
|
+
updatedAt: meta.updatedAt ?? new Date().toISOString(),
|
|
174
|
+
...(meta.kind ? { kind: meta.kind } : {}),
|
|
175
|
+
...(mark ? { k: mark } : {}),
|
|
176
|
+
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
177
|
+
};
|
|
178
|
+
const iv = crypto.randomBytes(12);
|
|
179
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
180
|
+
if (bound) {
|
|
181
|
+
cipher.setAAD(vaultAad(tenant, workspace, name, rec));
|
|
182
|
+
rec.v = 2;
|
|
183
|
+
}
|
|
184
|
+
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
185
|
+
rec.iv = iv.toString("base64");
|
|
186
|
+
rec.tag = cipher.getAuthTag().toString("base64");
|
|
187
|
+
rec.data = enc.toString("base64");
|
|
188
|
+
return rec;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A record's value, opened for its place; null when it will not open. */
|
|
192
|
+
function openRecord(tenant: string, workspace: string | undefined, name: string, rec: StoredSecret | undefined): string | null {
|
|
193
|
+
if (!rec) return null;
|
|
194
|
+
if (rec.v === 2) {
|
|
195
|
+
// Metadata someone rewrote into a shape that is not a list fails closed,
|
|
196
|
+
// like a bad tag — never as an exception up the read path.
|
|
197
|
+
let aad: Buffer;
|
|
198
|
+
try {
|
|
199
|
+
aad = vaultAad(tenant, workspace, name, rec);
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return decrypt(tenant, rec, aad);
|
|
204
|
+
}
|
|
205
|
+
return requireBound() ? null : decrypt(tenant, rec);
|
|
206
|
+
}
|
|
207
|
+
|
|
112
208
|
function read(tenant: string, workspace?: string): SecretsFile {
|
|
113
209
|
return readFile(secretsFile(tenant, workspace));
|
|
114
210
|
}
|
|
@@ -199,18 +295,15 @@ export function setSecret(
|
|
|
199
295
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
200
296
|
}
|
|
201
297
|
if (value.length > 8192) throw new Error("secret too long");
|
|
202
|
-
const sealed = encryptValue(tenant, value);
|
|
203
298
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
204
299
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
205
300
|
// property of the credential's use, not of its current value, and a
|
|
206
301
|
// rotation that silently unbound it would put the new value in pods.
|
|
302
|
+
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
207
303
|
const sites = all[name]?.sites;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
...(kind ? { kind } : {}),
|
|
212
|
-
...(sites?.length ? { sites } : {}),
|
|
213
|
-
};
|
|
304
|
+
// Once bound, always bound: a re-save with the switch off must not
|
|
305
|
+
// quietly drop the record's authentication.
|
|
306
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
|
|
214
307
|
return all;
|
|
215
308
|
});
|
|
216
309
|
}
|
|
@@ -241,6 +334,15 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
241
334
|
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
242
335
|
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
243
336
|
}
|
|
337
|
+
// A bound record names its sites in its seal: open it with the old
|
|
338
|
+
// binding and seal it again with the new one. One that will not open is
|
|
339
|
+
// refused, never relabelled.
|
|
340
|
+
if (rec.v === 2) {
|
|
341
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
342
|
+
if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
343
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
344
|
+
return all;
|
|
345
|
+
}
|
|
244
346
|
if (clean.length) rec.sites = clean;
|
|
245
347
|
else delete rec.sites;
|
|
246
348
|
return all;
|
|
@@ -259,7 +361,11 @@ export function secretSites(tenant: string, names: string[], workspace?: string)
|
|
|
259
361
|
const own = workspace ? read(tenant, workspace) : {};
|
|
260
362
|
const account = read(tenant);
|
|
261
363
|
for (const name of names) {
|
|
262
|
-
|
|
364
|
+
// The record getSecret would take the VALUE from — the workspace's only
|
|
365
|
+
// when it opens, else the account's. Picking by presence paired an
|
|
366
|
+
// account value with a workspace record's (unauthenticated) sites.
|
|
367
|
+
const ownRec = own[name];
|
|
368
|
+
const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
|
|
263
369
|
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
264
370
|
}
|
|
265
371
|
return out;
|
|
@@ -303,8 +409,10 @@ export function decryptValue(
|
|
|
303
409
|
return decrypt(tenant, rec as StoredSecret | undefined);
|
|
304
410
|
}
|
|
305
411
|
|
|
306
|
-
function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
|
|
412
|
+
function decrypt(tenant: string, rec: StoredSecret | undefined, aad?: Buffer): string | null {
|
|
307
413
|
if (!rec) return null;
|
|
414
|
+
// A bound record opens only with its binding: one without is not a guess.
|
|
415
|
+
if (rec.v === 2 && !aad) return null;
|
|
308
416
|
try {
|
|
309
417
|
// The record says which key wrote it. An unmarked one predates per-account
|
|
310
418
|
// keys and is under the install key; guessing either way would turn a
|
|
@@ -334,6 +442,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
|
|
|
334
442
|
GCM,
|
|
335
443
|
);
|
|
336
444
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
445
|
+
if (aad) decipher.setAAD(aad);
|
|
337
446
|
return Buffer.concat([
|
|
338
447
|
decipher.update(Buffer.from(rec.data, "base64")),
|
|
339
448
|
decipher.final(),
|
|
@@ -350,10 +459,10 @@ export function getSecret(
|
|
|
350
459
|
workspace?: string,
|
|
351
460
|
): { value: string; scope: SecretScope } | null {
|
|
352
461
|
if (workspace) {
|
|
353
|
-
const own =
|
|
462
|
+
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
354
463
|
if (own !== null) return { value: own, scope: "workspace" };
|
|
355
464
|
}
|
|
356
|
-
const shared =
|
|
465
|
+
const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
|
|
357
466
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
358
467
|
}
|
|
359
468
|
|
|
@@ -490,7 +599,7 @@ export function rotateMasterKey(
|
|
|
490
599
|
const rewritten: RotationResult["rewritten"] = [];
|
|
491
600
|
const unreadable: string[] = [];
|
|
492
601
|
|
|
493
|
-
for (const { file } of secretFiles(tenant)) {
|
|
602
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
494
603
|
let count = 0;
|
|
495
604
|
mutateVaultFile(file, (all) => {
|
|
496
605
|
const next: SecretsFile = {};
|
|
@@ -505,10 +614,14 @@ export function rotateMasterKey(
|
|
|
505
614
|
next[name] = rec;
|
|
506
615
|
continue;
|
|
507
616
|
}
|
|
617
|
+
// A bound record keeps its binding: same place, same AAD.
|
|
618
|
+
let aad: Buffer | null = null;
|
|
508
619
|
let plain: string;
|
|
509
620
|
try {
|
|
621
|
+
if (rec.v === 2) aad = vaultAad(tenant, workspace, name, rec);
|
|
510
622
|
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
511
623
|
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
624
|
+
if (aad) d.setAAD(aad);
|
|
512
625
|
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
513
626
|
} catch {
|
|
514
627
|
unreadable.push(name);
|
|
@@ -517,6 +630,7 @@ export function rotateMasterKey(
|
|
|
517
630
|
}
|
|
518
631
|
const iv = crypto.randomBytes(12);
|
|
519
632
|
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
633
|
+
if (aad) c.setAAD(aad);
|
|
520
634
|
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
521
635
|
next[name] = {
|
|
522
636
|
iv: iv.toString("base64"),
|
|
@@ -526,6 +640,10 @@ export function rotateMasterKey(
|
|
|
526
640
|
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
527
641
|
// rotation, and a file/ssh/api secret stops being materialised.
|
|
528
642
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
643
|
+
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
644
|
+
// unbinds every bound secret puts their values into pods.
|
|
645
|
+
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
646
|
+
...(aad ? { v: 2 as const } : {}),
|
|
529
647
|
};
|
|
530
648
|
count++;
|
|
531
649
|
}
|
|
@@ -637,8 +755,11 @@ export function replaceOAuth2RefreshToken(tenant: string, name: string, workspac
|
|
|
637
755
|
let replaced = false;
|
|
638
756
|
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
639
757
|
const rec = all[name];
|
|
640
|
-
if (rec &&
|
|
641
|
-
|
|
758
|
+
if (rec && openRecord(tenant, scope, name, rec) === usedValue) {
|
|
759
|
+
// Built whole, not spread over the old record: a spread kept a stale
|
|
760
|
+
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
761
|
+
// record then opened with neither.
|
|
762
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
642
763
|
replaced = true;
|
|
643
764
|
}
|
|
644
765
|
return all;
|
|
@@ -747,7 +868,15 @@ export async function materializeSecrets(
|
|
|
747
868
|
env: Record<string, string>,
|
|
748
869
|
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
749
870
|
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
750
|
-
ctx?: {
|
|
871
|
+
ctx?: {
|
|
872
|
+
tenant: string;
|
|
873
|
+
workspace?: string;
|
|
874
|
+
/** A rotated refresh token goes here INSTEAD of the vault: a caller that
|
|
875
|
+
* cannot open the vault (the egress proxy without the install key)
|
|
876
|
+
* keeps it itself and has it written by a process that can. `next` is
|
|
877
|
+
* the stored value with the new refresh token. */
|
|
878
|
+
onRotate?: (name: string, usedValue: string, next: string) => unknown;
|
|
879
|
+
},
|
|
751
880
|
): Promise<Record<string, string>> {
|
|
752
881
|
const out: Record<string, string> = { ...env };
|
|
753
882
|
for (const [name, value] of Object.entries(env)) {
|
|
@@ -769,7 +898,8 @@ export async function materializeSecrets(
|
|
|
769
898
|
// the next run does not refresh again.
|
|
770
899
|
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
771
900
|
try {
|
|
772
|
-
if (ctx)
|
|
901
|
+
if (ctx?.onRotate) await ctx.onRotate(name, value, next);
|
|
902
|
+
else if (ctx) replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
773
903
|
} catch {
|
|
774
904
|
// The access token is good either way; the next refresh says if not.
|
|
775
905
|
}
|
|
@@ -1028,7 +1158,7 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
|
|
|
1028
1158
|
...(config.export ? { export: true } : {}),
|
|
1029
1159
|
};
|
|
1030
1160
|
if (!SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
1031
|
-
const
|
|
1161
|
+
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
1032
1162
|
// Written under the vault's own lock, with the binding checked in the same
|
|
1033
1163
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
1034
1164
|
// one would present the certificate to a whole domain.
|
|
@@ -1037,7 +1167,7 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
|
|
|
1037
1167
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
1038
1168
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
1039
1169
|
}
|
|
1040
|
-
all[name] =
|
|
1170
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
1041
1171
|
return all;
|
|
1042
1172
|
});
|
|
1043
1173
|
}
|
|
@@ -1143,6 +1273,44 @@ export function apiConfigOf(value: string): ApiConfig {
|
|
|
1143
1273
|
}
|
|
1144
1274
|
|
|
1145
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* Seal every unbound record of an account for its place (v2). Run at boot
|
|
1278
|
+
* once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
|
|
1279
|
+
* scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
|
|
1280
|
+
* value is opened first, and a file holding one that will not open is left
|
|
1281
|
+
* exactly as it is and reported. Idempotent: bound records are skipped.
|
|
1282
|
+
*/
|
|
1283
|
+
export function sealVaultBindings(tenant: string): { sealed: number; unreadable: string[] } {
|
|
1284
|
+
assertSafeName(tenant, "tenant");
|
|
1285
|
+
let sealed = 0;
|
|
1286
|
+
const unreadable: string[] = [];
|
|
1287
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
1288
|
+
mutateVaultFile(file, (all) => {
|
|
1289
|
+
const plain = new Map<string, string>();
|
|
1290
|
+
const bad: string[] = [];
|
|
1291
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
1292
|
+
if (rec.v === 2) continue;
|
|
1293
|
+
const value = decrypt(tenant, rec);
|
|
1294
|
+
if (value === null) bad.push(workspace ? `${workspace}/${name}` : name);
|
|
1295
|
+
else plain.set(name, value);
|
|
1296
|
+
}
|
|
1297
|
+
unreadable.push(...bad);
|
|
1298
|
+
if (plain.size === 0 || bad.length) return null;
|
|
1299
|
+
const next: SecretsFile = { ...all };
|
|
1300
|
+
for (const [name, value] of plain) {
|
|
1301
|
+
const rec = all[name];
|
|
1302
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1303
|
+
}
|
|
1304
|
+
sealed += plain.size;
|
|
1305
|
+
return next;
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
return { sealed, unreadable };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Whether this install writes bound vault records (and migrates at boot). */
|
|
1312
|
+
export const vaultWritesBound = writeBound;
|
|
1313
|
+
|
|
1146
1314
|
/**
|
|
1147
1315
|
* Move an account's secrets from the install key onto its own key.
|
|
1148
1316
|
*
|
|
@@ -1158,7 +1326,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
1158
1326
|
let moved = 0;
|
|
1159
1327
|
const unreadable: string[] = [];
|
|
1160
1328
|
|
|
1161
|
-
for (const { file } of secretFiles(tenant)) {
|
|
1329
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
1162
1330
|
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
1163
1331
|
// between our read and our write must not be lost.
|
|
1164
1332
|
mutateVaultFile(file, (all) => {
|
|
@@ -1166,7 +1334,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
1166
1334
|
const unreadableHere: string[] = [];
|
|
1167
1335
|
for (const [name, rec] of Object.entries(all)) {
|
|
1168
1336
|
if (rec.k === "tenant") continue; // already moved
|
|
1169
|
-
const value =
|
|
1337
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
1170
1338
|
if (value === null) {
|
|
1171
1339
|
unreadableHere.push(name);
|
|
1172
1340
|
continue;
|
|
@@ -1180,12 +1348,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
1180
1348
|
if (plain.size === 0 || unreadableHere.length) return null;
|
|
1181
1349
|
const next: SecretsFile = { ...all };
|
|
1182
1350
|
for (const [name, value] of plain) {
|
|
1183
|
-
next[name] = {
|
|
1184
|
-
...encryptValue(tenant, value),
|
|
1185
|
-
updatedAt: all[name].updatedAt,
|
|
1186
|
-
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
1187
|
-
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
1188
|
-
};
|
|
1351
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
1189
1352
|
}
|
|
1190
1353
|
moved += plain.size;
|
|
1191
1354
|
return next;
|
package/src/store.ts
CHANGED
|
@@ -2785,9 +2785,58 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
|
|
|
2785
2785
|
return out.sort();
|
|
2786
2786
|
}
|
|
2787
2787
|
|
|
2788
|
+
/**
|
|
2789
|
+
* Where a path really lands once every symlink on the way is followed — for
|
|
2790
|
+
* one not there yet, its nearest existing ancestor's real path with the rest
|
|
2791
|
+
* appended. null when a link on the way points nowhere. `followLast: false`
|
|
2792
|
+
* stops short of the last part: the link itself is what is removed or moved.
|
|
2793
|
+
*/
|
|
2794
|
+
function realLanding(full: string, followLast = true): string | null {
|
|
2795
|
+
const tail: string[] = followLast ? [] : [path.basename(full)];
|
|
2796
|
+
let probe = followLast ? full : path.dirname(full);
|
|
2797
|
+
for (;;) {
|
|
2798
|
+
try {
|
|
2799
|
+
fs.lstatSync(probe);
|
|
2800
|
+
} catch {
|
|
2801
|
+
const up = path.dirname(probe);
|
|
2802
|
+
if (up === probe) return null;
|
|
2803
|
+
tail.unshift(path.basename(probe));
|
|
2804
|
+
probe = up;
|
|
2805
|
+
continue;
|
|
2806
|
+
}
|
|
2807
|
+
try {
|
|
2808
|
+
return path.join(fs.realpathSync(probe), ...tail);
|
|
2809
|
+
} catch {
|
|
2810
|
+
return null; // a dangling link: where it would write is not knowable
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** Whether `full` stays in the workspace `dir` with symlinks followed (FOL-83).
|
|
2816
|
+
* The string checks (assertEditablePath, runOutputPath's prefix) read the
|
|
2817
|
+
* path as written; a link planted inside the workspace — `knowledge/x.md` →
|
|
2818
|
+
* /etc/passwd, or a folder → / — passed them, and reads, writes and the run
|
|
2819
|
+
* archive followed it out. */
|
|
2820
|
+
function landsInside(dir: string, full: string, followLast = true): boolean {
|
|
2821
|
+
let root: string;
|
|
2822
|
+
try {
|
|
2823
|
+
root = fs.realpathSync(dir);
|
|
2824
|
+
} catch {
|
|
2825
|
+
root = path.resolve(dir);
|
|
2826
|
+
}
|
|
2827
|
+
const at = realLanding(full, followLast);
|
|
2828
|
+
return at !== null && (at === root || at.startsWith(root + path.sep));
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function assertLandsInside(dir: string, full: string, rel: string, followLast = true) {
|
|
2832
|
+
if (!landsInside(dir, full, followLast)) throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2788
2835
|
export function readWorkspaceFile(tenant: string, workspace: string, rel: string): string {
|
|
2789
|
-
const
|
|
2836
|
+
const dir = workspaceDir(tenant, workspace);
|
|
2837
|
+
const p = path.join(dir, assertEditablePath(rel));
|
|
2790
2838
|
if (!fs.existsSync(p)) throw new Error(`file not found: ${rel}`);
|
|
2839
|
+
assertLandsInside(dir, p, rel);
|
|
2791
2840
|
return fs.readFileSync(p, "utf8");
|
|
2792
2841
|
}
|
|
2793
2842
|
|
|
@@ -2808,7 +2857,11 @@ export function writeWorkspaceFile(
|
|
|
2808
2857
|
// past 256 KB. Still a file a person edits, not a store for data.
|
|
2809
2858
|
if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
|
|
2810
2859
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
2811
|
-
const
|
|
2860
|
+
const dir = workspaceDir(tenant, workspace);
|
|
2861
|
+
const p = path.join(dir, assertEditablePath(rel));
|
|
2862
|
+
// Before anything is created: mkdir below would otherwise make folders
|
|
2863
|
+
// through a linked directory too.
|
|
2864
|
+
assertLandsInside(dir, p, rel);
|
|
2812
2865
|
const existed = fs.existsSync(p);
|
|
2813
2866
|
const before = existed ? fs.readFileSync(p, "utf8") : null;
|
|
2814
2867
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -2918,6 +2971,10 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
|
|
|
2918
2971
|
const dst = path.join(dir, assertEditablePath(to));
|
|
2919
2972
|
if (!fs.existsSync(src)) throw new Error(`${from} does not exist`);
|
|
2920
2973
|
if (fs.existsSync(dst)) throw new Error(`${to} already exists`);
|
|
2974
|
+
// The link itself may be moved; what is moved must not come from, or go
|
|
2975
|
+
// to, outside the workspace through a linked folder.
|
|
2976
|
+
assertLandsInside(dir, src, from, false);
|
|
2977
|
+
assertLandsInside(dir, dst, to);
|
|
2921
2978
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
2922
2979
|
const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
|
|
2923
2980
|
fs.renameSync(src, dst);
|
|
@@ -2992,12 +3049,16 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
|
|
|
2992
3049
|
if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
|
|
2993
3050
|
/^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
|
|
2994
3051
|
/^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
|
|
3052
|
+
// The last part may be a link — rmSync removes the link, not what it
|
|
3053
|
+
// names — but a linked folder ABOVE it would delete outside.
|
|
3054
|
+
assertLandsInside(dir, path.join(dir, norm), norm, false);
|
|
2995
3055
|
const gone = deletionsUnder(dir, norm);
|
|
2996
3056
|
fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
|
|
2997
3057
|
recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
|
|
2998
3058
|
return;
|
|
2999
3059
|
}
|
|
3000
3060
|
const p = path.join(dir, assertEditablePath(norm));
|
|
3061
|
+
assertLandsInside(dir, p, norm, false);
|
|
3001
3062
|
const gone = deletionsUnder(dir, norm);
|
|
3002
3063
|
fs.rmSync(p, { force: true });
|
|
3003
3064
|
recordRevision(tenant, workspace, gone, meta);
|
|
@@ -3836,6 +3897,7 @@ export function runOutputPath(
|
|
|
3836
3897
|
const full = path.resolve(root, rel);
|
|
3837
3898
|
if (full !== root && !full.startsWith(root + path.sep)) return null;
|
|
3838
3899
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
|
|
3900
|
+
if (!landsInside(workspaceDir(tenant, workspace), full)) return null;
|
|
3839
3901
|
return full;
|
|
3840
3902
|
}
|
|
3841
3903
|
|