Three questions, one analogy
Imagine you’re a courier showing up at a fortified embassy. Before they let you inside, three different officers question you, in order:- The marine at the front gate checks that you arrived via the embassy’s private armored convoy — not a stranger’s truck. If the convoy isn’t ours, you don’t even get to the gatehouse.
- The receptionist at the desk asks for your day-pass. It was issued this morning, expires at sundown, and lists which rooms you can enter. If the pass is expired or for the wrong room, you don’t get past the desk.
- The diplomat in the office opens the envelope you carried, checks the wax seal, and verifies the seal really was made by the foreign minister who claims to have sent it. If the seal is missing or fake, your message is rejected even though you got this far.
What each layer actually does
Layer 1: mTLS (transport)
What it answers: Is the TCP connection itself private and mutually authenticated? When agent A calls agent B over HTTPS, both ends present an X.509 certificate during the TLS handshake. Each cert was issued by Bindu’s private step-ca, which only signs certs after the requester proves it owns a Hydra OAuth2 identity. The cert’s Subject Alternative Name (SAN) embeds the agent’s DID. Practical consequences:- A man-in-the-middle can’t decrypt traffic — the session key was negotiated against B’s real cert.
- A man-in-the-middle can’t impersonate B — they don’t have B’s private key.
- A man-in-the-middle can’t impersonate A to B either — B verifies A’s client cert too.
- Bearer tokens never traverse the wire in cleartext — they’re inside the TLS tunnel.
Layer 2: Hydra OAuth2 (authorization)
What it answers: Should I let this DID perform this operation right now? Each agent registers itself in Hydra as an OAuth2 client. Theclient_id is the agent’s DID — so the DID lives in three places at once: the cert SAN, the OAuth2 client registry, and the message signature. They have to agree, or the request is rejected.
A caller fetches a bearer token from Hydra (client_credentials grant), then attaches it as Authorization: Bearer ... on every HTTP call. The receiver validates the token by introspecting against Hydra. Tokens last ~1h.
Bindu agents currently use a single scope (
agent:read agent:write). Fine-grained authorization is on the roadmap once Kratos lands.Layer 3: DID signature (integrity & non-repudiation)
What it answers: Was this exact JSON body authored by the DID it claims, and untampered with since? The sender signs the canonical JSON body of the request with their Ed25519 private key. Three HTTP headers carry the proof:A single request, in order
A poet agent sends a one-line A2A message to a math agent. Both have mTLS on. The full timeline:Turning mTLS on
mTLS is opt-in. The full env block to turn it on for any agent:aud=step-ca, exchanges it at step-ca for a 24h X.509 cert, drops the cert files in <your-agent>/.bindu/, and serves uvicorn over HTTPS.
Surface defaults
Verifying a running mTLS agent
Inspect the cert your agent is serving:https://hydra.getbindu.com#did:bindu:... — the DID lives in the URL fragment. step-ca’s Hydra OIDC provisioner emits it that way.
Peek at a fleet agent’s cert SAN over the wire:
Five real gotchas
Default-on mTLS surfaced five real bugs on a developer laptop. They’re all fixed in the current release, but worth knowing if you debug a similar stack from scratch.1. load_dotenv ordering
1. load_dotenv ordering
Bindu’s
app_settings = Settings() is constructed at module-import time. If your agent.py imports bindu before calling load_dotenv, your MTLS__ env vars land in os.environ but never reach the singleton — the agent silently serves plain HTTP even with MTLS__ENABLED=true in .env.Fix: always load_dotenv first, before any bindu imports.2. Hydra audience drift
2. Hydra audience drift
Agents register with Hydra and include
audience: ["step-ca"] in their client config only when mtls.enabled is True at registration time. Register without mTLS, then enable it later, and the existing-client branch returns early — never patches the audience array. step-ca then rejects token requests with 400: Requested audience 'step-ca' has not been whitelisted.Fix: the registration flow now reconciles drift on every boot.3. Reconciliation PUT rotated the secret
3. Reconciliation PUT rotated the secret
Hydra’s
PUT /admin/clients/{id} is a full replace, and GET never returns the client_secret. Building the PUT body from the GET response caused Hydra to overwrite the password with empty — the next client_credentials call then returned 401: passwords do not match.Fix: the reconciliation now re-sends the secret from local credentials in the PUT body.4. Agent card advertised http://localhost
4. Agent card advertised http://localhost
BinduApplication defaulted its url field to "http://localhost" and bindufy never passed deployment.url through. Peers fetching /.well-known/agent.json got an unreachable address — no port, wrong scheme.Fix: the resolved URL is now threaded into the constructor.5. Node 22 undici v6 vs pinned undici v8
5. Node 22 undici v6 vs pinned undici v8
Node’s bundled global
fetch uses undici 6.x. The inbox and gateway both pin undici 8.x for the dispatcher API. Passing a v8 Agent to the v6 global fetch throws an opaque TypeError: fetch failed.Fix: both call sites now switch to undici.fetch when a dispatcher is in play.Troubleshooting matrix
Operational quick reference
What’s next
Making Authenticated Requests
The four headers, four gates, and canonical fixture
DID Identity
Ed25519 keys, canonical JSON, key rotation