Skip to content

feat(core): add signed manifest schemas and canonicalization - #35

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

feat(core): add signed manifest schemas and canonicalization#35
marmar9615-cloud merged 2 commits into
mainfrom
feature/v050-signing-core-schemas

Conversation

@marmar9615-cloud

Copy link
Copy Markdown
Owner

Summary

First implementation PR for v0.5.0 signed manifests. Lands the
non-runtime contract surface — canonicalizer + Zod schemas +
spec update — that subsequent PRs build on. No signManifest(),
no verifyManifestSignature(), no scanner / MCP / CLI
enforcement, no version bump, no behavior change for unsigned
manifests.

Canonicalization helper

packages/core/src/signing/canonical.ts is a pure-JS RFC 8785
(JCS) canonicalizer. No new runtime dependency.

  • Object keys sorted lexicographically by UTF-16 code units
    (matches Array.prototype.sort default compare).
  • Arrays preserve order.
  • Strings via JSON.stringify (RFC 8259 minimal escaping ===
    RFC 8785 §3.2.2.2).
  • Numbers via JS String(n) (ECMA-262 7.1.12.1 === RFC 8785
    §3.2.2.3). -0 normalized to "0".
  • Throws CanonicalizationError on non-JSON values: NaN,
    Infinity, undefined, function, symbol, BigInt, non-plain
    objects (Date / Map / class instances). Errors carry a
    JSON-Pointer-style path to the offending value.
  • canonicalizeManifestForSigning(manifest) is the
    signature-stripping variant the signer / verifier (later PRs)
    will use.

Signature / key-set schemas

packages/core/src/signing/schemas.ts exports:

  • ManifestSignatureSchemaalg (EdDSA | ES256), kid,
    iss (canonical origin, refine-enforced), signedAt,
    expiresAt (ISO 8601 with offset support), value (base64url,
    optional padding).
  • PublicKeyJwkSchema — discriminated union over Ed25519
    ({kty: OKP, crv: Ed25519, x}) and ECDSA P-256
    ({kty: EC, crv: P-256, x, y}). .strict() so any extra
    field — including the private scalar d — is rejected, not
    silently stripped.
  • AgentBridgeKeySchema — one publisher key entry with
    optional notBefore / notAfter and required use: "manifest-sign".
  • AgentBridgeKeySetSchemaissuer, version: "1", keys
    (≥1), revokedKids (defaults to []).
  • validateKeySet(input) — same { ok, keySet | errors } shape
    as the existing validateManifest.

Manifest schema extension

packages/core/src/schemas.ts gains
signature: ManifestSignatureSchema.optional() on
AgentBridgeManifestSchema. Existing unsigned manifests
continue to validate exactly as in v0.4.x — covered by the
unchanged manifest.test.ts, spec-examples.test.ts, and a
fresh regression test in signature-schema.test.ts.

Spec update

  • spec/agentbridge-manifest.schema.json — adds optional
    signature to top-level properties and a $defs.Signature
    definition (with a base64url pattern, ISO format, and the
    same enum) mirroring the Zod shape.
  • spec/agentbridge-manifest.v0.1.md — adds a "Signature field
    (optional, v0.5.0+)" section explicitly stating that signing
    is optional in v0.5.0, sign / verify runtime APIs ship later,
    and verification is additive (does not bypass the
    confirmation gate, origin pinning, target-origin allowlist,
    audit redaction, stdio stdout hygiene, or the HTTP transport's
    auth / Origin allowlist).

The manifest spec stays at v0.1 — adding an optional field is
non-breaking. The first stable spec version that requires
signatures will be v1.0 (per docs/v1-readiness.md).

Tests added

  • packages/core/src/tests/canonical.test.ts38 tests
    covering primitives, arrays, objects, determinism, error
    handling, RFC 8785 sample vectors (key-sort, array-of-object,
    control-char escapes including BEL, number canonical form),
    and canonicalizeManifestForSigning round-trip.
  • packages/core/src/tests/signature-schema.test.ts39
    tests
    covering valid EdDSA / ES256 signatures, JWK strict
    rejection of d, key-set defaults and origin canonicalization,
    and the manifest-integration cases (unsigned regression, signed
    acceptance, malformed rejection).

