| Severity | critical |
| Status | fixed |
| Found | 2026-04-18 |
| Fixed | 2026-04-18 |
| Area | bindu/server/middleware/auth |
Symptom
The Hydra middleware inbindu/server/middleware/auth/hydra.py
ships a second-layer authentication check: for any OAuth caller whose
client_id starts with did:, every request body must be signed
with the caller’s Ed25519 private key, and the middleware verifies
that signature against the public key registered in Hydra client
metadata. This layer exists so that if a bearer token is stolen, the
thief still cannot send arbitrary requests — they’d also need the
private key, which never leaves the DID holder’s machine.
On the old code this layer was optional in practice. An attacker
holding a stolen bearer token for a DID client could bypass the
signature check entirely by one of two methods:
- Omit the signature headers. Send a normal HTTP request with
Authorization: Bearer <stolen-token>and noX-DID-Signature. The middleware noted “no signature headers present” and allowed the request to continue as if verification had succeeded. - Register a DID client in Hydra without a
public_keyin metadata. Send real-looking signature headers containing any bytes you like. The middleware tried to fetch the public key, got nothing back, and again allowed the request to continue as if verification had succeeded.
Root cause
_verify_did_signature_asgi at
hydra.py:158-223 is only entered when the outer __call__ gate
confirms the caller is a DID client (client_did.startswith("did:")
at hydra.py:269). At that point the contract is unambiguous:
this caller must sign, otherwise they should not be using a
did:* client_id.
But the function itself held a softer contract. Two branches
returned is_valid=True when the check couldn’t be performed:
did_verified: False flag in the returned dict was intended
to be advisory telemetry, but nothing downstream checked it. From
the caller’s perspective, is_valid=True meant “the request is
clean.” The function was saying “I couldn’t verify, but that’s OK”;
the caller was hearing “I verified, all good.” Two incompatible
contracts in neighboring files, and the outer file wrote the final
decision.
This is the textbook fail-open vs fail-closed pattern. For
security checks fail-closed is the only safe default — a check
that silently allows a request when it cannot determine
authenticity provides no security at all, only the illusion of it.
Diagnostic “reason” metadata that isn’t load-bearing is useful for
logs but does not substitute for a reject.
Fix
Both branches now returnFalse and log a clear operator-facing
warning explaining what the DID client did wrong or how to fix
their Hydra metadata:
- Missing
X-DID-Signature→ reason"missing_signature_headers",is_valid=False. The caller reaches theif not is_validgate at line 274 and receives a 403. - Hydra has no
metadata.public_keyfor this DID client → reason"public_key_unavailable",is_valid=False, same 403.
tests/unit/server/middleware/test_hydra_did_signature.py:
- The two previously-fail-open paths (missing headers / missing
public key) now return
is_valid=False. Also covered: an empty-string public key is handled identically to a missing one. - The pre-existing DID-mismatch branch (token claims one DID, the
X-DIDheader claims another) still returnsis_valid=Falsebefore the Hydra key lookup runs, so the check order remains correct. - The payload-size guard still rejects oversize bodies before crypto runs.
- The happy path — all headers present, DID matches, Hydra returns
a key,
verify_signaturereturns True — still accepts, and the body remains replayable to the downstream app via the cachedreceiveproxy. - An otherwise-valid request where
verify_signaturereturns False still rejects with reason"invalid_signature".
did: client_id for it.
Why the tests didn’t catch it
There were no unit tests for_verify_did_signature_asgi before
this fix. The middleware was exercised implicitly by integration
paths that always provided valid signatures, so every test case
was a happy-path test case. The interesting branches —
specifically the “can’t verify” branches — had no coverage at
all. Fail-open bugs are particularly invisible to happy-path
tests because they only manifest when verification fails in a
novel way and the failure mode itself is what’s broken.
The new test file covers the cross-product of signature-present
× public-key-present × crypto-result. Future changes to this
function will have a clear failure signal.
Class of bug — where else to watch
Fail-open is a shape. In any security-relevant function that returns a boolean “is the caller authorized / is the signature valid / is the payment valid,” ask: what is returned on the exception path, on the “couldn’t determine” path, on the “provider is down” path? If the answer is anything other than “False, with a reject,” that’s a variant of this bug. In this codebase the places most likely to hold the same shape:bindu/server/middleware/x402/x402_middleware.pylines 213–215 still fail open on body-parse errors — tracked inbugs/known-issues.mdasx402-middleware-fails-open-on-body-parse. Same pattern: the “can’t parse” branch callsawait call_next(request)instead of rejecting. Fixing this is independent from the DID fix but uses the same logic.bindu/utils/did/signature.pylines 114–126 (the underlyingverify_signaturehelper) catchExceptionbroadly and returnFalse. That is actually correctly fail-closed on the result, but the broad exception obscures which failure mode occurred — tracked asdid-signature-overbroad-exception-catch.- The Hydra introspection path at
hydra.py:102-104catches introspection errors and re-raises — correctly fail-closed. Worth a re-read when auditing. - New middleware or signature-verification code added in the future: the rule is return False when you can’t check, never True with a “reason”. Make the reject explicit at the decision point, not optional based on downstream consumption.
Follow-ups
- The broad
except Exceptioninverify_signatureshould be split into(ValueError, TypeError)for base58 decode errors andBadSignatureErrorfor cryptographic failures, so logs distinguish “malformed input” from “wrong signature.” Tracked asdid-signature-overbroad-exception-catchinbugs/known-issues.md. - The Hydra token introspection cache still holds revoked tokens
for up to 5 minutes (
hydra-token-cache-revocation-lagin known-issues). Independent from this fix; still worth addressing.