diff --git a/packages/cli/README.md b/packages/cli/README.md index a5894d9..4592445 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,6 +32,84 @@ agentbridge validate ./public/.well-known/agentbridge.json agentbridge validate http://localhost:3000 ``` +**Optional signed-manifest checks (v0.5.0).** Pass a publisher key +set with `--keys` to also verify the manifest's signature, or +require a signature without verifying with `--require-signature`. +Default behavior — neither flag — is unchanged from v0.4.x. + +```bash +# Verify a signed manifest against a key set you already loaded. +agentbridge validate ./manifest.json --keys ./agentbridge-keys.json + +# Reject unsigned manifests (exit 1 if no signature). +agentbridge validate ./manifest.json --require-signature + +# Combine: schema-valid AND signature verifies. +agentbridge validate ./manifest.json --require-signature --keys ./agentbridge-keys.json + +# Optional knobs forwarded to the verifier: +# --expected-issuer +# --now +# --clock-skew-seconds +``` + +The CLI does not fetch `/.well-known/agentbridge-keys.json` for +you. Pass the key set as a local file. Runtime fetch lands in the +MCP server PR. + +### `agentbridge verify --keys ` + +Dedicated signature-verification command. Always runs the verifier +(unlike `validate`, which makes verification opt-in). Returns a +structured outcome with stable failure-reason codes from +`@marmarlabs/agentbridge-core`. + +```bash +agentbridge verify ./manifest.json --keys ./agentbridge-keys.json +agentbridge verify https://orders.acme.example/.well-known/agentbridge.json \ + --keys ./acme-keys.json --expected-issuer https://orders.acme.example + +# Machine-readable output for CI. +agentbridge verify ./manifest.json --keys ./keys.json --json +# { +# "ok": true, +# "kid": "acme-orders-2026-04", +# "iss": "https://orders.acme.example", +# "alg": "EdDSA", +# "signedAt": "2026-04-28T12:00:00.000Z", +# "expiresAt": "2026-04-29T12:00:00.000Z" +# } +``` + +Failure outcomes carry a stable `reason` from the core verifier +(`signature-invalid`, `expired`, `unknown-kid`, `revoked-kid`, +`issuer-mismatch`, …). See +[`packages/core/README.md`](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/packages/core/README.md) +for the full enum. + +### `agentbridge keys generate` (local dev only) + +Generate an Ed25519 (or ES256) signing keypair, write the public +half to a complete `agentbridge-keys.json` document and the private +half to a separate file with mode `0o600`. Useful for bootstrapping +the first key set in a development project. + +```bash +agentbridge keys generate \ + --kid acme-2026-04 \ + --issuer https://acme.example \ + --out-public ./agentbridge-keys.json \ + --out-private ./acme.signing-key.json +``` + +> ⚠️ **Local-dev only.** The private key file is sensitive +> material. Do **not** commit it. Production signing keys belong +> in a KMS / HSM, never on a developer's filesystem. The CLI +> writes the private key with mode `0o600` (owner-only) on POSIX, +> never echoes the private `d` parameter to stdout/stderr, and +> requires explicit `--out-private` so it cannot silently discard +> freshly-generated material. + ### `agentbridge init` Scaffold an `agentbridge.config.ts` and starter manifest in the current @@ -88,10 +166,12 @@ most likely to copy: - `agentbridge mcp-config`, including stdio, Codex, Claude Desktop, Cursor / generic JSON, and the v0.4.0 HTTP transport block -The signed-manifest example verification is currently part of the -example regression suite, not a CLI enforcement mode. A future CLI -`--require-signature` flag will make signature verification available -as a command option. +The signed-manifest example is verified end-to-end by the example +regression suite. As of v0.5.0 the CLI also exposes +`agentbridge validate --require-signature [--keys …]` and +`agentbridge verify` directly, so adopters can wire signature +verification into their own scripts without re-implementing the +verifier. After building the workspace, run the same example validation pass manually with: diff --git a/packages/cli/src/commands/key-loader.ts b/packages/cli/src/commands/key-loader.ts new file mode 100644 index 0000000..7daca0c --- /dev/null +++ b/packages/cli/src/commands/key-loader.ts @@ -0,0 +1,41 @@ +/** + * Tiny helper shared by `validate --keys` and `verify --keys`. Loads + * a publisher key set from disk, parses JSON, runs `validateKeySet` + * from `@marmarlabs/agentbridge-core`, and returns a structured + * `{ ok, keySet | errors }` result so each command can fold it into + * its own output / exit-code logic without duplicating boilerplate. + * + * Local-file-only — no remote fetch in this PR. Runtime fetch of + * `/.well-known/agentbridge-keys.json` ships with the MCP server PR. + */ +import { promises as fs } from "node:fs"; +import { + validateKeySet, + type AgentBridgeKeySet, +} from "@marmarlabs/agentbridge-core"; + +export type LoadKeySetResult = + | { ok: true; keySet: AgentBridgeKeySet } + | { ok: false; errors: string[] }; + +export async function loadKeySetFromFile(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, "utf8"); + } catch (err) { + return { + ok: false, + errors: [`could not read key set "${filePath}": ${(err as Error).message}`], + }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + return { + ok: false, + errors: [`key set "${filePath}" is not valid JSON: ${(err as Error).message}`], + }; + } + return validateKeySet(parsed); +} diff --git a/packages/cli/src/commands/keys.ts b/packages/cli/src/commands/keys.ts new file mode 100644 index 0000000..38f7ae5 --- /dev/null +++ b/packages/cli/src/commands/keys.ts @@ -0,0 +1,190 @@ +/** + * `agentbridge keys generate` — local-dev helper for bootstrapping + * a publisher key set. Generates an asymmetric keypair, writes the + * public half to a complete `agentbridge-keys.json` document and + * the private half to a separate file with mode 0o600. + * + * **This command is for local development only.** Production signing + * keys should be generated inside a KMS / HSM and never written to + * a developer's filesystem. The command exits with a clear stderr + * warning that the on-disk private key is sensitive material. + * + * Output safety: + * - The private key is **never** written to stdout. + * - The private key file is created with mode 0o600 (owner-only). + * - Stdout / stderr never echoes the private `d` parameter, only + * the file paths and the kid/alg metadata. + * - `--out-private` is required; omitting it fails fast (we refuse + * to silently discard the freshly-generated key material). + */ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + generateKeyPairSync, + type KeyObject, +} from "node:crypto"; +import { + validateKeySet, + type SignatureAlgorithm, +} from "@marmarlabs/agentbridge-core"; +import { c } from "../colors"; + +export interface KeysGenerateOptions { + kid?: string; + alg?: string; + issuer?: string; + outPublic?: string; + outPrivate?: string; + notBefore?: string; + notAfter?: string; +} + +export async function runKeysGenerate(opts: KeysGenerateOptions): Promise { + const errors: string[] = []; + if (!opts.kid) errors.push("--kid is required"); + if (!opts.issuer) errors.push("--issuer is required"); + if (!opts.outPublic) errors.push("--out-public is required"); + if (!opts.outPrivate) + errors.push( + "--out-private is required (the freshly-generated private key must be written somewhere; the CLI refuses to silently discard it)", + ); + + const alg: SignatureAlgorithm = (opts.alg ?? "EdDSA") as SignatureAlgorithm; + if (alg !== "EdDSA" && alg !== "ES256") { + errors.push(`unsupported algorithm "${opts.alg}" — supported: EdDSA, ES256`); + } + + if (errors.length > 0) { + process.stderr.write( + `${c.red("error:")} usage: agentbridge keys generate --kid --issuer --out-public --out-private [--alg EdDSA|ES256]\n`, + ); + for (const e of errors) { + process.stderr.write(` ${c.red("·")} ${e}\n`); + } + return 2; + } + + // ── Reject non-canonical issuer up front ──────────────────────── + const issuer = opts.issuer as string; + try { + if (new URL(issuer).origin !== issuer) { + process.stderr.write( + `${c.red("error:")} --issuer must be a canonical origin (got "${issuer}", expected "${new URL(issuer).origin}")\n`, + ); + return 2; + } + } catch { + process.stderr.write( + `${c.red("error:")} --issuer "${issuer}" is not a valid URL\n`, + ); + return 2; + } + + // ── Generate keypair ──────────────────────────────────────────── + let publicKey: KeyObject; + let privateKey: KeyObject; + if (alg === "EdDSA") { + ({ publicKey, privateKey } = generateKeyPairSync("ed25519")); + } else { + ({ publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" })); + } + + const publicJwk = publicKey.export({ format: "jwk" }) as Record; + const privateJwk = privateKey.export({ format: "jwk" }) as Record; + + // ── Build the public key set document ─────────────────────────── + const kid = opts.kid as string; + const keySet = { + issuer, + version: "1" as const, + keys: [ + { + kid, + alg, + use: "manifest-sign" as const, + publicKey: publicJwk, + ...(opts.notBefore ? { notBefore: opts.notBefore } : {}), + ...(opts.notAfter ? { notAfter: opts.notAfter } : {}), + }, + ], + revokedKids: [], + }; + + // Sanity-check the document we're about to write through the same + // schema runtime callers will use. A failure here is a programmer + // bug in this command, not user input — surface it explicitly. + const validated = validateKeySet(keySet); + if (!validated.ok) { + process.stderr.write( + `${c.red("internal error:")} generated key set failed schema validation:\n`, + ); + for (const e of validated.errors) { + process.stderr.write(` ${c.red("·")} ${e}\n`); + } + return 1; + } + + // ── Build the private key envelope ────────────────────────────── + // We deliberately wrap the private JWK in a metadata envelope so + // it is *never* mistaken for a public key set by `validateKeySet`. + // The envelope's `_test_only` flag is an additional defensive + // marker — a future verifier that accidentally accepts this + // document still fails closed because there is no `keys` array. + const privateEnvelope = { + _comment: + "AgentBridge signing private key. Treat as secret. Do NOT commit this file. Production keys belong in a KMS / HSM.", + _test_only: true, + kid, + alg, + privateKeyJwk: privateJwk, + }; + + // ── Write output files ────────────────────────────────────────── + const outPublic = path.resolve(opts.outPublic as string); + const outPrivate = path.resolve(opts.outPrivate as string); + try { + await fs.writeFile(outPublic, `${JSON.stringify(keySet, null, 2)}\n`, "utf8"); + } catch (err) { + process.stderr.write( + `${c.red("error:")} could not write public key set to ${outPublic}: ${(err as Error).message}\n`, + ); + return 1; + } + try { + // mode: 0o600 — owner-read/write only. POSIX-only; Windows + // ignores the bits but the value still applies on Linux/macOS, + // which is where most adopters will run this command. + await fs.writeFile( + outPrivate, + `${JSON.stringify(privateEnvelope, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + // `fs.writeFile`'s `mode` only applies when the file is *created*; + // on a rewrite (e.g. operator regenerates a key against an + // existing path) Node preserves the existing permissions, so an + // older 0644 file would silently keep world-read after we wrote + // fresh private bytes into it. Explicit chmod on POSIX guarantees + // the documented owner-only contract on every invocation. + if (process.platform !== "win32") { + await fs.chmod(outPrivate, 0o600); + } + } catch (err) { + process.stderr.write( + `${c.red("error:")} could not write private key to ${outPrivate}: ${(err as Error).message}\n`, + ); + return 1; + } + + // ── Summarize. The private JWK is **never** printed. ──────────── + process.stdout.write( + `${c.green("✓")} generated ${alg} key ${c.bold(`kid=${kid}`)}\n`, + ); + process.stdout.write(` ${c.dim("public key set:")} ${outPublic}\n`); + process.stdout.write(` ${c.dim("private key:")} ${outPrivate}\n`); + process.stderr.write( + `\n${c.yellow("warning:")} the private key file is sensitive material.\n` + + ` Do NOT commit it. Production signing keys belong in a KMS / HSM.\n` + + ` This command is for local development and integration testing only.\n`, + ); + return 0; +} diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 92b883a..cfefb85 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,9 +1,27 @@ import { promises as fs } from "node:fs"; -import { validateManifest } from "@marmarlabs/agentbridge-core"; +import { + validateManifest, + verifyManifestSignature, + type VerifyManifestSignatureResult, +} from "@marmarlabs/agentbridge-core"; import { c } from "../colors"; +import { loadKeySetFromFile } from "./key-loader"; export interface ValidateOptions { json?: boolean; + /** Path to a publisher key set JSON file. When set, the verifier runs. */ + keys?: string; + /** + * When true, an unsigned manifest is rejected (exit 1) and a signed + * manifest with no `--keys` supplied is also rejected. Always + * additive: unsigned manifests still validate by default. + */ + requireSignature?: boolean; + expectedIssuer?: string; + /** ISO datetime / Date used for freshness checks. Defaults to "now". */ + now?: string; + /** Allowed clock skew (seconds) for `signedAt`/`expiresAt`. */ + clockSkewSeconds?: number; } export async function runValidate( @@ -11,7 +29,7 @@ export async function runValidate( opts: ValidateOptions, ): Promise { if (!source) { - process.stderr.write(`${c.red("error:")} usage: agentbridge validate \n`); + process.stderr.write(`${c.red("error:")} usage: agentbridge validate [--keys ] [--require-signature]\n`); return 2; } @@ -53,9 +71,20 @@ export async function runValidate( } const result = validateManifest(parsed); + + // ── Signature verification (opt-in via --keys / --require-signature) ── + let signatureOutcome: SignatureOutcome | undefined; + if (opts.keys || opts.requireSignature) { + signatureOutcome = await runSignaturePhase(parsed, opts); + } + if (opts.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - return result.ok ? 0 : 1; + const out: Record = { ...result }; + if (signatureOutcome) out.signature = signatureOutcome.payload; + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + if (!result.ok) return 1; + if (signatureOutcome && !signatureOutcome.ok) return 1; + return 0; } if (result.ok) { @@ -66,16 +95,140 @@ export async function runValidate( process.stdout.write( ` ${c.dim("baseUrl:")} ${m.baseUrl}\n ${c.dim("actions:")} ${m.actions.length} ${c.dim("resources:")} ${m.resources.length}\n`, ); - return 0; + } else { + process.stderr.write(`${c.red("✗")} manifest failed validation\n`); + for (const e of result.errors) { + process.stderr.write(` ${c.red("·")} ${e}\n`); + } } - process.stderr.write(`${c.red("✗")} manifest failed validation\n`); - for (const e of result.errors) { - process.stderr.write(` ${c.red("·")} ${e}\n`); + if (signatureOutcome) { + printSignatureOutcomeHuman(signatureOutcome); } - return 1; + + if (!result.ok) return 1; + if (signatureOutcome && !signatureOutcome.ok) return 1; + return 0; } function looksLikeUrl(s: string): boolean { return s.startsWith("http://") || s.startsWith("https://"); } + +// ─── Signature phase ──────────────────────────────────────────────── + +interface SignatureOutcome { + ok: boolean; + /** Stable JSON payload for `--json` mode and downstream tooling. */ + payload: Record; + /** Human-friendly summary line. */ + summary: string; + /** Optional details printed after the summary in human mode. */ + detailLines?: string[]; +} + +async function runSignaturePhase( + parsedManifest: unknown, + opts: ValidateOptions, +): Promise { + // requireSignature without --keys: only the missing-signature gate + // is meaningful — verification cannot run without a key set. + if (!opts.keys) { + const hasSignature = + parsedManifest !== null && + typeof parsedManifest === "object" && + !Array.isArray(parsedManifest) && + (parsedManifest as Record).signature !== undefined; + if (!hasSignature) { + return { + ok: false, + payload: { + ok: false, + reason: "missing-signature", + message: + "manifest carries no signature, and --require-signature was set", + }, + summary: + "manifest signature missing (require-signature mode, no key set supplied)", + }; + } + // Signature present but no key set — surface as an explicit + // skipped-verification outcome with non-zero exit (the operator + // asked for require-signature; producing a "verified" outcome + // without verifying would be misleading). + return { + ok: false, + payload: { + ok: false, + reason: "no-key-set-supplied", + message: + "manifest carries a signature, but --keys was not supplied — verification was skipped", + }, + summary: + "signature present but verification skipped (no --keys); pass the publisher's key set to verify", + }; + } + + const ksLoad = await loadKeySetFromFile(opts.keys); + if (!ksLoad.ok) { + return { + ok: false, + payload: { + ok: false, + reason: "malformed-key-set", + message: ksLoad.errors.join("; "), + }, + summary: "supplied key set could not be loaded", + detailLines: ksLoad.errors, + }; + } + + const verifyOptions: Parameters[2] = {}; + if (opts.expectedIssuer !== undefined) verifyOptions.expectedIssuer = opts.expectedIssuer; + if (opts.now !== undefined) verifyOptions.now = opts.now; + if (opts.clockSkewSeconds !== undefined) + verifyOptions.clockSkewSeconds = opts.clockSkewSeconds; + + const result = verifyManifestSignature(parsedManifest, ksLoad.keySet, verifyOptions); + return summarizeVerifyResult(result); +} + +export function summarizeVerifyResult( + result: VerifyManifestSignatureResult, +): SignatureOutcome { + if (result.ok) { + return { + ok: true, + payload: { + ok: true, + kid: result.kid, + iss: result.iss, + alg: result.alg, + signedAt: result.signedAt, + expiresAt: result.expiresAt, + }, + summary: `signature verified — alg=${result.alg} kid=${result.kid} iss=${result.iss}`, + detailLines: [ + `signedAt: ${result.signedAt}`, + `expiresAt: ${result.expiresAt}`, + ], + }; + } + return { + ok: false, + payload: { ok: false, reason: result.reason, message: result.message }, + summary: `signature verification failed — ${result.reason}`, + detailLines: [result.message], + }; +} + +function printSignatureOutcomeHuman(outcome: SignatureOutcome): void { + const sink: NodeJS.WritableStream = outcome.ok ? process.stdout : process.stderr; + const marker = outcome.ok ? c.green("✓") : c.red("✗"); + sink.write(`${marker} ${outcome.summary}\n`); + if (outcome.detailLines) { + for (const line of outcome.detailLines) { + sink.write(` ${c.dim("·")} ${line}\n`); + } + } +} diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 0000000..e7c6451 --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -0,0 +1,131 @@ +/** + * `agentbridge verify --keys ` — dedicated + * signature-verification subcommand. Always runs the verifier (unlike + * `validate`, which makes verification opt-in via `--keys`). Local + * file or fetched URL for the manifest; **local file only** for the + * key set. + * + * Exit codes: + * - 0 : signature verified + * - 1 : signature failed verification (any reason) + * - 2 : usage error (missing positional / required flag) + */ +import { promises as fs } from "node:fs"; +import { + verifyManifestSignature, + type VerifyManifestSignatureResult, +} from "@marmarlabs/agentbridge-core"; +import { c } from "../colors"; +import { loadKeySetFromFile } from "./key-loader"; +import { summarizeVerifyResult } from "./validate"; + +export interface VerifyOptions { + keys?: string; + expectedIssuer?: string; + now?: string; + clockSkewSeconds?: number; + json?: boolean; +} + +export async function runVerify( + source: string | undefined, + opts: VerifyOptions, +): Promise { + if (!source) { + process.stderr.write( + `${c.red("error:")} usage: agentbridge verify --keys [--expected-issuer ] [--now ] [--clock-skew-seconds ] [--json]\n`, + ); + return 2; + } + if (!opts.keys) { + process.stderr.write( + `${c.red("error:")} agentbridge verify requires --keys \n`, + ); + return 2; + } + + // ── Load manifest ───────────────────────────────────────────────── + let raw: string; + try { + if (looksLikeUrl(source)) { + const res = await fetch(source); + if (!res.ok) { + return failUsage(opts.json, `HTTP ${res.status} fetching ${source}`); + } + raw = await res.text(); + } else { + raw = await fs.readFile(source, "utf8"); + } + } catch (err) { + return failUsage(opts.json, (err as Error).message); + } + + let manifest: unknown; + try { + manifest = JSON.parse(raw); + } catch (err) { + return failUsage(opts.json, `manifest is not valid JSON: ${(err as Error).message}`); + } + + // ── Load key set ────────────────────────────────────────────────── + const ksLoad = await loadKeySetFromFile(opts.keys); + if (!ksLoad.ok) { + if (opts.json) { + process.stdout.write( + `${JSON.stringify({ ok: false, reason: "malformed-key-set", message: ksLoad.errors.join("; ") }, null, 2)}\n`, + ); + } else { + process.stderr.write( + `${c.red("✗")} key set "${opts.keys}" failed to load\n`, + ); + for (const err of ksLoad.errors) { + process.stderr.write(` ${c.red("·")} ${err}\n`); + } + } + return 1; + } + + // ── Verify ──────────────────────────────────────────────────────── + const verifyOptions: Parameters[2] = {}; + if (opts.expectedIssuer !== undefined) verifyOptions.expectedIssuer = opts.expectedIssuer; + if (opts.now !== undefined) verifyOptions.now = opts.now; + if (opts.clockSkewSeconds !== undefined) + verifyOptions.clockSkewSeconds = opts.clockSkewSeconds; + + const result: VerifyManifestSignatureResult = verifyManifestSignature( + manifest, + ksLoad.keySet, + verifyOptions, + ); + const outcome = summarizeVerifyResult(result); + + if (opts.json) { + process.stdout.write(`${JSON.stringify(outcome.payload, null, 2)}\n`); + } else { + const sink: NodeJS.WritableStream = outcome.ok ? process.stdout : process.stderr; + const marker = outcome.ok ? c.green("✓") : c.red("✗"); + sink.write(`${marker} ${outcome.summary}\n`); + if (outcome.detailLines) { + for (const line of outcome.detailLines) { + sink.write(` ${c.dim("·")} ${line}\n`); + } + } + } + + return outcome.ok ? 0 : 1; +} + +function looksLikeUrl(s: string): boolean { + return s.startsWith("http://") || s.startsWith("https://"); +} + +function failUsage(json: boolean | undefined, message: string): number { + if (json) { + process.stdout.write( + `${JSON.stringify({ ok: false, reason: "input-error", message }, null, 2)}\n`, + ); + } else { + process.stderr.write(`${c.red("error:")} ${message}\n`); + } + return 1; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cffb57b..4ba4afc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,8 @@ import { runValidate } from "./commands/validate"; import { runInit } from "./commands/init"; import { runGenerateOpenApi } from "./commands/generate-openapi"; import { runMcpConfig } from "./commands/mcp-config"; +import { runVerify } from "./commands/verify"; +import { runKeysGenerate } from "./commands/keys"; export interface RunCliOptions { argv?: string[]; @@ -29,7 +31,30 @@ export async function runCli(opts: RunCliOptions = {}): Promise { case "scan": return runScan(args.subcommand, { json: args.flags.json === true }); case "validate": - return runValidate(args.subcommand, { json: args.flags.json === true }); + return runValidate(args.subcommand, { + json: args.flags.json === true, + keys: typeof args.flags.keys === "string" ? args.flags.keys : undefined, + requireSignature: args.flags["require-signature"] === true, + expectedIssuer: + typeof args.flags["expected-issuer"] === "string" + ? (args.flags["expected-issuer"] as string) + : undefined, + now: typeof args.flags.now === "string" ? args.flags.now : undefined, + clockSkewSeconds: parseSkew(args.flags["clock-skew-seconds"]), + }); + case "verify": + return runVerify(args.subcommand, { + json: args.flags.json === true, + keys: typeof args.flags.keys === "string" ? args.flags.keys : undefined, + expectedIssuer: + typeof args.flags["expected-issuer"] === "string" + ? (args.flags["expected-issuer"] as string) + : undefined, + now: typeof args.flags.now === "string" ? args.flags.now : undefined, + clockSkewSeconds: parseSkew(args.flags["clock-skew-seconds"]), + }); + case "keys": + return runKeys(args.subcommand, args.flags); case "init": return runInit({ force: args.flags.force === true, @@ -46,6 +71,33 @@ export async function runCli(opts: RunCliOptions = {}): Promise { } } +async function runKeys( + sub: string | undefined, + flags: Record, +): Promise { + if (sub !== "generate") { + process.stderr.write( + `${c.red("error:")} usage: agentbridge keys generate --kid --issuer --out-public --out-private [--alg EdDSA|ES256]\n`, + ); + return 2; + } + return runKeysGenerate({ + kid: typeof flags.kid === "string" ? flags.kid : undefined, + alg: typeof flags.alg === "string" ? flags.alg : undefined, + issuer: typeof flags.issuer === "string" ? flags.issuer : undefined, + outPublic: typeof flags["out-public"] === "string" ? (flags["out-public"] as string) : undefined, + outPrivate: typeof flags["out-private"] === "string" ? (flags["out-private"] as string) : undefined, + notBefore: typeof flags["not-before"] === "string" ? (flags["not-before"] as string) : undefined, + notAfter: typeof flags["not-after"] === "string" ? (flags["not-after"] as string) : undefined, + }); +} + +function parseSkew(input: string | boolean | undefined): number | undefined { + if (typeof input !== "string") return undefined; + const n = Number(input); + return Number.isFinite(n) ? n : undefined; +} + async function runGenerate( sub: string | undefined, positionals: string[], @@ -74,13 +126,25 @@ ${c.bold("Usage")} ${c.bold("Commands")} ${c.cyan("scan ")} Score a URL's AgentBridge readiness. ${c.cyan("validate ")} Validate a manifest from disk or URL. + ${c.cyan("verify ")} Verify a manifest signature against a publisher key set. + ${c.cyan("keys generate")} Generate a local Ed25519 / ES256 signing keypair (dev only). ${c.cyan("init")} Scaffold an agentbridge.config and starter manifest. ${c.cyan("generate openapi ")} Generate a manifest from an OpenAPI 3.x doc. ${c.cyan("mcp-config")} Print example MCP client config. ${c.cyan("version")} Print CLI version. ${c.bold("Options")} - ${c.dim("--json")} Output raw JSON (scan, validate, generate). + ${c.dim("--json")} Output raw JSON (scan, validate, verify, generate). + ${c.dim("--keys PATH")} Publisher key set JSON for signature verification (validate, verify). + ${c.dim("--require-signature")} Reject unsigned manifests (validate). + ${c.dim("--expected-issuer ORIGIN")} Require signature.iss to equal this origin (validate, verify). + ${c.dim("--now ISO")} Override "now" for freshness checks (validate, verify). + ${c.dim("--clock-skew-seconds N")} Allowed clock skew for signedAt/expiresAt (validate, verify). + ${c.dim("--kid ID")} Key id (keys generate). + ${c.dim("--alg EdDSA|ES256")} Signature algorithm (keys generate; default EdDSA). + ${c.dim("--issuer ORIGIN")} Canonical publisher origin (keys generate). + ${c.dim("--out-public PATH")} Public key set output path (keys generate). + ${c.dim("--out-private PATH")} Private key output path; required (keys generate). ${c.dim("--force")} Overwrite existing files (init). ${c.dim("--format ts|json")} Choose config file format (init). ${c.dim("--out PATH")} Output path (generate openapi). @@ -90,10 +154,22 @@ ${c.bold("Options")} ${c.bold("Examples")} ${c.dim("$")} agentbridge scan http://localhost:3000 ${c.dim("$")} agentbridge validate ./public/.well-known/agentbridge.json + ${c.dim("$")} agentbridge validate ./manifest.json --keys ./agentbridge-keys.json + ${c.dim("$")} agentbridge validate ./manifest.json --require-signature --keys ./agentbridge-keys.json + ${c.dim("$")} agentbridge verify ./manifest.json --keys ./agentbridge-keys.json --json + ${c.dim("$")} agentbridge keys generate --kid acme-2026-04 --issuer https://acme.example --out-public keys.json --out-private acme.priv.json ${c.dim("$")} agentbridge init --force ${c.dim("$")} agentbridge generate openapi ./openapi.json --base-url http://localhost:3000 ${c.dim("$")} agentbridge mcp-config `); } -export { runScan, runValidate, runInit, runGenerateOpenApi, runMcpConfig }; +export { + runScan, + runValidate, + runVerify, + runKeysGenerate, + runInit, + runGenerateOpenApi, + runMcpConfig, +}; diff --git a/packages/cli/src/tests/signature-cli.test.ts b/packages/cli/src/tests/signature-cli.test.ts new file mode 100644 index 0000000..f88ea93 --- /dev/null +++ b/packages/cli/src/tests/signature-cli.test.ts @@ -0,0 +1,636 @@ +/** + * CLI signed-manifest command tests (v0.5.0 PR 5). + * + * Covers: + * - `agentbridge validate --keys ` and `--require-signature`. + * - `agentbridge verify --keys ` with --json, + * --expected-issuer, --now, --clock-skew-seconds. + * - `agentbridge keys generate` happy path + safety (no private key + * bytes on stdout, restrictive file mode). + * + * Uses `spec/signing/test-vectors.json` for the verified happy paths + * so tests stay deterministic against the same vectors implementers + * in other languages cross-check against. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { promises as fs, statSync, readFileSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { runCli } from "../index"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../../.."); +const vectorsPath = path.join(repoRoot, "spec", "signing", "test-vectors.json"); + +interface TestVectors { + vectors: Array<{ + name: string; + manifest?: Record; + keySet?: Record; + }>; +} +function loadVectors(): TestVectors { + return JSON.parse(readFileSync(vectorsPath, "utf8")) as TestVectors; +} + +function captureStdio(): { + out: string[]; + err: string[]; + restore: () => void; +} { + const out: string[] = []; + const err: string[] = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: unknown) => { + out.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown) => { + err.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + return { + out, + err, + restore: () => { + process.stdout.write = origOut; + process.stderr.write = origErr; + }, + }; +} + +const NOW_INSIDE_WINDOW = "2026-04-28T18:00:00.000Z"; + +let tmpDir: string; +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "agentbridge-cli-sig-")); +}); +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +async function writeJson(filename: string, value: unknown): Promise { + const p = path.join(tmpDir, filename); + await fs.writeFile(p, JSON.stringify(value, null, 2), "utf8"); + return p; +} + +async function writeVectorPair( + vectorName: "eddsa-valid" | "es256-valid", +): Promise<{ manifestPath: string; keySetPath: string; vector: TestVectors["vectors"][number] }> { + const v = loadVectors().vectors.find((x) => x.name === vectorName); + if (!v) throw new Error(`vector ${vectorName} missing from test-vectors.json`); + const manifestPath = await writeJson("manifest.json", v.manifest); + const keySetPath = await writeJson("keys.json", v.keySet); + return { manifestPath, keySetPath, vector: v }; +} + +// ─── validate: backward-compat ─────────────────────────────────────── + +describe("validate command — backward compatibility", () => { + it("default unsigned validation behavior unchanged", async () => { + const manifest = { + name: "Plain", + version: "1.0.0", + baseUrl: "https://example.com", + actions: [ + { + name: "list", + title: "List", + description: "Returns items.", + inputSchema: { type: "object", properties: {} }, + method: "GET", + endpoint: "/api/agentbridge/actions/list", + risk: "low", + requiresConfirmation: false, + }, + ], + }; + const file = await writeJson("plain.json", manifest); + const cap = captureStdio(); + const code = await runCli({ argv: ["validate", file] }); + cap.restore(); + expect(code).toBe(0); + expect(cap.out.join("")).toContain("valid manifest"); + }); +}); + +// ─── validate: --require-signature ─────────────────────────────────── + +describe("validate --require-signature", () => { + it("rejects an unsigned manifest with exit 1", async () => { + const file = await writeJson("plain.json", { + name: "Plain", + version: "1.0.0", + baseUrl: "https://example.com", + actions: [], + }); + const cap = captureStdio(); + const code = await runCli({ argv: ["validate", file, "--require-signature"] }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("missing"); + }); + + it("rejects a signed manifest when --require-signature is set without --keys (would falsely report verified)", async () => { + const { manifestPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ argv: ["validate", manifestPath, "--require-signature"] }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("verification skipped"); + }); +}); + +// ─── validate --keys ───────────────────────────────────────────────── + +describe("validate --keys", () => { + it("verifies a valid signed manifest (Ed25519) and exits 0", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(0); + expect(cap.out.join("")).toContain("signature verified"); + }); + + it("verifies a valid signed manifest (ES256) and exits 0", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("es256-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(0); + expect(cap.out.join("")).toContain("alg=ES256"); + }); + + it("rejects a tampered signed manifest with exit 1 and signature-invalid", async () => { + const { manifestPath, keySetPath, vector } = await writeVectorPair("eddsa-valid"); + const tampered = JSON.parse(JSON.stringify(vector.manifest)); + tampered.description = "MUTATED — must fail verification"; + await fs.writeFile(manifestPath, JSON.stringify(tampered, null, 2), "utf8"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("signature-invalid"); + }); + + it("rejects an unknown kid", async () => { + const { manifestPath, keySetPath, vector } = await writeVectorPair("eddsa-valid"); + const m = JSON.parse(JSON.stringify(vector.manifest)); + (m.signature as { kid: string }).kid = "kid-that-does-not-exist"; + await fs.writeFile(manifestPath, JSON.stringify(m, null, 2), "utf8"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("unknown-kid"); + }); + + it("rejects an expired signature", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "validate", + manifestPath, + "--keys", + keySetPath, + "--now", + "2030-01-01T00:00:00Z", + "--clock-skew-seconds", + "60", + ], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("expired"); + }); + + it("emits a clean error for a missing key set path", async () => { + const { manifestPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", path.join(tmpDir, "no-such-keys.json")], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("could not read key set"); + }); + + it("emits a clean error for a malformed key set", async () => { + const { manifestPath } = await writeVectorPair("eddsa-valid"); + const badKeys = await writeJson("bad-keys.json", { wrong: "shape" }); + const cap = captureStdio(); + const code = await runCli({ + argv: ["validate", manifestPath, "--keys", badKeys], + }); + cap.restore(); + expect(code).toBe(1); + // Either malformed-key-set or schema validation surfaces. + const stderr = cap.err.join(""); + expect(stderr).toMatch(/issuer|version|keys|malformed/); + }); +}); + +// ─── verify ────────────────────────────────────────────────────────── + +describe("verify command", () => { + it("verifies a valid signed manifest and exits 0", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["verify", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(0); + expect(cap.out.join("")).toContain("signature verified"); + }); + + it("returns exit 2 when neither --keys nor a manifest is supplied", async () => { + const cap = captureStdio(); + const code = await runCli({ argv: ["verify"] }); + cap.restore(); + expect(code).toBe(2); + expect(cap.err.join("")).toContain("usage: agentbridge verify"); + }); + + it("returns exit 2 when --keys is missing", async () => { + const { manifestPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ argv: ["verify", manifestPath] }); + cap.restore(); + expect(code).toBe(2); + expect(cap.err.join("")).toContain("--keys"); + }); + + it("rejects a tampered manifest with reason=signature-invalid", async () => { + const { manifestPath, keySetPath, vector } = await writeVectorPair("eddsa-valid"); + const tampered = JSON.parse(JSON.stringify(vector.manifest)); + tampered.description = "MUTATED"; + await fs.writeFile(manifestPath, JSON.stringify(tampered, null, 2), "utf8"); + const cap = captureStdio(); + const code = await runCli({ + argv: ["verify", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("signature-invalid"); + }); + + it("--json emits a parseable JSON object on stdout (no prose)", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "verify", + manifestPath, + "--keys", + keySetPath, + "--now", + NOW_INSIDE_WINDOW, + "--json", + ], + }); + cap.restore(); + expect(code).toBe(0); + const parsed = JSON.parse(cap.out.join("")); + expect(parsed.ok).toBe(true); + expect(parsed.alg).toBe("EdDSA"); + expect(parsed.kid).toBeDefined(); + expect(parsed.iss).toBeDefined(); + // No prose on stdout in JSON mode. + expect(cap.out.join("")).not.toContain("verified —"); + }); + + it("--json emits a parseable failure object", async () => { + const { manifestPath, keySetPath, vector } = await writeVectorPair("eddsa-valid"); + const tampered = JSON.parse(JSON.stringify(vector.manifest)); + tampered.description = "MUTATED"; + await fs.writeFile(manifestPath, JSON.stringify(tampered, null, 2), "utf8"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "verify", + manifestPath, + "--keys", + keySetPath, + "--now", + NOW_INSIDE_WINDOW, + "--json", + ], + }); + cap.restore(); + expect(code).toBe(1); + const parsed = JSON.parse(cap.out.join("")); + expect(parsed.ok).toBe(false); + expect(parsed.reason).toBe("signature-invalid"); + expect(parsed.message).toBeDefined(); + }); + + it("--expected-issuer mismatch exits 1 with reason issuer-mismatch", async () => { + const { manifestPath, keySetPath } = await writeVectorPair("eddsa-valid"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "verify", + manifestPath, + "--keys", + keySetPath, + "--now", + NOW_INSIDE_WINDOW, + "--expected-issuer", + "https://different.example", + ], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("issuer-mismatch"); + }); + + it("rejects a malformed key set with exit 1", async () => { + const { manifestPath } = await writeVectorPair("eddsa-valid"); + const badKeys = await writeJson("bad-keys.json", { not: "a key set" }); + const cap = captureStdio(); + const code = await runCli({ + argv: ["verify", manifestPath, "--keys", badKeys, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(1); + expect(cap.err.join("")).toContain("failed to load"); + }); + + it("error and success outputs never echo the public-key x or y bytes", async () => { + // Verified path: stdout should mention kid/iss but never the + // public-key x. Failure path: same. + const { manifestPath, keySetPath, vector } = await writeVectorPair("eddsa-valid"); + const publicKeyX = (vector.keySet as { keys: Array<{ publicKey: { x: string } }> }) + .keys[0].publicKey.x; + + const cap1 = captureStdio(); + await runCli({ argv: ["verify", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW] }); + cap1.restore(); + const out1 = cap1.out.join("") + cap1.err.join(""); + expect(out1).not.toContain(publicKeyX); + + // Tamper for the failure path. + const tampered = JSON.parse(JSON.stringify(vector.manifest)); + tampered.description = "MUTATED"; + await fs.writeFile(manifestPath, JSON.stringify(tampered, null, 2), "utf8"); + const cap2 = captureStdio(); + await runCli({ argv: ["verify", manifestPath, "--keys", keySetPath, "--now", NOW_INSIDE_WINDOW] }); + cap2.restore(); + const out2 = cap2.out.join("") + cap2.err.join(""); + expect(out2).not.toContain(publicKeyX); + }); +}); + +// ─── keys generate ─────────────────────────────────────────────────── + +describe("keys generate command", () => { + it("generates a valid Ed25519 keypair and writes a schema-valid public key set", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "test-key-1", + "--issuer", + "https://example.com", + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + cap.restore(); + expect(code).toBe(0); + + // Public key set was written and is schema-valid. + const publicRaw = readFileSync(outPublic, "utf8"); + const publicJson = JSON.parse(publicRaw); + expect(publicJson.issuer).toBe("https://example.com"); + expect(publicJson.version).toBe("1"); + expect(publicJson.keys).toHaveLength(1); + expect(publicJson.keys[0].kid).toBe("test-key-1"); + expect(publicJson.keys[0].alg).toBe("EdDSA"); + expect(publicJson.keys[0].publicKey.kty).toBe("OKP"); + expect(publicJson.keys[0].publicKey.crv).toBe("Ed25519"); + // Public-key set must NOT contain the private scalar `d`. + expect(publicJson.keys[0].publicKey.d).toBeUndefined(); + }); + + it("private key file is owner-only on POSIX (mode 0o600)", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "test-key-2", + "--issuer", + "https://example.com", + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + if (process.platform !== "win32") { + const stat = statSync(outPrivate); + // Mask out type bits, keep permission bits. + // eslint-disable-next-line no-bitwise + const perms = stat.mode & 0o777; + expect(perms).toBe(0o600); + } + }); + + it("rewriting an existing private key file re-applies mode 0o600 (regression)", async () => { + if (process.platform === "win32") return; // POSIX-only contract. + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + + // Pre-create the private file with 0o644 (world-read), as a + // careless operator's prior key file might be. fs.writeFile's + // `mode` only applies on creation, so without an explicit chmod + // a re-run of `keys generate` against this existing path would + // leave the world-read bit set and leak the freshly-generated + // key on shared systems — exactly the scenario Codex flagged. + await fs.writeFile(outPrivate, "{}", { mode: 0o644 }); + const before = statSync(outPrivate).mode & 0o777; + expect(before).toBe(0o644); + + const code = await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "rewrite-key", + "--issuer", + "https://example.com", + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + expect(code).toBe(0); + + // After the re-run, mode must be 0o600 regardless of the prior file's mode. + const after = statSync(outPrivate).mode & 0o777; + expect(after).toBe(0o600); + }); + + it("private key bytes never appear on stdout or stderr", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + const cap = captureStdio(); + await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "test-key-3", + "--issuer", + "https://example.com", + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + cap.restore(); + const privateRaw = readFileSync(outPrivate, "utf8"); + const privateJson = JSON.parse(privateRaw); + const dValue: string = privateJson.privateKeyJwk.d; + expect(dValue).toBeTruthy(); + const stdout = cap.out.join(""); + const stderr = cap.err.join(""); + expect(stdout).not.toContain(dValue); + expect(stderr).not.toContain(dValue); + // Output must include a sensitivity warning so operators see it. + expect(stderr).toContain("private key file is sensitive"); + }); + + it("rejects when --out-private is omitted (refuses to silently discard private material)", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "test-key-4", + "--issuer", + "https://example.com", + "--out-public", + outPublic, + ], + }); + cap.restore(); + expect(code).toBe(2); + expect(cap.err.join("")).toContain("--out-private"); + }); + + it("rejects a non-canonical issuer (trailing slash)", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + const cap = captureStdio(); + const code = await runCli({ + argv: [ + "keys", + "generate", + "--kid", + "test-key-5", + "--issuer", + "https://example.com/", + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + cap.restore(); + expect(code).toBe(2); + expect(cap.err.join("")).toContain("canonical origin"); + }); + + it("the generated keypair can sign and verify a manifest end-to-end", async () => { + const outPublic = path.join(tmpDir, "keys.json"); + const outPrivate = path.join(tmpDir, "private.json"); + const kid = "round-trip-key"; + const issuer = "https://acme.example"; + await runCli({ + argv: [ + "keys", + "generate", + "--kid", + kid, + "--issuer", + issuer, + "--out-public", + outPublic, + "--out-private", + outPrivate, + ], + }); + + // Sign a manifest using the freshly-generated private JWK. + const { createPrivateKey, sign: cryptoSign } = await import("node:crypto"); + const { canonicalizeManifestForSigning } = await import("@marmarlabs/agentbridge-core"); + const privateJwk = JSON.parse(readFileSync(outPrivate, "utf8")).privateKeyJwk; + const privateKey = createPrivateKey({ key: privateJwk, format: "jwk" }); + + const manifest = { + name: "Round-trip", + version: "1.0.0", + baseUrl: issuer, + resources: [], + actions: [ + { + name: "noop", + title: "Noop", + description: "Returns the empty object.", + method: "GET", + endpoint: "/api/agentbridge/actions/noop", + risk: "low", + requiresConfirmation: false, + inputSchema: { type: "object", properties: {} }, + permissions: [], + examples: [], + }, + ], + } as Record; + const signedAt = "2026-04-28T12:00:00.000Z"; + const expiresAt = "2026-04-29T12:00:00.000Z"; + const canonical = canonicalizeManifestForSigning(manifest); + const value = cryptoSign(null, Buffer.from(canonical, "utf8"), privateKey).toString("base64url"); + manifest.signature = { alg: "EdDSA", kid, iss: issuer, signedAt, expiresAt, value }; + + const manifestPath = path.join(tmpDir, "round-trip.json"); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + + // Use the CLI's verify command against the freshly-generated public set. + const cap = captureStdio(); + const code = await runCli({ + argv: ["verify", manifestPath, "--keys", outPublic, "--now", NOW_INSIDE_WINDOW], + }); + cap.restore(); + expect(code).toBe(0); + expect(cap.out.join("")).toContain("signature verified"); + }); +});