feat(core): add signed manifest schemas and canonicalization - #35
Merged
Conversation
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
force-pushed
the
feature/v050-signing-core-schemas
branch
from
April 28, 2026 20:39
b937c99 to
c8bcbf2
Compare
There was a problem hiding this comment.
💡 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".
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>
This was referenced Apr 28, 2026
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>
This was referenced Apr 28, 2026
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>
This was referenced Apr 29, 2026
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
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 / CLIenforcement, no version bump, no behavior change for unsigned
manifests.
docs/designs/signed-manifests.mddocs/adr/0002-signed-manifests.md54b74d6Canonicalization helper
packages/core/src/signing/canonical.tsis a pure-JS RFC 8785(JCS) canonicalizer. No new runtime dependency.
(matches
Array.prototype.sortdefault compare).JSON.stringify(RFC 8259 minimal escaping ===RFC 8785 §3.2.2.2).
String(n)(ECMA-262 7.1.12.1 === RFC 8785§3.2.2.3).
-0normalized to"0".CanonicalizationErroron non-JSON values:NaN,Infinity,undefined, function, symbol, BigInt, non-plainobjects (Date / Map / class instances). Errors carry a
JSON-Pointer-style
pathto the offending value.canonicalizeManifestForSigning(manifest)is thesignature-stripping variant the signer / verifier (later PRs)
will use.
Signature / key-set schemas
packages/core/src/signing/schemas.tsexports:ManifestSignatureSchema—alg(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 extrafield — including the private scalar
d— is rejected, notsilently stripped.
AgentBridgeKeySchema— one publisher key entry withoptional
notBefore/notAfterand requireduse: "manifest-sign".AgentBridgeKeySetSchema—issuer,version: "1",keys(≥1),
revokedKids(defaults to[]).validateKeySet(input)— same{ ok, keySet | errors }shapeas the existing
validateManifest.Manifest schema extension
packages/core/src/schemas.tsgainssignature: ManifestSignatureSchema.optional()onAgentBridgeManifestSchema. Existing unsigned manifestscontinue to validate exactly as in v0.4.x — covered by the
unchanged
manifest.test.ts,spec-examples.test.ts, and afresh regression test in
signature-schema.test.ts.Spec update
spec/agentbridge-manifest.schema.json— adds optionalsignatureto top-levelpropertiesand a$defs.Signaturedefinition (with a base64url
pattern, ISOformat, and thesame 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.ts— 38 testscovering primitives, arrays, objects, determinism, error
handling, RFC 8785 sample vectors (key-sort, array-of-object,
control-char escapes including BEL, number canonical form),
and
canonicalizeManifestForSigninground-trip.packages/core/src/tests/signature-schema.test.ts— 39tests 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
manifest.test.ts,spec-examples.test.ts, the newregression test, and
node packages/cli/dist/bin.js validateagainst all three example manifests.schema and canonicalizer only.
signManifest/verifyManifestSignatureland in subsequent PRs.MCP server verification, and CLI
--require-signatureremain future PRs in the v0.5.0 line.
pack:dry-runconfirmsall six
@marmarlabs/agentbridge-*packages still at0.4.0.importis the existingzodalready incore.no edits under
packages/cli/*,packages/scanner/*,examples/*,scripts/*, orCHANGELOG.md.pinning, target-origin allowlist, audit redaction, stdio
stdout hygiene, HTTP transport auth/origin checks all
unchanged.
Commands run
Test plan
0.4.0.🤖 Generated with Claude Code