From 82e513a360e59a55280bd8bc3fe317eed1613a7f Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 20 Sep 2026 23:08:28 +0200 Subject: [PATCH] Add the mesh-vault module; redis takes its password from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-vault provides `secret` (novox/hq ADR 0085, design 24). The value is the pair credential the controller mints — the vault holds no copy, only a ledger of who holds one, its fingerprint and every rotation, and two tools that answer by fingerprint and never by value. Rotation is `rotate secret`, unchanged machinery pointed at a secret with an owner (design 13). Named in the mesh's own namespace, beside mesh-controller and mesh-catalog, because it is the mesh's own code rather than wrapped software. redis is the first consumer: its own password stops being an own-secret nothing could rotate and becomes a `secret` it requires, read from the same file into the same hole. The server now restarts on its config, or it would keep the password it started with through every rotation (playbook 06). --- modules/lavinmq/module.json | 6 +- modules/mesh-vault/Dockerfile | 22 +++ modules/mesh-vault/client.ts | 172 ++++++++++++++++++++++++ modules/mesh-vault/index.ts | 31 +++++ modules/mesh-vault/module.json | 105 +++++++++++++++ modules/mesh-vault/package.json | 17 +++ modules/mesh-vault/provisioner/index.ts | 48 +++++++ modules/mesh-vault/test/ledger.test.ts | 80 +++++++++++ modules/mesh-vault/tools/index.ts | 131 ++++++++++++++++++ modules/mesh-vault/tsconfig.json | 12 ++ modules/postgres/module.json | 5 +- modules/redis/module.json | 12 +- 12 files changed, 635 insertions(+), 6 deletions(-) create mode 100644 modules/mesh-vault/Dockerfile create mode 100644 modules/mesh-vault/client.ts create mode 100644 modules/mesh-vault/index.ts create mode 100644 modules/mesh-vault/module.json create mode 100644 modules/mesh-vault/package.json create mode 100644 modules/mesh-vault/provisioner/index.ts create mode 100644 modules/mesh-vault/test/ledger.test.ts create mode 100644 modules/mesh-vault/tools/index.ts create mode 100644 modules/mesh-vault/tsconfig.json diff --git a/modules/lavinmq/module.json b/modules/lavinmq/module.json index a2afd95..1437ad1 100644 --- a/modules/lavinmq/module.json +++ b/modules/lavinmq/module.json @@ -36,6 +36,7 @@ "amqp": "/var/lib/lavinmq-module/grants" }, "own-secrets": { + "admin": "/var/lib/lavinmq-module/admin.secret", "broker": "/var/lib/mesh/lavinmq/broker" }, "listens": [ @@ -99,14 +100,15 @@ "network": "host", "volumes": [ "/var/lib/mesh/lavinmq/broker:/run/secrets/broker:ro", - "/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro" + "/var/lib/lavinmq-module/grants:/var/lib/lavinmq-module/grants:ro", + "/var/lib/lavinmq-module/admin.secret:/run/secrets/admin:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/lavinmq-module/grants/mesh.json", "MESH_PROVISION_LAVINMQ": "http://127.0.0.1:15672", "MESH_PROVISION_ADMIN_USER": "guest", - "MESH_LAVINMQ_ADMIN_PASSWORD": "guest" + "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin" } } ], diff --git a/modules/mesh-vault/Dockerfile b/modules/mesh-vault/Dockerfile new file mode 100644 index 0000000..ffe09b0 --- /dev/null +++ b/modules/mesh-vault/Dockerfile @@ -0,0 +1,22 @@ +# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event +# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no +# server, because what it provides is a value the mesh already delivered to its node. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout (novox/hq ADR 0069). Two bases, named rather than +# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from +# `build.on` in module.json (novox/hq issue 044). +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +WORKDIR /app/modules/vault +COPY . . +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/vault/dist /app/modules/vault/dist +# The entrypoints a tool host loads from this module: its event consumer, its tools and its +# provisioner — one image, one process, one broker account (novox/hq ADR 0052). +ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js diff --git a/modules/mesh-vault/client.ts b/modules/mesh-vault/client.ts new file mode 100644 index 0000000..c322db2 --- /dev/null +++ b/modules/mesh-vault/client.ts @@ -0,0 +1,172 @@ +// mesh-vault's ledger — vault's own code, living in the module (novox/hq ADR 0039). The provisioner and +// the tools both import it, and nothing outside vault does. +// +// **The vault holds no value.** A `secret` is an ordinary pair credential: the controller mints it, +// seals it to the consumer's node and to this one, and the host unseals this node's copy into the +// file the contribution names (ADR 0048). That file is already on this machine, readable by nothing +// but the vault's runtime, and it is the only copy the vault ever sees. Writing a second copy — +// plain, or sealed to a key the vault keeps — would put back exactly the single place that can open +// everything, which is what sealing to the machine was built to remove (ADR 0085's open question is +// how to recover WITHOUT that; the answer is not "keep one anyway"). +// +// So what the vault keeps is what makes a secret *owned* rather than merely delivered: who holds +// one, since when, its fingerprint, and every time it changed. Enough to say "this holder's value is +// the one the mesh last delivered" and "it has been rotated twice, last on Tuesday" — and never +// enough to say what it is. The fingerprint is the only thing a tool may take or return, which is +// the rule the source mesh's secret tools were built on: the secret is never an argument. + +import { createHash } from "node:crypto"; +import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** One holder of a secret this vault provides — everything the vault knows, and no value. */ +export interface Held { + /** The login the mesh derived for the consumer — `-`, so it names the holder. */ + readonly as: string; + /** The consumer's node. */ + readonly consumer: string; + /** sha256 of the value the mesh last delivered, `sha256:`. Compared, never inverted. */ + readonly fingerprint: string; + /** Length of the value, so a holder can tell a truncated file from a wrong one. */ + readonly length: number; + /** When this holder was first granted a secret. */ + readonly since: string; + /** When the value last changed — equal to `since` until the first rotation. */ + readonly changed: string; + /** How many times the value has changed since `since`. */ + readonly rotations: number; + /** Every earlier fingerprint, oldest first: the audit trail a rotation leaves. */ + readonly history: readonly { readonly fingerprint: string; readonly until: string }[]; +} + +/** What recording a delivery found: a new holder, a changed value, or nothing new. */ +export type Outcome = "granted" | "rotated" | "unchanged"; + +/** sha256 of a value, as `sha256:`. The one thing about a secret that may be spoken. */ +export function fingerprint(value: string): string { + return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex"); +} + +export class Ledger { + private readonly dir: string; + + constructor(dir: string) { + this.dir = dir; + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + /** Build from the module's resolved environment: $MESH_VAULT_LEDGER is where holders are kept. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): Ledger { + const dir = env.MESH_VAULT_LEDGER; + if (!dir) { + throw new Error("MESH_VAULT_LEDGER is not set — the vault has nowhere to keep its ledger"); + } + return new Ledger(dir); + } + + /** + * Record that the mesh delivered `value` for `as`. Idempotent: the same value again changes + * nothing, a different value is a rotation and is remembered as one. The value is fingerprinted + * here and goes no further. + */ + record(as: string, consumer: string, value: string, now = new Date()): { held: Held; outcome: Outcome } { + const fp = fingerprint(value); + const at = now.toISOString(); + const before = this.get(as); + if (!before) { + const held: Held = { + as, consumer, fingerprint: fp, length: value.length, + since: at, changed: at, rotations: 0, history: [], + }; + this.write(held); + return { held, outcome: "granted" }; + } + if (before.fingerprint === fp && before.length === value.length) { + return { held: before, outcome: "unchanged" }; + } + const held: Held = { + ...before, consumer, fingerprint: fp, length: value.length, changed: at, + rotations: before.rotations + 1, + history: [...before.history, { fingerprint: before.fingerprint, until: at }], + }; + this.write(held); + return { held, outcome: "rotated" }; + } + + /** Forget a holder the mesh withdrew. Returns whether there was one to forget. */ + withdraw(as: string): boolean { + try { + unlinkSync(this.pathOf(as)); + return true; + } catch { + return false; + } + } + + get(as: string): Held | undefined { + try { + return JSON.parse(readFileSync(this.pathOf(as), "utf8")) as Held; + } catch { + return undefined; + } + } + + /** Every holder, by login. */ + list(): Held[] { + let names: string[]; + try { + names = readdirSync(this.dir); + } catch { + return []; + } + return names + .filter((n) => n.endsWith(".json")) + .map((n) => this.get(n.slice(0, -".json".length))) + .filter((h): h is Held => h !== undefined) + .sort((a, b) => a.as.localeCompare(b.as)); + } + + private pathOf(as: string): string { + if (!/^[a-z0-9][a-z0-9_.-]*$/.test(as)) { + throw new Error(`a login is a name, not a path: ${JSON.stringify(as)}`); + } + return join(this.dir, `${as}.json`); + } + + /** Written whole and renamed into place, so a reader never sees half a record. */ + private write(held: Held): void { + const final = this.pathOf(held.as); + const tmp = `${final}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(held, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, final); + } +} + +/** One entry of the mesh's contributions file, as the vault reads it for its tools. */ +export interface Contribution { + readonly from?: string; + readonly node?: string; + readonly as: string; + readonly secret: string; +} + +/** The consumers the mesh currently asks this vault to serve — the `receives` file, read plainly. */ +export function contributions(receives: string): Contribution[] { + let doc: { given?: Contribution[] }; + try { + doc = JSON.parse(readFileSync(receives, "utf8")) as { given?: Contribution[] }; + } catch { + return []; + } + return (doc.given ?? []).filter((g) => g.as && g.secret); +} + +/** Fingerprint of the value the host currently holds for one contribution, or why it could not. */ +export function deliveredFingerprint(c: Contribution): { fingerprint: string; length: number } | { error: string } { + try { + const value = readFileSync(c.secret, "utf8").replace(/\n$/, ""); + return { fingerprint: fingerprint(value), length: value.length }; + } catch (err) { + return { error: `the delivered secret is not readable: ${err}` }; + } +} diff --git a/modules/mesh-vault/index.ts b/modules/mesh-vault/index.ts new file mode 100644 index 0000000..6ef05e3 --- /dev/null +++ b/modules/mesh-vault/index.ts @@ -0,0 +1,31 @@ +// mesh-vault's events entrypoint, loaded by the per-node tool host (the provisioner runs in the same +// process — ADR 0052). The lifecycle events are EMITTED from the provisioner, where custody +// actually changes (novox/hq ADR 0041/0042): +// module.mesh-vault.secret.provisioned — a consumer was granted a secret +// module.mesh-vault.secret.rotated — that consumer's value changed (`rotate secret`) +// module.mesh-vault.secret.deprovisioned — the consumer went away and its secret was withdrawn +// Here the vault reacts to them, keeping a lightweight audit line of who holds what and when it +// moved — the audit an owner of secrets is best placed to log. Fingerprints, never values. + +import { on } from "@novox/mesh-sdk/events"; + +interface SecretEvent { + as: string; + consumer?: string; + fingerprint?: string; + rotations?: number; +} + +await on("module.mesh-vault.secret.provisioned", async (e) => { + console.log(`[mesh-vault] secret provisioned for ${e.body.as} on ${e.body.consumer} (${e.body.fingerprint})`); +}); + +await on("module.mesh-vault.secret.rotated", async (e) => { + console.log(`[mesh-vault] secret rotated for ${e.body.as} — rotation ${e.body.rotations} (${e.body.fingerprint})`); +}); + +await on("module.mesh-vault.secret.deprovisioned", async (e) => { + console.log(`[mesh-vault] secret withdrawn from ${e.body.as}`); +}); + +console.log("[mesh-vault] auditing secret lifecycle events"); diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json new file mode 100644 index 0000000..a875fbe --- /dev/null +++ b/modules/mesh-vault/module.json @@ -0,0 +1,105 @@ +{ + "module": "mesh-vault", + "version": "1", + "provides": [ + { + "name": "secret", + "scope": "mesh" + } + ], + "capabilities": [ + "container-runtime" + ], + "emits": [ + "module.mesh-vault.secret.provisioned", + "module.mesh-vault.secret.rotated", + "module.mesh-vault.secret.deprovisioned" + ], + "consumes": [ + "module.mesh-vault.secret.provisioned", + "module.mesh-vault.secret.rotated", + "module.mesh-vault.secret.deprovisioned" + ], + "receives": { + "secret": "/var/lib/mesh-vault/grants/mesh.json" + }, + "grants": { + "secret": "/var/lib/mesh-vault/grants" + }, + "keeps": "/var/lib/mesh-vault/root", + "own-secrets": { + "broker": "/var/lib/mesh/mesh-vault/broker" + }, + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/mesh-vault", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/mesh-vault", + "mode": "0700" + }, + { + "id": "grants", + "type": "directory", + "path": "/var/lib/mesh-vault/grants", + "mode": "0700" + }, + { + "id": "ledger", + "type": "directory", + "path": "/var/lib/mesh-vault/ledger", + "mode": "0700" + }, + { + "id": "root", + "type": "directory", + "path": "/var/lib/mesh-vault/root", + "mode": "0700" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-vault", + "network": "host", + "volumes": [ + "/var/lib/mesh/mesh-vault/broker:/run/secrets/broker:ro", + "/var/lib/mesh-vault/grants:/var/lib/mesh-vault/grants:ro", + "/var/lib/mesh-vault/ledger:/var/lib/mesh-vault/ledger", + "/var/lib/mesh-vault/root:/var/lib/mesh-vault/root:ro" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_RECEIVES": "/var/lib/mesh-vault/grants/mesh.json", + "MESH_VAULT_LEDGER": "/var/lib/mesh-vault/ledger", + "MESH_VAULT_ROOT": "/var/lib/mesh-vault/root" + }, + "artifact": "runtime" + } + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } +} diff --git a/modules/mesh-vault/package.json b/modules/mesh-vault/package.json new file mode 100644 index 0000000..585de85 --- /dev/null +++ b/modules/mesh-vault/package.json @@ -0,0 +1,17 @@ +{ + "name": "@novox/module-mesh-vault", + "version": "0.1.0", + "description": "mesh-vault — provides the mesh `secret` interface: a module's own secret as an ordinary pair credential, held, audited and rotated like any other (novox/hq ADR 0085). Its ledger, provisioner, tools and events live here (ADR 0039).", + "type": "module", + "private": true, + "scripts": { + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/mesh-vault/provisioner/index.ts b/modules/mesh-vault/provisioner/index.ts new file mode 100644 index 0000000..010d78e --- /dev/null +++ b/modules/mesh-vault/provisioner/index.ts @@ -0,0 +1,48 @@ +// mesh-vault's provisioner — the adapter that makes vault a provider of the mesh `secret` interface. The +// reconcile loop, the contributions file, and reading the mesh's minted value are the sdk harness's; +// this writes only the per-service half (novox/hq ADR 0039/0040/0048) — and for a vault that half is +// taking custody, not creating anything. +// +// The `secret` interface (ADR 0085, design 24): a consumer requires a value for its own use — the +// password of a store it runs privately, an internal token — and reads it from the file the mesh +// writes on its machine. There is no server to create a login on. **The value is the pair +// credential itself**: the controller minted it, sealed it to both nodes, and delivered each its +// copy. What makes it *owned* is this: the vault records who holds it and its fingerprint, notices +// when `rotate secret` delivers a different one, and says so on the mesh. Rotation is not new +// machinery — it is the machinery that already moves a database password, pointed at a secret the +// vault provides (design 13). + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { emit } from "@novox/mesh-sdk/events"; +import { Ledger } from "../client.js"; + +const ledger = Ledger.fromEnv(); + +/** Emit a lifecycle event without letting a broker hiccup fail the custody itself. */ +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[provisioner:secret] emit ${type} failed: ${err}`); + } +} + +runProvisioner("secret", { + async create(p: Provision): Promise { + const { held, outcome } = ledger.record(p.as, p.consumer ?? "", p.password); + if (outcome === "unchanged") return; // the harness re-runs create on restart; nothing happened + console.log(`[mesh-vault] ${outcome}: ${held.as} (${held.fingerprint.slice(0, 19)}…, rotations ${held.rotations})`); + await announce(`module.mesh-vault.secret.${outcome === "granted" ? "provisioned" : "rotated"}`, { + consumer: held.consumer, + as: held.as, + fingerprint: held.fingerprint, + rotations: held.rotations, + }); + }, + + async remove(p: { as: string }): Promise { + if (!ledger.withdraw(p.as)) return; + console.log(`[mesh-vault] withdrawn: ${p.as}`); + await announce("module.mesh-vault.secret.deprovisioned", { as: p.as }); + }, +}); diff --git a/modules/mesh-vault/test/ledger.test.ts b/modules/mesh-vault/test/ledger.test.ts new file mode 100644 index 0000000..e2ca648 --- /dev/null +++ b/modules/mesh-vault/test/ledger.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Ledger, fingerprint, contributions, deliveredFingerprint } from "../client.ts"; + +function fresh(): Ledger { + return new Ledger(mkdtempSync(join(tmpdir(), "vault-ledger-"))); +} + +test("a first delivery is a grant, the same value again is nothing, a new value is a rotation", () => { + const ledger = fresh(); + const t0 = new Date("2026-09-20T10:00:00Z"); + const t1 = new Date("2026-09-21T10:00:00Z"); + + const granted = ledger.record("anchor-redis", "anchor", "first-value", t0); + assert.equal(granted.outcome, "granted"); + assert.equal(granted.held.rotations, 0); + assert.equal(granted.held.since, t0.toISOString()); + assert.equal(granted.held.fingerprint, fingerprint("first-value")); + + assert.equal(ledger.record("anchor-redis", "anchor", "first-value", t1).outcome, "unchanged"); + assert.equal(ledger.get("anchor-redis")!.rotations, 0, "an unchanged delivery counted as a rotation"); + + const rotated = ledger.record("anchor-redis", "anchor", "second-value", t1); + assert.equal(rotated.outcome, "rotated"); + assert.equal(rotated.held.rotations, 1); + assert.equal(rotated.held.since, t0.toISOString(), "a rotation reset the grant date"); + assert.equal(rotated.held.changed, t1.toISOString()); + assert.equal(rotated.held.fingerprint, fingerprint("second-value")); + assert.deepEqual(rotated.held.history, [{ fingerprint: fingerprint("first-value"), until: t1.toISOString() }]); +}); + +test("the ledger holds fingerprints and never the value, in files nobody else can read", () => { + const dir = mkdtempSync(join(tmpdir(), "vault-ledger-")); + const ledger = new Ledger(dir); + ledger.record("anchor-redis", "anchor", "the-actual-password", new Date()); + ledger.record("anchor-redis", "anchor", "the-rotated-password", new Date()); + for (const name of readdirSync(dir)) { + const raw = readFileSync(join(dir, name), "utf8"); + assert.doesNotMatch(raw, /the-actual-password|the-rotated-password/, `${name} holds a value`); + assert.equal(statSync(join(dir, name)).mode & 0o777, 0o600, `${name} is readable by others`); + } +}); + +test("withdrawing forgets a holder, and listing is by login", () => { + const ledger = fresh(); + ledger.record("b-app", "b", "x", new Date()); + ledger.record("a-app", "a", "y", new Date()); + assert.deepEqual(ledger.list().map((h) => h.as), ["a-app", "b-app"]); + assert.equal(ledger.withdraw("a-app"), true); + assert.equal(ledger.withdraw("a-app"), false, "withdrawing twice said it found something"); + assert.deepEqual(ledger.list().map((h) => h.as), ["b-app"]); +}); + +test("a login is a name, not a path", () => { + const ledger = fresh(); + assert.throws(() => ledger.record("../etc/passwd", "n", "v"), /a login is a name/); +}); + +test("what the mesh delivers is read from the contributions file and fingerprinted, never returned", () => { + const dir = mkdtempSync(join(tmpdir(), "vault-grants-")); + const secret = join(dir, "anchor.redis.secret"); + writeFileSync(secret, "minted-value\n"); // the host may leave a trailing newline; the value has none + const receives = join(dir, "mesh.json"); + writeFileSync(receives, JSON.stringify({ + requirement: "secret", + given: [ + { from: "redis", node: "anchor", as: "anchor-redis", secret }, + { from: "offer-only", node: "anchor" }, // a contribution with no login grants nothing + ], + })); + const asked = contributions(receives); + assert.deepEqual(asked.map((c) => c.as), ["anchor-redis"]); + const seen = deliveredFingerprint(asked[0]); + assert.deepEqual(seen, { fingerprint: fingerprint("minted-value"), length: "minted-value".length }); + assert.match(JSON.stringify(deliveredFingerprint({ as: "x", secret: join(dir, "missing") })), /not readable/); +}); diff --git a/modules/mesh-vault/tools/index.ts b/modules/mesh-vault/tools/index.ts new file mode 100644 index 0000000..c6c533b --- /dev/null +++ b/modules/mesh-vault/tools/index.ts @@ -0,0 +1,131 @@ +// mesh-vault's tools — vault's own code (novox/hq ADR 0039), served through the sdk's tool harness. They +// return structured data about the secrets this vault provides, and **never a value**: a holder is +// identified by its login and a value by its fingerprint. That is the rule the source mesh's +// secret_locate / secret_verify were built on, after a secret printed into a transcript. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { Ledger, contributions, deliveredFingerprint, type Held } from "../client.js"; + +/** The mesh's export of every operator-sealed secret, as the mesh wrote it into the root dir. */ +interface KeptExport { + export: number; + "operator-key": string; + fingerprint: string; + kept: { node: string; module: string; name: string; origin: string; sealed: string; key: string; "made-at": string }[]; + unrecoverable?: { node: string; module: string; name: string }[]; +} + +export function getVaultTools(ledger: Ledger, receives: string | undefined, root: string | undefined): ToolDefinition[] { + return [ + { + name: "secret_export", + description: + "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " + + "sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can " + + "open a line of it; the operator, holding the private key off the mesh, recovers one with " + + "`mesh-controller secret recover --from-export`. Also lists what is NOT recoverable: secrets " + + "made before the mesh had an operator key.", + input: { + sealed: { + type: "boolean", + description: "include the sealed blobs (default true); false lists holders and the key only", + }, + }, + run: async (args) => { + if (!root) return { available: false, error: "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)" }; + let doc: KeptExport; + try { + doc = JSON.parse(readFileSync(join(root, "export.json"), "utf8")) as KeptExport; + } catch (err) { + return { available: false, error: `the mesh has not written an export here yet: ${err}` }; + } + const withBlobs = args.sealed !== false; + return { + available: true, + export: doc.export, + "operator-key": doc["operator-key"], + fingerprint: doc.fingerprint, + count: doc.kept.length, + kept: doc.kept.map((k) => (withBlobs ? k : { node: k.node, module: k.module, name: k.name, origin: k.origin, "made-at": k["made-at"] })), + unrecoverable: doc.unrecoverable ?? [], + }; + }, + }, + { + name: "secret_holders", + description: + "Who holds a secret from this vault: each consumer's login, node and module, when it was " + + "granted, how many times it has been rotated and when, and the fingerprint of the current " + + "value. Fingerprints only — the value is never returned.", + input: {}, + run: async () => { + const asked = receives ? contributions(receives) : []; + const holders = ledger.list().map((h) => ({ + ...h, + module: asked.find((c) => c.as === h.as)?.from ?? null, + asked: asked.some((c) => c.as === h.as), + })); + return { holders, count: holders.length }; + }, + }, + { + name: "secret_verify", + description: + "Check one holder's secret without seeing it: the fingerprint the vault recorded against " + + "the fingerprint of the value the mesh currently delivers here, and optionally against a " + + "fingerprint computed on the holder's own machine (sha256 of the file, as `sha256:`). " + + "Two ends agreeing proves they agree, not that either works — the login itself is the test.", + input: { + as: { type: "string", description: "the holder's login, e.g. anchor-redis" }, + fingerprint: { + type: "string", + description: "optional: sha256: of the value as the holder reads it, computed there — never the value", + }, + }, + run: async (args) => { + const as = String(args.as ?? ""); + const recorded = ledger.get(as); + if (!recorded) return { as, known: false, error: `this vault holds nothing for ${as}` }; + const asked = receives ? contributions(receives).find((c) => c.as === as) : undefined; + const delivered = asked ? deliveredFingerprint(asked) : { error: "the mesh does not currently ask this vault to serve that login" }; + const given = args.fingerprint ? String(args.fingerprint) : undefined; + return verdict(recorded, delivered, given); + }, + }, + ]; +} + +function verdict( + recorded: Held, + delivered: { fingerprint: string; length: number } | { error: string }, + given: string | undefined, +): Record { + const deliveredMatches = "fingerprint" in delivered ? delivered.fingerprint === recorded.fingerprint : null; + const givenMatches = given === undefined ? null : given === recorded.fingerprint; + return { + as: recorded.as, + known: true, + recorded: recorded.fingerprint, + rotations: recorded.rotations, + changed: recorded.changed, + delivered: "fingerprint" in delivered ? delivered.fingerprint : null, + deliveredError: "error" in delivered ? delivered.error : null, + deliveredMatchesRecorded: deliveredMatches, + given: given ?? null, + givenMatchesRecorded: givenMatches, + givenIsAnEarlierValue: given === undefined ? null : recorded.history.some((h) => h.fingerprint === given), + ok: deliveredMatches !== false && givenMatches !== false, + }; +} + +// The tools exist only when the ledger can be reached from the environment; without it, vault +// contributes none rather than failing the whole tool runtime. +registerModuleTools("mesh-vault", (env) => { + try { + return getVaultTools(Ledger.fromEnv(env), env.MESH_RECEIVES, env.MESH_VAULT_ROOT); + } catch { + return []; + } +}); diff --git a/modules/mesh-vault/tsconfig.json b/modules/mesh-vault/tsconfig.json new file mode 100644 index 0000000..51f4046 --- /dev/null +++ b/modules/mesh-vault/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] +} diff --git a/modules/postgres/module.json b/modules/postgres/module.json index 4841894..320c941 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -72,14 +72,15 @@ "name": "mesh-store", "image": "postgres@sha256:7456ef82e5f5bc43d997f4781bbd7c0d6389bff397564649a356e206ba473aee", "env": { - "POSTGRES_PASSWORD": "bootstrap", + "POSTGRES_PASSWORD_FILE": "/run/secrets/superuser", "PGDATA": "/var/lib/postgresql/data/pgdata" }, "ports": [ "5432:5432" ], "volumes": [ - "mesh-store-data:/var/lib/postgresql/data" + "mesh-store-data:/var/lib/postgresql/data", + "/var/lib/postgres/superuser.secret:/run/secrets/superuser:ro" ] }, { diff --git a/modules/redis/module.json b/modules/redis/module.json index 22d2960..907db43 100644 --- a/modules/redis/module.json +++ b/modules/redis/module.json @@ -7,6 +7,9 @@ "scope": "mesh" } ], + "requires": [ + "secret" + ], "capabilities": [ "container-runtime" ], @@ -29,8 +32,10 @@ "grants": { "redis-cache": "/var/lib/redis-module/grants" }, + "secrets": { + "secret": "/var/lib/redis-module/default.secret" + }, "own-secrets": { - "default": "/var/lib/redis-module/default.secret", "broker": "/var/lib/mesh/redis/broker" }, "listens": [ @@ -72,7 +77,7 @@ "type": "file", "path": "/var/lib/redis-module/redis.conf", "mode": "0600", - "content": "requirepass ${secret:default}\nappendonly yes\ndir /data\n", + "content": "requirepass ${secret:secret}\nappendonly yes\ndir /data\n", "owner": "999:999" }, { @@ -95,6 +100,9 @@ ], "args": [ "/etc/redis/redis.conf" + ], + "restart-on": [ + "server-conf" ] }, { -- 2.54.0