Files
mesh-catalog/modules/mesh-vault/client.ts
T
jschoubben 82e513a360 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).
2026-09-21 00:48:13 +02:00

173 lines
6.8 KiB
TypeScript

// 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}` };
}
}