Skip to content

feat(gateway): DID signing for outbound A2A requests (Phase 1a)#472

Merged
raahulrahl merged 3 commits intomainfrom
feat/gateway-did-signing
Apr 19, 2026
Merged

feat(gateway): DID signing for outbound A2A requests (Phase 1a)#472
raahulrahl merged 3 commits intomainfrom
feat/gateway-did-signing

Conversation

@raahulrahl
Copy link
Copy Markdown
Contributor

@raahulrahl raahulrahl commented Apr 19, 2026

Closes the calling-side DID gap we identified earlier: gateway can now sign outbound A2A requests so DID-enforcing peers like agno_example accept them instead of returning 403 missing_signature_headers.

Scope (Phase 1a)

  • ✅ Gateway DID identity primitive (loads seed from env, derives keypair, signs bodies)
  • did_signed peer auth variant in the resolver
  • ✅ Body-serialize-once invariant in rpc() — same bytes signed and sent
  • ✅ Client layer injects identity into runCall / runCancel
  • ✅ Boot-time env loading with fail-fast partial-config error
  • ✅ Operator docs in gateway/.env.example + gateway/README.md
  • Cross-language contract test — JS signer byte-for-byte identical to Python reference signer for a canonical fixture

Deferred to follow-ups (explicitly scoped in the last commit message):

  • Phase 1b: auto Hydra client registration on boot (user decision was "Auto" — this PR supports Manual registration only; Auto needs a Hydra admin client module + OAuth token-refresh cache)
  • Phase 1b: gateway's own /.well-known/did.json endpoint
  • Phase 1c: frontend POC (localStorage + tweetnacl, per Decision 4)
  • Phase 1d: dedicated Postman collection
  • Phase 1e: deep docs in docs/GATEWAY_DID_SETUP.md

Each is a separate reviewable PR; this one keeps the diff focused on the core signing contract.

Three commits, reviewable in order

  1. 22ca7bf — DID identity primitive with cross-language parity test. 466 lines. The signing primitive + 18 tests. The critical test asserts byte-exact match against a Python-generated fixture — any drift breaks CI before anyone sees a real-world 403.

  2. f203827 — plumb DID signing through auth resolver and rpc. 358 lines. Adds did_signed variant; makes buildAuthHeaders async; rpc() serializes body once and signs. 11 new auth-resolver tests including regression guards.

  3. 6520176 — boot-time identity loading + operator docs. 226 lines. buildAppLayer(identity?) factory, tryLoadIdentity() with fail-fast partial-config error, boot log printing the DID + public key so operators can register them, .env.example + README.md updates. 5 new boot tests.

Cross-language contract

The load-bearing assertion, from gateway/tests/bindu/identity-local.test.ts:

seed:       32 zero bytes
did:        did:bindu:test
body:       {"test": "value"}
timestamp:  1000
expected:   3SfU4VPTHLbzZzCn17ZqU6y2tnzHQbdo2nnXQr6XZXk34XgyzwSKRrCYEWRmmGXrV39mdkyhTsy5oasfTpNuqyM2

Generated by bindu/utils/did/signature.py (Python reference). If the JS signer ever drifts by one character — separator spacing, key order, encoding — this test flips red.

Operator enable flow

export BINDU_GATEWAY_DID_SEED="$(python -c 'import os,base64;print(base64.b64encode(os.urandom(32)).decode())')"
export BINDU_GATEWAY_AUTHOR=ops@example.com
export BINDU_GATEWAY_NAME=gateway

npm run dev
# → [bindu-gateway] DID identity loaded: did:bindu:ops_at_example_com:gateway:...
# → [bindu-gateway] public key (base58): 7dNzT2ZzYKsib...

Copy both lines into each peer's Hydra, stash the issued OAuth token in an env var, and set auth.type: "did_signed" on the peer entry.

Failure modes — all fail fast at boot or call time with clear errors

Scenario When it fails Message
All three env vars unset Never fails — pre-DID mode no DID identity configured (set BINDU_GATEWAY_DID_SEED, _AUTHOR, _NAME to enable did_signed peer auth)
Two of three set Boot Partial DID identity config — set all three or none: ...
Seed malformed Boot BINDU_GATEWAY_DID_SEED must decode to exactly 32 bytes
did_signed peer but no identity loaded First outbound call did_signed peer requires a gateway LocalIdentity — check that BINDU_GATEWAY_DID_SEED is set at boot
did_signed peer with missing OAuth env var First outbound call env var "X" is not set (did_signed peer requires an OAuth bearer token alongside the DID signature)

No mystery 403s. No silent failures.

Test summary

