> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getbindu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Security Stack: mTLS + Hydra + DID

> Three layers, three different questions, one chain of trust

The [Authentication overview](/bindu/learn/authentication/overview) covered the bearer-token side. The [DID page](/bindu/learn/did/overview) covered the signature side. This page is about **how all three layers compose** on a single request — and why you need all three.

The short version of where each layer fits:

```
Layer 3:  DID signature     →  "is this message tampered with?"     (Ed25519, in the body)
Layer 2:  OAuth2 token      →  "are you allowed to make this call?" (Hydra, in the header)
Layer 1:  mTLS certificate  →  "are you on the wire I think I am?"  (step-ca, at the socket)
```

You need all three because they answer different questions. OAuth2 alone — an attacker on the wire reads your tokens. mTLS alone — anyone with a valid cert can call anything. DID signatures alone — you know who wrote a message but not whether to accept it. Together they form a chain, and any one can fail safe.

***

## 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:

1. **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.*
2. **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.*
3. **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.*

Three different officers. Three different checks. None of them is redundant — each catches a category of attack the others can't see.

| Embassy        | Bindu                                                                                             |
| -------------- | ------------------------------------------------------------------------------------------------- |
| Armored convoy | **mTLS** — both endpoints present X.509 certs from step-ca; nobody else can speak on this socket  |
| Day-pass       | **OAuth2 bearer token** — Hydra issued it, \~1h TTL, scoped to specific operations                |
| Wax seal       | **DID signature** — Ed25519 signature over the JSON body, verifiable with the sender's public key |

***

## 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.

Cert TTL is **24 hours**. The agent silently renews \~8 hours before expiry. There is no CRL or OCSP — short TTL **is** the revocation strategy.

### 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. The `client_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.

<Note>
  Bindu agents currently use a single scope (`agent:read agent:write`). Fine-grained authorization is on the roadmap once Kratos lands.
</Note>

### 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:

```
X-DID:            did:bindu:<author>:<name>:<uuid>
X-DID-Timestamp:  <unix-seconds>
X-DID-Signature:  <base58-encoded 64-byte signature>
```

The receiver fetches the sender's public key from Hydra client metadata, recomputes the canonical body, and verifies. Even if the bearer token was leaked and the TLS session was somehow compromised, a body that doesn't match the signature gets rejected.

Equally important: **the signature is non-repudiable**. The sender can't later claim "that wasn't me" — no one else has the private key that produced the signature.

See [Making Authenticated Requests](/bindu/learn/authentication/making-requests) for the exact signing payload and a canonical fixture.

***

## 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:

```
poet (https://127.0.0.1:5776)                      math (https://127.0.0.1:5775)
    │
    │  TCP SYN, SYN-ACK, ACK
    │ ──────────────────────────────────────────────►│
    │                                                │
    │  TLS ClientHello (presents poet's cert)        │
    │ ──────────────────────────────────────────────►│
    │                                                │
    │  TLS ServerHello (presents math's cert)        │
    │ ◄──────────────────────────────────────────────│
    │                                                │
    │  Both ends verify chain → step-ca root         │
    │  Both ends pin DID in SAN                      │
    │                                                │
    │  [TLS session established — encrypted from here]
    │                                                │
    │  POST /                                        │
    │    Authorization: Bearer ory_at_...            │
    │    X-DID:           did:bindu:...:poet:...     │
    │    X-DID-Timestamp: 1715961234                 │
    │    X-DID-Signature: 3xK9...base58              │
    │    Content-Type: application/json              │
    │                                                │
    │    {"jsonrpc":"2.0", "method":"message/send",  │
    │     "params":{"message":{"parts":[...]}}}      │
    │ ──────────────────────────────────────────────►│
    │                                                │
    │                                                │  middleware fires in order:
    │                                                │  1. mTLS already verified cert → DID = poet
    │                                                │  2. Auth middleware introspects Bearer
    │                                                │     token at Hydra → token belongs to poet
    │                                                │  3. DID middleware recomputes signature
    │                                                │     over body → confirms poet authored it
    │                                                │  4. All three identities must match
    │                                                │
    │                                                │  → hand to the handler
```

Any one of those four checks failing rejects the request. The handler never sees an unauthenticated, unverified, or impersonated call.

***

## Turning mTLS on

mTLS is opt-in. The full env block to turn it on for any agent:

```bash theme={null}
export AUTH__ENABLED=true
export AUTH__PROVIDER=hydra
export HYDRA__ADMIN_URL=https://hydra-admin.getbindu.com
export HYDRA__PUBLIC_URL=https://hydra.getbindu.com
export MTLS__ENABLED=true
export MTLS__MODE=hybrid                    # hybrid keeps Hydra on inbound too
export MTLS__REQUIRE_CLIENT_CERT=false      # set true for full strict-mTLS
export MTLS__CA_URL=https://ca.getbindu.com
export MTLS__CA_ROOT_URL=https://ca.getbindu.com/roots.pem
export BINDU_ALLOW_PRIVATE_WEBHOOK_RANGES=1 # for local 127.0.0.1 webhooks
```

