From 0a3120a89d55f040c053744853d4b70c70b9344e Mon Sep 17 00:00:00 2001 From: MarMar Labs Date: Tue, 28 Apr 2026 20:52:09 -0500 Subject: [PATCH] feat(scanner): add signed manifest checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth implementation PR for v0.5.0 signed manifests. Wires the core verifyManifestSignature() helper from PR #39 into the scanner, emitting stable manifest.signature.* check IDs the CLI / MCP / Studio layers will branch on next. Local key-set input only — no remote /.well-known/agentbridge-keys.json fetch in this PR. Adds: - packages/scanner/src/tests/signature-checks.test.ts — 24 tests covering: * Default unsigned scanner output unchanged (regression). * scoreManifest with no signature options or empty signature options is observably identical to v0.4.x. * requireSignature mode emits manifest.signature.missing as error (deduction 15) on unsigned manifests. * Default mode emits manifest.signature.missing as info (deduction 0) when signature options are present but keySet is omitted — informational only. * Signed manifest with no keySet emits manifest.signature.unverified-no-key-set (info, no deduction) instead of `missing`. * Verified Ed25519 + ES256 manifests emit manifest.signature.verified. * Failure-mode mappings: tampered → invalid (25), unknown kid → unknown-kid (25), revoked kid → revoked-kid (30), expired → expired (20), issuer mismatch (incl. baseUrl origin binding) → issuer-mismatch (25), inverted-window or shape-bad signature → malformed (25), malformed key set → key-set-malformed (warning, deduction 0 — operator-side issue, manifest readiness unaffected), key-type mismatch (alg vs JWK) → key-type-mismatch (20). * Hygiene: scanner output never echoes public-key x/y bytes (pinned regression). * scanUrl integration: default invocation introduces no signature checks; with signature.keySet routes to verifier; with requireSignature=true emits the missing-signature error. * Round-trip the eddsa-valid + es256-valid + tampered-manifest vectors from spec/signing/test-vectors.json through scoreManifest. Modifies: - packages/scanner/src/score.ts — adds SignatureScoringOptions + ScoringOptions; scoreManifest accepts an optional second arg; scoreSignature helper runs verifyManifestSignature and maps every VerifyManifestSignatureFailure to a stable scanner check ID with severity + deduction per the design's §13.5 matrix. Defensive `never` exhaustiveness check on the failure mapping so a future verifier addition surfaces explicitly rather than silently dropping. Default behavior — no second arg — produces bit-for-bit identical output to v0.4.x for both unsigned and signed manifests. - packages/scanner/src/scanner.ts — adds optional `signature: SignatureScoringOptions` to ScanOptions; threads it into scoreManifest. No remote key-set fetch and no change to the existing manifest-fetch network path. - packages/scanner/README.md — adds a concise "Signed-manifest checks (v0.5.0, optional)" section with example, full check ID table (severity, deduction, when), and explicit notes on what is deferred (remote key fetch, MCP/CLI enforcement) and the additive-verification reminder. Does NOT (deliberately): - add MCP server enforcement - add CLI --require-signature - fetch remote key sets - require signatures by default - bump any package version - add any runtime dependency - publish, tag, or release Browser / computer-use status: - Not needed. The scanner is a Node library; no UI/browser surface changed. No Next.js builds touched. The CLI smoke validations (validate:examples, validate:mcp-config-examples) and the npm test / build / pack:dry-run are the appropriate smoke for this surface. Verified locally: - npm run typecheck:clean (clean) - npm test (409/409 across 25 files; was 385/24 on main = +24) - npm run build (all packages built) - npm run pack:dry-run (all six @marmarlabs/agentbridge-* OK at 0.4.0; scanner packed 16.3 → 24.1KB for the new module) - npm run validate:examples (✅ all examples validate) - npm run validate:mcp-config-examples (✅ all client-config examples validate) - npx vitest run packages/scanner/src/tests (44/44 — the existing 20 + 24 new) Tracking: #31 Co-Authored-By: Claude Opus 4.7 --- packages/scanner/README.md | 75 ++- packages/scanner/src/scanner.ts | 15 +- packages/scanner/src/score.ts | 317 +++++++++++- .../src/tests/signature-checks.test.ts | 485 ++++++++++++++++++ 4 files changed, 889 insertions(+), 3 deletions(-) create mode 100644 packages/scanner/src/tests/signature-checks.test.ts diff --git a/packages/scanner/README.md b/packages/scanner/README.md index 04c46d1..c6371fa 100644 --- a/packages/scanner/README.md +++ b/packages/scanner/README.md @@ -42,11 +42,84 @@ for (const check of result.checks) { ## Categories Checks are grouped into: -- `safety` — confirmation gates, risk classification, idempotency +- `safety` — confirmation gates, risk classification, idempotency, **signed-manifest verification (v0.5.0)** - `schema` — manifest shape, JSON Schema validity, examples - `docs` — descriptions, summaries, contact info - `developerExperience` — discoverability, latency, error responses +## Signed-manifest checks (v0.5.0, optional) + +The scanner can verify a manifest's signature against a publisher +key set you supply. **The default scanner behavior is unchanged** — +unsigned manifests still score the same and signed manifests trigger +no signature check unless you opt in via the `signature` option. + +```ts +import { scanUrl } from "@marmarlabs/agentbridge-scanner"; + +const keySet = await loadYourKeySetSomehow(); // e.g. from /.well-known/agentbridge-keys.json + +const result = await scanUrl("https://orders.acme.example", { + signature: { + keySet, // required to verify + expectedIssuer: "https://orders.acme.example", // optional strict check + requireSignature: false, // default: missing signature → info, no deduction + now: new Date(), // optional override for testing + clockSkewSeconds: 60, // forwarded to the verifier + }, +}); + +const verified = result.passed.find((c) => c.id === "manifest.signature.verified"); +const invalid = result.checks.find((c) => c.id === "manifest.signature.invalid"); +``` + +`scoreManifest(manifest, options?)` accepts the same `signature` block. + +### Check IDs + +Stable identifiers — once shipped, renaming any of them is a major +bump per +[`docs/v1-readiness.md` §13](https://github.com/marmar9615-cloud/agentbridge-protocol/blob/main/docs/v1-readiness.md#13-compatibility-guarantees). +All sit under category `safety`. + +| Check | Severity (default) | Severity (`requireSignature`) | Deduction | When | +|---|---|---|---|---| +| `manifest.signature.verified` (passed) | info | info | 0 | Signature verified successfully | +| `manifest.signature.missing` | info | error | 0 / 15 | Manifest carries no `signature` block | +| `manifest.signature.unverified-no-key-set` | info | info | 0 | Signature present but no `keySet` was supplied — verification skipped | +| `manifest.signature.malformed` | error | error | 25 | Signature block fails schema validation, including inverted/zero-length time window | +| `manifest.signature.key-set-malformed` | warning | warning | 0 | Operator-supplied key set fails schema validation (manifest readiness unaffected) | +| `manifest.signature.unsupported-algorithm` | error | error | 20 | Algorithm outside `EdDSA` / `ES256` | +| `manifest.signature.unknown-kid` | error | error | 25 | `kid` not in `keySet.keys[]` | +| `manifest.signature.revoked-kid` | error | error | 30 | `kid` listed in `keySet.revokedKids[]` | +| `manifest.signature.issuer-mismatch` | error | error | 25 | `signature.iss` ≠ `keySet.issuer` / `manifest.baseUrl` origin / `expectedIssuer` | +| `manifest.signature.before-signed-at` | error | error | 20 | `now` < `signedAt − skew` | +| `manifest.signature.expired` | error | error | 20 | `now` > `expiresAt + skew` | +| `manifest.signature.canonicalization-failed` | error | error | 25 | Manifest contains values that cannot be canonicalized (circular references, etc.) | +| `manifest.signature.invalid` | error | error | 25 | Signature did not verify against the supplied public key | +| `manifest.signature.key-type-mismatch` | error | error | 20 | Key entry alg ≠ signature alg, or JWK kty/crv mismatch alg | + +### Out of scope for this release + +- **No remote key fetch.** The scanner does not fetch + `/.well-known/agentbridge-keys.json` — your code does that and + passes the result in via `signature.keySet`. A runtime helper for + remote fetch lands in a later v0.5.0 PR. +- **No MCP server / CLI enforcement.** This package only emits + scanner check IDs. The MCP server's `--require-signature` mode + and the CLI's verify / require-signature commands ship in + subsequent v0.5.0 PRs. +- **Verification is additive.** Even when a manifest verifies, the + existing safety controls — confirmation gate, origin pinning, + target-origin allowlist, audit redaction, stdio stdout hygiene, + HTTP transport auth/origin checks — all continue to enforce on + top. + +> ⚠️ **Private keys never belong inside a manifest or a key set.** +> The scanner's key-set input is the **public** half only; +> `AgentBridgeKeySetSchema` rejects JWKs that include the private +> scalar `d`. + ## Scanner regression fixtures The repo includes scanner fixtures in diff --git a/packages/scanner/src/scanner.ts b/packages/scanner/src/scanner.ts index be0157f..57892fa 100644 --- a/packages/scanner/src/scanner.ts +++ b/packages/scanner/src/scanner.ts @@ -3,6 +3,7 @@ import { scoreManifest, type ScannerCheck, type RecommendationCategory, + type SignatureScoringOptions, } from "./score"; import { probePage, type PageProbeResult } from "./playwright"; @@ -42,6 +43,15 @@ export interface ScanOptions { fetcher?: typeof fetch; /** Manifest fetch timeout (ms). Default 5000. */ timeoutMs?: number; + /** + * Optional signed-manifest checking (v0.5.0). When omitted, scanner + * output is identical to v0.4.x — unsigned manifests still score + * the same, signed manifests trigger no signature check. The + * scanner does NOT fetch `/.well-known/agentbridge-keys.json` for + * you; pass the key set in via `signature.keySet`. See + * `SignatureScoringOptions` for the full surface. + */ + signature?: SignatureScoringOptions; } const EMPTY_GROUPS: Record = { @@ -151,7 +161,10 @@ export async function scanUrl(rawUrl: string, opts: ScanOptions = {}): Promise`). When `keySet` is omitted + * but `requireSignature` is true, the scanner still emits + * `manifest.signature.missing` if no signature is present. + * + * No remote key fetch. The scanner accepts a key set the operator + * already loaded; runtime fetch of `/.well-known/agentbridge-keys.json` + * is reserved for a later v0.5.0 PR. + * + * Signature checks never authorize anything — they sit alongside the + * existing readiness checks. Verification is additive. + */ +export interface SignatureScoringOptions { + /** + * The publisher's key set, typically loaded from + * `/.well-known/agentbridge-keys.json`. Anything that conforms to + * `AgentBridgeKeySetSchema` from @marmarlabs/agentbridge-core. When + * omitted, the scanner cannot verify signatures and only the + * `manifest.signature.missing` check is exercised (and only when + * `requireSignature` is true). + */ + keySet?: unknown; + /** + * Optional strict-equality check on `signature.iss`. Forwarded to + * `verifyManifestSignature`. Useful when the scanner is invoked + * with knowledge of the origin the manifest was fetched from. + */ + expectedIssuer?: string; + /** + * When true, an unsigned manifest emits `manifest.signature.missing` + * as `error` (deduction 15). When false (default), an unsigned + * manifest emits the same check id as `info` with no deduction — + * informational only, scanner output otherwise unchanged. + */ + requireSignature?: boolean; + /** + * Override "now" for freshness checks. Forwarded to + * `verifyManifestSignature`. + */ + now?: Date | string; + /** + * Allowed clock skew for `signedAt`/`expiresAt` comparisons. + * Forwarded to `verifyManifestSignature`. + */ + clockSkewSeconds?: number; +} + +/** Optional second argument to `scoreManifest`. */ +export interface ScoringOptions { + signature?: SignatureScoringOptions; +} + export interface ScannerCheck { /** Stable identifier — useful for suppression/CI rules. */ id: string; @@ -46,7 +110,10 @@ const DESTRUCTIVE_METHODS = new Set(["DELETE"]); // Each check produces a ScannerCheck. Build a list of checks first, then // derive score / grouped recommendations / legacy fields from it. -export function scoreManifest(manifest: AgentBridgeManifest): ScoringResult { +export function scoreManifest( + manifest: AgentBridgeManifest, + options: ScoringOptions = {}, +): ScoringResult { const checks: ScannerCheck[] = []; const passed: ScannerCheck[] = []; @@ -184,6 +251,13 @@ export function scoreManifest(manifest: AgentBridgeManifest): ScoringResult { } } + // ── Signed-manifest checks (v0.5.0, opt-in) ──────────────────────── + if (options.signature !== undefined) { + const sig = scoreSignature(manifest, options.signature); + for (const c of sig.failed) checks.push(c); + for (const c of sig.passed) passed.push(c); + } + // ── Derive aggregate fields ──────────────────────────────────────── const totalDeduction = checks.reduce((sum, c) => sum + c.deduction, 0); const score = Math.max(0, Math.round(100 - totalDeduction)); @@ -414,3 +488,244 @@ function pushOrPass( }); } } + +// ── Signed-manifest scoring ────────────────────────────────────────── +// +// Maps `verifyManifestSignature` outcomes to stable scanner check IDs +// per docs/designs/signed-manifests.md §13.5. Severity / deduction +// reflect the v0.5.0 default mode unless `requireSignature` flips +// the missing-signature severity to error. +// +// What this function deliberately does NOT do: +// - No remote fetch of /.well-known/agentbridge-keys.json. The +// operator passes the key set in. +// - No bypass of any other safety check. Verification is additive. +// - No deduction for `malformed-key-set` — that's an +// operator-supplied input problem, not a manifest readiness +// defect. Surfaced as warning so the operator notices, but the +// manifest's score is unaffected. + +function scoreSignature( + manifest: AgentBridgeManifest, + options: SignatureScoringOptions, +): { failed: ScannerCheck[]; passed: ScannerCheck[] } { + const failed: ScannerCheck[] = []; + const passed: ScannerCheck[] = []; + + const requireSig = options.requireSignature === true; + const hasSig = manifest.signature !== undefined; + + // Branch 1: no signature on the manifest. + if (!hasSig) { + if (requireSig) { + failed.push({ + id: "manifest.signature.missing", + severity: "error", + message: "Manifest does not carry a `signature` block, and the scanner is in require-signature mode.", + path: "signature", + recommendation: + "Sign the manifest with `signManifest()` from @marmarlabs/agentbridge-sdk and publish the public key set at /.well-known/agentbridge-keys.json.", + category: "safety", + deduction: 15, + }); + } else { + // Default mode: informational only. The score does not move, + // and v0.4.x scanner output is preserved when callers don't + // enable require-signature mode. + failed.push({ + id: "manifest.signature.missing", + severity: "info", + message: "Manifest does not carry a `signature` block (informational; verification is opt-in in v0.5.0).", + path: "signature", + recommendation: + "Optional: sign the manifest with `signManifest()` from @marmarlabs/agentbridge-sdk so agents can verify its publisher offline.", + category: "safety", + deduction: 0, + }); + } + return { failed, passed }; + } + + // Branch 2: signature present but no key set was supplied — verifier + // can't run. Emit an info note so the operator knows verification + // was skipped; do not deduct (it's an operator config gap, not a + // manifest defect). + if (options.keySet === undefined) { + failed.push({ + id: "manifest.signature.unverified-no-key-set", + severity: "info", + message: "Manifest carries a signature, but no key set was supplied to the scanner — verification was skipped.", + path: "signature", + recommendation: + "Pass the publisher's key set (typically from /.well-known/agentbridge-keys.json) via the scanner's signature.keySet option to verify.", + category: "safety", + deduction: 0, + }); + return { failed, passed }; + } + + // Branch 3: both signature and key set present — run the verifier. + const result = verifyManifestSignature(manifest, options.keySet, { + expectedIssuer: options.expectedIssuer, + now: options.now, + clockSkewSeconds: options.clockSkewSeconds, + }); + + if (result.ok) { + passed.push({ + id: "manifest.signature.verified", + severity: "info", + message: `Signature verified (alg=${result.alg}, kid=${result.kid}, iss=${result.iss}).`, + path: "signature", + category: "safety", + deduction: 0, + }); + return { failed, passed }; + } + + const mapped = mapVerifyFailure(result.reason); + failed.push({ + id: mapped.id, + severity: mapped.severity, + message: result.message, + path: "signature", + recommendation: mapped.recommendation, + category: "safety", + deduction: mapped.deduction, + }); + return { failed, passed }; +} + +interface MappedSignatureFailure { + id: string; + severity: CheckSeverity; + deduction: number; + recommendation: string; +} + +/** + * Maps a `VerifyManifestSignatureFailure` to its scanner check ID, + * severity, deduction, and recommendation. Stable identifiers — once + * shipped, renaming any of them is a major bump per + * docs/v1-readiness.md §13. + */ +function mapVerifyFailure( + reason: VerifyManifestSignatureFailure, +): MappedSignatureFailure { + switch (reason) { + case "missing-signature": + // Only reachable here when `keySet` was provided (we'd have + // returned earlier otherwise). Treat as the same default-mode + // info as the no-key-set branch. + return { + id: "manifest.signature.missing", + severity: "info", + deduction: 0, + recommendation: + "Optional: sign the manifest with `signManifest()` from @marmarlabs/agentbridge-sdk so agents can verify its publisher offline.", + }; + case "malformed-signature": + return { + id: "manifest.signature.malformed", + severity: "error", + deduction: 25, + recommendation: + "Re-sign the manifest with a current SDK; the signature block fails schema validation (bad iss / dates / value, or expiresAt not after signedAt).", + }; + case "malformed-key-set": + // Operator-side issue; surfaced for visibility but not deducted + // from the manifest's readiness score. + return { + id: "manifest.signature.key-set-malformed", + severity: "warning", + deduction: 0, + recommendation: + "Fix the supplied key set so it conforms to AgentBridgeKeySetSchema. Verification was skipped — the manifest itself may still be valid.", + }; + case "unsupported-algorithm": + return { + id: "manifest.signature.unsupported-algorithm", + severity: "error", + deduction: 20, + recommendation: + "Use one of the v0.5.0 supported algorithms: EdDSA (Ed25519, default) or ES256 (ECDSA P-256).", + }; + case "unknown-kid": + return { + id: "manifest.signature.unknown-kid", + severity: "error", + deduction: 25, + recommendation: + "Sign with a kid present in the publisher's key set, or rotate the key set to include the kid the manifest references.", + }; + case "revoked-kid": + return { + id: "manifest.signature.revoked-kid", + severity: "error", + deduction: 30, + recommendation: + "Re-sign the manifest with a current, non-revoked kid. The key id used here appears in keySet.revokedKids.", + }; + case "issuer-mismatch": + return { + id: "manifest.signature.issuer-mismatch", + severity: "error", + deduction: 25, + recommendation: + "Ensure signature.iss equals the canonical origin of manifest.baseUrl AND keySet.issuer (and any expectedIssuer the runtime supplies).", + }; + case "before-signed-at": + return { + id: "manifest.signature.before-signed-at", + severity: "error", + deduction: 20, + recommendation: + "Check the signer's clock — signedAt is in the future relative to the verifier's now (outside the configured skew window).", + }; + case "expired": + return { + id: "manifest.signature.expired", + severity: "error", + deduction: 20, + recommendation: + "Ask the publisher to re-sign the manifest. The signature's expiresAt has passed (outside the configured skew window).", + }; + case "canonicalization-failed": + return { + id: "manifest.signature.canonicalization-failed", + severity: "error", + deduction: 25, + recommendation: + "The manifest contains values that cannot be canonicalized (circular references, BigInt, etc.). Remove them and re-sign.", + }; + case "signature-invalid": + return { + id: "manifest.signature.invalid", + severity: "error", + deduction: 25, + recommendation: + "Verify the manifest has not been tampered with after signing, that the kid resolves to the right public key, and that the signature was produced over the canonical bytes.", + }; + case "key-type-mismatch": + return { + id: "manifest.signature.key-type-mismatch", + severity: "error", + deduction: 20, + recommendation: + "Match the key entry's alg + JWK kty/crv to the signature's alg (EdDSA → kty=OKP/crv=Ed25519; ES256 → kty=EC/crv=P-256).", + }; + default: { + // Defensive — TypeScript exhaustiveness. If a new failure + // reason is added to the verifier without updating this + // mapping, surface it explicitly rather than silently dropping. + const _exhaustive: never = reason; + return { + id: `manifest.signature.unknown-failure-${String(_exhaustive)}`, + severity: "error", + deduction: 0, + recommendation: + "An unrecognized verifier failure reason was returned; please file an issue.", + }; + } + } +} diff --git a/packages/scanner/src/tests/signature-checks.test.ts b/packages/scanner/src/tests/signature-checks.test.ts new file mode 100644 index 0000000..8e174a7 --- /dev/null +++ b/packages/scanner/src/tests/signature-checks.test.ts @@ -0,0 +1,485 @@ +/** + * Scanner signed-manifest check tests (v0.5.0 PR 4). + * + * Generates ephemeral test keypairs at runtime and reuses the + * deterministic vectors at `spec/signing/test-vectors.json`. The + * scanner package depends on `@marmarlabs/agentbridge-core` (which + * owns `verifyManifestSignature`) and on Node `crypto` for the + * ad-hoc signing the synthetic test cases need — no SDK dependency, + * no new package dependency. + */ +import { describe, it, expect } from "vitest"; +import { + generateKeyPairSync, + sign as cryptoSign, + type KeyObject, +} from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + canonicalizeManifestForSigning, + type AgentBridgeKeySet, + type AgentBridgeManifest, + type SignatureAlgorithm, +} from "@marmarlabs/agentbridge-core"; +import { scoreManifest } from "../score"; +import { scanUrl } from "../scanner"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../../.."); +const vectorsPath = path.join(repoRoot, "spec", "signing", "test-vectors.json"); + +// ── 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 baseUnsignedManifest: AgentBridgeManifest = { + name: "Acme Orders", + version: "1.4.2", + baseUrl: ISSUER, + contact: "platform@acme.example", + auth: { type: "none" }, + 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" } }, + }, + permissions: [], + examples: [{ description: "List", input: {} }], + humanReadableSummaryTemplate: "List orders", + }, + ], +}; + +function signEd25519( + manifest: Record, + privateKey: KeyObject, +): string { + return cryptoSign( + null, + Buffer.from(canonicalizeManifestForSigning(manifest), "utf8"), + privateKey, + ).toString("base64url"); +} + +function buildSignedManifest( + alg: SignatureAlgorithm, + publicKey: KeyObject, + privateKey: KeyObject, + kid: string, + overrides: { signedAt?: string; expiresAt?: string; iss?: string } = {}, +): { manifest: AgentBridgeManifest; keySet: AgentBridgeKeySet } { + const signedAt = overrides.signedAt ?? SIGNED_AT; + const expiresAt = overrides.expiresAt ?? EXPIRES_AT; + const iss = overrides.iss ?? ISSUER; + const manifest = JSON.parse(JSON.stringify(baseUnsignedManifest)) as Record; + const value = + alg === "EdDSA" + ? signEd25519(manifest, privateKey) + : cryptoSign( + "sha256", + Buffer.from(canonicalizeManifestForSigning(manifest), "utf8"), + { key: privateKey, dsaEncoding: "ieee-p1363" }, + ).toString("base64url"); + 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: manifest as AgentBridgeManifest, keySet }; +} + +interface TestVectors { + vectors: Array<{ + name: string; + manifest?: Record; + keySet?: AgentBridgeKeySet; + _test_only_private_key_jwk?: Record; + }>; +} +function loadVectors(): TestVectors { + return JSON.parse(readFileSync(vectorsPath, "utf8")) as TestVectors; +} + +// ── Default behavior — unsigned scanner output unchanged ───────────── + +describe("scoreManifest — default behavior unchanged for unsigned manifests", () => { + it("emits no signature checks when no signature options passed", () => { + const r = scoreManifest(baseUnsignedManifest); + const sig = [...r.checks, ...r.passed].filter((c) => + c.id.startsWith("manifest.signature."), + ); + expect(sig.length).toBe(0); + }); + + it("does not mutate the existing check inventory or score", () => { + const a = scoreManifest(baseUnsignedManifest); + const b = scoreManifest(baseUnsignedManifest, {}); + expect(b.score).toBe(a.score); + expect(b.checks.length).toBe(a.checks.length); + expect(b.passed.length).toBe(a.passed.length); + }); +}); + +// ── requireSignature mode without a key set ────────────────────────── + +describe("scoreManifest — requireSignature mode", () => { + it("emits manifest.signature.missing as error when manifest is unsigned", () => { + const r = scoreManifest(baseUnsignedManifest, { + signature: { requireSignature: true }, + }); + const missing = r.checks.find((c) => c.id === "manifest.signature.missing"); + expect(missing).toBeDefined(); + expect(missing!.severity).toBe("error"); + expect(missing!.deduction).toBe(15); + }); + + it("default mode (no requireSignature) emits manifest.signature.missing as info with no deduction", () => { + const r = scoreManifest(baseUnsignedManifest, { signature: {} }); + const missing = r.checks.find((c) => c.id === "manifest.signature.missing"); + expect(missing).toBeDefined(); + expect(missing!.severity).toBe("info"); + expect(missing!.deduction).toBe(0); + }); + + it("signed manifest without a key set emits unverified-no-key-set info, not missing", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + const r = scoreManifest(manifest, { signature: {} }); + expect(r.checks.find((c) => c.id === "manifest.signature.missing")).toBeUndefined(); + const skipped = r.checks.find( + (c) => c.id === "manifest.signature.unverified-no-key-set", + ); + expect(skipped).toBeDefined(); + expect(skipped!.severity).toBe("info"); + expect(skipped!.deduction).toBe(0); + }); +}); + +// ── Verified happy paths ───────────────────────────────────────────── + +describe("scoreManifest — verified signatures emit manifest.signature.verified", () => { + it("Ed25519 signed manifest verifies via the scanner", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest( + "EdDSA", + publicKey, + privateKey, + "k1", + ); + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + const verified = r.passed.find((c) => c.id === "manifest.signature.verified"); + expect(verified).toBeDefined(); + expect(verified!.severity).toBe("info"); + expect(verified!.deduction).toBe(0); + expect(verified!.message).toContain("alg=EdDSA"); + expect(verified!.message).toContain("kid=k1"); + }); + + it("ES256 signed manifest verifies via the scanner", () => { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest, keySet } = buildSignedManifest( + "ES256", + publicKey, + privateKey, + "k1", + ); + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.passed.find((c) => c.id === "manifest.signature.verified")).toBeDefined(); + }); + + it("verified signatures do not introduce new failed checks", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + const sigFailed = r.checks.filter((c) => c.id.startsWith("manifest.signature.")); + expect(sigFailed.length).toBe(0); + }); +}); + +// ── Failure-mode mappings ──────────────────────────────────────────── + +describe("scoreManifest — verifier failures map to scanner check IDs", () => { + it("tampered manifest → manifest.signature.invalid (error, deduction 25)", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + (manifest as Record).contact = "tampered@evil.example"; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + const c = r.checks.find((c) => c.id === "manifest.signature.invalid"); + expect(c).toBeDefined(); + expect(c!.severity).toBe("error"); + expect(c!.deduction).toBe(25); + }); + + it("unknown kid → manifest.signature.unknown-kid", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k-active"); + (manifest.signature as { kid: string }).kid = "k-mystery"; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.unknown-kid")).toBeDefined(); + }); + + it("revoked kid → manifest.signature.revoked-kid (deduction 30 — highest)", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + keySet.revokedKids = ["k1"]; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + const c = r.checks.find((c) => c.id === "manifest.signature.revoked-kid"); + expect(c).toBeDefined(); + expect(c!.deduction).toBe(30); + }); + + it("expired signature → manifest.signature.expired", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + const r = scoreManifest(manifest, { + signature: { keySet, now: "2030-01-01T00:00:00.000Z", clockSkewSeconds: 60 }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.expired")).toBeDefined(); + }); + + it("issuer mismatch (signature.iss != manifest.baseUrl) → manifest.signature.issuer-mismatch", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + // Sign over a manifest where signature.iss differs from baseUrl. + const manifest = JSON.parse(JSON.stringify(baseUnsignedManifest)) as Record; + const value = signEd25519(manifest, privateKey); + manifest.signature = { + alg: "EdDSA", + kid: "k1", + iss: "https://attacker.example", + signedAt: SIGNED_AT, + expiresAt: EXPIRES_AT, + value, + }; + const keySet: AgentBridgeKeySet = { + issuer: "https://attacker.example", + version: "1", + keys: [ + { + kid: "k1", + alg: "EdDSA", + use: "manifest-sign", + publicKey: publicKey.export({ format: "jwk" }) as never, + }, + ], + revokedKids: [], + }; + const r = scoreManifest(manifest as AgentBridgeManifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.issuer-mismatch")).toBeDefined(); + }); + + it("malformed key set → manifest.signature.key-set-malformed (warning, no deduction)", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + const r = scoreManifest(manifest, { + signature: { keySet: { wrong: "shape" }, now: NOW_INSIDE_WINDOW }, + }); + const c = r.checks.find((c) => c.id === "manifest.signature.key-set-malformed"); + expect(c).toBeDefined(); + expect(c!.severity).toBe("warning"); + expect(c!.deduction).toBe(0); + }); + + it("malformed signature block → manifest.signature.malformed", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + // Strip required fields. + manifest.signature = { alg: "EdDSA", kid: "k1" } as never; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.malformed")).toBeDefined(); + }); + + it("inverted-window signature → manifest.signature.malformed", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + // ExpiresAt before signedAt — verifier rejects as malformed-signature. + (manifest.signature as { expiresAt: string }).expiresAt = "2026-04-28T11:00:00.000Z"; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.malformed")).toBeDefined(); + }); + + it("key-type mismatch (key entry alg vs signature alg) → manifest.signature.key-type-mismatch", () => { + const ed = generateKeyPairSync("ed25519"); + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const { manifest } = buildSignedManifest("EdDSA", ed.publicKey, ed.privateKey, "k1"); + // Key set advertises alg=ES256 with a P-256 JWK while 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 = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.key-type-mismatch")).toBeDefined(); + }); +}); + +// ── Hygiene: no key material in scanner output ─────────────────────── + +describe("scanner output never includes private key material", () => { + it("error messages and recommendations never echo public key x/y bytes", () => { + const ed = generateKeyPairSync("ed25519"); + const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const ecJwk = ec.publicKey.export({ format: "jwk" }) as { x: string; y: string }; + const { manifest } = buildSignedManifest("EdDSA", ed.publicKey, ed.privateKey, "k1"); + const keySet: AgentBridgeKeySet = { + issuer: ISSUER, + version: "1", + keys: [ + { + kid: "k1", + alg: "ES256", + use: "manifest-sign", + publicKey: ecJwk as never, + }, + ], + revokedKids: [], + }; + const r = scoreManifest(manifest, { + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + for (const check of r.checks) { + expect(check.message).not.toContain(ecJwk.x); + expect(check.message).not.toContain(ecJwk.y); + if (check.recommendation) { + expect(check.recommendation).not.toContain(ecJwk.x); + expect(check.recommendation).not.toContain(ecJwk.y); + } + } + }); +}); + +// ── scanUrl integration — preserves existing network behavior ──────── + +describe("scanUrl — signature options route through to scoreManifest", () => { + function makeFetch(handler: (url: string) => Response): typeof fetch { + return ((url: RequestInfo | URL) => + Promise.resolve(handler(typeof url === "string" ? url : url.toString()))) as typeof fetch; + } + + it("default scanUrl invocation does not introduce signature checks", async () => { + const fetcher = makeFetch(() => + new Response(JSON.stringify(baseUnsignedManifest), { status: 200 }), + ); + const r = await scanUrl(ISSUER, { fetcher, allowAnyUrl: true }); + const sig = [...r.checks, ...r.passed].filter((c) => + c.id.startsWith("manifest.signature."), + ); + expect(sig.length).toBe(0); + }); + + it("scanUrl with signature.keySet runs the verifier", async () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const { manifest, keySet } = buildSignedManifest("EdDSA", publicKey, privateKey, "k1"); + const fetcher = makeFetch(() => + new Response(JSON.stringify(manifest), { status: 200 }), + ); + const r = await scanUrl(ISSUER, { + fetcher, + allowAnyUrl: true, + signature: { keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.passed.find((c) => c.id === "manifest.signature.verified")).toBeDefined(); + }); + + it("scanUrl with requireSignature=true on an unsigned manifest emits the missing-signature error", async () => { + const fetcher = makeFetch(() => + new Response(JSON.stringify(baseUnsignedManifest), { status: 200 }), + ); + const r = await scanUrl(ISSUER, { + fetcher, + allowAnyUrl: true, + signature: { requireSignature: true }, + }); + const c = r.checks.find((c) => c.id === "manifest.signature.missing"); + expect(c).toBeDefined(); + expect(c!.severity).toBe("error"); + expect(c!.deduction).toBe(15); + }); +}); + +// ── spec/signing/test-vectors.json round-trip ──────────────────────── + +describe("scoreManifest — spec/signing/test-vectors.json", () => { + const fixtures = loadVectors(); + + it("verifies the eddsa-valid vector through the scanner", () => { + const v = fixtures.vectors.find((x) => x.name === "eddsa-valid"); + expect(v).toBeDefined(); + const r = scoreManifest(v!.manifest as AgentBridgeManifest, { + signature: { keySet: v!.keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.passed.find((c) => c.id === "manifest.signature.verified")).toBeDefined(); + }); + + it("verifies the es256-valid vector through the scanner", () => { + const v = fixtures.vectors.find((x) => x.name === "es256-valid"); + expect(v).toBeDefined(); + const r = scoreManifest(v!.manifest as AgentBridgeManifest, { + signature: { keySet: v!.keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.passed.find((c) => c.id === "manifest.signature.verified")).toBeDefined(); + }); + + it("tampered eddsa-valid vector emits manifest.signature.invalid via the scanner", () => { + const v = fixtures.vectors.find((x) => x.name === "eddsa-valid"); + expect(v).toBeDefined(); + const tampered = JSON.parse(JSON.stringify(v!.manifest)); + tampered.description = "MUTATED"; + const r = scoreManifest(tampered, { + signature: { keySet: v!.keySet, now: NOW_INSIDE_WINDOW }, + }); + expect(r.checks.find((c) => c.id === "manifest.signature.invalid")).toBeDefined(); + }); +});