feat(gateway): DID signing for outbound A2A requests (Phase 1a)#472
feat(gateway): DID signing for outbound A2A requests (Phase 1a)#472raahulrahl merged 3 commits intomainfrom
Conversation
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>
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis PR introduces DID-based Ed25519 signing for outbound A2A requests in the gateway. It adds a new Changes
Sequence DiagramsequenceDiagram
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
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
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Closes the calling-side DID gap we identified earlier: gateway can now sign outbound A2A requests so DID-enforcing peers like
agno_exampleaccept them instead of returning 403missing_signature_headers.Scope (Phase 1a)
did_signedpeer auth variant in the resolverrpc()— same bytes signed and sentrunCall/runCancelgateway/.env.example+gateway/README.mdDeferred to follow-ups (explicitly scoped in the last commit message):
/.well-known/did.jsonendpointdocs/GATEWAY_DID_SETUP.mdEach is a separate reviewable PR; this one keeps the diff focused on the core signing contract.
Three commits, reviewable in order
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.f203827— plumb DID signing through auth resolver and rpc. 358 lines. Addsdid_signedvariant; makesbuildAuthHeadersasync;rpc()serializes body once and signs. 11 new auth-resolver tests including regression guards.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.mdupdates. 5 new boot tests.Cross-language contract
The load-bearing assertion, from
gateway/tests/bindu/identity-local.test.ts: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
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
no DID identity configured (set BINDU_GATEWAY_DID_SEED, _AUTHOR, _NAME to enable did_signed peer auth)Partial DID identity config — set all three or none: ...BINDU_GATEWAY_DID_SEED must decode to exactly 32 bytesdid_signedpeer but no identity loadeddid_signed peer requires a gateway LocalIdentity — check that BINDU_GATEWAY_DID_SEED is set at bootdid_signedpeer with missing OAuth env varenv 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
BINDU_GATEWAY_DID_SEED,BINDU_GATEWAY_AUTHOR,BINDU_GATEWAY_NAME).Documentation
Tests