Skip to content

feat(sdk): add signed manifest helpers - #36

Merged
marmar9615-cloud merged 3 commits into
mainfrom
feature/v050-sdk-sign-manifest
Apr 28, 2026
Merged

feat(sdk): add signed manifest helpers#36
marmar9615-cloud merged 3 commits into
mainfrom
feature/v050-sdk-sign-manifest

Conversation

@marmar9615-cloud

Copy link
Copy Markdown
Owner

Summary

Second implementation PR for v0.5.0 signed manifests. Adds the
publisher-side signer to @marmarlabs/agentbridge-sdk, building on
the canonicalization + Zod schemas from PR #35. No verifier, no
scanner / MCP / CLI enforcement, no version bump, no behavior change
for unsigned manifests.

signManifest(manifest, options)

Returns a NEW manifest with an attached signature block. Input is
never mutated. Any pre-existing signature is stripped before
canonicalization — re-signing produces a fresh signature, never one
over a payload that already contained a stale one.

import { generateKeyPairSync } from "node:crypto";
import { signManifest, createSignedManifest } from "@marmarlabs/agentbridge-sdk";

const { privateKey } = generateKeyPairSync("ed25519");
const signed = signManifest(manifest, {
  kid: "acme-orders-2026-04",
  privateKey,
  // alg defaults to "EdDSA"; pass "ES256" for ECDSA P-256.
  // issuer defaults to new URL(manifest.baseUrl).origin.
  // signedAt defaults to new Date().
  // expiresAt defaults to signedAt + 24h.
});

Algorithms supported

  • EdDSA (Ed25519) — default. crypto.sign(null, data, key),
    64-byte raw signature, deterministic.
  • ES256 (ECDSA P-256, SHA-256) — crypto.sign("sha256", data, { key, dsaEncoding: "ieee-p1363" }), 64-byte raw r||s output
    (matching JWS ES256, not DER-encoded). For HSM/KMS-bound
    publishers.

RSA-family (RS256, PS256, …) is intentionally excluded per the
v0.5.0 design — adding one in a later release is a non-breaking
enum extension.

Private key input formats

  • A Node KeyObject
    (recommended in production).
  • A PEM-encoded string.
  • A Buffer containing PEM bytes.

Raw 32-byte Ed25519 seeds are not supported in this PR — the
PKCS#8 conversion is non-trivial. Adopters can wrap a seed with their
own KeyObject adapter and pass the result.

Errors never echo private key material; PEM parse failures surface
only the Node error code (ERR_OSSL_BAD_KEY_TYPE etc.).

createSignedManifest(config, options)

Sugar for signManifest(createAgentBridgeManifest(config), options).
Useful for adopters whose build pipeline produces and signs a fresh
manifest each release.

Internals worth flagging

  • Canonical bytes match published bytes.
    canonicalizeManifestForSigning now JSON-roundtrips the
    signature-stripped manifest before canonicalizing. SDK-built
    manifests routinely carry undefined in optional slots
    (outputSchema, humanReadableSummaryTemplate, …) when an adopter
    omits them; the publisher's JSON.stringify drops them, and so
    must the signer or no realistic manifest could ever be signed. The
    strict canonicalizeJson is unchanged — it still throws on
    undefined / function / symbol / non-finite numbers when called
    directly. Two new tests in core/canonical.test.ts pin both
    behaviors.
  • iss is always a canonical origin. Default is
    new URL(manifest.baseUrl).origin. An explicit options.issuer
    is rejected if URL(s).origin !== s (no trailing slashes, no
    paths, etc.) — this keeps verifier comparisons deterministic.
  • Key/algorithm mismatch fails before signing. Ed25519 keys
    paired with alg: "ES256" (or vice versa) reject up front, so
    callers can't accidentally produce a signature with a key the
    verifier expects to be a different curve.

Tests added

packages/sdk/src/tests/signing.test.ts39 tests covering:

  • Mutation: input manifest unchanged; pre-existing signature
    stripped on re-sign.
  • Schema: signature passes ManifestSignatureSchema; alg/iss
    defaults; canonical-origin enforcement.
  • Time: signedAt/expiresAt as Date/string; expiresInSeconds;
    default 24h; expiry-before-signedAt rejected; non-positive
    expiresInSeconds rejected.
  • Crypto: Ed25519 round-trip via crypto.verify for KeyObject /
    PEM string / PEM Buffer inputs; tamper detection (mutating any
    non-signature field fails verification); cross-key verification
    fails. ES256 round-trip; 64-byte r||s output (not DER).
  • Validation: unsupported alg, wrong key type, public KeyObject,
    unparseable PEM (without echoing input), wrong-type
    privateKey, empty kid, malformed manifest.baseUrl with no
    issuer.
  • Integration: validateManifest accepts the signed result;
    createSignedManifest end-to-end; signing failures propagate.
  • Determinism: re-signing same manifest + same Ed25519 key +
    same signedAt yields identical bytes; createPublicKey
    round-trip.
  • Backward compat: unsigned createAgentBridgeManifest still
    validates.

packages/core/src/tests/canonical.test.ts gains 2 new tests for
the canonicalizeManifestForSigning undefined-drop behavior and
the unchanged strict canonicalizeJson.

