Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 84 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>
# --now <iso-datetime>
# --clock-skew-seconds <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 <file-or-url> --keys <path>`

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
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/commands/key-loader.ts
Original file line number Diff line number Diff line change
@@ -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<LoadKeySetResult> {
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);
}
190 changes: 190 additions & 0 deletions packages/cli/src/commands/keys.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const errors: string[] = [];
if (!opts.kid) errors.push("--kid <id> is required");
if (!opts.issuer) errors.push("--issuer <canonical-origin> is required");
if (!opts.outPublic) errors.push("--out-public <path> is required");
if (!opts.outPrivate)
errors.push(
"--out-private <path> 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 <id> --issuer <origin> --out-public <path> --out-private <path> [--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<string, unknown>;
const privateJwk = privateKey.export({ format: "jwk" }) as Record<string, unknown>;

// ── 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 },
);
Comment thread
marmar9615-cloud marked this conversation as resolved.
// `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;
}
Loading
Loading