Add the mesh-vault module; redis takes its password from it
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).
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -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 — `<node>-<module>`, so it names the holder. */
|
||||
readonly as: string;
|
||||
/** The consumer's node. */
|
||||
readonly consumer: string;
|
||||
/** sha256 of the value the mesh last delivered, `sha256:<hex>`. 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:<hex>`. 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}` };
|
||||
}
|
||||
}
|
||||
@@ -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<SecretEvent>("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<SecretEvent>("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<SecretEvent>("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");
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | number>): Promise<void> {
|
||||
try {
|
||||
await emit(type, body);
|
||||
} catch (err) {
|
||||
console.error(`[provisioner:secret] emit ${type} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
runProvisioner("secret", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
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<void> {
|
||||
if (!ledger.withdraw(p.as)) return;
|
||||
console.log(`[mesh-vault] withdrawn: ${p.as}`);
|
||||
await announce("module.mesh-vault.secret.deprovisioned", { as: p.as });
|
||||
},
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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:<hex>`). " +
|
||||
"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:<hex> 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<string, unknown> {
|
||||
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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user