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