77 gateway tests pass (61 existing + 16 new across three new test files).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added DID signing support for secure peer authentication. The gateway can now sign outbound requests using Ed25519 identity when configured with three new environment variables (BINDU_GATEWAY_DID_SEED, BINDU_GATEWAY_AUTHOR, BINDU_GATEWAY_NAME).
  • Documentation

    • Added comprehensive configuration guide explaining DID signing setup and integration with downstream peer authentication.
  • Tests

    • Added test coverage for DID identity loading, authentication header construction, and request signing functionality.

raahulrahl and others added 3 commits April 19, 2026 17:37
Phase 1 Commit 1 of the gateway DID calling-side work. Adds the
signing primitive the gateway will use for outbound A2A requests,
with a test that proves byte-for-byte parity with the Python
reference signer.

New file: gateway/src/bindu/identity/local.ts

  * loadLocalIdentity(config) — loads a 32-byte Ed25519 seed from
    BINDU_GATEWAY_DID_SEED (base64), derives the keypair, computes
    the DID as did:bindu:{author}:{name}:{agentId} where agentId
    is sha256(pubkey)[:16] UUID-formatted so the DID is stable
    across restarts as long as the seed doesn't change.
    Throws a clear error with generation instructions when the env
    var is missing.

  * sign(body, timestamp?) — produces the three X-DID-* headers
    matching the Python verifier's byte-exact expectation. The
    crux: Python's json.dumps default separators have SPACES after
    `:` and `,` — JS's JSON.stringify does not. One-character
    mismatch → signature mismatch. Replicated via
    pythonSortedJson() helper.

  * signPayload({seed, did, body, timestamp}) — low-level signer
    that the cross-language test drives with canonical fixture
    inputs. Split from sign() so a test can inject an arbitrary
    DID without going through loadLocalIdentity's derivation.

  * sanitizeAuthor / deriveAgentId — DID-format helpers mirroring
    bindu/extensions/did/did_agent_extension.py's conventions.

