diff --git a/packages/core/README.md b/packages/core/README.md index 7497faa..0be6ebf 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -40,12 +40,30 @@ npm install @marmarlabs/agentbridge-core - `canonicalizeJson` / `canonicalizeManifestForSigning` — RFC 8785 (JCS) canonicalizer used by the verifier and by `signManifest`. No new runtime dependency. - - **Sign / verify runtime APIs are not in this package yet.** - `signManifest` / `verifyManifestSignature` ship in subsequent - v0.5.0 PRs (planned to live in - `@marmarlabs/agentbridge-sdk`). The schemas and canonicalizer - here are the contract those follow-ups target. See the - [signed-manifest design](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/designs/signed-manifests.md). + - `verifyManifestSignature(manifest, keySet, options?)` — local + signature verification. Returns a discriminated + `{ ok: true, kid, iss, alg, signedAt, expiresAt }` | + `{ ok: false, reason, message }` result with stable failure + enum values (`missing-signature`, `malformed-signature`, + `malformed-key-set`, `unsupported-algorithm`, `unknown-kid`, + `revoked-kid`, `issuer-mismatch`, `before-signed-at`, + `expired`, `canonicalization-failed`, `signature-invalid`, + `key-type-mismatch`). Pure, no network, no SDK dependency. + Ed25519 + ES256. Reference test vectors live at + [`spec/signing/test-vectors.json`](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/spec/signing/test-vectors.json) + so non-JS implementers can check their canonicalizer + verifier + against fixed bytes. + - **Runtime enforcement is not in this package yet.** + `signManifest` is in `@marmarlabs/agentbridge-sdk`. Scanner + signature checks, MCP server enforcement, CLI + `--require-signature`, and the remote + `/.well-known/agentbridge-keys.json` fetch ship in subsequent + v0.5.0 PRs. Until then, **unsigned manifests continue to + validate exactly as in v0.4.x**, and verification is + **additive** — even when a manifest verifies, the existing + confirmation gate, origin pinning, target-origin allowlist, + audit redaction, stdio stdout hygiene, and HTTP transport + auth/origin checks all continue to enforce on top. ## Quick example diff --git a/packages/core/src/signing/index.ts b/packages/core/src/signing/index.ts index 503ace1..723773f 100644 --- a/packages/core/src/signing/index.ts +++ b/packages/core/src/signing/index.ts @@ -1,2 +1,3 @@ export * from "./canonical"; export * from "./schemas"; +export * from "./verify"; diff --git a/packages/core/src/signing/verify.ts b/packages/core/src/signing/verify.ts new file mode 100644 index 0000000..56825a7 --- /dev/null +++ b/packages/core/src/signing/verify.ts @@ -0,0 +1,450 @@ +/** + * Local AgentBridge manifest signature verification (v0.5.0 PR 3). + * + * Builds on the canonicalization + Zod schemas from PR #35. Returns a + * structured `VerifyManifestSignatureResult` for every in-scope outcome + * — happy path or failure — so callers (the future scanner check, the + * MCP server enforcement, the CLI `--require-signature` flag) can branch + * on a stable enum without try/catch gymnastics. + * + * Design references: + * - [docs/designs/signed-manifests.md](../../../../docs/designs/signed-manifests.md) + * §6 (algorithms / key formats), §11 (freshness, expiry, replay), + * §12 (verification behavior and failure modes). + * - [docs/adr/0002-signed-manifests.md](../../../../docs/adr/0002-signed-manifests.md). + * + * What this module verifies (locally, with no network): + * - The manifest has a `signature` block (else `missing-signature`). + * - The block matches `ManifestSignatureSchema` (else + * `malformed-signature`). + * - The supplied key set matches `AgentBridgeKeySetSchema` (else + * `malformed-key-set`). + * - `signature.iss` equals `keySet.issuer` (else `issuer-mismatch`). + * Optionally, `signature.iss` equals `options.expectedIssuer`. + * - `signature.kid` is in `keySet.keys[]` (else `unknown-kid`) and + * not in `keySet.revokedKids[]` (else `revoked-kid`). + * - The matching key entry's `alg` equals `signature.alg`, and the + * JWK's `kty`/`crv` match the algorithm (else `key-type-mismatch`). + * - Now is within `[signedAt − skew, expiresAt + skew]` (else + * `before-signed-at` or `expired`). + * - Canonicalization succeeds (else `canonicalization-failed`). + * - The signature bytes verify under the public key (else + * `signature-invalid`). + * + * What this module deliberately does NOT verify (deferred to later + * v0.5.0 PRs): + * - **No remote key-set fetch.** Callers fetch the key set + * themselves and pass it in. The runtime PR (MCP server) adds + * `key-set-fetch-failed` on the failure boundary. + * - **No fetch-origin comparison.** Callers that know the origin + * they fetched the manifest from pass `expectedIssuer` to enforce + * `signature.iss === fetched origin`. The scanner / MCP PRs + * surface `origin-mismatch` as a separate failure name when the + * fetch origin is part of their context. + * - **No scanner check IDs / MCP enforcement / CLI + * `--require-signature`.** This module is the pure verifier; the + * enforcement layers wrap it. + * + * Errors are *never* thrown for normal verification outcomes. The + * function returns `{ ok: false, reason, message }` for every covered + * failure. Programmer errors (bad TypeScript usage) may still throw. + * + * Private key material is never read by this module — only public + * keys via `crypto.createPublicKey({ key: jwk, format: "jwk" })`. + */ + +import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto"; +import { + ManifestSignatureSchema, + AgentBridgeKeySetSchema, + type ManifestSignature, + type AgentBridgeKey, + type AgentBridgeKeySet, + type SignatureAlgorithm, +} from "./schemas"; +import { + canonicalizeManifestForSigning, + CanonicalizationError, +} from "./canonical"; + +/** + * In-scope failure reasons for v0.5.0 local verification. Stable + * identifiers — once shipped, renaming any of them is a major bump + * per [v1 readiness §13](../../../../docs/v1-readiness.md#13-compatibility-guarantees). + * + * The design defines additional reasons (`key-set-fetch-failed`, + * `origin-mismatch`) that depend on context this module does not + * have. Those names are reserved by the scanner / MCP / CLI layers. + */ +export type VerifyManifestSignatureFailure = + | "missing-signature" + | "malformed-signature" + | "malformed-key-set" + | "unsupported-algorithm" + | "unknown-kid" + | "revoked-kid" + | "issuer-mismatch" + | "before-signed-at" + | "expired" + | "canonicalization-failed" + | "signature-invalid" + | "key-type-mismatch"; + +export type VerifyManifestSignatureResult = + | { + ok: true; + kid: string; + iss: string; + alg: SignatureAlgorithm; + signedAt: string; + expiresAt: string; + } + | { + ok: false; + reason: VerifyManifestSignatureFailure; + message: string; + }; + +export interface VerifyManifestSignatureOptions { + /** + * Override "now" for freshness checks. Useful for testing and for + * replay-the-past tooling. Defaults to `new Date()`. + */ + now?: Date | string; + /** + * Allowed clock skew (in seconds) when comparing `now` against + * `signedAt` and `expiresAt`. Default 60 seconds. Bounded to + * 0–600 seconds; out-of-range values are clamped to the nearest + * bound. + */ + clockSkewSeconds?: number; + /** + * Optional strict-equality check on `signature.iss`. When set, the + * verifier asserts `signature.iss === expectedIssuer` (the runtime + * caller's view of "where did this manifest come from?"). When + * unset, only the `signature.iss === keySet.issuer` invariant is + * enforced. + */ + expectedIssuer?: string; +} + +/** + * Verify an AgentBridge manifest signature against a publisher key + * set. Pure, local, no network. See module docstring for the failure + * matrix and what is intentionally deferred. + */ +export function verifyManifestSignature( + manifest: unknown, + keySet: unknown, + options: VerifyManifestSignatureOptions = {}, +): VerifyManifestSignatureResult { + // ── Manifest shape gate ────────────────────────────────────────── + if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) { + return failure( + "malformed-signature", + "manifest must be a non-null object with a `signature` field", + ); + } + const manifestObj = manifest as Record; + const rawSignature = manifestObj.signature; + + // ── missing-signature ──────────────────────────────────────────── + if (rawSignature === undefined) { + return failure( + "missing-signature", + "manifest does not carry a `signature` field", + ); + } + + // ── malformed-signature ────────────────────────────────────────── + const sigParse = ManifestSignatureSchema.safeParse(rawSignature); + if (!sigParse.success) { + return failure( + "malformed-signature", + `signature block failed schema validation: ${formatZodIssues(sigParse.error.issues)}`, + ); + } + const signature: ManifestSignature = sigParse.data; + + // Inverted-window guard: the schema accepts both dates independently, + // but a signature whose expiresAt isn't strictly after signedAt is + // logically malformed and would otherwise sneak through the + // freshness window (e.g. expiresAt 30s before signedAt with a 60s + // skew satisfies *both* `now ≥ signedAt − skew` and + // `now ≤ expiresAt + skew`). Rejecting here catches buggy or + // hostile signers up front. + const signedAtMsEarly = Date.parse(signature.signedAt); + const expiresAtMsEarly = Date.parse(signature.expiresAt); + if (!(expiresAtMsEarly > signedAtMsEarly)) { + return failure( + "malformed-signature", + `signature.expiresAt (${signature.expiresAt}) must be strictly after signature.signedAt (${signature.signedAt})`, + ); + } + + // ── malformed-key-set ──────────────────────────────────────────── + const ksParse = AgentBridgeKeySetSchema.safeParse(keySet); + if (!ksParse.success) { + return failure( + "malformed-key-set", + `key set failed schema validation: ${formatZodIssues(ksParse.error.issues)}`, + ); + } + const keys: AgentBridgeKeySet = ksParse.data; + + // ── unsupported-algorithm ──────────────────────────────────────── + // SignatureAlgorithm enum is { "EdDSA", "ES256" }. Anything outside + // that is rejected by ManifestSignatureSchema upstream, so reaching + // this branch implies the schema accepted an alg the verifier + // doesn't support — kept as a defensive guard against future schema + // additions that outpace verifier coverage. + if (signature.alg !== "EdDSA" && signature.alg !== "ES256") { + return failure( + "unsupported-algorithm", + `signature.alg "${String(signature.alg)}" is not supported by this verifier`, + ); + } + + // ── revoked-kid ────────────────────────────────────────────────── + // Check revocation BEFORE the active-set lookup. A `kid` listed in + // `revokedKids` must fail closed even if it also appears (e.g. + // accidentally) in `keys[]` — revocation always wins. + if (keys.revokedKids.includes(signature.kid)) { + return failure( + "revoked-kid", + `signature kid "${signature.kid}" is listed in keySet.revokedKids`, + ); + } + + // ── unknown-kid ────────────────────────────────────────────────── + const keyEntry = keys.keys.find((k) => k.kid === signature.kid); + if (!keyEntry) { + return failure( + "unknown-kid", + `signature kid "${signature.kid}" was not found in keySet.keys[]`, + ); + } + + // ── issuer-mismatch ────────────────────────────────────────────── + // Three issuer-binding checks combined under one reason: + // 1. signature.iss === keySet.issuer (which key set authored the + // signature). + // 2. signature.iss === new URL(manifest.baseUrl).origin (the + // signed publisher identity matches the action origin the + // manifest itself declares; a verified signature whose iss + // points elsewhere doesn't authorize cross-origin actions). + // 3. signature.iss === options.expectedIssuer (the runtime + // caller's view of where this manifest came from). + // All three must hold; failure of any is `issuer-mismatch`. + if (signature.iss !== keys.issuer) { + return failure( + "issuer-mismatch", + `signature.iss (${signature.iss}) does not equal keySet.issuer (${keys.issuer})`, + ); + } + let manifestBaseUrlOrigin: string; + try { + manifestBaseUrlOrigin = new URL(String(manifestObj.baseUrl)).origin; + } catch { + return failure( + "issuer-mismatch", + `manifest.baseUrl (${JSON.stringify(manifestObj.baseUrl)}) is not a parseable URL — cannot bind signature.iss to a manifest origin`, + ); + } + if (signature.iss !== manifestBaseUrlOrigin) { + return failure( + "issuer-mismatch", + `signature.iss (${signature.iss}) does not equal manifest.baseUrl origin (${manifestBaseUrlOrigin})`, + ); + } + if ( + options.expectedIssuer !== undefined && + signature.iss !== options.expectedIssuer + ) { + return failure( + "issuer-mismatch", + `signature.iss (${signature.iss}) does not equal expectedIssuer (${options.expectedIssuer})`, + ); + } + + // ── key-type-mismatch ──────────────────────────────────────────── + // Three internal-consistency checks combined under one reason: + // 1. The key entry's declared alg must equal signature.alg. + // 2. The JWK's kty/crv must match signature.alg. + // The first catches a publisher who lists the wrong alg next to a + // correct JWK; the second catches a malformed JWK paired with a + // matching alg label. + if (keyEntry.alg !== signature.alg) { + return failure( + "key-type-mismatch", + `key entry alg "${keyEntry.alg}" does not match signature.alg "${signature.alg}"`, + ); + } + const jwkMismatch = jwkMatchesAlg(keyEntry, signature.alg); + if (jwkMismatch !== undefined) { + return failure("key-type-mismatch", jwkMismatch); + } + + // ── before-signed-at / expired ─────────────────────────────────── + const skewSeconds = clampSkew(options.clockSkewSeconds); + const now = resolveNow(options.now); + const signedAtMs = Date.parse(signature.signedAt); + const expiresAtMs = Date.parse(signature.expiresAt); + const skewMs = skewSeconds * 1000; + if (now.getTime() < signedAtMs - skewMs) { + return failure( + "before-signed-at", + `current time is before signedAt (${signature.signedAt}) outside the ${skewSeconds}s skew window — clock skew likely`, + ); + } + if (now.getTime() > expiresAtMs + skewMs) { + return failure( + "expired", + `current time is after expiresAt (${signature.expiresAt}) outside the ${skewSeconds}s skew window — ask the publisher to re-sign`, + ); + } + + // ── canonicalization-failed ────────────────────────────────────── + let canonical: string; + try { + canonical = canonicalizeManifestForSigning(manifestObj); + } catch (err) { + if (err instanceof CanonicalizationError) { + return failure("canonicalization-failed", err.message); + } + return failure( + "canonicalization-failed", + `unexpected canonicalization error: ${(err as Error).message}`, + ); + } + + // ── signature-invalid ──────────────────────────────────────────── + let publicKey: KeyObject; + try { + publicKey = createPublicKey({ key: keyEntry.publicKey, format: "jwk" }); + } catch (err) { + // A malformed JWK should already have been caught by + // AgentBridgeKeySetSchema, so reaching here means a key that + // schema-validated but Node refused to import. Treat it as a + // key-type-mismatch — the bytes the publisher published cannot be + // turned into a usable verification key. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return failure( + "key-type-mismatch", + `could not construct public key from JWK${code ? ` (${code})` : ""}`, + ); + } + + let signatureBytes: Buffer; + try { + signatureBytes = Buffer.from(signature.value, "base64url"); + } catch { + // ManifestSignatureSchema's regex prevents this in practice, but + // keep the catch so a future schema relaxation cannot crash the + // verifier. + return failure( + "malformed-signature", + "signature.value is not a valid base64url string", + ); + } + + let verified: boolean; + try { + verified = + signature.alg === "EdDSA" + ? cryptoVerify(null, Buffer.from(canonical, "utf8"), publicKey, signatureBytes) + : cryptoVerify( + "sha256", + Buffer.from(canonical, "utf8"), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + signatureBytes, + ); + } catch (err) { + // Node throws on, e.g., signature length mismatch for ES256 + // (raw form expects exactly 64 bytes). Surface that as + // signature-invalid — the bytes don't constitute a valid + // signature for this key/algorithm. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return failure( + "signature-invalid", + `signature could not be verified${code ? ` (${code})` : ""}`, + ); + } + + if (!verified) { + return failure( + "signature-invalid", + "signature did not verify against the supplied public key", + ); + } + + // ── ok ─────────────────────────────────────────────────────────── + return { + ok: true, + kid: signature.kid, + iss: signature.iss, + alg: signature.alg, + signedAt: signature.signedAt, + expiresAt: signature.expiresAt, + }; +} + +// ─── helpers ───────────────────────────────────────────────────────── + +function failure( + reason: VerifyManifestSignatureFailure, + message: string, +): VerifyManifestSignatureResult { + return { ok: false, reason, message }; +} + +function formatZodIssues( + issues: ReadonlyArray<{ path: ReadonlyArray; message: string }>, +): string { + return issues + .map((i) => `${i.path.length > 0 ? i.path.join(".") : ""}: ${i.message}`) + .join("; "); +} + +function clampSkew(input: number | undefined): number { + if (input === undefined) return 60; + if (!Number.isFinite(input) || input < 0) return 0; + if (input > 600) return 600; + return Math.floor(input); +} + +function resolveNow(input: Date | string | undefined): Date { + if (input === undefined) return new Date(); + const d = input instanceof Date ? new Date(input.getTime()) : new Date(input); + // Programmer error if `now` is unparseable — throw so the bug surfaces + // up the call stack instead of silently coercing to "now". + if (Number.isNaN(d.getTime())) { + throw new Error( + `verifyManifestSignature: options.now is not a valid date`, + ); + } + return d; +} + +/** + * Confirm the JWK shape matches the requested algorithm. Returns + * `undefined` on a match, or a human-readable mismatch message that + * will be wrapped in `{ ok: false, reason: "key-type-mismatch", … }`. + */ +function jwkMatchesAlg( + keyEntry: AgentBridgeKey, + alg: SignatureAlgorithm, +): string | undefined { + const jwk = keyEntry.publicKey; + if (alg === "EdDSA") { + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") { + return `alg=EdDSA requires an Ed25519 JWK (kty=OKP, crv=Ed25519); got kty="${jwk.kty}"`; + } + } else { + // alg === "ES256" + if (jwk.kty !== "EC" || jwk.crv !== "P-256") { + return `alg=ES256 requires a P-256 JWK (kty=EC, crv=P-256); got kty="${jwk.kty}"`; + } + } + return undefined; +} diff --git a/packages/core/src/tests/verify-signature.test.ts b/packages/core/src/tests/verify-signature.test.ts new file mode 100644 index 0000000..a760e76 --- /dev/null +++ b/packages/core/src/tests/verify-signature.test.ts @@ -0,0 +1,666 @@ +/** + * Verifier tests. Generates ephemeral keypairs at runtime; never + * commits real private keys. Cross-checks against the deterministic + * test vectors at `spec/signing/test-vectors.json`. + * + * Core does NOT depend on @marmarlabs/agentbridge-sdk; these tests + * sign manifests using Node `crypto` directly so the verifier can + * stand alone without a circular import. + */ +import { describe, it, expect } from "vitest"; +import { + generateKeyPairSync, + createPublicKey, + sign as cryptoSign, + type KeyObject, +} from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + verifyManifestSignature, + canonicalizeManifestForSigning, + type AgentBridgeKeySet, + type SignatureAlgorithm, +} from "../signing"; +import { validateManifest } from "../manifest"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../../.."); +const vectorsPath = path.join(repoRoot, "spec", "signing", "test-vectors.json"); + +// ─── Test fixtures ─────────────────────────────────────────────────── + +const ISSUER = "https://orders.acme.example"; +const SIGNED_AT = "2026-04-28T12:00:00.000Z"; +const EXPIRES_AT = "2026-04-29T12:00:00.000Z"; +const NOW_INSIDE_WINDOW = "2026-04-28T18:00:00.000Z"; + +const baseManifest = { + name: "Acme Orders", + version: "1.4.2", + baseUrl: ISSUER, + resources: [], + actions: [ + { + name: "list_orders", + title: "List Orders", + description: "Returns all orders.", + method: "GET", + endpoint: "/api/agentbridge/actions/list_orders", + risk: "low", + requiresConfirmation: false, + inputSchema: { type: "object", properties: {} }, + permissions: [], + examples: [], + }, + ], +}; + +interface TestVectors { + vectors: Array<{ + name: string; + alg?: SignatureAlgorithm; + manifest?: Record; + keySet?: AgentBridgeKeySet; + expectedVerifyResult: { ok: boolean; reason?: string }; + derivedFrom?: string; + }>; +} + +function loadVectors(): TestVectors { + return JSON.parse(readFileSync(vectorsPath, "utf8")) as TestVectors; +} + +function sign( + alg: SignatureAlgorithm, + privateKey: KeyObject, + manifest: Record, +): string { + const canonical = canonicalizeManifestForSigning(manifest); + const buf = + alg === "EdDSA" + ? cryptoSign(null, Buffer.from(canonical, "utf8"), privateKey) + : cryptoSign( + "sha256", + Buffer.from(canonical, "utf8"), + { key: privateKey, dsaEncoding: "ieee-p1363" }, + ); + return buf.toString("base64url"); +} + +function buildSignedManifest( + alg: SignatureAlgorithm, + privateKey: KeyObject, + publicKey: KeyObject, + kid: string, + overrides: { signedAt?: string; expiresAt?: string; iss?: string } = {}, +): { manifest: Record; keySet: AgentBridgeKeySet } { + const signedAt = overrides.signedAt ?? SIGNED_AT; + const expiresAt = overrides.expiresAt ?? EXPIRES_AT; + const iss = overrides.iss ?? ISSUER; + + // Deep-clone the manifest so tests can mutate the result without + // contaminating other tests that share `baseManifest`. A spread + // would only shallow-copy, leaving `actions` and other arrays + // shared by reference. + const manifest = JSON.parse(JSON.stringify(baseManifest)) as Record; + // We sign first, then attach the signature block. The signed bytes + // are over canonicalize(manifest minus signature), which is what + // canonicalizeManifestForSigning produces. + const value = sign(alg, privateKey, manifest); + manifest.signature = { alg, kid, iss, signedAt, expiresAt, value }; + + const keySet: AgentBridgeKeySet = { + issuer: ISSUER, + version: "1", + keys: [ + { + kid, + alg, + use: "manifest-sign", + publicKey: publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; + return { manifest, keySet }; +} + +// ─── Happy paths ──────────────────────────────────────────────────── + +describe("verifyManifestSignature — valid signatures", () => { + it("verifies an Ed25519 signed manifest", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest( + "EdDSA", + privateKey, + publicKey, + "k1", + ); + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.alg).toBe("EdDSA"); + expect(r.kid).toBe("k1"); + expect(r.iss).toBe(ISSUER); + expect(r.signedAt).toBe(SIGNED_AT); + expect(r.expiresAt).toBe(EXPIRES_AT); + } + }); + + it("verifies an ES256 signed manifest", () => { + const { publicKey, privateKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const { manifest, keySet } = buildSignedManifest( + "ES256", + privateKey, + publicKey, + "k1", + ); + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.alg).toBe("ES256"); + }); +}); + +// ─── Failure: signature presence + shape ──────────────────────────── + +describe("verifyManifestSignature — signature presence & shape", () => { + it("returns missing-signature when the manifest carries no signature", () => { + const r = verifyManifestSignature(baseManifest, fakeKeySet()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("missing-signature"); + }); + + it("returns malformed-signature for a non-object manifest", () => { + const r = verifyManifestSignature(null, fakeKeySet()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-signature"); + }); + + it("returns malformed-signature when the signature block fails schema validation", () => { + const bad = { + ...baseManifest, + signature: { alg: "EdDSA", kid: "k1" }, // missing iss/signedAt/expiresAt/value + }; + const r = verifyManifestSignature(bad, fakeKeySet()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-signature"); + }); + + it("returns malformed-signature when signature.expiresAt is not strictly after signedAt", () => { + // Inverted window — without an explicit guard this can pass the + // freshness check (e.g. expiresAt 30s before signedAt with 60s + // skew satisfies both inequalities). Reject up front. + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + (manifest.signature as { expiresAt: string }).expiresAt = "2026-04-28T11:59:30.000Z"; // 30s before signedAt + const r = verifyManifestSignature(manifest, keySet, { + now: NOW_INSIDE_WINDOW, + clockSkewSeconds: 60, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-signature"); + }); + + it("returns malformed-signature when signature.expiresAt equals signedAt", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + (manifest.signature as { expiresAt: string }).expiresAt = SIGNED_AT; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-signature"); + }); + + it("returns malformed-signature when signature.value is not base64url", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + (manifest.signature as { value: string }).value = "abc def!!"; // illegal chars + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-signature"); + }); +}); + +// ─── Failure: key set ─────────────────────────────────────────────── + +describe("verifyManifestSignature — key set validation", () => { + it("returns malformed-key-set when the keySet fails schema validation", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const r = verifyManifestSignature(manifest, { wrong: "shape" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-key-set"); + }); + + it("returns malformed-key-set when keys[] is empty", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const empty = { issuer: ISSUER, version: "1", keys: [], revokedKids: [] }; + const r = verifyManifestSignature(manifest, empty); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("malformed-key-set"); + }); + + it("returns unknown-kid when the signature kid is absent from keys[]", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k-active"); + (manifest.signature as { kid: string }).kid = "k-mystery"; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("unknown-kid"); + }); + + it("returns revoked-kid when the kid is listed in revokedKids", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + keySet.revokedKids = ["k1"]; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("revoked-kid"); + }); + + it("revoked-kid wins even when the kid is also in keys[]", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + keySet.revokedKids = ["k1"]; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("revoked-kid"); + }); +}); + +// ─── Failure: issuer ──────────────────────────────────────────────── + +describe("verifyManifestSignature — issuer enforcement", () => { + it("returns issuer-mismatch when signature.iss != keySet.issuer", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + (manifest.signature as { iss: string }).iss = "https://attacker.example"; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("issuer-mismatch"); + }); + + it("returns issuer-mismatch when expectedIssuer is supplied and differs", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const r = verifyManifestSignature(manifest, keySet, { + now: NOW_INSIDE_WINDOW, + expectedIssuer: "https://different.example", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("issuer-mismatch"); + }); + + it("returns issuer-mismatch when signature.iss differs from manifest.baseUrl origin", () => { + // Bind signature.iss to manifest.baseUrl. A signature whose + // issuer points elsewhere — even when paired with a key set + // whose own `issuer` matches the signature — does not authorize + // actions on the manifest's declared origin. + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const attackerOrigin = "https://attacker.example"; + const attackerKeySet: AgentBridgeKeySet = { + issuer: attackerOrigin, + version: "1", + keys: [ + { + kid: "k1", + alg: "EdDSA", + use: "manifest-sign", + publicKey: publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; + // Sign a fresh manifest payload that claims attackerOrigin as its + // signature.iss while keeping baseUrl=ISSUER. Self-consistent on + // signature.iss vs keySet.issuer; inconsistent vs manifest.baseUrl. + const manifest = JSON.parse(JSON.stringify(baseManifest)) as Record; + const value = sign("EdDSA", privateKey, manifest); + manifest.signature = { + alg: "EdDSA", + kid: "k1", + iss: attackerOrigin, + signedAt: SIGNED_AT, + expiresAt: EXPIRES_AT, + value, + }; + const r = verifyManifestSignature(manifest, attackerKeySet, { + now: NOW_INSIDE_WINDOW, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("issuer-mismatch"); + }); + + it("returns issuer-mismatch when manifest.baseUrl is unparseable", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // Replace baseUrl with something URL() refuses. + (manifest as Record).baseUrl = "::not a url::"; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("issuer-mismatch"); + }); + + it("accepts a matching expectedIssuer", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const r = verifyManifestSignature(manifest, keySet, { + now: NOW_INSIDE_WINDOW, + expectedIssuer: ISSUER, + }); + expect(r.ok).toBe(true); + }); +}); + +// ─── Failure: key/algorithm mismatch ──────────────────────────────── + +describe("verifyManifestSignature — key-type-mismatch", () => { + it("rejects an EdDSA signature against an ES256 key entry", () => { + const ed = generateKeyPairSync("ed25519"); + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest } = buildSignedManifest("EdDSA", ed.privateKey, ed.publicKey, "k1"); + // Build a key set whose entry says alg=ES256 with a P-256 JWK, + // even though the signature claims alg=EdDSA. + const keySet: AgentBridgeKeySet = { + issuer: ISSUER, + version: "1", + keys: [ + { + kid: "k1", + alg: "ES256", + use: "manifest-sign", + publicKey: ec.publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("key-type-mismatch"); + }); + + it("rejects a key entry whose JWK kty/crv contradict its alg", () => { + const ed = generateKeyPairSync("ed25519"); + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest } = buildSignedManifest("ES256", ec.privateKey, ec.publicKey, "k1"); + // Key entry claims alg=ES256 but pairs an Ed25519 JWK. + const keySet: AgentBridgeKeySet = { + issuer: ISSUER, + version: "1", + keys: [ + { + kid: "k1", + alg: "ES256", + use: "manifest-sign", + publicKey: ed.publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("key-type-mismatch"); + }); +}); + +// ─── Failure: time / freshness ────────────────────────────────────── + +describe("verifyManifestSignature — freshness", () => { + it("returns before-signed-at when now is before signedAt outside the skew window", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // 1 hour before signedAt, with 60s skew → outside. + const r = verifyManifestSignature(manifest, keySet, { + now: "2026-04-28T11:00:00.000Z", + clockSkewSeconds: 60, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("before-signed-at"); + }); + + it("passes when now is before signedAt but within the skew window", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // 30s before signedAt, with 60s skew → inside. + const r = verifyManifestSignature(manifest, keySet, { + now: "2026-04-28T11:59:30.000Z", + clockSkewSeconds: 60, + }); + expect(r.ok).toBe(true); + }); + + it("returns expired when now is after expiresAt outside the skew window", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const r = verifyManifestSignature(manifest, keySet, { + now: "2026-04-29T13:00:00.000Z", + clockSkewSeconds: 60, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("expired"); + }); + + it("passes when now is after expiresAt but within the skew window", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // 30s after expiresAt, with 60s skew → inside. + const r = verifyManifestSignature(manifest, keySet, { + now: "2026-04-29T12:00:30.000Z", + clockSkewSeconds: 60, + }); + expect(r.ok).toBe(true); + }); + + it("clamps clockSkewSeconds to a sane range (negative → 0)", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // 30s before signedAt, but we ask for negative skew (clamped to 0). + const r = verifyManifestSignature(manifest, keySet, { + now: "2026-04-28T11:59:30.000Z", + clockSkewSeconds: -5, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("before-signed-at"); + }); + + it("throws for an unparseable options.now (programmer error)", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + expect(() => + verifyManifestSignature(manifest, keySet, { now: "not-a-date" }), + ).toThrow(/options\.now is not a valid date/); + }); +}); + +// ─── Failure: tamper / signature-invalid ──────────────────────────── + +describe("verifyManifestSignature — signature-invalid", () => { + it("returns signature-invalid when a non-signature manifest field is mutated", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + (manifest as Record).description = "tampered after signing"; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("signature-invalid"); + }); + + it("returns signature-invalid when verifying with the wrong public key", () => { + const { privateKey } = generateKeyPairSync("ed25519"); + const otherPair = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest( + "EdDSA", + privateKey, + otherPair.publicKey, // mismatched public key + "k1", + ); + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("signature-invalid"); + }); + + it("returns signature-invalid for an ES256 signature of the wrong byte length", () => { + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest, keySet } = buildSignedManifest("ES256", ec.privateKey, ec.publicKey, "k1"); + // Truncate the signature to 32 bytes (raw r||s expects 64). + const truncated = Buffer.from( + (manifest.signature as { value: string }).value, + "base64url", + ).subarray(0, 32); + (manifest.signature as { value: string }).value = truncated.toString("base64url"); + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("signature-invalid"); + }); +}); + +// ─── Failure: canonicalization ────────────────────────────────────── + +describe("verifyManifestSignature — canonicalization-failed", () => { + it("returns canonicalization-failed when the manifest contains a circular reference", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + // Inject a circular reference in actions[0]. + (manifest.actions as Array>)[0].self = + manifest.actions as unknown as Record; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("canonicalization-failed"); + }); +}); + +// ─── Mutation, hygiene, integration ───────────────────────────────── + +describe("verifyManifestSignature — hygiene & integration", () => { + it("does not mutate the manifest or the keySet", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", privateKey, publicKey, "k1"); + const beforeManifest = JSON.stringify(manifest); + const beforeKeySet = JSON.stringify(keySet); + verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(JSON.stringify(manifest)).toBe(beforeManifest); + expect(JSON.stringify(keySet)).toBe(beforeKeySet); + }); + + it("error messages never include the public key x parameter", () => { + // The verifier should not echo public-key bytes in error + // messages. Public keys aren't secret, but echoing key material + // makes diagnosing harder and risks leaking other key fields if + // the error formatting changes later. This pins the contract. + const ed = generateKeyPairSync("ed25519"); + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest } = buildSignedManifest("EdDSA", ed.privateKey, ed.publicKey, "k1"); + const ecJwk = ec.publicKey.export({ format: "jwk" }) as { x: string; y: string }; + const keySet: AgentBridgeKeySet = { + issuer: ISSUER, + version: "1", + keys: [ + { + kid: "k1", + alg: "ES256", + use: "manifest-sign", + publicKey: ecJwk as never, + }, + ], + revokedKids: [], + }; + const r = verifyManifestSignature(manifest, keySet, { now: NOW_INSIDE_WINDOW }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.message).not.toContain(ecJwk.x); + expect(r.message).not.toContain(ecJwk.y); + } + }); + + it("returns missing-signature for an unsigned manifest, while validateManifest still accepts it", () => { + const r = verifyManifestSignature(baseManifest, fakeKeySet()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("missing-signature"); + // Regression: unsigned validation path is unchanged. + expect(validateManifest(baseManifest).ok).toBe(true); + }); +}); + +// ─── spec/signing/test-vectors.json round-trip ────────────────────── + +describe("spec/signing/test-vectors.json — verifier round-trip", () => { + const fixtures = loadVectors(); + + it("file declares a stable format header", () => { + const obj = JSON.parse(readFileSync(vectorsPath, "utf8")); + expect(obj.format).toBe("agentbridge-signed-manifest-test-vectors"); + expect(obj.version).toBe("1"); + expect(Array.isArray(obj.vectors)).toBe(true); + expect(obj.vectors.length).toBeGreaterThanOrEqual(2); + }); + + it("verifies the eddsa-valid vector", () => { + const v = fixtures.vectors.find((x) => x.name === "eddsa-valid"); + expect(v).toBeDefined(); + const r = verifyManifestSignature(v!.manifest!, v!.keySet!, { + now: NOW_INSIDE_WINDOW, + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.alg).toBe("EdDSA"); + }); + + it("verifies the es256-valid vector", () => { + const v = fixtures.vectors.find((x) => x.name === "es256-valid"); + expect(v).toBeDefined(); + const r = verifyManifestSignature(v!.manifest!, v!.keySet!, { + now: NOW_INSIDE_WINDOW, + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.alg).toBe("ES256"); + }); + + it("the tampered-manifest vector documents a signature-invalid expectation", () => { + // The fixture itself doesn't ship the tampered manifest object — + // the convention is "take eddsa-valid, mutate one field, verify + // returns signature-invalid". We exercise that here so the + // documented expectation is enforced by tests. + const v = fixtures.vectors.find((x) => x.name === "eddsa-valid"); + expect(v).toBeDefined(); + const tampered = JSON.parse(JSON.stringify(v!.manifest)); + tampered.description = "MUTATED — must fail verification"; + const r = verifyManifestSignature(tampered, v!.keySet!, { + now: NOW_INSIDE_WINDOW, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("signature-invalid"); + }); + + it("public key sets in the vectors do not include private `d` material", () => { + for (const v of fixtures.vectors) { + if (!v.keySet) continue; + for (const key of v.keySet.keys) { + expect((key.publicKey as Record).d).toBeUndefined(); + } + } + }); +}); + +// ─── helpers ───────────────────────────────────────────────────────── + +function fakeKeySet(): AgentBridgeKeySet { + // Trivial valid key set used for "signature missing" / "manifest + // shape" tests where the key set is irrelevant. Generated once per + // call so tests don't share state. + const { publicKey } = generateKeyPairSync("ed25519"); + return { + issuer: ISSUER, + version: "1", + keys: [ + { + kid: "ignored", + alg: "EdDSA", + use: "manifest-sign", + publicKey: publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; +} diff --git a/spec/agentbridge-manifest.v0.1.md b/spec/agentbridge-manifest.v0.1.md index 993409a..6fdabae 100644 --- a/spec/agentbridge-manifest.v0.1.md +++ b/spec/agentbridge-manifest.v0.1.md @@ -172,7 +172,21 @@ The publisher hosts a key set at `/.well-known/agentbridge-keys.json` [`packages/core/src/signing/schemas.ts`](../packages/core/src/signing/schemas.ts) and the full design is in [`docs/designs/signed-manifests.md`](../docs/designs/signed-manifests.md)). -A formal JSON Schema for the key set will land alongside the verifier. +A formal JSON Schema for the key set will land alongside the runtime +fetch helpers. + +**Local verifier (v0.5.0).** `verifyManifestSignature(manifest, keySet, +options?)` from +[`@marmarlabs/agentbridge-core`](../packages/core/README.md) does +schema-checked, local-only signature verification — Ed25519 and ES256 +— and returns a stable failure-reason enum suitable for downstream +scanner / MCP / CLI integration. Reference test vectors covering both +algorithms (and a tampered-manifest case) ship at +[`spec/signing/test-vectors.json`](signing/test-vectors.json) so +non-JS implementers can pin their canonicalizer + verifier against +fixed bytes. Runtime fetching of `/.well-known/agentbridge-keys.json`, +scanner signature checks, and MCP server `--require-signature` +enforcement are subsequent v0.5.0 PRs. ## Security considerations diff --git a/spec/signing/test-vectors.json b/spec/signing/test-vectors.json new file mode 100644 index 0000000..e08a511 --- /dev/null +++ b/spec/signing/test-vectors.json @@ -0,0 +1,204 @@ +{ + "format": "agentbridge-signed-manifest-test-vectors", + "version": "1", + "description": "Reference test vectors for AgentBridge v0.5.0 signed-manifest verification. Local-only, no network. See docs/designs/signed-manifests.md and packages/core/src/signing/.", + "generatedBy": "packages/core (one-off generator); see PR feat(core): add signed manifest verifier.", + "vectors": [ + { + "name": "eddsa-valid", + "description": "Valid EdDSA signature over the reference manifest. The verifier must return ok:true with kid=acme-orders-ed25519-2026-04.", + "alg": "EdDSA", + "canonicalPayload": "{\"actions\":[{\"description\":\"Returns all orders, paginated.\",\"endpoint\":\"/api/agentbridge/actions/list_orders\",\"examples\":[],\"inputSchema\":{\"properties\":{},\"type\":\"object\"},\"method\":\"GET\",\"name\":\"list_orders\",\"outputSchema\":{\"properties\":{\"orders\":{\"items\":{},\"type\":\"array\"}},\"type\":\"object\"},\"permissions\":[],\"requiresConfirmation\":false,\"risk\":\"low\",\"title\":\"List Orders\"}],\"baseUrl\":\"https://orders.acme.example\",\"contact\":\"platform@acme.example\",\"description\":\"Reference test-vector manifest for AgentBridge signed-manifest verification.\",\"name\":\"Acme Orders\",\"resources\":[{\"description\":\"Customer orders.\",\"name\":\"orders\",\"url\":\"/orders\"}],\"version\":\"1.4.2\"}", + "_test_only_private_key_jwk": { + "crv": "Ed25519", + "d": "xFZcugjbaWO-2Dxt0l2qzScSnXJRXnG90PyhDR39emg", + "x": "QnFtUajHPMsJgIPkko1V2Kl7YSOz0TRjVVmx-OPiyhE", + "kty": "OKP" + }, + "manifest": { + "name": "Acme Orders", + "description": "Reference test-vector manifest for AgentBridge signed-manifest verification.", + "version": "1.4.2", + "baseUrl": "https://orders.acme.example", + "contact": "platform@acme.example", + "resources": [ + { + "name": "orders", + "description": "Customer orders.", + "url": "/orders" + } + ], + "actions": [ + { + "name": "list_orders", + "title": "List Orders", + "description": "Returns all orders, paginated.", + "method": "GET", + "endpoint": "/api/agentbridge/actions/list_orders", + "risk": "low", + "requiresConfirmation": false, + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": {} + } + } + }, + "permissions": [], + "examples": [] + } + ], + "signature": { + "alg": "EdDSA", + "kid": "acme-orders-ed25519-2026-04", + "iss": "https://orders.acme.example", + "signedAt": "2026-04-28T12:00:00.000Z", + "expiresAt": "2026-04-29T12:00:00.000Z", + "value": "tV7IN8QCPmCgvXTgZIg_OgiuQh8CjbsjTEW5bZY5e19ucViZwu1cnjn4C3IEl-mGYGTu9-Gq8VHs5IHXwLehCA" + } + }, + "keySet": { + "issuer": "https://orders.acme.example", + "version": "1", + "keys": [ + { + "kid": "acme-orders-ed25519-2026-04", + "alg": "EdDSA", + "use": "manifest-sign", + "publicKey": { + "crv": "Ed25519", + "x": "QnFtUajHPMsJgIPkko1V2Kl7YSOz0TRjVVmx-OPiyhE", + "kty": "OKP" + }, + "notBefore": "2026-01-01T00:00:00.000Z", + "notAfter": "2026-12-31T23:59:59.000Z" + } + ], + "revokedKids": [] + }, + "expectedVerifyResult": { + "ok": true, + "kid": "acme-orders-ed25519-2026-04", + "iss": "https://orders.acme.example", + "alg": "EdDSA", + "signedAt": "2026-04-28T12:00:00.000Z", + "expiresAt": "2026-04-29T12:00:00.000Z" + }, + "notes": [ + "Use options.now between signedAt and expiresAt (e.g. \"2026-04-28T18:00:00Z\").", + "Ed25519 signatures are deterministic; bytes are reproducible across runs.", + "_test_only_private_key_jwk is provided for cross-language implementers to reproduce the vector. It is NOT part of the v0.5.0 key-set schema and is silently stripped by validateKeySet (Zod default)." + ] + }, + { + "name": "es256-valid", + "description": "Valid ES256 signature over the reference manifest. The verifier must return ok:true with kid=acme-orders-es256-2026-04.", + "alg": "ES256", + "canonicalPayload": "{\"actions\":[{\"description\":\"Returns all orders, paginated.\",\"endpoint\":\"/api/agentbridge/actions/list_orders\",\"examples\":[],\"inputSchema\":{\"properties\":{},\"type\":\"object\"},\"method\":\"GET\",\"name\":\"list_orders\",\"outputSchema\":{\"properties\":{\"orders\":{\"items\":{},\"type\":\"array\"}},\"type\":\"object\"},\"permissions\":[],\"requiresConfirmation\":false,\"risk\":\"low\",\"title\":\"List Orders\"}],\"baseUrl\":\"https://orders.acme.example\",\"contact\":\"platform@acme.example\",\"description\":\"Reference test-vector manifest for AgentBridge signed-manifest verification.\",\"name\":\"Acme Orders\",\"resources\":[{\"description\":\"Customer orders.\",\"name\":\"orders\",\"url\":\"/orders\"}],\"version\":\"1.4.2\"}", + "_test_only_private_key_jwk": { + "kty": "EC", + "x": "mimLJnjcREs5c1gQxNL6Au4aTL5pJHqa6ZCiGXM9dc0", + "y": "xHIwK98OwYQ0m1YxCy1NmXxF8S6YkVy9hQXA80rB4Fo", + "crv": "P-256", + "d": "yWJGtMSeDqm9-RAnhfiUqfRUZP-aZzf_lC63SJJF48A" + }, + "manifest": { + "name": "Acme Orders", + "description": "Reference test-vector manifest for AgentBridge signed-manifest verification.", + "version": "1.4.2", + "baseUrl": "https://orders.acme.example", + "contact": "platform@acme.example", + "resources": [ + { + "name": "orders", + "description": "Customer orders.", + "url": "/orders" + } + ], + "actions": [ + { + "name": "list_orders", + "title": "List Orders", + "description": "Returns all orders, paginated.", + "method": "GET", + "endpoint": "/api/agentbridge/actions/list_orders", + "risk": "low", + "requiresConfirmation": false, + "inputSchema": { + "type": "object", + "properties": {} + }, + "outputSchema": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": {} + } + } + }, + "permissions": [], + "examples": [] + } + ], + "signature": { + "alg": "ES256", + "kid": "acme-orders-es256-2026-04", + "iss": "https://orders.acme.example", + "signedAt": "2026-04-28T12:00:00.000Z", + "expiresAt": "2026-04-29T12:00:00.000Z", + "value": "qnefAEnQ9QfPC3es3yAmu_mVL6jFbZHrMbU8NOehpibZrbAU3LmrQkO9tAZqMkCkOB_qSJmZDQeOt_biqvwbsA" + } + }, + "keySet": { + "issuer": "https://orders.acme.example", + "version": "1", + "keys": [ + { + "kid": "acme-orders-es256-2026-04", + "alg": "ES256", + "use": "manifest-sign", + "publicKey": { + "kty": "EC", + "x": "mimLJnjcREs5c1gQxNL6Au4aTL5pJHqa6ZCiGXM9dc0", + "y": "xHIwK98OwYQ0m1YxCy1NmXxF8S6YkVy9hQXA80rB4Fo", + "crv": "P-256" + }, + "notBefore": "2026-01-01T00:00:00.000Z", + "notAfter": "2026-12-31T23:59:59.000Z" + } + ], + "revokedKids": [] + }, + "expectedVerifyResult": { + "ok": true, + "kid": "acme-orders-es256-2026-04", + "iss": "https://orders.acme.example", + "alg": "ES256", + "signedAt": "2026-04-28T12:00:00.000Z", + "expiresAt": "2026-04-29T12:00:00.000Z" + }, + "notes": [ + "Use options.now between signedAt and expiresAt (e.g. \"2026-04-28T18:00:00Z\").", + "ES256 signatures are non-deterministic (random k); verify the embedded signature, do not compare bytes.", + "_test_only_private_key_jwk is provided for cross-language implementers to reproduce the vector. It is NOT part of the v0.5.0 key-set schema and is silently stripped by validateKeySet (Zod default)." + ] + }, + { + "name": "tampered-manifest", + "description": "The Ed25519 vector with the manifest's `description` field mutated after signing. The verifier MUST return ok:false reason:signature-invalid because the canonical bytes no longer match the signed bytes.", + "derivedFrom": "ed25519-valid", + "tamperingNote": "Tester: take the ed25519-valid vector, mutate any non-signature field of the manifest, and assert verifyManifestSignature returns reason:signature-invalid.", + "expectedVerifyResult": { + "ok": false, + "reason": "signature-invalid" + } + } + ] +} \ No newline at end of file