Confirmations

  • Unsigned manifests still validate. Verified by
    manifest.test.ts, spec-examples.test.ts, the new
    regression test, and node packages/cli/dist/bin.js validate against all three example manifests.
  • No runtime sign / verify API yet. This PR ships the
    schema and canonicalizer only. signManifest /
    verifyManifestSignature land in subsequent PRs.
  • No scanner / MCP / CLI enforcement. Scanner check IDs,
    MCP server verification, and CLI --require-signature
    remain future PRs in the v0.5.0 line.
  • No package versions changed. pack:dry-run confirms
    all six @marmarlabs/agentbridge-* packages still at
    0.4.0.
  • No new dependencies. Pure JS canonicalizer; the only
    import is the existing zod already in core.
  • No npm publish, no git tag, no GitHub release.
  • Dependabot PRs untouched.
  • Codex CLI / examples / scripts paths avoided
    no edits under packages/cli/*, packages/scanner/*,
    examples/*, scripts/*, 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
npm test                  # 282 / 282 passed (19 files; +77 vs 205 on main)
npm run build             # all packages built
npm run pack:dry-run      # all six @marmarlabs/agentbridge-* OK at 0.4.0
npx vitest run packages/core/src/tests
node packages/cli/dist/bin.js validate examples/adopter-quickstart/manifest.basic.json
node packages/cli/dist/bin.js validate examples/adopter-quickstart/manifest.production-shaped.json
node packages/cli/dist/bin.js validate examples/scanner-regression/manifest.good.json

Test plan

  • CI green on Node 20.x and 22.x.
  • All package versions remain 0.4.0.
  • Added tests — 77 net (38 canonical + 39 schema).

🤖 Generated with Claude Code

First implementation PR for v0.5.0 signed manifests. Lands the
non-runtime contract surface — canonicalizer + Zod schemas + spec
update — that subsequent PRs (signManifest/verifyManifestSignature
in the SDK; scanner checks; MCP server enforcement) build on.

Adds:
- packages/core/src/signing/canonical.ts — RFC 8785 (JCS)
  canonicalizer. Pure JS, no new runtime dependency. Throws on
  non-JSON values (NaN/Infinity, undefined, function, symbol,
  BigInt, Date / Map / class instances). Exports
  canonicalizeJson() and canonicalizeManifestForSigning() (the
  signature-stripped variant the signer / verifier use).
- packages/core/src/signing/schemas.ts —
  ManifestSignatureSchema, PublicKeyJwkSchema (Ed25519 / P-256,
  strict so private 'd' is rejected), AgentBridgeKeySchema,
  AgentBridgeKeySetSchema, validateKeySet(). The iss/issuer
  refine enforces canonical-origin form so verifier comparisons
  are deterministic.
- packages/core/src/signing/index.ts — re-export barrel.
- packages/core/src/tests/canonical.test.ts (38 tests) —
  primitives, arrays, objects, determinism, RFC 8785 vectors
  (key-sort, array-of-object, control-char escapes, number
  canonical form), canonicalizeManifestForSigning round-trip.
- packages/core/src/tests/signature-schema.test.ts (39 tests) —
  signature shape, JWK strict-rejection of 'd', key-set defaults
  and origin canonicalization, manifest integration (unsigned
  regression, signed acceptance, malformed rejection).

Modifies:
- packages/core/src/schemas.ts — adds optional
  signature: ManifestSignatureSchema.optional() to
  AgentBridgeManifestSchema. Unsigned manifests still validate
  (regression covered by existing manifest.test.ts and the new
  test).
- packages/core/src/index.ts — re-exports the signing surface so
  callers can `import { canonicalizeJson, validateKeySet, ... }`
  from @marmarlabs/agentbridge-core.
- spec/agentbridge-manifest.schema.json — adds optional
  signature to top-level properties and a $defs.Signature
  definition mirroring the Zod shape.
- spec/agentbridge-manifest.v0.1.md — adds a "Signature field
  (optional, v0.5.0+)" section explicitly noting that signing is
  optional in v0.5.0, runtime sign / verify APIs ship later, and
  verification is additive (does not bypass confirmation gate /
  origin pinning / target-origin allowlist / audit redaction /
  HTTP transport auth).
- packages/core/README.md — short bullet enumerating the new
  schema surface; explicitly notes sign / verify runtime APIs
  are not yet shipped.

Does NOT (deliberately, scoped to this PR):
- add signManifest() or verifyManifestSignature()
- fetch remote keys
- add scanner signature checks
- add MCP server signature enforcement
- add CLI --require-signature flag
- change unsigned-manifest behavior
- bump any package version
- publish, tag, or release

Verified locally:
- npm run typecheck:clean (clean)
- npm test (282/282 across 19 files; was 205/17 on main)
- npm run build (all packages built)
- npm run pack:dry-run (all six @marmarlabs/agentbridge-* OK at
  0.4.0 — core grew 12.3KB → 22.9KB packed for the new module)
- node packages/cli/dist/bin.js validate examples/adopter-quickstart/manifest.basic.json
- node packages/cli/dist/bin.js validate examples/adopter-quickstart/manifest.production-shaped.json
- node packages/cli/dist/bin.js validate examples/scanner-regression/manifest.good.json
  (all three unsigned examples still validate)

Tracking: #31

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@marmar9615-cloud
marmar9615-cloud force-pushed the feature/v050-signing-core-schemas branch from b937c99 to c8bcbf2 Compare April 28, 2026 20:39

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

Copy link
Copy Markdown

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: b937c9961c

ℹ️ 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/canonical.ts
Comment thread packages/core/src/signing/canonical.ts
Two correctness fixes flagged in Codex review of PR #35:

1. (P1) Preserve __proto__ in canonicalizeManifestForSigning.

   The signature-stripping copy was assigning fields with
   `copy[k] = manifest[k]` on a plain `{}`. When `k === "__proto__"`
   that triggers Object.prototype's __proto__ setter and re-parents
   the copy, dropping the field from the signed payload. Two
   manifests differing only in __proto__ would canonicalize to the
   same bytes — a determinism + integrity hole.

   Fix: use `Object.create(null)` for the copy so __proto__
   becomes an ordinary own property. Test added that JSON.parses
   two manifests differing only in __proto__ value and asserts
   they produce different canonical bytes both containing the
   field.

2. (P2) Reject lone UTF-16 surrogates in canonString.

   `JSON.stringify` happily emits `\udxxx` for an unpaired
   surrogate, but RFC 8259 (and therefore RFC 8785) defines a
   JSON string as valid Unicode. Strict JCS implementations in
   other languages refuse to parse such bytes, so signed
   payloads with lone surrogates would fail cross-language
   verification.

   Fix: `canonString` now scans for unpaired high (D800–DBFF) or
   low (DC00–DFFF) surrogates and throws CanonicalizationError.
   Valid surrogate pairs (e.g. emoji) round-trip unchanged. Tests
   added for: lone-high, lone-low, valid pair, lone surrogate
   inside object key.

No package version change. No runtime-API surface change.
Unsigned manifests still validate.

Verified locally:
- npx vitest run packages/core/src/tests/canonical.test.ts (43/43)
- npm test (298/298 across 20 files)

Refs: PR #35, issue #31

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@marmar9615-cloud
marmar9615-cloud merged commit 46adc2c into main Apr 28, 2026
2 checks passed
marmar9615-cloud added a commit that referenced this pull request Apr 28, 2026
Second implementation PR for v0.5.0 signed manifests. Builds on the
canonicalization + Zod schemas from PR #35 and adds the
publisher-side signer to @marmarlabs/agentbridge-sdk. No verifier,
no scanner / MCP / CLI enforcement, no version bump, no behavior
change for unsigned manifests.

Adds:
- packages/sdk/src/signing.ts — signManifest(manifest, options)
  and createSignedManifest(config, options). Uses Node `crypto`
  exclusively; no new runtime dependency.

  signManifest API:
    - alg: "EdDSA" (default) | "ES256"
    - kid: string (required)
    - issuer: string (defaults to new URL(manifest.baseUrl).origin)
    - privateKey: KeyObject | string (PEM) | Buffer (PEM bytes)
    - signedAt: Date | string (defaults to new Date())
    - expiresAt: Date | string (precedence over expiresInSeconds)
    - expiresInSeconds: number (sets expiresAt = signedAt + N s)
    - default expiresAt = signedAt + 24h

  Internals:
    - Strips any pre-existing `signature` field via
      canonicalizeManifestForSigning() (re-signing produces a
      fresh signature, never one over a payload that already
      contained a stale signature).
    - For EdDSA: crypto.sign(null, data, key) — 64-byte raw.
    - For ES256: crypto.sign("sha256", data,
      { key, dsaEncoding: "ieee-p1363" }) — 64-byte raw r||s,
      matching JWS ES256 (NOT DER-encoded).
    - Validates the produced signature object via the core
      ManifestSignatureSchema before attaching.
    - Returns a NEW manifest object; input is never mutated.
    - Validates the resolved `iss` is a canonical origin
      (URL(s).origin === s).
    - Verifies the supplied private key matches the requested
      algorithm (ed25519 vs P-256/prime256v1).
    - Errors never echo private key material; createPrivateKey
      failures surface only the Node error code (e.g.
      ERR_OSSL_BAD_KEY_TYPE).

  Raw 32-byte Ed25519 seeds are intentionally NOT supported in
  this PR. The PKCS#8 conversion is non-trivial; adopters can
  wrap a seed with their own KeyObject adapter.

- packages/sdk/src/tests/signing.test.ts — 39 tests:
    * Mutation: input manifest is unchanged; pre-existing
      signature stripped on re-sign without mutating input.
    * Schema: attached signature passes ManifestSignatureSchema;
      defaults (alg=EdDSA, iss=baseUrl origin); explicit
      canonical-origin issuer respected; non-canonical / unparseable
      issuer rejected.
    * Time: signedAt as Date / ISO string; expiresAt explicit;
      expiresInSeconds; default 24h; expiresAt <= signedAt
      rejected; expiresInSeconds <= 0 rejected; malformed
      signedAt rejected.
    * Crypto: Ed25519 round-trip via crypto.verify (KeyObject /
      PEM string / PEM Buffer inputs); tamper detection
      (mutated non-signature field fails verification); cross-key
      verification fails. ES256 round-trip; 64-byte r||s output
      (not DER); KeyObject + PEM inputs.
    * Validation: unsupported alg rejected; wrong key type for
      alg rejected; public KeyObject rejected; unparseable PEM
      rejected without echoing input; non-buffer/non-string/
      non-KeyObject privateKey rejected; empty kid rejected;
      malformed manifest.baseUrl with no issuer rejected.
    * Integration: validateManifest accepts the signed result;
      createSignedManifest signs a manifest produced by
      createAgentBridgeManifest; signing failures propagate
      through createSignedManifest.
    * Determinism: re-signing the same manifest with the same
      Ed25519 key + same signedAt yields identical bytes;
      createPublicKey(privateKey) verifies (round-trip via
      derived public key).
    * Backward compat: unsigned createAgentBridgeManifest still
      validates.

Modifies:
- packages/core/src/signing/canonical.ts —
  canonicalizeManifestForSigning now JSON-roundtrips the
  signature-stripped manifest before canonicalizing. SDK-built
  manifests routinely carry `undefined` in optional slots
  (outputSchema, humanReadableSummaryTemplate, etc.) when an
  adopter omits them; without this step every realistic manifest
  failed canonicalization with "undefined property values are
  not representable in canonical JSON". The bytes the signer
  produces now match the bytes the publisher serves (which goes
  through JSON.stringify and drops the same fields). The strict
  canonicalizeJson is unchanged — it still throws on undefined
  / function / symbol / non-finite numbers when called directly.
- packages/core/src/tests/canonical.test.ts — adds two tests:
  one that asserts canonicalizeManifestForSigning drops
  undefined optional fields (matches publisher JSON.stringify),
  and one that asserts the strict canonicalizeJson still
  rejects undefined properties (the relaxation is scoped to
  the manifest helper).
- packages/sdk/src/index.ts — re-exports the signing surface.
- packages/sdk/README.md — adds a concise "Signing manifests
  (v0.5.0)" section with example, algorithm matrix, key input
  formats, and explicit notes that private keys never belong
  in manifests, raw seeds aren't supported in this PR, the
  verifier and runtime enforcement are planned follow-ups, and
  unsigned manifests still validate.

Does NOT (deliberately):
- add verifyManifestSignature (verifier ships in a later PR)
- fetch remote keys
- add scanner signature checks
- add MCP server enforcement
- add CLI --require-signature
- change unsigned-manifest behavior
- bump any package version
- add any runtime dependency
- publish, tag, or release

Verified locally:
- npm run typecheck:clean (clean)
- npm test (339/339 across 21 files; was 298/19 on main = +41)
- npm run build (all packages built)
- npm run pack:dry-run (all six @marmarlabs/agentbridge-* OK at
  0.4.0; SDK packed 9.9 → 16.5KB for the new module)
- npx tsx examples/sdk-basic/manifest.ts | node packages/cli/dist/bin.js validate
  (unsigned SDK example still validates: SDK Basic Support v1.0.0)

Tracking: #31

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marmar9615-cloud added a commit that referenced this pull request Apr 28, 2026
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>
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