Tests: gateway/tests/bindu/identity-local.test.ts

  18 cases across four suites:

  * pythonSortedJson (7 cases): spaces after : and , ;
    alphabetical keys ; recursive sort ; primitive round-trips ;
    string escaping ; non-finite number rejection ; AND the
    fixture-exact payload string.

  * Helper functions (5 cases): sanitizeAuthor ; deriveAgentId is
    deterministic, UUID-formatted, and collision-free.

  * CROSS-LANGUAGE CONTRACT (2 cases) — the load-bearing assertion:
    signPayload with (seed=32 zero bytes, did='did:bindu:test',
    body='{"test": "value"}', timestamp=1000) produces exactly
    '3SfU4VPTHLbzZzCn17ZqU6y2tnzHQbdo2nnXQr6XZXk34XgyzwSKRrCYEWRmmGXrV39mdkyhTsy5oasfTpNuqyM2'
    — generated by the Python signer at bindu/utils/did/signature.py.
    Deterministic Ed25519 + identical payload string → identical
    signature. Any drift in either implementation flips this test
    red before it can produce mystery 403s downstream.

  * loadLocalIdentity (4 cases): missing env var clear error ;
    wrong-length seed clear error ; valid seed derives correct
    public key (matches Python's) + a working signer ; timestamp
    defaults to Math.floor(Date.now()/1000).

All 61 gateway tests pass (18 new + 43 existing).

No plumbing into the HTTP path yet — that's the next commit.
Reviewers can evaluate the contract in isolation here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Commit 2. Wires the signing primitive from Commit 1 into the
outbound HTTP path so a peer configured with auth.type=did_signed
gets a per-request Ed25519 signature alongside its OAuth bearer.

Auth resolver (gateway/src/bindu/auth/resolver.ts):

  * PeerAuth schema grows a fourth variant:
      { type: "did_signed", tokenEnvVar: string }
    The tokenEnvVar holds the OAuth bearer issued by the peer's Hydra
    to the gateway's DID client_id. Bundled with the DID signature so
    the peer satisfies both its OAuth layer and its DID layer in one
    request.

  * authHeaders() → buildAuthHeaders() — async, now takes the
    serialized body string and an optional LocalIdentity. The three
    existing variants (none / bearer / bearer_env) keep their
    semantics. did_signed composes the Authorization header with
    Ed25519-signed X-DID-* headers.

  * Error paths: did_signed without identity → clear error pointing
    at BINDU_GATEWAY_DID_SEED. did_signed without the OAuth env var
    → clear error naming the env var. Both fail BEFORE the request
    is sent, so a misconfigured gateway doesn't produce mysterious
    403s at the peer.

RPC transport (gateway/src/bindu/client/fetch.ts):

  * RpcInput gains auth + identity fields (body-aware signing needs
    them). The old `headers` field is renamed `extraHeaders` and is
    now documented as "static headers that don't depend on the body"
    — tracing propagation, etc.

  * rpc() serializes the JSON-RPC request ONCE into `bodyStr`, calls
    buildAuthHeaders(auth, bodyStr, identity) to get headers, and
    sends the same exact bytes it signed. This is the load-bearing
    invariant: any re-serialization between signing and sending
    breaks the signature and produces crypto_mismatch at the peer.

Polling client (gateway/src/bindu/client/poll.ts):

  * SendAndPollInput gains auth/identity/extraHeaders, passed
    through to every rpc() call (message/send + each tasks/get +
    the final tasks/cancel on timeout). Signatures are fresh
    per-request so each has its own timestamp within the 300s
    replay window.

Client service (gateway/src/bindu/client/index.ts):

  * `layer` → `makeLayer(identity?)` factory. Bootstrap wires the
    loaded LocalIdentity once and injects it into the service.
    Default `layer` export kept for backward compat (no identity —
    did_signed peers fail at call time with a clear error).

  * runCall / runCancel take the identity and pass it through to
    the poll/cancel helpers.

Tests (gateway/tests/bindu/auth-resolver.test.ts — 11 new cases):

  * PeerAuth schema accepts all four variants; rejects unknown
    types and missing required fields.
  * none / bearer / bearer_env happy paths + bearer_env unset error.
  * did_signed without identity → clear error matching the "set
    BINDU_GATEWAY_DID_SEED" guidance.
  * did_signed without OAuth env var → clear error matching the
    "OAuth bearer token alongside the DID signature" guidance.
  * did_signed happy path produces Authorization + three X-DID-*
    headers with the expected shapes.
  * Regression guard: buildAuthHeaders signs the exact body string
    it's given — compared against identity.sign() called directly
    with the same frozen timestamp.

All 72 gateway tests pass (61 previous + 11 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 Commit 3. Final chunk of the gateway-side DID signing
landing. Wires boot-time env loading into main(), factored the
layer composition into a function so the loaded identity can be
injected into BinduClient.makeLayer, and documented the enable-path
for operators.

Boot wiring (gateway/src/index.ts):

  * appLayer is built by a new `buildAppLayer(identity?)` factory.
    main() calls tryLoadIdentity() first, then constructs the
    runtime with whatever it got. Default export `appLayer` keeps
    the zero-identity variant for backward compat with tests.

  * tryLoadIdentity reads three env vars:
        BINDU_GATEWAY_DID_SEED    (base64 32-byte seed)
        BINDU_GATEWAY_AUTHOR      (email-like)
        BINDU_GATEWAY_NAME        (short name)
    All three unset → returns undefined (pre-DID mode).
    Any of the three set partially → THROWS with a clear error
    listing which vars are missing. Half-loaded identity is the
    worst of all worlds; we refuse to boot into it.

  * On successful boot:
      [bindu-gateway] DID identity loaded: did:bindu:…
      [bindu-gateway] public key (base58): 7dNzT2ZzYKsib…
      [bindu-gateway] register this DID + public key with each
                      peer's Hydra before talking to auth.type=
                      did_signed peers.
    The log is the operational contract: operators copy those two
    lines into the peer's Hydra client registration.

Tests (gateway/tests/index-identity.test.ts — 5 cases):

  * all three env vars unset → returns undefined
  * seed set alone → clear partial-config error
  * author set alone → same error (different var missing first)
  * all three set with valid seed → loads identity matching
    did:bindu:{sanitizedAuthor}:{name}:{agentId} format
  * all three set with malformed seed → surfaces the 32-bytes
    error from decodeSeed rather than swallowing it

Docs:

  * gateway/.env.example — new section documenting the three env
    vars, why they're optional, how to generate the seed, and that
    partial config fails fast.

  * gateway/README.md — new "DID signing for downstream peers"
    section between §Architecture and §Tests. Explains the enable
    flow end-to-end: env setup, boot log, Hydra registration, peer
    config with auth.type=did_signed, the three signed request
    headers that result.

All 77 gateway tests pass (72 previous + 5 new).

Phase 1 is now end-to-end functional for operators who:
  1. Set the three env vars
  2. Pre-register the gateway's DID + public key in each peer's
     Hydra (manual for now — see follow-ups)
  3. Stash the peer's issued OAuth token in an env var
  4. Set auth.type = did_signed on the peer entry

Explicit follow-ups deferred from this PR:
  * Auto Hydra client registration on boot (the user asked for
    "Auto" in Decision 2; this commit supports Manual registration
    only — Auto needs a Hydra admin client module + token-refresh
    cache, scoped as Phase 1b)
  * Gateway's own /.well-known/did.json endpoint (for peers that
    want to resolve the gateway's DID A2A-natively instead of via
    Hydra metadata)
  * Frontend POC (Phase 1c — localStorage + tweetnacl, per
    Decision 4)
  * Dedicated Postman collection (Phase 1d)
  * Deep docs in docs/GATEWAY_DID_SETUP.md (Phase 1e)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR introduces DID-based Ed25519 signing for outbound A2A requests in the gateway. It adds a new did_signed auth variant, implements a LocalIdentity module for key derivation and payload signing, integrates per-request body signing into the RPC client workflow, and wires the identity through the service layer at boot time.

Changes

Cohort / File(s) Summary
Configuration & Documentation
gateway/.env.example, gateway/README.md
Added three new DID-related environment variables (BINDU_GATEWAY_DID_SEED, BINDU_GATEWAY_AUTHOR, BINDU_GATEWAY_NAME) with detailed documentation of the signing flow, registration process, and fail-fast boot behavior for partial configurations.
Auth System
gateway/src/bindu/auth/resolver.ts, gateway/tests/bindu/auth-resolver.test.ts
Extended PeerAuth discriminated union with did_signed variant; converted authHeaders to async buildAuthHeaders accepting serialized body and optional LocalIdentity for per-request signing; added comprehensive test coverage for all auth variants and error handling.
Client Layer
gateway/src/bindu/client/fetch.ts, gateway/src/bindu/client/index.ts, gateway/src/bindu/client/poll.ts
Replaced generic headers parameter with auth-driven construction via auth, identity, and extraHeaders fields; introduced single JSON-RPC serialization passed to both HTTP body and signing; updated RpcInput interface and wired identity through the layer factory; modified SendAndPollInput to accept auth/identity configuration.
Identity Module
gateway/src/bindu/identity/local.ts, gateway/tests/bindu/identity-local.test.ts
Implemented new module for local DID identity with Ed25519 signing, agent ID derivation via SHA-256, and Python-compatible JSON serialization; provides loadLocalIdentity factory and sign method returning DID signature headers; includes cross-language parity test against Python reference implementation.
Entry Point & Boot
gateway/src/index.ts, gateway/tests/index-identity.test.ts
Added tryLoadIdentity() to load DID config from environment with fail-fast partial-config validation; refactored service composition into buildAppLayer(identity) factory; wired identity loading and layer composition into main() with boot-time logging.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RPC as RPC Client
    participant Auth as Auth Resolver
    participant Identity as LocalIdentity
    participant Peer

    Client->>RPC: rpc(input: {peer, body, identity})
    RPC->>RPC: bodyStr = JSON.stringify(body, {sort_keys})
    RPC->>Auth: buildAuthHeaders(auth, bodyStr, identity)
    
    alt auth.type === "did_signed"
        Auth->>Identity: sign(bodyStr)
        Identity->>Identity: payload = {body, did, timestamp}
        Identity->>Identity: signature = Ed25519.sign(payload)
        Identity-->>Auth: {X-DID, X-DID-Timestamp, X-DID-Signature}
        Auth->>Auth: merge with OAuth Authorization header
    else auth.type === "bearer_env"
        Auth->>Auth: resolve token from process.env
    else auth.type === "bearer"
        Auth->>Auth: use static token
    else auth.type === "none"
        Auth->>Auth: return empty headers
    end
    
    Auth-->>RPC: headers
    RPC->>Peer: HTTP POST(bodyStr, headers)
    Peer-->>RPC: response
    RPC-->>Client: outcome
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The changes span multiple interconnected modules with async pattern shifts, introduce cryptographic operations requiring cross-language parity verification, modify several public API signatures, and affect the service composition layer. However, good test coverage and clear separation of concerns help reduce review complexity.

Possibly related PRs

  • feat: Bindu Gateway + bug-tracking infrastructure #463: Directly extends the same gateway modules (bindu/auth/resolver.ts, client/fetch.ts, client/index.ts, identity/*) with the core DID signing implementation and environment configuration that forms the foundation for this PR's integration work.

Poem

🐰 A gateway now signs with a key, so fine,
Ed25519 threads through each JSON line,
Headers adorned with Did's cryptic grace,
Python and TypeScript in signing embrace! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(gateway): DID signing for outbound A2A requests (Phase 1a)' clearly and specifically describes the main change: adding DID signing capability for outbound A2A requests in the gateway.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering problem/solution, scope, commit breakdown, cross-language contract details, operator flow, failure modes, and test results, though it deviates from the template structure.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gateway-did-signing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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