feat(core): add signed manifest verifier#39
Merged
Conversation
Third implementation PR for v0.5.0 signed manifests. Adds the local manifest signature verifier to @marmarlabs/agentbridge-core, building on the canonicalization + Zod schemas from PR #35 and producing a stable failure-reason enum the future scanner / MCP / CLI layers can branch on. Core does NOT depend on the SDK; tests sign manifests with Node `crypto` directly to avoid a circular package dependency. Adds: - packages/core/src/signing/verify.ts — verifyManifestSignature( manifest, keySet, options? ) → { ok: true, kid, iss, alg, signedAt, expiresAt } | { ok: false, reason, message }. Failure reasons (stable enum): - 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 Options: now (Date | string), clockSkewSeconds (default 60, clamped to 0–600), expectedIssuer (strict-equality check on signature.iss when set). Verification semantics: - Schema-validates signature and key set up front. - Revocation check beats active-set lookup ("revoked wins"). - Enforces signature.iss === keySet.issuer always; also signature.iss === expectedIssuer when supplied. - Asserts internal alg/JWK consistency: key entry alg == signature alg, and JWK kty/crv match signature.alg. - Bounded clock-skew window for signedAt/expiresAt. - Canonicalizes via canonicalizeManifestForSigning() — bytes that match what publishers actually serve. - Verifies via Node crypto.verify: * EdDSA: crypto.verify(null, …) * ES256: crypto.verify("sha256", …, { dsaEncoding: "ieee-p1363" }) — raw r||s, matches JWS ES256. - Public key built via createPublicKey({ key: jwk, format: "jwk" }). Private keys are never read by this module. - Never throws on a normal verification outcome (bad date input is the only `throw` — programmer-error path). - Error messages never include public-key x/y bytes (pinned by a regression test). Deferred to scanner / MCP / runtime PRs: - key-set-fetch-failed (no remote fetch in this module) - origin-mismatch (no fetch-URL context in this module — callers that have one pass it via expectedIssuer) - spec/signing/test-vectors.json — reference vectors for cross- language implementers: * "eddsa-valid" — Ed25519 vector with deterministic signature bytes (Ed25519 has no nonce; any conforming signer reproduces them). * "es256-valid" — ES256 vector. Cross-language implementers verify rather than compare bytes (random k). * "tampered-manifest" — documents the expected signature-invalid outcome when any non-signature field is mutated. Each vector includes the manifest, the key set, the canonical payload string, and the expected verifyManifestSignature result. Test-only private keys are emitted under `_test_only_private_key_jwk` (silently stripped by validateKeySet thanks to Zod default; clearly labeled as non-production). Public key sets contain no private `d` material. - packages/core/src/tests/verify-signature.test.ts — 34 tests covering: * Happy paths (Ed25519 + ES256). * Signature presence/shape: missing-signature, malformed-signature (non-object manifest, bad block, non-base64url value). * Key set: malformed-key-set, empty keys[], unknown-kid, revoked-kid (including the "revoked wins" precedence). * Issuer: signature.iss vs keySet.issuer mismatch, expectedIssuer mismatch and accept. * Key-type-mismatch: alg label vs signature.alg; JWK kty/crv vs alg. * Freshness: before-signed-at outside skew, before-signed-at within skew passes, expired outside skew, expired within skew passes, negative skew clamped to 0, programmer-error for unparseable now. * Signature-invalid: tampered manifest, wrong public key, truncated ES256 signature. * Canonicalization-failed: circular reference. * Hygiene: manifest/keySet not mutated; error messages do not echo public-key x/y; unsigned manifests still validate via validateManifest. * spec/signing/test-vectors.json round-trip: format header, eddsa-valid, es256-valid, tampered-manifest derivation, no private `d` in public key sets. Modifies: - packages/core/src/signing/index.ts — re-exports verify module. - packages/core/README.md — adds a concise note on verifyManifestSignature() with the failure enum, the test-vectors pointer, and a reminder that runtime enforcement (scanner / MCP / CLI / remote key fetch) ships in subsequent v0.5.0 PRs. - spec/agentbridge-manifest.v0.1.md — adds a small "Local verifier (v0.5.0)" note pointing at spec/signing/test-vectors.json. Does NOT (deliberately): - add MCP server enforcement - add scanner signature checks - add CLI --require-signature - fetch remote key sets - add HTTP/runtime enforcement - bump any package version - add any runtime dependency - publish, tag, or release Verified locally: - npm run typecheck:clean (clean) - npm test (380/380 across 23 files; was 342/21 on main = +38) - npm run build (all packages built) - npm run pack:dry-run (all six @marmarlabs/agentbridge-* OK at 0.4.0; core packed 24.8 → 33.4KB for the new module + vectors) - npm run validate:examples (all examples still validate) - npm run validate:mcp-config-examples (all client-config examples still validate) Tracking: #31 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6ec2143 to
9d9991f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6ec2143a63
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two correctness gaps flagged in Codex review of PR #39: 1. (P1) Bind signature.iss to manifest.baseUrl origin. Previously the verifier only checked signature.iss against keySet.issuer (and optionally expectedIssuer). A signed manifest could declare baseUrl=https://acme.example/api while signature.iss=https://attacker.example, and so long as the key set was issued by https://attacker.example the verifier returned ok:true — breaking the issuer/origin binding contract from the design (§9.1: "iss === manifest.baseUrl origin compare"). Fix: add a third issuer-binding check — signature.iss must equal new URL(manifest.baseUrl).origin. An unparseable baseUrl is rejected as issuer-mismatch with a clear message. Two tests added: * cross-origin signed manifest with self-consistent signature.iss vs keySet.issuer but mismatched baseUrl is rejected (issuer-mismatch). * unparseable manifest.baseUrl is rejected (issuer-mismatch). 2. (P2) Reject inverted signature time windows. Previously the freshness check only enforced `now ≥ signedAt − skew` AND `now ≤ expiresAt + skew` without checking that expiresAt > signedAt. A signature with expiresAt 30s before signedAt and a 60s skew satisfies both inequalities, so the verifier returned ok:true on a logically invalid signature. Fix: after schema validation, enforce expiresAt > signedAt strictly. An inverted or zero-length window is rejected as malformed-signature with the exact reason in the message. Two tests added (inverted window; expiresAt === signedAt). Both gaps would let a verified-but-invalid signature reach a caller that trusts the ok:true outcome — the future scanner / MCP enforcement is exactly that caller, so closing these now keeps the contract clean before downstream layers depend on it. No package version change. No runtime-API surface change. Unsigned manifests still validate. Existing happy-path test vectors (eddsa-valid, es256-valid in spec/signing/test-vectors.json) continue to verify because their signature.iss already matches manifest.baseUrl origin. Verified locally: - npx vitest run packages/core/src/tests/verify-signature.test.ts (38/38) - npm test (385/385 across 24 files) Refs: PR #39, issue #31 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced Apr 29, 2026
marmar9615-cloud
added a commit
that referenced
this pull request
Apr 29, 2026
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 <noreply@anthropic.com>
This was referenced Apr 29, 2026
marmar9615-cloud
added a commit
that referenced
this pull request
Apr 29, 2026
Fifth implementation PR for v0.5.0 signed manifests. Wires the core verifier from PR #39 + the scanner check IDs from PR #41 into operator-facing CLI commands. No MCP server enforcement, no remote /.well-known/agentbridge-keys.json fetch in this PR. Adds: - packages/cli/src/commands/verify.ts — `agentbridge verify <manifest> --keys <keyset.json>` with optional flags --expected-issuer, --now, --clock-skew-seconds, --json. Exit 0 on verified, exit 1 on any failure (carries the verifier's stable reason enum), exit 2 on usage error. --json mode emits a parseable structured outcome on stdout with no prose. - packages/cli/src/commands/keys.ts — `agentbridge keys generate --kid <id> --issuer <origin> --out-public <path> --out-private <path> [--alg EdDSA|ES256]`. Local-dev helper for bootstrapping a key set. Defaults to Ed25519. Refuses to silently discard freshly-generated material (--out-private is required). Writes the private key with mode 0o600 (owner-only) on POSIX. Never echoes the private `d` parameter to stdout/stderr; prints a sensitivity warning routed to stderr. - packages/cli/src/commands/key-loader.ts — small shared helper that loads a key set from disk and runs validateKeySet(). - packages/cli/src/tests/signature-cli.test.ts — 25 tests: * Default unsigned validation behavior unchanged. * --require-signature on an unsigned manifest exits 1. * --require-signature without --keys on a signed manifest exits 1 (refuses to silently report "verified" without verifying). * Ed25519 + ES256 happy paths via the spec/signing/test-vectors.json vectors. * Tampered, unknown-kid, expired, malformed-key-set, missing key-set path. * verify happy path, --json structure (success + failure), --expected-issuer mismatch, missing --keys (exit 2), missing manifest (exit 2). * Output never echoes the public-key x/y bytes (pinned for both success and failure paths). * keys generate: Ed25519 keypair, schema-valid public key set (no private `d`), POSIX mode 0o600 on the private file, no private bytes on stdout/stderr, sensitivity warning on stderr, --out-private required (exit 2 if omitted), non-canonical issuer rejected, freshly-generated key round-trips through Node crypto sign + agentbridge verify. Modifies: - packages/cli/src/commands/validate.ts — accepts new flags --keys, --require-signature, --expected-issuer, --now, --clock-skew-seconds. Default (no signature flags) behavior bit-identical to v0.4.x. With --keys, runs verifier and prints outcome. With --require-signature without --keys, refuses silent "verified" outcomes — surfaces missing-signature (unsigned) or no-key-set-supplied (signed but unverifiable) with exit 1. JSON mode includes a `signature` field next to the existing schema-validation result. Exports summarizeVerifyResult so the verify command shares the same output shape. - packages/cli/src/index.ts — dispatches `verify` and `keys` subcommands; threads new flags into runValidate / runVerify; expands --help with the new commands and a parseSkew helper. - packages/cli/README.md — adds concise sections for validate --keys / --require-signature, the verify command, the keys generate command (with explicit local-dev / KMS-in-production warnings), updated regression-tested-examples blurb, no-MCP-enforcement-yet reminder. Does NOT (deliberately): - add MCP server signature enforcement - 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 CLI is a Node binary; no UI/browser surface changed. The Studio dashboard and demo-app weren't touched. Live CLI command smoke against the built dist (validate default, validate --keys, verify happy, verify --json, verify tampered → exit 1 + signature-invalid, keys generate with POSIX 0o600 + private bytes never on stdout) is the relevant live test for this surface and is documented in the PR body. Verified locally: - npm run typecheck:clean (clean) - npx vitest run packages/cli/src/tests/signature-cli.test.ts (25/25) - npx vitest run packages/cli/src/tests (17 + 11 + 11 + 5 + 25 = 69/69) - npm test (435/435 across 26 files; was 409/25 on main = +26) - npm run build (all packages built) - npm run pack:dry-run (all six @marmarlabs/agentbridge-* OK at 0.4.0; CLI packed 29.4 → 57.4KB for the new modules) - npm run validate:examples (✅ all examples validate) - npm run validate:mcp-config-examples (✅ all client-config examples validate) - live: validate (default + --keys), verify (happy + --json + tampered), keys generate (mode 600, no private bytes on stdout) — all pass against `node packages/cli/dist/bin.js`. Tracking: #31 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third implementation PR for v0.5.0 signed manifests. Adds the
local manifest signature verifier to
@marmarlabs/agentbridge-core, plus deterministic reference testvectors at
spec/signing/test-vectors.json. Builds on thecanonicalization + Zod schemas from PR #35 and the SDK signer from
PR #36. Core does NOT depend on the SDK — tests sign manifests
with Node
cryptodirectly to avoid a circular package dependency.docs/designs/signed-manifests.mddocs/adr/0002-signed-manifests.md46adc2c, feat(sdk): add signed manifest helpers #36 (SDK signing) atc0cd51d.verifyManifestSignature(manifest, keySet, options?)Returns a discriminated union — never throws on a normal
verification outcome. Stable failure-reason enum the future
scanner / MCP / CLI layers branch on.
Failure modes covered (in this PR)
missing-signaturesignaturefieldmalformed-signatureManifestSignatureSchemarejects the block (bad iss, bad date, bad base64url, etc.)malformed-key-setAgentBridgeKeySetSchemarejects the keySetunsupported-algorithmunknown-kidsignature.kidnot inkeySet.keys[]revoked-kidsignature.kidlisted inkeySet.revokedKids(revocation always wins)issuer-mismatchsignature.iss !== keySet.issuer, orsignature.iss !== expectedIssuerwhen setbefore-signed-atsignedAt - skewexpiredexpiresAt + skewcanonicalization-failedsignature-invalidkey-type-mismatchFailure modes deferred (later PRs in the v0.5.0 line)
key-set-fetch-failedorigin-mismatchexpectedIssuerAlgorithms supported
alg: "EdDSA") —crypto.verify(null, …). Deterministic.alg: "ES256") —crypto.verify("sha256", …, { dsaEncoding: "ieee-p1363" }). Rawr||sform, matching JWS ES256 — not DER.Public keys built via
crypto.createPublicKey({ key: jwk, format: "jwk" }). Private keys are never read by this module. Public-key bytes never appear in error messages (pinned by a regression test).Test vectors
Added
spec/signing/test-vectors.json:eddsa-valid— full reference Ed25519 vector with deterministic signature bytes (Ed25519 has no nonce, so cross-language implementers can compare byte-for-byte).es256-valid— full reference ES256 vector. Cross-language implementers verify rather than compare bytes (randomk).tampered-manifest— documents the expectedsignature-invalidoutcome when any non-signature field is mutated; the test enforces it.Each vector includes the manifest, the key set, the canonical payload string, and the expected
verifyManifestSignatureresult. Test-only private keys are emitted under_test_only_private_key_jwk(silently stripped byvalidateKeySet, clearly labeled as non-production). Public key sets contain no privatedmaterial — pinned by a test that walks every vector.Tests added
packages/core/src/tests/verify-signature.test.ts— 34 tests:keys[], unknown-kid, revoked-kid (including "revoked wins" precedence).nowthrows (programmer error).validateManifest.din public key sets.Confirmations
verify.tsimports only fromnode:cryptoand./schemas/./canonical. Tests sign with Nodecryptodirectly.validateManifest(unsigned).ok === truewhileverifyManifestSignature(unsigned, …).reason === "missing-signature".pack:dry-runconfirms all six@marmarlabs/agentbridge-*still at0.4.0.crypto.package-lock.jsonuntouched.packages/cli/*,packages/scanner/*,examples/*,scripts/*,apps/mcp-server/*,docs/releases/*, the root README, orCHANGELOG.md.Commands run
Test plan
0.4.0.🤖 Generated with Claude Code