okengine 0.11.1 → 0.11.2
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 +1 -1
- package/site/content/docs/elements/ai.mdx +22 -1
- package/site/content/docs/elements/store.mdx +3 -1
- package/site/content/docs/elements/vault.mdx +19 -11
- package/site/content/docs/get-started/installation.mdx +7 -1
- package/site/content/docs/recipes/llama-cpp.mdx +10 -9
- package/site/content/docs/reference/cli.md +6 -2
- package/site/content/docs/reference/environment-variables.mdx +9 -9
- package/src/cli/ai-setup/ai-setup.test.ts +3 -1
- package/src/cli/ai-setup/apply.ts +61 -1
- package/src/cli/ask-seed.test.ts +4 -3
- package/src/cli/ask-seed.ts +5 -6
- package/src/cli/client-add.test.ts +2 -1
- package/src/cli/dev.test.ts +116 -0
- package/src/cli/dev.ts +107 -18
- package/src/cli/project-state.test.ts +50 -0
- package/src/cli/project-state.ts +123 -0
- package/src/cli/vault-cmd.test.ts +47 -18
- package/src/cli/vault-cmd.ts +2 -1
- package/src/compiler/extract.ts +12 -1
- package/src/console/server/console.test.ts +3 -1
- package/src/console/server/operator-db.test.ts +48 -17
- package/src/console/server/operator-db.ts +5 -1
- package/src/docker/derive.ts +24 -3
- package/src/docker/docker.test.ts +4 -2
- package/src/docker/index.ts +1 -0
- package/src/docker/recipes/index.ts +1 -0
- package/src/docker/recipes/llama-cpp.ts +20 -4
- package/src/drivers/ai-openai-compatible.ts +15 -3
- package/src/drivers/vault-builtin.test.ts +50 -42
- package/src/elements/ai/declare.ts +73 -3
- package/src/elements/ai/errors.test.ts +35 -0
- package/src/elements/ai/errors.ts +139 -0
- package/src/elements/ai/eval.ts +26 -1
- package/src/elements/ai/runtime.ts +140 -80
- package/src/elements/ai/tools.test.ts +1 -1
- package/src/elements/ai.test.ts +99 -2
- package/src/elements/ai.ts +11 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/store/index-boot.test.ts +23 -6
- package/src/elements/store/resource.test.ts +38 -19
- package/src/elements/store/sql-session.test.ts +55 -58
- package/src/elements/vault/builtin-adapter.test.ts +115 -58
- package/src/elements/vault/builtin-adapter.ts +241 -47
- package/src/elements/vault/chaos-child.ts +424 -0
- package/src/elements/vault/chaos.test.ts +651 -0
- package/src/elements/vault/resilience.ts +6 -1
- package/src/elements/vault/security-checklist.test.ts +10 -8
- package/src/elements/vault/storage.ts +130 -27
- package/src/elements/vault/test-helpers.ts +368 -0
- package/src/elements/vault.ts +6 -0
- package/src/index.ts +2 -0
- package/src/kernel/app.ts +83 -10
- package/src/kernel/auto-registry.test.ts +52 -1
- package/src/kernel/element-registries.ts +19 -4
- package/src/kernel/errors.ts +3 -3
- package/src/kernel/fx.test.ts +25 -0
- package/src/kernel/fx.ts +4 -1
- package/src/manifest/types.ts +4 -0
- package/src/test/create-test-app.ts +16 -11
- package/src/test/reset-element-registries.ts +17 -9
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault-builtin adversarial proofs — real OS processes, real SIGKILL,
|
|
3
|
+
* real concurrent access (same discipline as Signal/Clock/Journal chaos).
|
|
4
|
+
*
|
|
5
|
+
* Live multi-process Postgres cases: set `OKE_TEST_POSTGRES_URL` (or
|
|
6
|
+
* `OKE_TEST_POSTGRES=1` + `DATABASE_URL`). Without a live Postgres those
|
|
7
|
+
* describes skip visibly. On-disk PGlite covers sequential crash/resume
|
|
8
|
+
* and single-writer proofs that do not need two concurrent DB clients.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { connectPglite } from "../../drivers/pglite.ts";
|
|
17
|
+
import { connectPostgres } from "../../drivers/postgres.ts";
|
|
18
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
19
|
+
import {
|
|
20
|
+
BACKUP_END_MARKER,
|
|
21
|
+
BACKUP_MAGIC,
|
|
22
|
+
createBuiltinVaultAdapter,
|
|
23
|
+
sqlConnectionAsExec,
|
|
24
|
+
writeBackupFileAtomic,
|
|
25
|
+
} from "./builtin-adapter.ts";
|
|
26
|
+
import { generateMasterKey } from "./crypto.ts";
|
|
27
|
+
import { isVaultError } from "./errors.ts";
|
|
28
|
+
import { resetVaultTables } from "./test-helpers.ts";
|
|
29
|
+
|
|
30
|
+
const childPath = join(import.meta.dir, "chaos-child.ts");
|
|
31
|
+
|
|
32
|
+
const LIVE_URL =
|
|
33
|
+
process.env.OKE_TEST_POSTGRES_URL?.trim() ||
|
|
34
|
+
(process.env.OKE_TEST_POSTGRES === "1"
|
|
35
|
+
? (process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.trim()
|
|
36
|
+
: undefined);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wait until `path` exists on disk.
|
|
40
|
+
*
|
|
41
|
+
* @param path - Absolute path
|
|
42
|
+
* @param timeoutMs - Deadline
|
|
43
|
+
*/
|
|
44
|
+
async function waitForFile(path: string, timeoutMs = 20_000): Promise<boolean> {
|
|
45
|
+
const deadline = Date.now() + timeoutMs;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (await Bun.file(path).exists()) return true;
|
|
48
|
+
await Bun.sleep(20);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read JSONL results written by racing children.
|
|
55
|
+
*
|
|
56
|
+
* @param path - JSONL file
|
|
57
|
+
*/
|
|
58
|
+
async function readJsonl(path: string): Promise<readonly Record<string, unknown>[]> {
|
|
59
|
+
if (!(await Bun.file(path).exists())) return [];
|
|
60
|
+
return (await Bun.file(path).text())
|
|
61
|
+
.trim()
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared in-memory PGlite for dual-adapter (same-process) races. */
|
|
68
|
+
let sharedConn: SqlConnection;
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
sharedConn = await connectPglite({ url: "memory://vault-chaos-shared" });
|
|
72
|
+
}, 15_000);
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await sharedConn.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("chaos — 1. crash mid-rotation (SIGKILL)", () => {
|
|
79
|
+
test("SIGKILL mid-rewrap: survivor resumes; mixed KEK generations finish cleanly", async () => {
|
|
80
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-rotate-"));
|
|
81
|
+
const sqlUrl = join(dir, "pgdata");
|
|
82
|
+
const markerPath = join(dir, "mid.json");
|
|
83
|
+
const statePath = join(dir, "state.json");
|
|
84
|
+
const resultPath = join(dir, "result.json");
|
|
85
|
+
const secretCount = 6;
|
|
86
|
+
const killAfter = 2;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const doomed = Bun.spawn({
|
|
90
|
+
cmd: [
|
|
91
|
+
"bun",
|
|
92
|
+
childPath,
|
|
93
|
+
"rotate-mid-rewrap",
|
|
94
|
+
sqlUrl,
|
|
95
|
+
markerPath,
|
|
96
|
+
statePath,
|
|
97
|
+
String(secretCount),
|
|
98
|
+
"10",
|
|
99
|
+
String(killAfter),
|
|
100
|
+
],
|
|
101
|
+
stdout: "pipe",
|
|
102
|
+
stderr: "pipe",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(await waitForFile(markerPath)).toBe(true);
|
|
106
|
+
doomed.kill(9);
|
|
107
|
+
await doomed.exited;
|
|
108
|
+
|
|
109
|
+
const survivor = Bun.spawn({
|
|
110
|
+
cmd: ["bun", childPath, "resume-rewrap", sqlUrl, statePath, resultPath],
|
|
111
|
+
stdout: "pipe",
|
|
112
|
+
stderr: "pipe",
|
|
113
|
+
});
|
|
114
|
+
const code = await survivor.exited;
|
|
115
|
+
const stderr = await new Response(survivor.stderr).text();
|
|
116
|
+
expect(code).toBe(0);
|
|
117
|
+
expect(stderr).not.toMatch(/error/i);
|
|
118
|
+
|
|
119
|
+
const result = (await Bun.file(resultPath).json()) as {
|
|
120
|
+
statusBefore: {
|
|
121
|
+
rewrapTargetKekVersion?: number;
|
|
122
|
+
kekVersion: number;
|
|
123
|
+
};
|
|
124
|
+
statusAfter: {
|
|
125
|
+
rewrapTargetKekVersion?: number;
|
|
126
|
+
kekVersion: number;
|
|
127
|
+
};
|
|
128
|
+
values: string[];
|
|
129
|
+
kekVersions: number[];
|
|
130
|
+
remaining: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Mid-crash left a detectable in-flight rotation (not silent mixed state).
|
|
134
|
+
expect(result.statusBefore.rewrapTargetKekVersion).toBe(2);
|
|
135
|
+
expect(result.remaining).toBe(0);
|
|
136
|
+
expect(result.statusAfter.rewrapTargetKekVersion).toBeUndefined();
|
|
137
|
+
expect(result.statusAfter.kekVersion).toBe(2);
|
|
138
|
+
expect(result.values).toEqual(Array.from({ length: secretCount }, (_, i) => `value-${i}`));
|
|
139
|
+
expect(result.kekVersions.every((v) => v === 2)).toBe(true);
|
|
140
|
+
} finally {
|
|
141
|
+
await rm(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
}, 60_000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("chaos — 2. concurrent rotateMaster", () => {
|
|
147
|
+
test("two adapters on one DB: exactly one wins loud; zero DEK under unsaved key", async () => {
|
|
148
|
+
await resetVaultTables(sharedConn);
|
|
149
|
+
const db = sqlConnectionAsExec(sharedConn);
|
|
150
|
+
const a = createBuiltinVaultAdapter({ db, kekRewrapBatchSize: 2, rotateLeaseMs: 5_000 });
|
|
151
|
+
const b = createBuiltinVaultAdapter({ db, kekRewrapBatchSize: 2, rotateLeaseMs: 5_000 });
|
|
152
|
+
const init = await a.initialize();
|
|
153
|
+
await a.unseal(init.masterKey);
|
|
154
|
+
await b.unseal(init.masterKey);
|
|
155
|
+
for (let i = 0; i < 8; i += 1) {
|
|
156
|
+
await a.set(`chaos/concurrent-${i}`, `v-${i}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const keyA = generateMasterKey();
|
|
160
|
+
const keyB = generateMasterKey();
|
|
161
|
+
const [ra, rb] = await Promise.allSettled([a.rotateMaster(keyA), b.rotateMaster(keyB)]);
|
|
162
|
+
|
|
163
|
+
const fulfilled = [ra, rb].filter((r) => r.status === "fulfilled");
|
|
164
|
+
const rejected = [ra, rb].filter((r) => r.status === "rejected");
|
|
165
|
+
expect(fulfilled).toHaveLength(1);
|
|
166
|
+
expect(rejected).toHaveLength(1);
|
|
167
|
+
const loser = rejected[0];
|
|
168
|
+
expect(loser).toBeDefined();
|
|
169
|
+
if (loser === undefined || loser.status !== "rejected") {
|
|
170
|
+
throw new Error("expected exactly one rejected rotateMaster");
|
|
171
|
+
}
|
|
172
|
+
const rejectMsg = loser.reason instanceof Error ? loser.reason.message : String(loser.reason);
|
|
173
|
+
expect(rejectMsg).toMatch(/lease held|already in progress/i);
|
|
174
|
+
|
|
175
|
+
const winnerKey = ra.status === "fulfilled" ? keyA : keyB;
|
|
176
|
+
const loserKey = ra.status === "fulfilled" ? keyB : keyA;
|
|
177
|
+
const winnerAdapter = ra.status === "fulfilled" ? a : b;
|
|
178
|
+
|
|
179
|
+
let progress =
|
|
180
|
+
ra.status === "fulfilled" ? ra.value : rb.status === "fulfilled" ? rb.value : undefined;
|
|
181
|
+
expect(progress).toBeDefined();
|
|
182
|
+
while (progress!.remaining > 0) {
|
|
183
|
+
progress = await winnerAdapter.continueRotateMaster();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Single most important assertion: no DEK wrapped under the loser's unsaved key.
|
|
187
|
+
const { createMemoryUnsealer } = await import("./unseal.ts");
|
|
188
|
+
const { unwrapDek, buildAad } = await import("./crypto.ts");
|
|
189
|
+
const loserUnsealer = createMemoryUnsealer(loserKey);
|
|
190
|
+
const loserKek = await loserUnsealer.unwrapKek();
|
|
191
|
+
const keyRows = await db.query<{
|
|
192
|
+
encrypted_dek: unknown;
|
|
193
|
+
dek_iv: unknown;
|
|
194
|
+
dek_auth_tag: unknown;
|
|
195
|
+
kek_version: number | string;
|
|
196
|
+
path: string;
|
|
197
|
+
version: number | string;
|
|
198
|
+
algorithm: string;
|
|
199
|
+
}>(
|
|
200
|
+
`SELECT k.encrypted_dek, k.dek_iv, k.dek_auth_tag, k.kek_version,
|
|
201
|
+
s.path, s.version, k.algorithm
|
|
202
|
+
FROM oke_vault_keys k
|
|
203
|
+
JOIN oke_vault_secrets s ON s.id = k.secret_id`,
|
|
204
|
+
);
|
|
205
|
+
const toBytes = (value: unknown): Uint8Array => {
|
|
206
|
+
if (value instanceof Uint8Array) return value;
|
|
207
|
+
if (typeof value === "string" && value.startsWith("\\x")) {
|
|
208
|
+
return new Uint8Array(Buffer.from(value.slice(2), "hex"));
|
|
209
|
+
}
|
|
210
|
+
return new Uint8Array();
|
|
211
|
+
};
|
|
212
|
+
let loserUnwraps = 0;
|
|
213
|
+
for (const row of keyRows) {
|
|
214
|
+
try {
|
|
215
|
+
await unwrapDek(
|
|
216
|
+
loserKek,
|
|
217
|
+
{
|
|
218
|
+
iv: toBytes(row.dek_iv),
|
|
219
|
+
ciphertext: toBytes(row.encrypted_dek),
|
|
220
|
+
tag: toBytes(row.dek_auth_tag),
|
|
221
|
+
},
|
|
222
|
+
buildAad(
|
|
223
|
+
row.path,
|
|
224
|
+
Number(row.version),
|
|
225
|
+
row.algorithm as "aes-256-gcm",
|
|
226
|
+
Number(row.kek_version),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
loserUnwraps += 1;
|
|
230
|
+
} catch {
|
|
231
|
+
// Expected: loser's key unwraps nothing.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
expect(loserUnwraps).toBe(0);
|
|
235
|
+
|
|
236
|
+
// Winner key is the only recoverable master after completion.
|
|
237
|
+
await winnerAdapter.seal();
|
|
238
|
+
await winnerAdapter.unseal((await import("./crypto.ts")).masterKeyToBase64(winnerKey));
|
|
239
|
+
for (let i = 0; i < 8; i += 1) {
|
|
240
|
+
expect((await winnerAdapter.get(`chaos/concurrent-${i}`))?.value).toBe(`v-${i}`);
|
|
241
|
+
}
|
|
242
|
+
console.log(
|
|
243
|
+
`[vault concurrent rotate] winner=${ra.status === "fulfilled" ? "a" : "b"} loserUnwraps=0`,
|
|
244
|
+
);
|
|
245
|
+
}, 30_000);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe.skipIf(!LIVE_URL)("chaos — 2b. concurrent rotateMaster (multi-process Postgres)", () => {
|
|
249
|
+
test("two OS processes: exactly one rotateMaster wins loud", async () => {
|
|
250
|
+
const url = LIVE_URL!;
|
|
251
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-rotate-race-"));
|
|
252
|
+
const resultPath = join(dir, "results.jsonl");
|
|
253
|
+
|
|
254
|
+
const schema = await connectPostgres({ url });
|
|
255
|
+
try {
|
|
256
|
+
await resetVaultTables(schema);
|
|
257
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(schema) });
|
|
258
|
+
const init = await adapter.initialize();
|
|
259
|
+
await adapter.unseal(init.masterKey);
|
|
260
|
+
for (let i = 0; i < 8; i += 1) {
|
|
261
|
+
await adapter.set(`chaos/race-${i}`, `race-${i}`);
|
|
262
|
+
}
|
|
263
|
+
await adapter.seal();
|
|
264
|
+
|
|
265
|
+
const a = Bun.spawn({
|
|
266
|
+
cmd: ["bun", childPath, "rotate-race", url, init.masterKey, "inst-a", resultPath, "8"],
|
|
267
|
+
stdout: "pipe",
|
|
268
|
+
stderr: "pipe",
|
|
269
|
+
});
|
|
270
|
+
const b = Bun.spawn({
|
|
271
|
+
cmd: ["bun", childPath, "rotate-race", url, init.masterKey, "inst-b", resultPath, "8"],
|
|
272
|
+
stdout: "pipe",
|
|
273
|
+
stderr: "pipe",
|
|
274
|
+
});
|
|
275
|
+
await Promise.all([a.exited, b.exited]);
|
|
276
|
+
|
|
277
|
+
const lines = await readJsonl(resultPath);
|
|
278
|
+
expect(lines.length).toBe(2);
|
|
279
|
+
const wins = lines.filter((l) => l.ok === true);
|
|
280
|
+
const losses = lines.filter((l) => l.ok === false);
|
|
281
|
+
console.log(`[vault pg rotate race] wins=${wins.length} losses=${losses.length}`);
|
|
282
|
+
expect(wins).toHaveLength(1);
|
|
283
|
+
expect(losses).toHaveLength(1);
|
|
284
|
+
} finally {
|
|
285
|
+
await resetVaultTables(schema).catch(() => undefined);
|
|
286
|
+
await schema.close();
|
|
287
|
+
await rm(dir, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
}, 60_000);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("chaos — 3. read during rotation", () => {
|
|
293
|
+
test("concurrent get() during rewrap returns intact pre/post values (never torn)", async () => {
|
|
294
|
+
await resetVaultTables(sharedConn);
|
|
295
|
+
const db = sqlConnectionAsExec(sharedConn);
|
|
296
|
+
const adapter = createBuiltinVaultAdapter({ db, kekRewrapBatchSize: 1 });
|
|
297
|
+
const init = await adapter.initialize();
|
|
298
|
+
await adapter.unseal(init.masterKey);
|
|
299
|
+
const path = "chaos/read-during";
|
|
300
|
+
const value = "stable-secret-value";
|
|
301
|
+
await adapter.set(path, value);
|
|
302
|
+
for (let i = 0; i < 12; i += 1) {
|
|
303
|
+
await adapter.set(`chaos/pad-${i}`, `pad-${i}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const next = generateMasterKey();
|
|
307
|
+
const reads: Array<{ ok: boolean; value?: string; error?: string }> = [];
|
|
308
|
+
let stop = false;
|
|
309
|
+
|
|
310
|
+
const reader = (async () => {
|
|
311
|
+
while (!stop) {
|
|
312
|
+
try {
|
|
313
|
+
const secret = await adapter.get(path);
|
|
314
|
+
reads.push({ ok: true, value: secret?.value });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
reads.push({
|
|
317
|
+
ok: false,
|
|
318
|
+
error: error instanceof Error ? error.message : String(error),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
await Bun.sleep(0);
|
|
322
|
+
}
|
|
323
|
+
})();
|
|
324
|
+
|
|
325
|
+
let progress = await adapter.rotateMaster(next);
|
|
326
|
+
while (progress.remaining > 0) {
|
|
327
|
+
progress = await adapter.continueRotateMaster();
|
|
328
|
+
}
|
|
329
|
+
stop = true;
|
|
330
|
+
await reader;
|
|
331
|
+
|
|
332
|
+
expect(reads.length).toBeGreaterThan(5);
|
|
333
|
+
const bad = reads.filter((r) => !r.ok || (r.value !== undefined && r.value !== value));
|
|
334
|
+
console.log(`[vault read-during-rotate] reads=${reads.length} bad=${bad.length}`);
|
|
335
|
+
expect(bad).toHaveLength(0);
|
|
336
|
+
expect((await adapter.get(path))?.value).toBe(value);
|
|
337
|
+
}, 30_000);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe.skipIf(!LIVE_URL)("chaos — 3b. read during rotation (second process, Postgres)", () => {
|
|
341
|
+
test("reader process with only old master sees intact values or loud failure — never torn bytes", async () => {
|
|
342
|
+
const url = LIVE_URL!;
|
|
343
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-read-"));
|
|
344
|
+
const readsPath = join(dir, "reads.jsonl");
|
|
345
|
+
const stopPath = join(dir, "stop");
|
|
346
|
+
const path = "chaos/cross-read";
|
|
347
|
+
|
|
348
|
+
const conn = await connectPostgres({ url });
|
|
349
|
+
try {
|
|
350
|
+
await resetVaultTables(conn);
|
|
351
|
+
const adapter = createBuiltinVaultAdapter({
|
|
352
|
+
db: sqlConnectionAsExec(conn),
|
|
353
|
+
kekRewrapBatchSize: 1,
|
|
354
|
+
});
|
|
355
|
+
const init = await adapter.initialize();
|
|
356
|
+
await adapter.unseal(init.masterKey);
|
|
357
|
+
await adapter.set(path, "cross-process-value");
|
|
358
|
+
for (let i = 0; i < 10; i += 1) {
|
|
359
|
+
await adapter.set(`chaos/cross-pad-${i}`, `p-${i}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const reader = Bun.spawn({
|
|
363
|
+
cmd: [
|
|
364
|
+
"bun",
|
|
365
|
+
childPath,
|
|
366
|
+
"read-loop",
|
|
367
|
+
url,
|
|
368
|
+
init.masterKey,
|
|
369
|
+
path,
|
|
370
|
+
readsPath,
|
|
371
|
+
stopPath,
|
|
372
|
+
"15000",
|
|
373
|
+
],
|
|
374
|
+
stdout: "pipe",
|
|
375
|
+
stderr: "pipe",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await Bun.sleep(200);
|
|
379
|
+
const next = generateMasterKey();
|
|
380
|
+
let progress = await adapter.rotateMaster(next);
|
|
381
|
+
while (progress.remaining > 0) {
|
|
382
|
+
progress = await adapter.continueRotateMaster();
|
|
383
|
+
}
|
|
384
|
+
await Bun.write(stopPath, "stop");
|
|
385
|
+
await reader.exited;
|
|
386
|
+
|
|
387
|
+
const lines = await readJsonl(readsPath);
|
|
388
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
389
|
+
const torn = lines.filter(
|
|
390
|
+
(l) =>
|
|
391
|
+
l.ok === true &&
|
|
392
|
+
typeof l.value === "string" &&
|
|
393
|
+
l.value !== "cross-process-value" &&
|
|
394
|
+
l.value.length > 0,
|
|
395
|
+
);
|
|
396
|
+
console.log(
|
|
397
|
+
`[vault pg read-during] total=${lines.length} ok=${lines.filter((l) => l.ok).length} fail=${lines.filter((l) => !l.ok).length} torn=${torn.length}`,
|
|
398
|
+
);
|
|
399
|
+
expect(torn).toHaveLength(0);
|
|
400
|
+
} finally {
|
|
401
|
+
await resetVaultTables(conn).catch(() => undefined);
|
|
402
|
+
await conn.close();
|
|
403
|
+
await rm(dir, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
}, 60_000);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe("chaos — 4. crash mid-backup", () => {
|
|
409
|
+
test("SIGKILL after partial bundle write: incomplete marker rejected before decrypt", async () => {
|
|
410
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-backup-"));
|
|
411
|
+
const sqlUrl = join(dir, "pgdata");
|
|
412
|
+
const statePath = join(dir, "state.json");
|
|
413
|
+
const bundlePath = join(dir, "vault.bundle");
|
|
414
|
+
const markerPath = join(dir, "partial.json");
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const doomed = Bun.spawn({
|
|
418
|
+
cmd: [
|
|
419
|
+
"bun",
|
|
420
|
+
childPath,
|
|
421
|
+
"backup-partial",
|
|
422
|
+
sqlUrl,
|
|
423
|
+
statePath,
|
|
424
|
+
bundlePath,
|
|
425
|
+
"0.35",
|
|
426
|
+
markerPath,
|
|
427
|
+
],
|
|
428
|
+
stdout: "pipe",
|
|
429
|
+
stderr: "pipe",
|
|
430
|
+
});
|
|
431
|
+
expect(await waitForFile(markerPath)).toBe(true);
|
|
432
|
+
doomed.kill(9);
|
|
433
|
+
await doomed.exited;
|
|
434
|
+
|
|
435
|
+
const marker = (await Bun.file(markerPath).json()) as {
|
|
436
|
+
fullBytes: number;
|
|
437
|
+
writtenBytes: number;
|
|
438
|
+
startsWithMagic: boolean;
|
|
439
|
+
};
|
|
440
|
+
expect(marker.writtenBytes).toBeLessThan(marker.fullBytes);
|
|
441
|
+
expect(marker.startsWithMagic).toBe(true);
|
|
442
|
+
|
|
443
|
+
const partial = new Uint8Array(await Bun.file(bundlePath).arrayBuffer());
|
|
444
|
+
expect(new TextDecoder().decode(partial.subarray(0, BACKUP_MAGIC.length))).toBe(BACKUP_MAGIC);
|
|
445
|
+
expect(
|
|
446
|
+
new TextDecoder().decode(partial.subarray(Math.max(0, partial.byteLength - 32))),
|
|
447
|
+
).not.toContain(BACKUP_END_MARKER.trim());
|
|
448
|
+
|
|
449
|
+
const state = (await Bun.file(statePath).json()) as { oldMasterKey: string };
|
|
450
|
+
const conn = await connectPglite({ url: sqlUrl });
|
|
451
|
+
try {
|
|
452
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(conn) });
|
|
453
|
+
await adapter.unseal(state.oldMasterKey);
|
|
454
|
+
const failure = await adapter.importBackup(partial).catch((e: unknown) => e);
|
|
455
|
+
expect(isVaultError(failure, "UNSUPPORTED")).toBe(true);
|
|
456
|
+
expect((failure as Error).message).toMatch(/incomplete|checksum|end marker/i);
|
|
457
|
+
} finally {
|
|
458
|
+
await conn.close();
|
|
459
|
+
}
|
|
460
|
+
} finally {
|
|
461
|
+
await rm(dir, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
}, 60_000);
|
|
464
|
+
|
|
465
|
+
test("writeBackupFileAtomic: temp+fsync+rename; final path never holds a partial", async () => {
|
|
466
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-atomic-"));
|
|
467
|
+
const dest = join(dir, "vault.bundle");
|
|
468
|
+
try {
|
|
469
|
+
await resetVaultTables(sharedConn);
|
|
470
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(sharedConn) });
|
|
471
|
+
const init = await adapter.initialize();
|
|
472
|
+
await adapter.unseal(init.masterKey);
|
|
473
|
+
await adapter.set("chaos/atomic", "payload");
|
|
474
|
+
const blob = await adapter.exportBackup();
|
|
475
|
+
expect(
|
|
476
|
+
new TextDecoder().decode(blob.subarray(blob.byteLength - BACKUP_END_MARKER.length)),
|
|
477
|
+
).toBe(BACKUP_END_MARKER);
|
|
478
|
+
|
|
479
|
+
await writeBackupFileAtomic(dest, blob);
|
|
480
|
+
expect(await Bun.file(dest).exists()).toBe(true);
|
|
481
|
+
const onDisk = new Uint8Array(await Bun.file(dest).arrayBuffer());
|
|
482
|
+
expect(onDisk.byteLength).toBe(blob.byteLength);
|
|
483
|
+
expect(Buffer.from(onDisk).equals(Buffer.from(blob))).toBe(true);
|
|
484
|
+
|
|
485
|
+
// No leftover temp next to dest.
|
|
486
|
+
const leftovers = await Array.fromAsync(
|
|
487
|
+
new Bun.Glob("vault.bundle.oke-tmp-*").scan({ cwd: dir }),
|
|
488
|
+
);
|
|
489
|
+
expect(leftovers).toHaveLength(0);
|
|
490
|
+
|
|
491
|
+
await adapter.delete("chaos/atomic");
|
|
492
|
+
await adapter.importBackup(onDisk);
|
|
493
|
+
expect((await adapter.get("chaos/atomic"))?.value).toBe("payload");
|
|
494
|
+
} finally {
|
|
495
|
+
await rm(dir, { recursive: true, force: true });
|
|
496
|
+
}
|
|
497
|
+
}, 30_000);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe("chaos — 5. tampered audit via raw SQL (attacker process)", () => {
|
|
501
|
+
test("separate process mutates oke_vault_audit; verifyAudit detects it", async () => {
|
|
502
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-audit-"));
|
|
503
|
+
const sqlUrl = join(dir, "pgdata");
|
|
504
|
+
const tamperResult = join(dir, "tamper.json");
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const conn = await connectPglite({ url: sqlUrl });
|
|
508
|
+
let masterKey = "";
|
|
509
|
+
try {
|
|
510
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(conn) });
|
|
511
|
+
const init = await adapter.initialize();
|
|
512
|
+
masterKey = init.masterKey;
|
|
513
|
+
await adapter.unseal(masterKey);
|
|
514
|
+
await adapter.set("chaos/audit", "before-tamper");
|
|
515
|
+
await adapter.get("chaos/audit");
|
|
516
|
+
await adapter.seal();
|
|
517
|
+
} finally {
|
|
518
|
+
await conn.close();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const attacker = Bun.spawn({
|
|
522
|
+
cmd: ["bun", childPath, "audit-tamper", sqlUrl, tamperResult],
|
|
523
|
+
stdout: "pipe",
|
|
524
|
+
stderr: "pipe",
|
|
525
|
+
});
|
|
526
|
+
expect(await attacker.exited).toBe(0);
|
|
527
|
+
const tamper = (await Bun.file(tamperResult).json()) as { ok: boolean; victim?: string };
|
|
528
|
+
expect(tamper.ok).toBe(true);
|
|
529
|
+
expect(tamper.victim).toBeString();
|
|
530
|
+
|
|
531
|
+
const reopen = await connectPglite({ url: sqlUrl });
|
|
532
|
+
try {
|
|
533
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(reopen) });
|
|
534
|
+
const result = await adapter.verifyAudit();
|
|
535
|
+
expect(result.ok).toBe(false);
|
|
536
|
+
expect(result.brokenAt).toBe(tamper.victim);
|
|
537
|
+
} finally {
|
|
538
|
+
await reopen.close();
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
await rm(dir, { recursive: true, force: true });
|
|
542
|
+
}
|
|
543
|
+
}, 60_000);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe("chaos — 6. multi-instance safety (dual adapter, shared PGlite)", () => {
|
|
547
|
+
test("two adapters concurrent set/get: secret values intact AND audit chain verifies", async () => {
|
|
548
|
+
await resetVaultTables(sharedConn);
|
|
549
|
+
const db = sqlConnectionAsExec(sharedConn);
|
|
550
|
+
const a = createBuiltinVaultAdapter({ db });
|
|
551
|
+
const b = createBuiltinVaultAdapter({ db });
|
|
552
|
+
const init = await a.initialize();
|
|
553
|
+
await a.unseal(init.masterKey);
|
|
554
|
+
await b.unseal(init.masterKey);
|
|
555
|
+
|
|
556
|
+
const writers = Array.from({ length: 20 }, (_, i) => {
|
|
557
|
+
const adapter = i % 2 === 0 ? a : b;
|
|
558
|
+
return adapter.set(`chaos/multi-${i}`, `val-${i}`);
|
|
559
|
+
});
|
|
560
|
+
await Promise.all(writers);
|
|
561
|
+
|
|
562
|
+
// Crypto path: concurrent writers must not tear secret envelopes.
|
|
563
|
+
for (let i = 0; i < 20; i += 1) {
|
|
564
|
+
const secret = await a.get(`chaos/multi-${i}`);
|
|
565
|
+
expect(secret?.value).toBe(`val-${i}`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const audit = await a.verifyAudit();
|
|
569
|
+
console.log(`[vault multi-adapter audit] ok=${audit.ok} brokenAt=${audit.brokenAt ?? "-"}`);
|
|
570
|
+
// Audit path: createSqlAuditWriter documents that concurrent appends must
|
|
571
|
+
// be serialized — without FOR UPDATE / lease, racing prev_hash breaks the
|
|
572
|
+
// chain (same exclusivity class as Signal competing consumers).
|
|
573
|
+
expect(audit.ok).toBe(true);
|
|
574
|
+
}, 30_000);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe.skipIf(!LIVE_URL)("chaos — 6b. multi-instance safety (multi-process Postgres)", () => {
|
|
578
|
+
test("two OS processes write distinct secrets; all readable; audit verifies", async () => {
|
|
579
|
+
const url = LIVE_URL!;
|
|
580
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-vault-chaos-multi-"));
|
|
581
|
+
const donePath = join(dir, "done.jsonl");
|
|
582
|
+
|
|
583
|
+
const conn = await connectPostgres({ url });
|
|
584
|
+
try {
|
|
585
|
+
await resetVaultTables(conn);
|
|
586
|
+
const adapter = createBuiltinVaultAdapter({ db: sqlConnectionAsExec(conn) });
|
|
587
|
+
const init = await adapter.initialize();
|
|
588
|
+
await adapter.unseal(init.masterKey);
|
|
589
|
+
await adapter.seal();
|
|
590
|
+
|
|
591
|
+
const a = Bun.spawn({
|
|
592
|
+
cmd: [
|
|
593
|
+
"bun",
|
|
594
|
+
childPath,
|
|
595
|
+
"set-race",
|
|
596
|
+
url,
|
|
597
|
+
init.masterKey,
|
|
598
|
+
"inst-a",
|
|
599
|
+
"chaos/mp",
|
|
600
|
+
"12",
|
|
601
|
+
donePath,
|
|
602
|
+
],
|
|
603
|
+
stdout: "pipe",
|
|
604
|
+
stderr: "pipe",
|
|
605
|
+
});
|
|
606
|
+
const b = Bun.spawn({
|
|
607
|
+
cmd: [
|
|
608
|
+
"bun",
|
|
609
|
+
childPath,
|
|
610
|
+
"set-race",
|
|
611
|
+
url,
|
|
612
|
+
init.masterKey,
|
|
613
|
+
"inst-b",
|
|
614
|
+
"chaos/mp",
|
|
615
|
+
"12",
|
|
616
|
+
donePath,
|
|
617
|
+
],
|
|
618
|
+
stdout: "pipe",
|
|
619
|
+
stderr: "pipe",
|
|
620
|
+
});
|
|
621
|
+
expect(await a.exited).toBe(0);
|
|
622
|
+
expect(await b.exited).toBe(0);
|
|
623
|
+
|
|
624
|
+
const done = await readJsonl(donePath);
|
|
625
|
+
expect(done).toHaveLength(2);
|
|
626
|
+
|
|
627
|
+
await adapter.unseal(init.masterKey);
|
|
628
|
+
for (const inst of ["inst-a", "inst-b"] as const) {
|
|
629
|
+
for (let i = 0; i < 12; i += 1) {
|
|
630
|
+
const secret = await adapter.get(`chaos/mp/${inst}-${i}`);
|
|
631
|
+
expect(secret?.value).toBe(`${inst}-value-${i}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const audit = await adapter.verifyAudit();
|
|
635
|
+
console.log(
|
|
636
|
+
`[vault pg multi-process audit] ok=${audit.ok} brokenAt=${audit.brokenAt ?? "-"}`,
|
|
637
|
+
);
|
|
638
|
+
expect(audit.ok).toBe(true);
|
|
639
|
+
} finally {
|
|
640
|
+
await resetVaultTables(conn).catch(() => undefined);
|
|
641
|
+
await conn.close();
|
|
642
|
+
await rm(dir, { recursive: true, force: true });
|
|
643
|
+
}
|
|
644
|
+
}, 60_000);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe.skipIf(Boolean(LIVE_URL))("chaos — postgres multi-process (skipped)", () => {
|
|
648
|
+
test("skip: vault multi-process chaos (set OKE_TEST_POSTGRES_URL or OKE_TEST_POSTGRES=1 + DATABASE_URL)", () => {
|
|
649
|
+
expect(LIVE_URL).toBeUndefined();
|
|
650
|
+
});
|
|
651
|
+
});
|
|
@@ -96,7 +96,7 @@ export async function withResilience<T>(
|
|
|
96
96
|
* @param config - Retry tunables
|
|
97
97
|
*/
|
|
98
98
|
export function createResilientSqlExec(db: SqlExec, config: RetryConfig = DEFAULT_RETRY): SqlExec {
|
|
99
|
-
|
|
99
|
+
const wrapped: SqlExec = {
|
|
100
100
|
query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
101
101
|
return withResilience(() => db.query<T>(sql, params), config);
|
|
102
102
|
},
|
|
@@ -104,4 +104,9 @@ export function createResilientSqlExec(db: SqlExec, config: RetryConfig = DEFAUL
|
|
|
104
104
|
return withResilience(() => db.execute(sql, params), config);
|
|
105
105
|
},
|
|
106
106
|
};
|
|
107
|
+
if (db.begin) {
|
|
108
|
+
wrapped.begin = <T>(fn: (tx: SqlExec) => Promise<T>): Promise<T> =>
|
|
109
|
+
db.begin!(async (tx) => fn(createResilientSqlExec(tx, config)));
|
|
110
|
+
}
|
|
111
|
+
return wrapped;
|
|
107
112
|
}
|