Beds for the vault and for genesis's root secrets #39

Merged
jschoubben merged 5 commits from feat/secrets-vault into main 2026-09-21 08:03:22 +00:00
Showing only changes of commit 9da2d01ca0 - Show all commits
+74 -3
View File
@@ -109,9 +109,9 @@ const CONTROL_PLANE = { module: "mesh-controller", repo: "mesh-controller", path
* is not one.
*/
const MUST_HOLD = ["mesh-controller", "distribution", "builder", "mesh-tools", "postgres",
"mesh-catalog", "lavinmq", "amqp-ping"];
"mesh-catalog", "lavinmq", "mesh-vault", "amqp-ping"];
const MUST_RUN = ["mesh-controller", "mesh-registry", "mesh-broker", "mesh-store",
"mesh-postgres", "mesh-catalog", "mesh-lavinmq", "amqp-ping"];
"mesh-postgres", "mesh-catalog", "mesh-lavinmq", "mesh-vault", "amqp-ping"];
/** Named once, because the step title is also how later steps say what they waited on. */
const NEEDS = "the mesh runs a broker for that module to talk to";
const GENESIS = "a bare machine becomes a mesh of one, raised by the installer";
@@ -133,6 +133,7 @@ const DESCRIBES = "the control plane can describe the mesh, and what it says is
const CATALOGUED = "the catalogue holds every module this mesh built";
const NETWORK = "the machine's networking is what the modules asked for";
const DECLARED = "every resource the mesh declared is true on the machine";
const ROOT_SECRETS = "the root secrets are the mesh's own, sealed to an operator key, and a person can recover them";
const FOLLOWS = "a change to a module's source reaches the machine on its own";
const STORE_UPGRADES = "the store is upgraded in place, and the controller reads it through the window";
const BROKER_UPGRADES = "the broker is upgraded in place, and the mesh talks over the window";
@@ -798,10 +799,13 @@ before(async () => {
// broker's loopback, reached by joining its network namespace.
const image = (await on(CONTROL,
`docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim();
// The administrator's password is the one genesis made, kept where the lavinmq module's own
// secret lives (novox/hq issue 071) — the image's default no longer opens the broker.
const adminPassword = (await must(CONTROL, `cat /var/lib/lavinmq-module/admin.secret`)).trim();
const ask = async (tool: string, args = "{}") =>
must(CONTROL,
`docker run --rm --network container:mesh-broker ` +
`-e MESH_BROKER_URL=amqp://guest:guest@127.0.0.1:5672/ ` +
`-e MESH_BROKER_URL=amqp://guest:${encodeURIComponent(adminPassword)}@127.0.0.1:5672/ ` +
`${image} invoke mesh-catalog ${tool} ${quote(args)}`,
120_000);
@@ -961,6 +965,73 @@ before(async () => {
].filter(Boolean).join("\n");
});
// ---- V5. AND ITS ROOT SECRETS ARE ITS OWN ------------------------------------------------------
//
// novox/hq ADR 0085 (amended), issue 071. The template raises the store and broker with fixed
// credentials; the installer replaces them with values it made, seals every secret a module
// holds for itself to an operator key it made first, installs the vault to keep those copies,
// and writes the export beside the key. Checked from outside every container — a login over
// loopback inside the store's container is trusted and proves nothing (design 13).
await step("V5", ROOT_SECRETS, DECLARED, async () => {
const said: string[] = [];
const storeImage = (await must(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-store`)).trim();
const psql = async (password: string) =>
on(CONTROL,
`docker run --rm --network host ${storeImage} psql ` +
`${quote(`postgresql://postgres:${encodeURIComponent(password)}@127.0.0.1:5432/postgres?sslmode=disable`)} -tAc 'select 1'`,
60_000);
// 1. The template's password does not open the store.
const stale = await psql("bootstrap");
assert.ok(!stale.ok || !/^1$/m.test(stale.out), `the template's password still opens the store:\n${stale.out}`);
said.push(` bootstrap refused by the store`);
// 2. The operator key and the export are beside the bundle, and only root can read them.
for (const f of ["/var/lib/mesh-host/operator.key", "/var/lib/mesh-host/root-secrets.export.json", "/var/lib/mesh-host/foundation.lock"]) {
const mode = (await must(CONTROL, `stat -c %a ${f}`)).trim();
assert.equal(mode, "600", `${f} is mode ${mode}`);
}
const exported = await must(CONTROL, `cat /var/lib/mesh-host/root-secrets.export.json`);
const doc = JSON.parse(exported) as { kept: { node: string; module: string; name: string; origin: string }[]; unrecoverable?: unknown[] };
for (const want of [["postgres", "superuser"], ["lavinmq", "admin"], ["mesh-controller", "inventory"]]) {
assert.ok(doc.kept.some((k) => k.module === want[0] && k.name === want[1]),
`the export lacks ${want.join("/")}:\n${doc.kept.map((k) => `${k.module}/${k.name}`).join(" ")}`);
}
const superuser = (await must(CONTROL, `cat /var/lib/postgres/superuser.secret`)).trim();
const admin = (await must(CONTROL, `cat /var/lib/lavinmq-module/admin.secret`)).trim();
assert.ok(!exported.includes(superuser) && !exported.includes(admin), "the export holds a plaintext root secret");
said.push(` exported ${doc.kept.length} secret(s) sealed to the operator key; ${(doc.unrecoverable ?? []).length} not recoverable`);
// 3. The vault keeps the same export on its own disk.
const kept = await must(CONTROL, `cat /var/lib/mesh-vault/root/export.json`);
assert.ok(kept.includes('"kept"') && !kept.includes(superuser), "the vault's copy is missing or holds plaintext");
said.push(` vault keeps the export at /var/lib/mesh-vault/root/export.json`);
// 4. A person with the key recovers the store's superuser from the export alone — off the
// mesh, in a throwaway container with no store or broker in reach — and it opens the store.
// The copies are relaxed to a scratch directory for the test only: the operator's real
// files stay root-owned at 0600 above.
const controllerImage = (await must(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-controller`)).trim();
await must(CONTROL, `rm -rf /tmp/operator && mkdir -p /tmp/operator && chmod 777 /tmp/operator && ` +
`cp /var/lib/mesh-host/operator.key /var/lib/mesh-host/root-secrets.export.json /tmp/operator/ && chmod 644 /tmp/operator/*`);
const recover = async (module: string, name: string) => {
await must(CONTROL,
`docker run --rm -v /tmp/operator:/work --entrypoint /mesh-controller ${controllerImage} ` +
`secret recover ${CONTROL} ${module} ${name} --key /work/operator.key --from-export /work/root-secrets.export.json --out /work/${module}.${name}`,
120_000);
return (await must(CONTROL, `cat /tmp/operator/${module}.${name}`)).replace(/\n$/, "");
};
const recoveredSuperuser = await recover("postgres", "superuser");
assert.equal(recoveredSuperuser, superuser, "the recovered superuser is not the one the store was raised with");
const opened = await psql(recoveredSuperuser);
assert.ok(opened.ok && /^1$/m.test(opened.out), `the recovered superuser does not open the store:\n${opened.out}`);
said.push(` recovered postgres/superuser with the operator key, and it opens the store`);
const recoveredAdmin = await recover("lavinmq", "admin");
assert.equal(recoveredAdmin, admin, "the recovered broker admin is not the one genesis made");
said.push(` recovered lavinmq/admin with the operator key — the broker's, as V2 dialled it`);
return said.join("\n");
});
// ---- 11. A CHANGE REACHES THE MACHINE ON ITS OWN ----------------------------------------------
//
// The whole point of the mesh, and the capability the migration depends on: move a module's