Merge pull request 'Add mesh-vault; redis, postgres and lavinmq take their passwords from files' (#30) from feat/secrets-vault into main
This commit was merged in pull request #30.
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