diff --git a/.gitignore b/.gitignore index cc5b132..21b8ded 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,16 @@ # secrets -.env* +.env +.env.* !.env.example -*.key -*.pem -# deps/build +# dependencies / build output node_modules/ dist/ -# generated evidence +# runtime artifacts outputs/ - -# misc *.log + +# OS / editor .DS_Store __MACOSX/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86026d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.2.0 (VaultMesh-grade artifact) + +- Safety boundary: `--dry-run` plans + `vmc apply` execution +- Per-server lockfiles with `--force` +- Receipts: + - canonical hashing (BLAKE3 + SHA256) + - `prev_blake3` chaining and `HEAD.json` + - optional operator signatures (Ed25519) without altering canonical hashes +- Verification: + - `vmc verify receipt ...` + - `vmc verify chain ...` +- Merkle receipts: + - `vmc merkle receipts` outputs a root commitment file diff --git a/README.md b/README.md index 996e833..bf0ca4c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,87 @@ # vm-cloud -Hetzner CLI + MCP tooling for VM ops and research notes. +Hetzner Cloud operator CLI (`vmc`) with audit-ready receipts, plan/apply safety, and a tamper-evident local ledger. ## Quick start -- npm install -- ./bin/vmc servers list -- ./bin/vmc snapshot servers -- ./bin/vmc research new "Title" +1) Install deps -## Env +```bash +npm install +``` -Set HCLOUD_TOKEN in ~/.env or ./.env. +2) Configure Hetzner token + +Create `~/.env` (recommended) or a project `.env` with: + +```bash +HCLOUD_TOKEN=xxx +``` + +3) Run + +```bash +# dev (runs TS directly) +npm run dev -- --help + +# or +./bin/vmc --help +``` + +## Commands + +### Read-only + +```bash +vmc servers list +vmc snapshot servers +vmc research new "Hetzner Baseline YYYY-MM-DD" +vmc research append --from outputs/hetzner/servers-*.json +``` + +### Mutations (safe) + +All mutations: +- resolve server by id/name (exact first, partial only when unambiguous) +- require confirmation (or `--yes`) +- write a receipt to `outputs/receipts/` +- acquire a per-server lock (`~/.cache/vm-cloud/locks/.lock`) to prevent concurrent ops + +#### Plan (dry-run) + +```bash +vmc servers labels env=prod owner=ops --dry-run +# => outputs/plans/plan-*.json with SHA256+BLAKE3 +``` + +#### Apply + +```bash +vmc apply --plan outputs/plans/plan-*.json --yes --reason "change ticket / intent" +``` + +### Ledger + verification + +```bash +vmc verify receipt outputs/receipts/.json --head --plan --sig +vmc verify chain --head --sig +``` + +### Signing + +```bash +vmc keygen +vmc sign receipt outputs/receipts/.json +``` + +### Merkle receipts + +```bash +vmc merkle receipts +# => outputs/ledger/merkle-*.json (root over receipt blake3 chain) +``` + +## Safety notes + +- Never commit `.env` or `outputs/` or `node_modules/` (see `.gitignore`). +- Rotate any leaked tokens immediately. diff --git a/UPGRADE_PATH.md b/UPGRADE_PATH.md new file mode 100644 index 0000000..8cdb316 --- /dev/null +++ b/UPGRADE_PATH.md @@ -0,0 +1,71 @@ +# vm-cloud Upgrade Path (VaultMesh) + +This repo is designed to evolve into a verifiable operator subsystem: **actions → receipts → ledger → merkle → anchors**. + +## Current state (this artifact) + +- Per-server lock files (`~/.cache/vm-cloud/locks/.lock`) with `--force` +- Receipts written to `outputs/receipts/` with: + - canonical hashing (`blake3 + sha256`) + - `prev_blake3` chaining + - `HEAD.json` updated on every receipt +- Plan/apply boundary: + - `--dry-run` emits a plan to `outputs/plans/` + - `vmc apply --plan ...` executes and links the plan hashes into the receipt +- Operator signing (Ed25519): + - `vmc keygen` + - `vmc sign receipt ` + - `vmc verify receipt --sig` + - `vmc verify chain --sig` +- Merkle commitment: + - `vmc merkle receipts` writes `outputs/ledger/merkle-*.json` + +## Next steps (recommended) + +### 1) CI gate: “ledger must verify” + +In CI, after tests: + +```bash +npm run build +node dist/cli.js verify chain --head +``` + +Optionally require signatures: + +```bash +node dist/cli.js verify chain --sig --head +``` + +### 2) Anchoring the Merkle root + +The merkle root file is your commitment point. Anchor it using your preferred substrate: + +- **btc-anchor**: commit root into an OP_RETURN / inscription scheme (policy-dependent) +- **eth-anchor**: store root in a contract/event log +- **rfc3161-anchor**: timestamp the root using an RFC3161 TSA + +The anchoring step should store: +- `root` +- `created_at` +- `leaf_count` +- `head_blake3` +- a signature over the above (operator key) + +### 3) Multi-operator keys + rotation + +- Add a `key_id` (KID) and embed it in receipts +- Allow multiple trusted public keys for verification +- Add `vmc keys list` and `vmc keys trust ` for distributed ops + +### 4) MCP plan/apply flow + +Expose “plan” and “apply” as MCP tools so an agent can: +- generate a plan (dry-run) +- have a human approve/sign the plan +- apply it and produce a linked receipt + +### 5) Drift + policy checks + +- Add `vmc policy check` that evaluates server labels, naming, and network posture +- Make policy checks emit signed attestations into the same ledger diff --git a/bin/vmc b/bin/vmc index 8da36b1..2a79de3 100755 --- a/bin/vmc +++ b/bin/vmc @@ -1,4 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -cd "$(dirname "$0")/.." -exec npx -y tsx src/cli.ts "$@" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [[ -f "$ROOT/dist/cli.js" ]]; then + exec node "$ROOT/dist/cli.js" "$@" +else + exec npx -y tsx "$ROOT/src/cli.ts" "$@" +fi diff --git a/package-lock.json b/package-lock.json index 983831e..14bf42c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,8 @@ "version": "0.0.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "@noble/ed25519": "^2.1.0", - "@noble/hashes": "^1.4.0", "commander": "^12.0.0", - "dotenv": "^16.0.0", - "json-canonicalize": "^1.0.6" + "dotenv": "^16.0.0" }, "bin": { "vmc": "bin/vmc" @@ -517,27 +514,6 @@ } } }, - "node_modules/@noble/ed25519": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", - "integrity": "sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -1219,12 +1195,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/json-canonicalize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/json-canonicalize/-/json-canonicalize-1.2.0.tgz", - "integrity": "sha512-TTdjBvqrqJKSADlEsY5rWbx8/1tOrVlTR/aSLU8N2VSInCTffP0p+byYB8Es+OmL4ZOeEftjUdvV+eJeSzJC/Q==", - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", diff --git a/package.json b/package.json index 78be5ff..95c1e69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vm-cloud", - "version": "0.0.1", + "version": "0.2.0", "description": "Hetzner ops + research documentation CLI", "type": "module", "bin": { @@ -10,15 +10,15 @@ "dev": "tsx src/cli.ts", "build": "tsc", "start": "node dist/cli.js", - "mcp": "tsx src/index.ts" + "mcp": "tsx src/index.ts", + "verify": "tsx src/cli.ts verify chain --head" }, "dependencies": { - "@noble/ed25519": "^2.1.0", "@modelcontextprotocol/sdk": "^1.0.0", - "@noble/hashes": "^1.4.0", "commander": "^12.0.0", "dotenv": "^16.0.0", - "json-canonicalize": "^1.0.6" + "@noble/hashes": "^1.4.0", + "@noble/ed25519": "^2.1.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/src/cli.ts b/src/cli.ts index 41ccbef..2bea770 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,22 +1,28 @@ import { Command } from "commander"; import { requireHcloudToken } from "./lib/env.js"; import { HcloudClient } from "./lib/hcloud.js"; + import { serversList } from "./commands/servers.list.js"; import { serversAction } from "./commands/servers.action.js"; import { serversLabels } from "./commands/servers.labels.js"; import { applyPlan } from "./commands/apply.js"; + +import { snapshotServers } from "./commands/snapshot.js"; + +import { researchNew } from "./commands/research.new.js"; +import { researchAppend } from "./commands/research.append.js"; + import { keygen } from "./commands/keygen.js"; import { signReceipt } from "./commands/sign.receipt.js"; import { verifyReceipt } from "./commands/verify.receipt.js"; -import { snapshotServers } from "./commands/snapshot.js"; -import { researchNew } from "./commands/research.new.js"; -import { researchAppend } from "./commands/research.append.js"; +import { verifyChain } from "./commands/verify.chain.js"; +import { merkleReceipts } from "./commands/merkle.receipts.js"; const program = new Command(); program .name("vmc") .description("vm-cloud: Hetzner ops + research documentation") - .version("0.0.1"); + .version("0.2.0"); const servers = program.command("servers").description("Server operations"); @@ -28,142 +34,105 @@ servers await serversList(client); }); -servers - .command("reboot") - .description("Reboot a server by name or id") - .argument("", "Server name or id") - .option("--yes", "Confirm the action without prompting") - .option("--reason ", "Reason for the action (optional)") - .option("--force", "Break an active server lock") - .option("--allow-partial", "Allow partial match when using --yes") - .option("--dry-run", "Plan only; do not call Hetzner") - .option("--sign", "Sign the receipt after mutation") - .option("--require-sig", "Fail if signing fails or key is missing") - .action( - async ( - nameOrId: string, - opts: { - yes?: boolean; - reason?: string; - force?: boolean; - allowPartial?: boolean; - dryRun?: boolean; - sign?: boolean; - requireSig?: boolean; - } - ) => { - const client = new HcloudClient(requireHcloudToken()); - await serversAction(client, nameOrId, "reboot", opts); +function addMutationFlags(cmd: Command) { + return cmd + .option("--yes", "Confirm the action without prompting") + .option("--reason ", "Reason for the action (required with --yes)") + .option("--force", "Break an active server lock") + .option("--allow-partial", "Allow partial match when using --yes") + .option("--dry-run", "Plan only; do not call Hetzner") + .option("--sign", "If an operator key exists, sign the resulting receipt"); +} + +addMutationFlags( + servers + .command("reboot") + .description("Reboot a server by name or id") + .argument("", "Server name or id") +).action( + async ( + nameOrId: string, + opts: { + yes?: boolean; + reason?: string; + force?: boolean; + allowPartial?: boolean; + dryRun?: boolean; + sign?: boolean; } - ); + ) => { + const client = new HcloudClient(requireHcloudToken()); + await serversAction(client, nameOrId, "reboot", opts); + } +); -servers - .command("poweroff") - .description("Power off a server by name or id") - .argument("", "Server name or id") - .option("--yes", "Confirm the action without prompting") - .option("--reason ", "Reason for the action (optional)") - .option("--force", "Break an active server lock") - .option("--allow-partial", "Allow partial match when using --yes") - .option("--dry-run", "Plan only; do not call Hetzner") - .option("--sign", "Sign the receipt after mutation") - .option("--require-sig", "Fail if signing fails or key is missing") - .action( - async ( - nameOrId: string, - opts: { - yes?: boolean; - reason?: string; - force?: boolean; - allowPartial?: boolean; - dryRun?: boolean; - sign?: boolean; - requireSig?: boolean; - } - ) => { - const client = new HcloudClient(requireHcloudToken()); - await serversAction(client, nameOrId, "poweroff", opts); +addMutationFlags( + servers + .command("poweroff") + .description("Power off a server by name or id") + .argument("", "Server name or id") +).action( + async ( + nameOrId: string, + opts: { + yes?: boolean; + reason?: string; + force?: boolean; + allowPartial?: boolean; + dryRun?: boolean; + sign?: boolean; } - ); + ) => { + const client = new HcloudClient(requireHcloudToken()); + await serversAction(client, nameOrId, "poweroff", opts); + } +); -servers - .command("poweron") - .description("Power on a server by name or id") - .argument("", "Server name or id") - .option("--yes", "Confirm the action without prompting") - .option("--reason ", "Reason for the action (optional)") - .option("--force", "Break an active server lock") - .option("--allow-partial", "Allow partial match when using --yes") - .option("--dry-run", "Plan only; do not call Hetzner") - .option("--sign", "Sign the receipt after mutation") - .option("--require-sig", "Fail if signing fails or key is missing") - .action( - async ( - nameOrId: string, - opts: { - yes?: boolean; - reason?: string; - force?: boolean; - allowPartial?: boolean; - dryRun?: boolean; - sign?: boolean; - requireSig?: boolean; - } - ) => { - const client = new HcloudClient(requireHcloudToken()); - await serversAction(client, nameOrId, "poweron", opts); +addMutationFlags( + servers + .command("poweron") + .description("Power on a server by name or id") + .argument("", "Server name or id") +).action( + async ( + nameOrId: string, + opts: { + yes?: boolean; + reason?: string; + force?: boolean; + allowPartial?: boolean; + dryRun?: boolean; + sign?: boolean; } - ); + ) => { + const client = new HcloudClient(requireHcloudToken()); + await serversAction(client, nameOrId, "poweron", opts); + } +); -servers - .command("labels") - .description("Set labels on a server by name or id") - .argument("", "Server name or id") - .argument("", "Label pairs in key=value form") - .option("--yes", "Confirm the action without prompting") - .option("--reason ", "Reason for the action (optional)") - .option("--force", "Break an active server lock") - .option("--allow-partial", "Allow partial match when using --yes") - .option("--dry-run", "Plan only; do not call Hetzner") - .option("--sign", "Sign the receipt after mutation") - .option("--require-sig", "Fail if signing fails or key is missing") - .action( - async ( - nameOrId: string, - labels: string[], - opts: { - yes?: boolean; - reason?: string; - force?: boolean; - allowPartial?: boolean; - dryRun?: boolean; - sign?: boolean; - requireSig?: boolean; - } - ) => { - const client = new HcloudClient(requireHcloudToken()); - await serversLabels(client, nameOrId, labels, opts); +addMutationFlags( + servers + .command("labels") + .description("Set labels on a server by name or id") + .argument("", "Server name or id") + .argument("", "Label pairs in key=value form") +).action( + async ( + nameOrId: string, + labels: string[], + opts: { + yes?: boolean; + reason?: string; + force?: boolean; + allowPartial?: boolean; + dryRun?: boolean; + sign?: boolean; } - ); - -program - .command("keygen") - .description("Generate an Ed25519 operator keypair") - .option("--force", "Overwrite existing keypair") - .action(async (opts: { force?: boolean }) => { - await keygen(opts); - }); - -const sign = program.command("sign").description("Signing tools"); - -sign - .command("receipt") - .description("Sign a receipt with the operator key") - .argument("", "Receipt JSON path") - .option("--force", "Overwrite existing signature fields") - .action(async (p: string, opts: { force?: boolean }) => { - await signReceipt(p, opts); - }); + ) => { + const client = new HcloudClient(requireHcloudToken()); + await serversLabels(client, nameOrId, labels, opts); + } +); program .command("apply") @@ -173,28 +142,24 @@ program .option("--reason ", "Reason for the action (required with --yes)") .option("--force", "Break an active server lock") .option("--allow-partial", "Allow partial match when using --yes") - .option("--sign", "Sign the receipt after mutation") - .option("--require-sig", "Fail if signing fails or key is missing") + .option("--sign", "If an operator key exists, sign the resulting receipt") .action( - async ( - opts: { - plan: string; - yes?: boolean; - reason?: string; - force?: boolean; - allowPartial?: boolean; - sign?: boolean; - requireSig?: boolean; - } - ) => { + async (opts: { + plan: string; + yes?: boolean; + reason?: string; + force?: boolean; + allowPartial?: boolean; + sign?: boolean; + }) => { const client = new HcloudClient(requireHcloudToken()); await applyPlan(client, opts.plan, opts); } ); -program - .command("snapshot") - .description("Write snapshots to outputs/") +const snapshot = program.command("snapshot").description("Write snapshots to outputs/"); + +snapshot .command("servers") .description("Snapshot Hetzner servers JSON") .action(async () => { @@ -221,20 +186,55 @@ research researchAppend(opts.from, opts.note); }); +program + .command("keygen") + .description("Generate an Ed25519 operator keypair") + .option("--force", "Overwrite existing keypair") + .action(async (opts: { force?: boolean }) => { + await keygen(opts); + }); + +const sign = program.command("sign").description("Signing tools"); + +sign + .command("receipt") + .description("Sign a receipt with the operator key") + .argument("", "Receipt JSON path") + .option("--force", "Overwrite existing signature fields") + .action(async (p: string, opts: { force?: boolean }) => { + await signReceipt(p, opts); + }); + const verify = program.command("verify").description("Verification tools"); verify .command("receipt") .description("Verify a receipt file and optional plan/head linkage") .argument("", "Receipt JSON path") - .option("--head", "If receipt matches HEAD.file, validate HEAD.blake3 too") + .option("--head", "Assert this receipt is the current HEAD and validate HEAD.blake3") .option("--plan", "If receipt references a plan file, verify its hashes too") .option("--sig", "Verify the Ed25519 signature on the receipt") - .action( - async (p: string, opts: { head?: boolean; plan?: boolean; sig?: boolean }) => { - await verifyReceipt(p, opts); - } - ); + .action(async (p: string, opts: { head?: boolean; plan?: boolean; sig?: boolean }) => { + await verifyReceipt(p, opts); + }); + +verify + .command("chain") + .description("Verify the receipt chain via prev_blake3 pointers (from HEAD back)") + .option("--head", "Ensure HEAD.file exists") + .option("--sig", "Require and verify Ed25519 signatures on every receipt") + .action(async (opts: { head?: boolean; sig?: boolean }) => { + await verifyChain(opts); + }); + +const merkle = program.command("merkle").description("Merkle tools"); + +merkle + .command("receipts") + .description("Compute a Merkle root over the receipt chain (genesis->HEAD)") + .action(() => { + merkleReceipts(); + }); program.parseAsync(process.argv).catch((err) => { console.error(err?.message ?? err); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index c782108..a185241 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -2,49 +2,17 @@ import fs from "node:fs"; import path from "node:path"; import { HcloudClient } from "../lib/hcloud.js"; import { requireConfirmation } from "../lib/confirm.js"; -import { readPrivateKey } from "../lib/keys.js"; import { acquireServerLock } from "../lib/lock.js"; import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js"; -import { - readPlan, - resolvePlanPath, - type PlanAction, - type PlanMatch, - type ServerPlan -} from "../lib/plan.js"; +import { readPlan, resolvePlanPath, validatePlan, type ServerPlan } from "../lib/plan.js"; import { writeReceipt } from "../lib/receipt.js"; -import { signReceiptFile } from "../lib/signature.js"; +import { maybeAutoSignReceipt } from "../lib/signing.js"; -const ACTIONS: PlanAction[] = ["poweron", "poweroff", "reboot", "labels"]; -const MATCHES: PlanMatch[] = ["id", "exact", "partial"]; - -function planError(message: string): never { - const err = new Error(message); - (err as { exitCode?: number }).exitCode = 4; - throw err; -} - -function validatePlan(plan: ServerPlan) { - if (plan.plan_version !== "1") { - planError(`Unsupported plan_version: ${String(plan.plan_version)}`); - } - if (!plan.created_at || Number.isNaN(Date.parse(plan.created_at))) { - planError("Plan created_at is missing or invalid."); - } - if (!ACTIONS.includes(plan.action)) { - planError(`Unsupported action: ${String(plan.action)}`); - } - if (!plan.server || typeof plan.server.id !== "number" || !plan.server.name) { - planError("Plan server id/name is missing or invalid."); - } - if (!MATCHES.includes(plan.match)) { - planError(`Unsupported match type: ${String(plan.match)}`); - } - if (!plan.request?.method || !plan.request?.path) { - planError("Plan request method/path is missing."); - } - if (plan.applied !== false) { - planError("Plan must have applied=false."); +function mustExist(p: string, label: string) { + if (!fs.existsSync(p)) { + const err = new Error(`${label} not found: ${p}`); + (err as { exitCode?: number }).exitCode = 2; + throw err; } } @@ -57,84 +25,66 @@ export async function applyPlan( force?: boolean; allowPartial?: boolean; sign?: boolean; - requireSig?: boolean; } = {} ) { const absPlan = resolvePlanPath(planPath); - if (!fs.existsSync(absPlan)) { - throw new Error(`Plan not found: ${absPlan}`); - } + mustExist(absPlan, "Plan"); const plan = readPlan(absPlan); - const plan_sha256 = hashSha256Hex(plan); - const plan_blake3 = hashBlake3Hex(plan); validatePlan(plan); + const plan_sha256 = hashSha256Hex(plan); + const plan_blake3 = hashBlake3Hex(plan); + const plan_file = path.relative(process.cwd(), absPlan) || absPlan; + if (plan.match === "partial" && opts.yes && !opts.allowPartial) { - planError("Plan was created from a partial match. Use --allow-partial."); + const err = new Error( + `Plan match is partial. Use --allow-partial to proceed.` + ); + (err as { exitCode?: number }).exitCode = 4; + throw err; } const reasonRaw = opts.reason?.trim(); - if (opts.yes && !reasonRaw) { - const err = new Error("Reason is required when using --yes."); + if (opts.yes && !reasonRaw && !plan.reason) { + const err = new Error("Reason is required when using --yes (or set plan.reason)."); (err as { exitCode?: number }).exitCode = 2; throw err; } - const expectedPath = - plan.action === "labels" - ? `/servers/${plan.server.id}` - : `/servers/${plan.server.id}/actions/${plan.action}`; - const expectedMethod = plan.action === "labels" ? "PUT" : "POST"; - if (plan.request.path !== expectedPath || plan.request.method !== expectedMethod) { - planError("Plan request does not match the expected action path/method."); - } - - if (plan.action === "labels") { - const body = plan.request.body as { labels?: Record } | undefined; - if (!body?.labels || typeof body.labels !== "object") { - planError("Plan labels body is missing or invalid."); - } - } - + // Ensure target still exists. const servers = await client.listServers(); const server = servers.find((s) => s.id === plan.server.id); if (!server) { - planError(`Server id ${plan.server.id} not found.`); - } - if (server.name !== plan.server.name) { - planError( - `Server name mismatch for id ${plan.server.id}: expected ${plan.server.name}, got ${server.name}` - ); - } - - if (opts.requireSig) { - try { - readPrivateKey(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const requireErr = new Error(`Signing key required: ${msg}`); - (requireErr as { exitCode?: number }).exitCode = 7; - throw requireErr; - } + const err = new Error(`No server found with id ${plan.server.id}`); + (err as { exitCode?: number }).exitCode = 3; + throw err; } const lock = acquireServerLock(server.id, { force: opts.force }); - const plan_file = path.relative(process.cwd(), absPlan) || absPlan; try { const reason = reasonRaw || plan.reason || "unspecified"; + await requireConfirmation({ action: plan.action, - server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, + server: { + id: server.id, + name: server.name, + ip: server.public_net?.ipv4?.ip + }, reason, yes: opts.yes, match: plan.match }); - let response; + let response: + | Awaited> + | Awaited>; + if (plan.action === "labels") { - const body = plan.request.body as { labels: Record }; - response = await client.updateServerLabels(server.id, body.labels); + const body = (plan.request.body ?? {}) as { labels?: Record }; + const labels = body.labels ?? {}; + response = await client.updateServerLabels(server.id, labels); } else { response = await client.powerAction(server.id, plan.action); } @@ -142,12 +92,12 @@ export async function applyPlan( const receipt = writeReceipt({ action: plan.action, reason, - server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, - request: { - method: plan.request.method, - path: plan.request.path, - body: plan.request.body + server: { + id: server.id, + name: server.name, + ip: server.public_net?.ipv4?.ip }, + request: plan.request, response: { status: response.status, ok: response.ok, @@ -164,20 +114,8 @@ export async function applyPlan( ); } - const mustSign = Boolean(opts.sign || opts.requireSig); - if (mustSign) { - try { - const signed = await signReceiptFile(receipt.file); - console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (opts.requireSig) { - const signErr = new Error(`Receipt signing failed: ${msg}`); - (signErr as { exitCode?: number }).exitCode = 7; - throw signErr; - } - console.warn(`WARN: receipt signing failed: ${msg}`); - } + if (opts.sign) { + await maybeAutoSignReceipt(receipt.file); } console.log( diff --git a/src/commands/keygen.ts b/src/commands/keygen.ts index 79ef459..7c786d5 100644 --- a/src/commands/keygen.ts +++ b/src/commands/keygen.ts @@ -1,8 +1,10 @@ -import { generateKeypair } from "../lib/keys.js"; +import { generateKeypair, keyPaths } from "../lib/keys.js"; export async function keygen(opts: { force?: boolean } = {}) { - const { privateKeyPath, publicKeyPath, publicKey } = await generateKeypair(opts); - console.log(`Private key: ${privateKeyPath}`); - console.log(`Public key: ${publicKeyPath}`); - console.log(`Public key (hex): ${publicKey}`); + const paths = keyPaths(); + const res = await generateKeypair({ force: opts.force }); + console.log(`Key directory: ${paths.dir}`); + console.log(`Public key: ${res.pub_path}`); + console.log(`Public hex: ${res.pub_hex}`); + console.log(`Private key: ${res.priv_path} (keep secret)`); } diff --git a/src/commands/merkle.receipts.ts b/src/commands/merkle.receipts.ts new file mode 100644 index 0000000..5ed094e --- /dev/null +++ b/src/commands/merkle.receipts.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { nowStamp, ensureDir } from "../lib/report.js"; +import { readHead, receiptsDir } from "../lib/ledger.js"; +import { merkleRootBlake3 } from "../lib/merkle.js"; + +type ReceiptEnv = { blake3?: string; prev_blake3?: string | null }; + +export function merkleReceipts() { + const head = readHead(); + if (!head) { + const err = new Error("HEAD.json not found or invalid"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + const dir = receiptsDir(); + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json") && f !== "HEAD.json") + .map((f) => path.join(dir, f)); + + const map = new Map(); + for (const f of files) { + try { + const raw = fs.readFileSync(f, "utf8"); + const env = JSON.parse(raw) as ReceiptEnv; + if (env.blake3) map.set(env.blake3, env); + } catch { + // ignore + } + } + + // Walk prev pointers from head to genesis. + const chain: string[] = []; + let cur: string | null = head.blake3; + while (cur) { + const env = map.get(cur); + if (!env) break; + chain.push(cur); + cur = env.prev_blake3 ?? null; + } + + const leaves = chain.slice().reverse(); // genesis -> head + const root = merkleRootBlake3(leaves); + + const { stamp } = nowStamp(); + const outDir = path.join(process.cwd(), "outputs", "ledger"); + ensureDir(outDir); + const outFile = path.join(outDir, `merkle-${stamp}.json`); + + const payload = { + created_at: new Date().toISOString(), + alg: "blake3", + leaf_count: leaves.length, + head_blake3: head.blake3, + root, + leaves + }; + + fs.writeFileSync(outFile, JSON.stringify(payload, null, 2), "utf8"); + console.log(`Merkle root: ${root}`); + console.log(`Leaves: ${leaves.length}`); + console.log(`Wrote: ${outFile}`); +} diff --git a/src/commands/servers.action.ts b/src/commands/servers.action.ts index 25edc32..8e1cf40 100644 --- a/src/commands/servers.action.ts +++ b/src/commands/servers.action.ts @@ -1,11 +1,10 @@ import { HcloudClient } from "../lib/hcloud.js"; import { requireConfirmation } from "../lib/confirm.js"; -import { readPrivateKey } from "../lib/keys.js"; import { acquireServerLock } from "../lib/lock.js"; import { writePlan } from "../lib/plan.js"; import { writeReceipt } from "../lib/receipt.js"; -import { signReceiptFile } from "../lib/signature.js"; import { resolveServer } from "../lib/resolve.js"; +import { maybeAutoSignReceipt } from "../lib/signing.js"; export async function serversAction( client: HcloudClient, @@ -18,38 +17,21 @@ export async function serversAction( allowPartial?: boolean; dryRun?: boolean; sign?: boolean; - requireSig?: boolean; } = {} ) { const { server, match } = await resolveServer(client, input); + if (match === "partial") { console.warn( `Partial match: "${input}" resolved to ${server.name} (${server.id})` ); } - if (match === "partial" && opts.yes && !opts.allowPartial) { - const err = new Error( - `Partial match for "${input}". Use --allow-partial to proceed.` - ); - (err as { exitCode?: number }).exitCode = 4; - throw err; - } - const reasonRaw = opts.reason?.trim(); - if (!opts.dryRun && opts.yes && !reasonRaw) { - const err = new Error("Reason is required when using --yes."); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - const path = `/servers/${server.id}/actions/${action}`; + // Plan-only mode (no locks, no confirmation, no API calls) + const reqPath = `/servers/${server.id}/actions/${action}`; if (opts.dryRun) { - if (opts.sign || opts.requireSig) { - const err = new Error("Cannot sign receipts during --dry-run."); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } const plan = { plan_version: "1", created_at: new Date().toISOString(), @@ -60,40 +42,41 @@ export async function serversAction( ip: server.public_net?.ipv4?.ip }, match, - request: { method: "POST", path }, + request: { method: "POST", path: reqPath }, reason: reasonRaw, applied: false } as const; + const { file, sha256, blake3 } = writePlan(plan); console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`); - console.log(`Request: POST ${path}`); + console.log(`Request: POST ${reqPath}`); console.log(`Plan: ${file}`); console.log(`BLAKE3: ${blake3}`); console.log(`SHA256: ${sha256}`); return plan; } - if (opts.requireSig) { - try { - readPrivateKey(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const requireErr = new Error(`Signing key required: ${msg}`); - (requireErr as { exitCode?: number }).exitCode = 7; - throw requireErr; - } + if (match === "partial" && opts.yes && !opts.allowPartial) { + const err = new Error( + `Partial match for "${input}". Use --allow-partial to proceed.` + ); + (err as { exitCode?: number }).exitCode = 4; + throw err; + } + + if (opts.yes && !reasonRaw) { + const err = new Error("Reason is required when using --yes."); + (err as { exitCode?: number }).exitCode = 2; + throw err; } const lock = acquireServerLock(server.id, { force: opts.force }); try { const reason = reasonRaw || "unspecified"; + await requireConfirmation({ action, - server: { - id: server.id, - name: server.name, - ip: server.public_net?.ipv4?.ip - }, + server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, reason, yes: opts.yes, match @@ -103,12 +86,8 @@ export async function serversAction( const receipt = writeReceipt({ action, reason, - server: { - id: server.id, - name: server.name, - ip: server.public_net?.ipv4?.ip - }, - request: { method: "POST", path }, + server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, + request: { method: "POST", path: reqPath }, response: { status: response.status, ok: response.ok, @@ -118,28 +97,16 @@ export async function serversAction( lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force } }); + if (opts.sign) { + await maybeAutoSignReceipt(receipt.file); + } + if (!response.ok) { throw new Error( `Hetzner API error ${response.status}. Receipt: ${receipt.file}` ); } - const mustSign = Boolean(opts.sign || opts.requireSig); - if (mustSign) { - try { - const signed = await signReceiptFile(receipt.file); - console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (opts.requireSig) { - const signErr = new Error(`Receipt signing failed: ${msg}`); - (signErr as { exitCode?: number }).exitCode = 7; - throw signErr; - } - console.warn(`WARN: receipt signing failed: ${msg}`); - } - } - console.log( `OK ${action} ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}` ); diff --git a/src/commands/servers.labels.ts b/src/commands/servers.labels.ts index ab1d05d..fdc6259 100644 --- a/src/commands/servers.labels.ts +++ b/src/commands/servers.labels.ts @@ -1,27 +1,26 @@ import { HcloudClient } from "../lib/hcloud.js"; import { requireConfirmation } from "../lib/confirm.js"; -import { readPrivateKey } from "../lib/keys.js"; import { acquireServerLock } from "../lib/lock.js"; import { writePlan } from "../lib/plan.js"; import { writeReceipt } from "../lib/receipt.js"; -import { signReceiptFile } from "../lib/signature.js"; import { resolveServer } from "../lib/resolve.js"; +import { maybeAutoSignReceipt } from "../lib/signing.js"; function parseLabels(args: string[]) { - if (!args.length) { - throw new Error("At least one label in key=value form is required"); - } - const labels: Record = {}; - for (const raw of args) { - const idx = raw.indexOf("="); - if (idx <= 0 || idx === raw.length - 1) { - throw new Error(`Invalid label "${raw}". Use key=value`); + for (const a of args) { + const idx = a.indexOf("="); + if (idx <= 0 || idx === a.length - 1) { + const err = new Error(`Invalid label "${a}". Use key=value.`); + (err as { exitCode?: number }).exitCode = 2; + throw err; } - const key = raw.slice(0, idx).trim(); - const value = raw.slice(idx + 1).trim(); + const key = a.slice(0, idx).trim(); + const value = a.slice(idx + 1).trim(); if (!key || !value) { - throw new Error(`Invalid label "${raw}". Use key=value`); + const err = new Error(`Invalid label "${a}". Use key=value.`); + (err as { exitCode?: number }).exitCode = 2; + throw err; } labels[key] = value; } @@ -39,41 +38,23 @@ export async function serversLabels( allowPartial?: boolean; dryRun?: boolean; sign?: boolean; - requireSig?: boolean; } = {} ) { const { server, match } = await resolveServer(client, input); + if (match === "partial") { console.warn( `Partial match: "${input}" resolved to ${server.name} (${server.id})` ); } - if (match === "partial" && opts.yes && !opts.allowPartial) { - const err = new Error( - `Partial match for "${input}". Use --allow-partial to proceed.` - ); - (err as { exitCode?: number }).exitCode = 4; - throw err; - } - const reasonRaw = opts.reason?.trim(); - if (!opts.dryRun && opts.yes && !reasonRaw) { - const err = new Error("Reason is required when using --yes."); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - const labels = parseLabels(args); const next = { ...(server.labels ?? {}), ...labels }; - const path = `/servers/${server.id}`; + const reqPath = `/servers/${server.id}`; + // Plan-only mode if (opts.dryRun) { - if (opts.sign || opts.requireSig) { - const err = new Error("Cannot sign receipts during --dry-run."); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } const plan = { plan_version: "1", created_at: new Date().toISOString(), @@ -84,13 +65,14 @@ export async function serversLabels( ip: server.public_net?.ipv4?.ip }, match, - request: { method: "PUT", path, body: { labels: next } }, + request: { method: "PUT", path: reqPath, body: { labels: next } }, reason: reasonRaw, applied: false } as const; + const { file, sha256, blake3 } = writePlan(plan); console.log(`Resolved: ${server.name} (id=${server.id}) match=${match}`); - console.log(`Request: PUT ${path}`); + console.log(`Request: PUT ${reqPath}`); console.log(JSON.stringify({ labels: next }, null, 2)); console.log(`Plan: ${file}`); console.log(`BLAKE3: ${blake3}`); @@ -98,27 +80,27 @@ export async function serversLabels( return plan; } - if (opts.requireSig) { - try { - readPrivateKey(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const requireErr = new Error(`Signing key required: ${msg}`); - (requireErr as { exitCode?: number }).exitCode = 7; - throw requireErr; - } + if (match === "partial" && opts.yes && !opts.allowPartial) { + const err = new Error( + `Partial match for "${input}". Use --allow-partial to proceed.` + ); + (err as { exitCode?: number }).exitCode = 4; + throw err; + } + + if (opts.yes && !reasonRaw) { + const err = new Error("Reason is required when using --yes."); + (err as { exitCode?: number }).exitCode = 2; + throw err; } const lock = acquireServerLock(server.id, { force: opts.force }); try { const reason = reasonRaw || "unspecified"; + await requireConfirmation({ action: "labels", - server: { - id: server.id, - name: server.name, - ip: server.public_net?.ipv4?.ip - }, + server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, reason, yes: opts.yes, match @@ -128,12 +110,8 @@ export async function serversLabels( const receipt = writeReceipt({ action: "labels", reason, - server: { - id: server.id, - name: server.name, - ip: server.public_net?.ipv4?.ip - }, - request: { method: "PUT", path, body: { labels: next } }, + server: { id: server.id, name: server.name, ip: server.public_net?.ipv4?.ip }, + request: { method: "PUT", path: reqPath, body: { labels: next } }, response: { status: response.status, ok: response.ok, @@ -143,28 +121,16 @@ export async function serversLabels( lock: { file: lock.file, started_at: lock.info.started_at, force: opts.force } }); + if (opts.sign) { + await maybeAutoSignReceipt(receipt.file); + } + if (!response.ok) { throw new Error( `Hetzner API error ${response.status}. Receipt: ${receipt.file}` ); } - const mustSign = Boolean(opts.sign || opts.requireSig); - if (mustSign) { - try { - const signed = await signReceiptFile(receipt.file); - console.log(`Signed: ${signed.file} signer_kid=${signed.signer_kid}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (opts.requireSig) { - const signErr = new Error(`Receipt signing failed: ${msg}`); - (signErr as { exitCode?: number }).exitCode = 7; - throw signErr; - } - console.warn(`WARN: receipt signing failed: ${msg}`); - } - } - console.log( `OK labels ${server.name} (id=${server.id}) receipt=${receipt.file} blake3=${receipt.blake3} sha256=${receipt.sha256}` ); diff --git a/src/commands/sign.receipt.ts b/src/commands/sign.receipt.ts index c5c0566..a5cbaa2 100644 --- a/src/commands/sign.receipt.ts +++ b/src/commands/sign.receipt.ts @@ -1,12 +1,8 @@ -import { signReceiptFile } from "../lib/signature.js"; +import { signReceiptFile } from "../lib/signing.js"; -export async function signReceipt( - receiptPath: string, - opts: { force?: boolean } = {} -) { - const signed = await signReceiptFile(receiptPath, opts); - console.log("OK receipt signed"); - console.log(`file: ${signed.file}`); - console.log(`signer_pub: ${signed.signer_pub}`); - console.log(`signer_kid: ${signed.signer_kid}`); +export async function signReceipt(path: string, opts: { force?: boolean } = {}) { + const res = await signReceiptFile(path, { force: opts.force }); + console.log(`Signed: ${path}`); + console.log(`signer_pub: ${res.signer_pub}`); + console.log(`signature: ${res.signature}`); } diff --git a/src/commands/verify.chain.ts b/src/commands/verify.chain.ts new file mode 100644 index 0000000..af4c4a2 --- /dev/null +++ b/src/commands/verify.chain.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import path from "node:path"; +import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js"; +import { readHead, receiptsDir } from "../lib/ledger.js"; +import { verifyMessage } from "../lib/keys.js"; + +type ReceiptEnvelope = Record & { + blake3?: string; + sha256?: string; + prev_blake3?: string | null; + + sig_alg?: string; + signer_pub?: string; + signature?: string; +}; + +function stripForHash(env: ReceiptEnvelope) { + const { + hash_alg: _hash_alg, + blake3: _blake3, + sha256: _sha256, + sig_alg: _sig_alg, + signer_pub: _signer_pub, + signature: _signature, + signed_at: _signed_at, + ...body + } = env as Record as any; + return body; +} + +export async function verifyChain(opts: { sig?: boolean; head?: boolean } = {}) { + const head = readHead(); + if (!head) { + const err = new Error("HEAD.json not found or invalid"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + const dir = receiptsDir(); + if (!fs.existsSync(dir)) { + const err = new Error(`Receipts dir not found: ${dir}`); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".json") && f !== "HEAD.json") + .map((f) => path.join(dir, f)); + + const map = new Map(); + for (const f of files) { + try { + const raw = fs.readFileSync(f, "utf8"); + const env = JSON.parse(raw) as ReceiptEnvelope; + const b3 = String(env.blake3 ?? ""); + if (b3) map.set(b3, { file: f, env }); + } catch { + // ignore unreadable receipts + } + } + + const failures: string[] = []; + let cur: string | null = head.blake3; + let count = 0; + + while (cur) { + const item = map.get(cur); + if (!item) { + failures.push(`missing receipt for blake3=${cur}`); + break; + } + + const { env, file } = item; + const body = stripForHash(env); + const b3_calc = hashBlake3Hex(body); + const sha_calc = hashSha256Hex(body); + + if (env.blake3 !== b3_calc) failures.push(`blake3 mismatch: ${path.basename(file)}`); + if (env.sha256 !== sha_calc) failures.push(`sha256 mismatch: ${path.basename(file)}`); + + if (opts.sig) { + if (env.sig_alg !== "ed25519" || !env.signature || !env.signer_pub) { + failures.push(`missing signature fields: ${path.basename(file)}`); + } else { + const msg = new TextEncoder().encode(String(env.blake3 ?? "")); + const ok = await verifyMessage(msg, String(env.signature), String(env.signer_pub)); + if (!ok) failures.push(`bad signature: ${path.basename(file)}`); + } + } + + count += 1; + cur = env.prev_blake3 ? String(env.prev_blake3) : null; + } + + if (opts.head) { + // HEAD.file should exist and match the head hash. + const headFileAbs = path.resolve(head.file); + if (!fs.existsSync(headFileAbs)) { + failures.push(`HEAD.file missing: ${head.file}`); + } + } + + if (failures.length) { + const err = new Error("Ledger chain verification failed:\n- " + failures.join("\n- ")); + (err as { exitCode?: number }).exitCode = 5; + throw err; + } + + console.log(`OK chain receipts=${count} head=${head.blake3}`); +} diff --git a/src/commands/verify.receipt.ts b/src/commands/verify.receipt.ts index 69dd217..f2e7e40 100644 --- a/src/commands/verify.receipt.ts +++ b/src/commands/verify.receipt.ts @@ -2,37 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { hashBlake3Hex, hashSha256Hex } from "../lib/hash.js"; import { readHead } from "../lib/ledger.js"; -import { keyIdFromPublicHex, verifyMessage } from "../lib/keys.js"; - -type ReceiptEnvelope = { - receipt_version: "1"; - created_at: string; - cwd: string; - user: string; - hostname: string; - argv: string[]; - reason: string; - lock_file: string | null; - lock_started_at: string | null; - force: boolean; - plan_file: string | null; - plan_sha256: string | null; - plan_blake3: string | null; - target: { id: number; name: string; ip?: string | null }; - request: unknown; - response: unknown; - prev_blake3: string | null; - hash_alg: "blake3+sha256"; - blake3: string; - sha256: string; - sig_alg?: "ed25519"; - signer_pub?: string; - signer_kid?: string; - signed_at?: string; - signature?: string; -}; - -type ReceiptBody = Omit; +import { verifyMessage } from "../lib/keys.js"; function mustExist(p: string, label: string) { if (!fs.existsSync(p)) { @@ -42,16 +12,31 @@ function mustExist(p: string, label: string) { } } -function stripHashes(env: ReceiptEnvelope): ReceiptBody { +type ReceiptEnvelope = Record & { + hash_alg?: string; + blake3?: string; + sha256?: string; + prev_blake3?: string | null; + + plan_file?: string | null; + plan_sha256?: string | null; + plan_blake3?: string | null; + + sig_alg?: string; + signer_pub?: string; + signature?: string; + signed_at?: string; +}; + +function stripForHash(env: ReceiptEnvelope) { const { hash_alg: _hash_alg, blake3: _blake3, sha256: _sha256, sig_alg: _sig_alg, signer_pub: _signer_pub, - signer_kid: _signer_kid, - signed_at: _signed_at, signature: _signature, + signed_at: _signed_at, ...body } = env; return body; @@ -65,95 +50,69 @@ export async function verifyReceipt( mustExist(abs, "Receipt"); const raw = fs.readFileSync(abs, "utf8"); - let env: ReceiptEnvelope; - try { - env = JSON.parse(raw) as ReceiptEnvelope; - } catch { - const err = new Error("Receipt is not valid JSON"); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - if (env.hash_alg !== "blake3+sha256" || !env.blake3 || !env.sha256) { - const err = new Error( - "Receipt missing required hash fields (hash_alg/blake3/sha256)" - ); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - const body = stripHashes(env); - const blake3 = hashBlake3Hex(body); - const sha256 = hashSha256Hex(body); + const env = JSON.parse(raw) as ReceiptEnvelope; const failures: string[] = []; - if (blake3 !== env.blake3) { - failures.push(`BLAKE3 mismatch (expected ${env.blake3}, got ${blake3})`); - } - if (sha256 !== env.sha256) { - failures.push(`SHA256 mismatch (expected ${env.sha256}, got ${sha256})`); + + if (env.hash_alg !== "blake3+sha256") { + failures.push(`hash_alg mismatch (got ${String(env.hash_alg)})`); } - if (opts.plan && env.plan_file) { - const planAbs = path.isAbsolute(env.plan_file) - ? env.plan_file - : path.join(process.cwd(), env.plan_file); - try { - mustExist(planAbs, "Plan file referenced by receipt"); - const planRaw = fs.readFileSync(planAbs, "utf8"); - const planObj = JSON.parse(planRaw) as unknown; - const planSha = hashSha256Hex(planObj); - const planB3 = hashBlake3Hex(planObj); - if (!env.plan_sha256 || !env.plan_blake3) { - failures.push("Receipt is missing plan hash fields"); + const body = stripForHash(env); + const blake3_calc = hashBlake3Hex(body); + const sha256_calc = hashSha256Hex(body); + + if (env.blake3 !== blake3_calc) failures.push("blake3 mismatch"); + if (env.sha256 !== sha256_calc) failures.push("sha256 mismatch"); + + if (opts.plan) { + const planFile = env.plan_file ? String(env.plan_file) : ""; + const planSha = env.plan_sha256 ? String(env.plan_sha256) : ""; + const planBlake = env.plan_blake3 ? String(env.plan_blake3) : ""; + + if (!planFile) { + failures.push("receipt has no plan_file"); + } else { + const planAbs = path.resolve(planFile); + if (!fs.existsSync(planAbs)) { + failures.push(`plan file not found: ${planFile}`); } else { - if (env.plan_sha256 !== planSha) { - failures.push( - `Plan SHA256 mismatch (expected ${env.plan_sha256}, got ${planSha})` - ); - } - if (env.plan_blake3 !== planB3) { - failures.push( - `Plan BLAKE3 mismatch (expected ${env.plan_blake3}, got ${planB3})` - ); + try { + const planRaw = fs.readFileSync(planAbs, "utf8"); + const plan = JSON.parse(planRaw) as unknown; + const sha = hashSha256Hex(plan); + const b3 = hashBlake3Hex(plan); + if (planSha && sha !== planSha) failures.push("plan_sha256 mismatch"); + if (planBlake && b3 !== planBlake) failures.push("plan_blake3 mismatch"); + } catch (e) { + failures.push(`failed to read/parse plan: ${(e as Error).message}`); } } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - failures.push(msg); } } if (opts.head) { const head = readHead(); if (!head) { - failures.push("HEAD.json missing"); + failures.push("HEAD.json not found or invalid"); } else { const rel = path.relative(process.cwd(), abs) || abs; - if (head.file === rel && head.blake3 !== env.blake3) { - failures.push( - `HEAD blake3 mismatch (HEAD=${head.blake3}, receipt=${env.blake3})` - ); + if (head.file !== rel) { + failures.push(`receipt is not HEAD.file (HEAD.file=${head.file})`); + } + if (head.blake3 !== env.blake3) { + failures.push("HEAD.blake3 mismatch"); } } } if (opts.sig) { - if (!env.signature || !env.signer_pub || env.sig_alg !== "ed25519") { - const err = new Error("Receipt signature fields missing or invalid"); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - const msg = new TextEncoder().encode(env.blake3); - const ok = await verifyMessage(msg, env.signature, env.signer_pub); - if (!ok) failures.push("Signature verification failed"); - if (env.signer_kid) { - const kid = keyIdFromPublicHex(env.signer_pub); - if (env.signer_kid !== kid) { - failures.push( - `Signer key id mismatch (expected ${env.signer_kid}, got ${kid})` - ); - } + if (env.sig_alg !== "ed25519" || !env.signature || !env.signer_pub) { + failures.push("signature fields missing (need sig_alg, signer_pub, signature)"); + } else { + const msg = new TextEncoder().encode(String(env.blake3 ?? "")); + const ok = await verifyMessage(msg, String(env.signature), String(env.signer_pub)); + if (!ok) failures.push("signature verification failed"); } } @@ -163,10 +122,8 @@ export async function verifyReceipt( throw err; } - console.log("OK receipt verified"); - console.log(`file: ${abs}`); - console.log(`blake3: ${env.blake3}`); - console.log(`sha256: ${env.sha256}`); - if (env.prev_blake3) console.log(`prev_blake3: ${env.prev_blake3}`); - if (env.plan_file) console.log(`plan: ${env.plan_file}`); + console.log(`OK receipt ${receiptPath}`); + console.log(`blake3=${env.blake3}`); + console.log(`sha256=${env.sha256}`); + if (opts.sig && env.signature) console.log(`sig=ok signer_pub=${env.signer_pub}`); } diff --git a/src/lib/hash.ts b/src/lib/hash.ts index d33cc3f..3c1395a 100644 --- a/src/lib/hash.ts +++ b/src/lib/hash.ts @@ -1,17 +1,59 @@ +import crypto from "node:crypto"; import { blake3 } from "@noble/hashes/blake3"; -import { sha256 } from "@noble/hashes/sha256"; -import { bytesToHex } from "@noble/hashes/utils"; -import { canonicalize } from "json-canonicalize"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils"; -export function canonicalBytes(obj: unknown): Uint8Array { - const json = canonicalize(obj); - return new TextEncoder().encode(json); +type Json = + | null + | boolean + | number + | string + | Json[] + | { [k: string]: Json }; + +/** + * Deterministic JSON normalization: recursively sorts object keys and removes + * `undefined` so hashing is stable across runs. + * + * Note: this is *not* a full RFC 8785 implementation, but it is stable for the + * structures we emit (plain JSON objects). + */ +export function canonicalize(value: unknown): Json { + if (value === null) return null; + + const t = typeof value; + if (t === "string" || t === "number" || t === "boolean") return value as Json; + + if (Array.isArray(value)) { + return value.map((v) => canonicalize(v)) as Json; + } + + if (t === "object") { + const obj = value as Record; + const out: Record = {}; + for (const k of Object.keys(obj).sort()) { + const v = obj[k]; + if (v === undefined) continue; + out[k] = canonicalize(v); + } + return out; + } + + // functions / symbols / bigint should never appear in our receipts/plans + return String(value) as unknown as Json; } -export function hashBlake3Hex(obj: unknown): string { - return bytesToHex(blake3(canonicalBytes(obj))); +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); } -export function hashSha256Hex(obj: unknown): string { - return bytesToHex(sha256(canonicalBytes(obj))); +export function hashSha256Hex(value: unknown): string { + return crypto + .createHash("sha256") + .update(canonicalJson(value), "utf8") + .digest("hex"); +} + +export function hashBlake3Hex(value: unknown): string { + const bytes = utf8ToBytes(canonicalJson(value)); + return bytesToHex(blake3(bytes)); } diff --git a/src/lib/keys.ts b/src/lib/keys.ts index 991ae5c..efc6bde 100644 --- a/src/lib/keys.ts +++ b/src/lib/keys.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { getPublicKey, sign, verify, utils, etc } from "@noble/ed25519"; -import { blake3 } from "@noble/hashes/blake3"; import { sha512 } from "@noble/hashes/sha512"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; @@ -10,6 +9,7 @@ const KEY_DIR = path.join(os.homedir(), ".config", "vm-cloud", "keys"); const PRIV_PATH = path.join(KEY_DIR, "operator_ed25519.key"); const PUB_PATH = path.join(KEY_DIR, "operator_ed25519.pub"); +// noble/ed25519 requires a SHA-512 implementation to be wired. if (!etc.sha512Sync) { etc.sha512Sync = (...messages) => sha512(etc.concatBytes(...messages)); } @@ -19,34 +19,26 @@ function ensureKeyDir() { } export function keyPaths() { - return { dir: KEY_DIR, privateKey: PRIV_PATH, publicKey: PUB_PATH }; + return { dir: KEY_DIR, priv: PRIV_PATH, pub: PUB_PATH }; } -export function readPrivateKey(): Uint8Array { - if (!fs.existsSync(PRIV_PATH)) { - const err = new Error(`Private key not found: ${PRIV_PATH}`); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - const hex = fs.readFileSync(PRIV_PATH, "utf8").trim(); - return hexToBytes(hex); +export function hasKeypair(): boolean { + return fs.existsSync(PRIV_PATH) && fs.existsSync(PUB_PATH); } -export function readPublicKey(): Uint8Array { - if (!fs.existsSync(PUB_PATH)) { - const err = new Error(`Public key not found: ${PUB_PATH}`); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - const hex = fs.readFileSync(PUB_PATH, "utf8").trim(); - return hexToBytes(hex); +export function readPublicKeyHex(): string { + return fs.readFileSync(PUB_PATH, "utf8").trim(); +} + +export function readPrivateKeyHex(): string { + return fs.readFileSync(PRIV_PATH, "utf8").trim(); } export async function generateKeypair(opts: { force?: boolean } = {}) { ensureKeyDir(); if (!opts.force && (fs.existsSync(PRIV_PATH) || fs.existsSync(PUB_PATH))) { const err = new Error( - `Key already exists. Use --force to overwrite: ${PRIV_PATH}` + `Keypair already exists. Use --force to overwrite: ${PUB_PATH}` ); (err as { exitCode?: number }).exitCode = 2; throw err; @@ -58,33 +50,24 @@ export async function generateKeypair(opts: { force?: boolean } = {}) { fs.writeFileSync(PRIV_PATH, bytesToHex(priv), { mode: 0o600 }); fs.writeFileSync(PUB_PATH, bytesToHex(pub), { mode: 0o644 }); - return { - privateKeyPath: PRIV_PATH, - publicKeyPath: PUB_PATH, - publicKey: bytesToHex(pub) - }; + return { priv_path: PRIV_PATH, pub_path: PUB_PATH, pub_hex: bytesToHex(pub) }; } -export function keyIdFromPublicHex(publicKeyHex: string) { - const pub = hexToBytes(publicKeyHex); - return bytesToHex(blake3(pub)); -} - -export async function signMessage(message: Uint8Array) { - const priv = readPrivateKey(); - const pub = await getPublicKey(priv); - const signature = await sign(message, priv); - const publicKey = bytesToHex(pub); - const signerKid = keyIdFromPublicHex(publicKey); - return { signature: bytesToHex(signature), publicKey, signerKid }; +export async function signMessage( + message: Uint8Array, + privHex?: string +): Promise { + const priv = hexToBytes(privHex ?? readPrivateKeyHex()); + const sig = await sign(message, priv); + return bytesToHex(sig); } export async function verifyMessage( message: Uint8Array, - signatureHex: string, - publicKeyHex: string -) { - const signature = hexToBytes(signatureHex); - const publicKey = hexToBytes(publicKeyHex); - return verify(signature, message, publicKey); + sigHex: string, + pubHex?: string +): Promise { + const sig = hexToBytes(sigHex); + const pub = hexToBytes(pubHex ?? readPublicKeyHex()); + return verify(sig, message, pub); } diff --git a/src/lib/ledger.ts b/src/lib/ledger.ts index 3760971..e42b404 100644 --- a/src/lib/ledger.ts +++ b/src/lib/ledger.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { ensureDir } from "./report.js"; -function receiptsDir() { +export function receiptsDir() { return path.join(process.cwd(), "outputs", "receipts"); } @@ -12,28 +12,22 @@ function headPath() { export type ReceiptHead = { blake3: string; file: string; created_at: string }; +export function writeHead(head: ReceiptHead) { + ensureDir(receiptsDir()); + fs.writeFileSync(headPath(), JSON.stringify(head, null, 2), "utf8"); +} + export function readHead(): ReceiptHead | null { try { const raw = fs.readFileSync(headPath(), "utf8"); - const data = JSON.parse(raw) as ReceiptHead; + const data = JSON.parse(raw) as Partial; if (!data?.blake3 || !data?.file || !data?.created_at) return null; - return data; + return data as ReceiptHead; } catch { return null; } } export function readPrevReceiptHash(): string | null { - try { - const raw = fs.readFileSync(headPath(), "utf8"); - const data = JSON.parse(raw) as { blake3?: string }; - return typeof data.blake3 === "string" ? data.blake3 : null; - } catch { - return null; - } -} - -export function writeHead(next: { blake3: string; file: string; created_at: string }) { - ensureDir(receiptsDir()); - fs.writeFileSync(headPath(), JSON.stringify(next, null, 2), { mode: 0o600 }); + return readHead()?.blake3 ?? null; } diff --git a/src/lib/merkle.ts b/src/lib/merkle.ts new file mode 100644 index 0000000..0ebd64c --- /dev/null +++ b/src/lib/merkle.ts @@ -0,0 +1,24 @@ +import { blake3 } from "@noble/hashes/blake3"; +import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils"; + +export function merkleRootBlake3(leavesHex: string[]): string { + if (!leavesHex.length) { + const err = new Error("No leaves provided for merkle root"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + let level = leavesHex.map((h) => hexToBytes(h)); + + while (level.length > 1) { + const next: Uint8Array[] = []; + for (let i = 0; i < level.length; i += 2) { + const left = level[i]; + const right = i + 1 < level.length ? level[i + 1] : left; // duplicate last + next.push(blake3(concatBytes(left, right))); + } + level = next; + } + + return bytesToHex(level[0]); +} diff --git a/src/lib/plan.ts b/src/lib/plan.ts index 0e718e8..04234e8 100644 --- a/src/lib/plan.ts +++ b/src/lib/plan.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { hashBlake3Hex, hashSha256Hex } from "./hash.js"; import { ensureDir, nowStamp } from "./report.js"; +import { hashBlake3Hex, hashSha256Hex } from "./hash.js"; export type PlanAction = "poweron" | "poweroff" | "reboot" | "labels"; export type PlanMatch = "id" | "exact" | "partial"; @@ -17,25 +17,41 @@ export type ServerPlan = { applied: false; }; -export function resolvePlanPath(planPath: string) { - return path.isAbsolute(planPath) ? planPath : path.join(process.cwd(), planPath); -} - -export function writePlan(plan: ServerPlan) { +export function writePlan(plan: ServerPlan): { + file: string; + sha256: string; + blake3: string; +} { const { stamp } = nowStamp(); const dir = path.join(process.cwd(), "outputs", "plans"); - const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`; ensureDir(dir); + const name = `plan-${stamp}-${plan.action}-${plan.server.id}.json`; const file = path.join(dir, name); - const json = JSON.stringify(plan, null, 2); - fs.writeFileSync(file, json, "utf8"); + fs.writeFileSync(file, JSON.stringify(plan, null, 2), "utf8"); const sha256 = hashSha256Hex(plan); const blake3 = hashBlake3Hex(plan); return { file, sha256, blake3 }; } export function readPlan(planPath: string): ServerPlan { - const abs = resolvePlanPath(planPath); - const raw = fs.readFileSync(abs, "utf8"); - return JSON.parse(raw) as ServerPlan; + const raw = fs.readFileSync(planPath, "utf8"); + const data = JSON.parse(raw) as unknown; + validatePlan(data); + return data; +} + +export function validatePlan(plan: unknown): asserts plan is ServerPlan { + const p = plan as Partial; + if (!p || typeof p !== "object") throw new Error("Invalid plan (not an object)"); + if (p.plan_version !== "1") throw new Error("Invalid plan_version"); + if (!p.created_at) throw new Error("Invalid created_at"); + if (!p.action) throw new Error("Invalid action"); + if (!p.server?.id || !p.server?.name) throw new Error("Invalid server"); + if (!p.match) throw new Error("Invalid match"); + if (!p.request?.method || !p.request?.path) throw new Error("Invalid request"); + if (p.applied !== false) throw new Error("Plan.applied must be false"); +} + +export function resolvePlanPath(p: string) { + return path.resolve(p); } diff --git a/src/lib/receipt.ts b/src/lib/receipt.ts index 1f4d22c..668d44e 100644 --- a/src/lib/receipt.ts +++ b/src/lib/receipt.ts @@ -14,8 +14,8 @@ type ReceiptRequest = { type ReceiptResponse = { status: number; ok: boolean; - data?: unknown; - raw?: string; + data: unknown; + raw: string; }; type ReceiptInput = { @@ -36,22 +36,34 @@ type ReceiptBody = { hostname: string; argv: string[]; reason: string; + lock_file: string | null; lock_started_at: string | null; force: boolean; + plan_file: string | null; plan_sha256: string | null; plan_blake3: string | null; + target: { id: number; name: string; ip?: string | null }; + request: ReceiptRequest; response: ReceiptResponse; + prev_blake3: string | null; }; -type ReceiptEnvelope = ReceiptBody & { +export type ReceiptEnvelope = ReceiptBody & { hash_alg: "blake3+sha256"; blake3: string; sha256: string; + + // Optional signing metadata. These fields are NOT included in the canonical + // receipt hash (verification strips them before hashing). + sig_alg?: "ed25519"; + signer_pub?: string; + signature?: string; + signed_at?: string; }; function fileStamp(d = new Date()) { @@ -64,51 +76,59 @@ function fileStamp(d = new Date()) { return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`; } -export function writeReceipt(input: ReceiptInput) { +export function writeReceipt(input: ReceiptInput): { + file: string; + sha256: string; + blake3: string; +} { const dir = path.join(process.cwd(), "outputs", "receipts"); ensureDir(dir); - const file = path.join( - dir, - `${fileStamp()}-${input.action}-${input.server.id}.json` - ); - const created_at = new Date().toISOString(); const prev_blake3 = readPrevReceiptHash(); + const body: ReceiptBody = { receipt_version: "1", created_at, cwd: process.cwd(), user: process.env.USER ?? process.env.LOGNAME ?? "unknown", hostname: os.hostname(), - argv: process.argv, + argv: process.argv.slice(), reason: input.reason || "unspecified", + lock_file: input.lock?.file ?? null, lock_started_at: input.lock?.started_at ?? null, force: Boolean(input.lock?.force), + plan_file: input.plan?.file ?? null, plan_sha256: input.plan?.sha256 ?? null, plan_blake3: input.plan?.blake3 ?? null, + target: { id: input.server.id, name: input.server.name, ip: input.server.ip ?? null }, + request: input.request, response: input.response, + prev_blake3 }; const blake3 = hashBlake3Hex(body); const sha256 = hashSha256Hex(body); - const envelope: ReceiptEnvelope = { + + const env: ReceiptEnvelope = { ...body, hash_alg: "blake3+sha256", blake3, sha256 }; - fs.writeFileSync(file, JSON.stringify(envelope, null, 2), "utf8"); + const file = path.join(dir, `${fileStamp()}-${input.action}-${input.server.id}.json`); + fs.writeFileSync(file, JSON.stringify(env, null, 2), "utf8"); + const relFile = path.relative(process.cwd(), file) || file; writeHead({ blake3, file: relFile, created_at }); diff --git a/src/lib/signature.ts b/src/lib/signature.ts deleted file mode 100644 index 89b907d..0000000 --- a/src/lib/signature.ts +++ /dev/null @@ -1,63 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { signMessage } from "./keys.js"; - -type ReceiptEnvelope = { - blake3: string; - sig_alg?: string; - signer_pub?: string; - signer_kid?: string; - signed_at?: string; - signature?: string; -}; - -export async function signReceiptFile( - receiptPath: string, - opts: { force?: boolean } = {} -) { - const abs = path.resolve(receiptPath); - if (!fs.existsSync(abs)) { - const err = new Error(`Receipt not found: ${abs}`); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - const raw = fs.readFileSync(abs, "utf8"); - let env: ReceiptEnvelope; - try { - env = JSON.parse(raw) as ReceiptEnvelope; - } catch { - const err = new Error("Receipt is not valid JSON"); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - if (!env.blake3) { - const err = new Error("Receipt missing blake3 field"); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - if ((env.signature || env.signer_pub) && !opts.force) { - const err = new Error("Receipt already signed. Use --force to overwrite."); - (err as { exitCode?: number }).exitCode = 2; - throw err; - } - - const msg = new TextEncoder().encode(env.blake3); - const { signature, publicKey, signerKid } = await signMessage(msg); - const signedAt = new Date().toISOString(); - - const next = { - ...env, - sig_alg: "ed25519", - signer_pub: publicKey, - signer_kid: signerKid, - signed_at: signedAt, - signature - }; - - fs.writeFileSync(abs, JSON.stringify(next, null, 2), "utf8"); - - return { file: abs, signer_pub: publicKey, signer_kid: signerKid, signed_at: signedAt, signature }; -} diff --git a/src/lib/signing.ts b/src/lib/signing.ts new file mode 100644 index 0000000..06b2aa9 --- /dev/null +++ b/src/lib/signing.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { hasKeypair, readPublicKeyHex, signMessage } from "./keys.js"; + +export async function signReceiptFile( + receiptPath: string, + opts: { force?: boolean } = {} +) { + const abs = path.resolve(receiptPath); + const raw = fs.readFileSync(abs, "utf8"); + const env = JSON.parse(raw) as Record; + + const blake3 = String(env["blake3"] ?? ""); + if (!blake3) { + const err = new Error("Receipt missing blake3 field"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + const hasSig = + Boolean(env["signature"]) || Boolean(env["signer_pub"]) || Boolean(env["sig_alg"]); + if (hasSig && !opts.force) { + const err = new Error("Receipt already has signature fields (use --force to overwrite)"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + if (!hasKeypair()) { + const err = new Error("No operator keypair found. Run: vmc keygen"); + (err as { exitCode?: number }).exitCode = 2; + throw err; + } + + const msg = new TextEncoder().encode(blake3); + const signature = await signMessage(msg); + const signer_pub = readPublicKeyHex(); + + env["sig_alg"] = "ed25519"; + env["signer_pub"] = signer_pub; + env["signature"] = signature; + env["signed_at"] = new Date().toISOString(); + + fs.writeFileSync(abs, JSON.stringify(env, null, 2), "utf8"); + return { signature, signer_pub }; +} + +/** + * Best-effort auto-sign: if keys exist and the receipt is unsigned, sign it. + * Never throws (returns null on failure). + */ +export async function maybeAutoSignReceipt(receiptPath: string) { + if (!hasKeypair()) return null; + try { + return await signReceiptFile(receiptPath, { force: false }); + } catch { + return null; + } +}