The agent handles the rest itself: registers with Hydra, requests an OIDC token with `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

| Surface                                               | Default                                                               | How to flip                          |
| ----------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------ |
| **Inbox personal agent**                              | mTLS **on** when `BINDU_PERSONAL_MTLS=1` is set on the comms server   | Set the env var before `npm run dev` |
| **Spawned gateway**                                   | mTLS on iff the personal agent has cert files on disk (inherits them) | Automatic — no action                |
| **Fleet agents** (`examples/gateway_test_fleet/*.py`) | Plain HTTP unless the full mTLS env block is exported                 | Export the env block above           |
| **Custom agents**                                     | Plain HTTP unless `MTLS__ENABLED=true` and Hydra config is provided   | Export the env block above           |

***

## Verifying a running mTLS agent

Inspect the cert your agent is serving:

```bash theme={null}
openssl x509 \
  -in ~/.bindu/personal/.bindu/tls_cert.pem \
  -noout -subject -issuer -ext subjectAltName
```

You should see something like:

```
subject=CN=did:bindu:you_at_local:leonard-hofstadter:53f9d49f-e15b-...
issuer=CN=Bindu Intermediate CA
X509v3 Subject Alternative Name:
    URI:https://hydra.getbindu.com#did:bindu:you_at_local:leonard-hofstadter:53f9d49f-e15b-...
```

The SAN URI is `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:

```bash theme={null}
openssl s_client -connect 127.0.0.1:5775 -servername math_agent \
  2>/dev/null </dev/null \
  | openssl x509 -noout -ext subjectAltName
```

Force-renew before TTL (deletes the files; agent regenerates on next request):

```bash theme={null}
rm ~/.bindu/personal/.bindu/tls_*.pem ~/.bindu/personal/.bindu/ca_bundle.pem
```

***

## 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.

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

***

## Troubleshooting matrix

| Symptom                                                     | Most likely cause                                                                   | Fix                                                                                                                                                                               |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Spawn hangs in `starting` for >60s                          | mTLS env didn't reach `app_settings` (load\_dotenv order) OR Hydra audience missing | Check `/health` over both `http://` and `https://`. Whichever responds tells you which scheme the agent picked. Grep boot log for `Bootstrapping mTLS` and `Patched Hydra client` |
| `transport: fetch failed` in gateway plan trace             | undici v6/v8 mismatch, OR peer's cert SAN doesn't match expected DID                | Upgrade to the current release                                                                                                                                                    |
| `400 Requested audience 'step-ca' has not been whitelisted` | Hydra client was created before mTLS was enabled                                    | Restart the agent — registration patches drift automatically. Still failing? Delete `.bindu/oauth_credentials.json` to force a fresh registration                                 |
| `401 passwords do not match` after toggling mTLS            | A pre-fix reconciliation rotated the client secret                                  | Delete `.bindu/oauth_credentials.json` and restart; the agent will recreate the Hydra client with matching secret                                                                 |
| Inspector shows `fetch failed` on a healthy agent           | `agents` table row points to a stale port from a prior spawn                        | Re-spawn. Or `sqlite3 inbox/data/events.db "DELETE FROM agents WHERE id='me'"` and re-add                                                                                         |
| `/.well-known/agent.json` shows `url: "http://localhost"`   | Agent built on an older bindu — URL wasn't threaded into BinduApplication           | Upgrade to the current release                                                                                                                                                    |

***

## Operational quick reference

```bash theme={null}
# What cert is my personal agent serving?
openssl x509 -in ~/.bindu/personal/.bindu/tls_cert.pem \
  -noout -subject -dates -ext subjectAltName

# What audience is my Hydra client configured for?
curl -s "https://hydra-admin.getbindu.com/admin/clients/$(echo -n 'did:bindu:...' | jq -sRr @uri)" \
  | jq '.audience'

# Is the gateway actually using my cert?
ps eww $(pgrep -f gateway.*src/index) | tr ' ' '\n' | grep BINDU_GATEWAY_TLS

# Verify peer DID from a fleet agent's cert SAN
openssl s_client -connect 127.0.0.1:5775 -servername math_agent \
  2>/dev/null </dev/null \
  | openssl x509 -noout -ext subjectAltName

# Force-renew a cert before TTL (delete + restart)
rm ~/.bindu/personal/.bindu/tls_*.pem ~/.bindu/personal/.bindu/ca_bundle.pem
```

***

## What's next

<CardGroup cols={2}>
  <Card title="Making Authenticated Requests" icon="key" href="/bindu/learn/authentication/making-requests">
    The four headers, four gates, and canonical fixture
  </Card>

  <Card title="DID Identity" icon="id-card" href="/bindu/learn/did/overview">
    Ed25519 keys, canonical JSON, key rotation
  </Card>
</CardGroup>

<span className="brand-quote">
  <img src="https://mintcdn.com/pebbling/x2BFCGEbWywg69kQ/logo/light.svg?fit=max&auto=format&n=x2BFCGEbWywg69kQ&q=85&s=a69e734bb925e661b3c2ca2a20a050a9" alt="Sunflower Logo" width="32" className="clean-icon" data-path="logo/light.svg" />

  <span className="brand-quote-text">
    Three layers,{" "}

    <span className="brand-quote-highlight">
      three different questions, one chain of trust
    </span>

    .
  </span>
</span>