Confirmations

  • Input manifests are not mutated. Verified by mutation
    tests and by structural-clone semantics in the implementation.
  • Signed manifests validate through core
    validateManifest.
    Verified by integration test.
  • No verifier / MCP / scanner / CLI enforcement — those
    remain follow-up PRs in the v0.5.0 line.
  • Unsigned manifest behavior unchanged. Verified by
    manifest.test.ts, spec-examples.test.ts,
    validateManifest({ ...withoutSignature }) returning the
    same shape, and by re-running the SDK example through
    agentbridge validate.
  • No package versions changed. pack:dry-run confirms all
    six @marmarlabs/agentbridge-* packages still at 0.4.0.
  • No new dependencies. Pure Node crypto usage.
  • package-lock.json untouched.
  • 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/*, 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/sdk/src/tests/signing.test.ts            # 39/39
npm test                                                         # 339/339 across 21 files (+41 vs 298 on main)
npm run build                                                    # all packages built
npm run pack:dry-run                                             # all six @marmarlabs/agentbridge-* OK at 0.4.0
npx tsx examples/sdk-basic/manifest.ts > /tmp/sdk-basic.agentbridge.json
node packages/cli/dist/bin.js validate /tmp/sdk-basic.agentbridge.json

Test plan

  • CI green on Node 20.x and 22.x.
  • All package versions remain 0.4.0.
  • +41 net new tests (39 SDK signing + 2 core canonical).

🤖 Generated with Claude Code

@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: 0362c52b3c

ℹ️ 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/sdk/src/signing.ts Outdated
Comment thread packages/sdk/src/signing.ts Outdated
marmar9615-cloud added a commit that referenced this pull request Apr 28, 2026
Two correctness fixes flagged in Codex review of PR #36:

1. (P1) Deep-clone the signed payload before returning.

   `signManifest` previously returned `{ ...rest, signature }` —
   a shallow copy that still shared nested references (`actions`,
   `resources`, `auth`, etc.) with the caller's input manifest.
   A caller who mutated `manifest.actions[0]` after signing would
   silently mutate `signed.actions[0]` too, and the signature
   would no longer cover the published bytes.

   Fix: round-trip the signature-stripped manifest through
   JSON.parse(JSON.stringify(...)) before attaching the signature.
   This produces an independent tree shaped exactly like the
   bytes a publisher would serve, so subsequent mutations to the
   input cannot affect the signed output. Test added: build a
   manifest, sign it, push a tampered entry into
   `manifest.actions`, assert `signed.actions.length` is
   unchanged.

2. (P2) Preserve sub-second expiresInSeconds.

   `resolveExpiresAt` used `Math.floor(expiresInSeconds) * 1000`,
   which truncated values between 0 and 1 to 0ms — producing a
   signature that was expired the moment it was minted. A user
   passing 0.5 (legal positive finite number) silently got
   expiresAt === signedAt.

   Fix: compute the offset in milliseconds with
   `Math.round(expiresInSeconds * 1000)`, and add an explicit
   "expiresAt must be strictly after signedAt" guard that catches
   any rounding that collapses to zero (e.g. 0.0001s). Two tests
   added: 0.5s → 500ms offset accepted; 0.0001s rejected.

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

Verified locally:
- npx vitest run packages/sdk/src/tests/signing.test.ts (42/42)
- npm test (342/342 across 21 files)

Refs: PR #36, issue #31

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
marmar9615-cloud and others added 3 commits April 28, 2026 17:15
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>
The "re-signing the same manifest with the same Ed25519 key yields
identical bytes" test built the manifest twice via `buildManifest()`,
which calls `createAgentBridgeManifest` and stamps a fresh
`generatedAt: new Date().toISOString()` on each call. When the two
calls landed in different millisecond ticks (visible on CI, hidden
on a fast local machine) the manifests differed, so the canonical
bytes differed, so the signatures differed.

Build the manifest once, sign it twice. Same key + same payload +
same fixed signedAt → same Ed25519 signature value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two correctness fixes flagged in Codex review of PR #36:

1. (P1) Deep-clone the signed payload before returning.

   `signManifest` previously returned `{ ...rest, signature }` —
   a shallow copy that still shared nested references (`actions`,
   `resources`, `auth`, etc.) with the caller's input manifest.
   A caller who mutated `manifest.actions[0]` after signing would
   silently mutate `signed.actions[0]` too, and the signature
   would no longer cover the published bytes.

   Fix: round-trip the signature-stripped manifest through
   JSON.parse(JSON.stringify(...)) before attaching the signature.
   This produces an independent tree shaped exactly like the
   bytes a publisher would serve, so subsequent mutations to the
   input cannot affect the signed output. Test added: build a
   manifest, sign it, push a tampered entry into
   `manifest.actions`, assert `signed.actions.length` is
   unchanged.

2. (P2) Preserve sub-second expiresInSeconds.

   `resolveExpiresAt` used `Math.floor(expiresInSeconds) * 1000`,
   which truncated values between 0 and 1 to 0ms — producing a
   signature that was expired the moment it was minted. A user
   passing 0.5 (legal positive finite number) silently got
   expiresAt === signedAt.

   Fix: compute the offset in milliseconds with
   `Math.round(expiresInSeconds * 1000)`, and add an explicit
   "expiresAt must be strictly after signedAt" guard that catches
   any rounding that collapses to zero (e.g. 0.0001s). Two tests
   added: 0.5s → 500ms offset accepted; 0.0001s rejected.

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

Verified locally:
- npx vitest run packages/sdk/src/tests/signing.test.ts (42/42)
- npm test (342/342 across 21 files)

Refs: PR #36, issue #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