Skip to content

feat(core): add signed manifest verifier#39

Merged
marmar9615-cloud merged 2 commits into
mainfrom
feature/v050-core-verify-manifest
Apr 28, 2026
Merged

feat(core): add signed manifest verifier#39
marmar9615-cloud merged 2 commits into
mainfrom
feature/v050-core-verify-manifest

Conversation

@marmar9615-cloud
Copy link
Copy Markdown
Owner

Summary

Third implementation PR for v0.5.0 signed manifests. Adds the
local manifest signature verifier to
@marmarlabs/agentbridge-core, plus deterministic reference test
vectors at spec/signing/test-vectors.json. Builds on the
canonicalization + Zod schemas from PR #35 and the SDK signer from
PR #36. Core does NOT depend on the SDK — tests sign manifests
with Node crypto directly to avoid a circular package dependency.

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.

import { verifyManifestSignature } from "@marmarlabs/agentbridge-core";

const result = verifyManifestSignature(manifest, keySet, {
  now: new Date(),                    // optional, default = new Date()
  clockSkewSeconds: 60,               // optional, default 60, clamped 0–600
  expectedIssuer: "https://acme.example",  // optional strict-equality check
});

if (result.ok) {
  // result.kid, .iss, .alg, .signedAt, .expiresAt
} else {
  // result.reason: VerifyManifestSignatureFailure
  // result.message: human-readable detail (never includes private key
  // material; never echoes public-key x/y bytes)
}

Failure modes covered (in this PR)

Reason When
missing-signature manifest has no signature field
malformed-signature ManifestSignatureSchema rejects the block (bad iss, bad date, bad base64url, etc.)
malformed-key-set AgentBridgeKeySetSchema rejects the keySet
unsupported-algorithm defensive guard for future schema additions outpacing verifier coverage
unknown-kid signature.kid not in keySet.keys[]
revoked-kid signature.kid listed in keySet.revokedKids (revocation always wins)
issuer-mismatch signature.iss !== keySet.issuer, or signature.iss !== expectedIssuer when set
before-signed-at now < signedAt - skew
expired now > expiresAt + skew
canonicalization-failed manifest contains circular references / non-JSON values
signature-invalid crypto.verify returns false (tamper, wrong key, wrong-length signature, …)
key-type-mismatch key entry alg ≠ signature alg, or JWK kty/crv don't match alg, or Node refuses the JWK

Failure modes deferred (later PRs in the v0.5.0 line)

Reason Lands in
key-set-fetch-failed scanner / MCP runtime PR — this module does no network I/O
origin-mismatch scanner / MCP runtime PR — those layers know the fetched origin and pass it via expectedIssuer

Algorithms supported

  • Ed25519 (alg: "EdDSA")crypto.verify(null, …). Deterministic.
  • ES256 (alg: "ES256")crypto.verify("sha256", …, { dsaEncoding: "ieee-p1363" }). Raw r||s form, 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 (random k).
  • tampered-manifest — documents the expected signature-invalid outcome 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 verifyManifestSignature result. Test-only private keys are emitted under _test_only_private_key_jwk (silently stripped by validateKeySet, clearly labeled as non-production). Public key sets contain no private d material — pinned by a test that walks every vector.

Tests added

packages/core/src/tests/verify-signature.test.ts34 tests:

  • Happy paths (Ed25519 + ES256).
  • Signature presence/shape: missing, malformed (non-object manifest, bad block, non-base64url value).
  • Key set: malformed, empty keys[], unknown-kid, revoked-kid (including "revoked wins" precedence).
  • Issuer: signature ↔ keySet mismatch, expectedIssuer mismatch and accept.
  • Key-type-mismatch: alg label vs signature.alg; JWK kty/crv vs alg.
  • Freshness: before-signed-at outside skew (fails), within skew (passes); expired outside skew (fails), within skew (passes); negative skew clamped to 0; unparseable now throws (programmer error).
  • Signature-invalid: tampered manifest, wrong public key, truncated ES256 signature.
  • Canonicalization-failed: circular reference.
  • Hygiene: manifest/keySet not mutated; error messages don't echo public-key x/y; unsigned manifests still validate via validateManifest.
  • Vector round-trip: format header, eddsa-valid, es256-valid, tampered-manifest derivation, no private d in public key sets.

Confirmations

  • Core does NOT depend on SDK. Verified by import inspection — verify.ts imports only from node:crypto and ./schemas / ./canonical. Tests sign with Node crypto directly.
  • No MCP / scanner / CLI enforcement. Pure module.
  • No remote key-set fetch. Caller passes the key set in.
  • Unsigned manifest behavior unchanged. Verified by the regression test that asserts validateManifest(unsigned).ok === true while verifyManifestSignature(unsigned, …).reason === "missing-signature".
  • No package versions changed. pack:dry-run confirms all six @marmarlabs/agentbridge-* still at 0.4.0.
  • No new dependencies. Pure Node crypto. package-lock.json untouched.
  • No npm publish, no git tag, no GitHub release.
  • Dependabot PRs untouched.
  • Codex examples / CLI / scripts paths avoided — no edits under packages/cli/*, packages/scanner/*, examples/*, scripts/*, apps/mcp-server/*, docs/releases/*, the root README, or CHANGELOG.md.
  • Safety invariants intact — confirmation gate, origin pinning, target-origin allowlist, audit redaction, stdio stdout hygiene, HTTP transport auth/origin checks all unchanged.

Commands run

npm run typecheck:clean                                          # clean
npx vitest run packages/core/src/tests/verify-signature.test.ts  # 34/34
npm test                                                         # 380/380 across 23 files (+38 vs 342 on main)
npm run build                                                    # all packages built
npm run pack:dry-run                                             # all six @marmarlabs/agentbridge-* OK at 0.4.0
npm run validate:examples                                        # ✅ all examples validate
npm run validate:mcp-config-examples                             # ✅ all client-config examples validate

Test plan

  • CI green on Node 20.x and 22.x.
  • All package versions remain 0.4.0.
  • +38 net new tests (34 verifier + ~4 from natural test suite growth on main).

🤖 Generated with Claude Code

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>
@marmar9615-cloud marmar9615-cloud force-pushed the feature/v050-core-verify-manifest branch from 6ec2143 to 9d9991f Compare April 28, 2026 22:44
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/core/src/signing/verify.ts
Comment thread packages/core/src/signing/verify.ts
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>
@marmar9615-cloud marmar9615-cloud merged commit 0bf7713 into main Apr 28, 2026
2 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant