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.
Read the Authentication page first if you haven’t. This page builds on it. In one line:
- Authentication (bearer tokens, Hydra) answers: are you allowed to make this request?
- DIDs answer the other half: are you really who you say you are?
The milk-truck problem
You run a coffee shop. Every morning at 6am, a truck pulls up claiming to be your milk delivery. The driver says, “I’m from Acme Dairy, same as always.” How do you know they really are? A few options:- Ask for an ID card. Anyone can print a card that says “Acme Dairy.” You can’t tell a real one from a fake one.
- Call Acme and ask, “is this driver yours?” Works — but now you’re calling Acme every single morning. And if Acme’s phone line is down, no milk for anyone. Your cappuccinos are ruined.
- Acme gives the driver a special key. A cryptographic one. The driver proves they have it, on the spot, without calling anyone. Even if Acme’s office burns down overnight, the key still works.
The passport and the day-pass
Imagine walking into a secure building. The security desk wants to know three things:- Is your passport a real passport? (Document authentic?)
- Does the face on it match you? (Really you?)
- Do you have a day-pass for today? (Allowed in today?)
- The passport — expensive to forge, issued once, lasts years. Proves who you are.
- The day-pass — a sticker you get at the front desk. Proves you have access today.
| Real life | Bindu |
|---|---|
| Passport | DID + signature — long-lived cryptographic identity |
| Day-pass | Bearer token — short-lived (~1 hour) access grant |
| Photo on passport | Public key stored in the DID document |
| Secret signature only you can make | Private key only you hold |
| Guard checks passport photo | Server checks DID signature |
| Guard checks day-pass | Server checks bearer token via Hydra |
Public and private keys, without the math
Before we go further, one concept. You’ve probably heard public-key cryptography before. Here’s what it actually means, stripped of math. A key pair is two matched pieces of data — a private key and a public key. Born together, in one moment, from one random number. They have two almost-magical properties:- You can give the public key to anyone. Strangers on the internet. Billboards. T-shirts. Doesn’t matter. That’s why it’s public.
- If you “sign” a message with the private key, anyone with the public key can verify the signature. They can’t make signatures — only you can, because only you have the private key. But they can check yours.
- A letter with the stamp is verifiably from the scribe.
- A letter without it is just paper.
- A forged stamp gets spotted instantly because the stamp has unique geometry nobody else can reproduce.
Cryptographers love to call this stuff “elegant.” What they mean is: someone really smart in the 1970s figured out how to build a lock where the key you give out (public) can only verify locks, but the key you keep (private) can actually make them. We’re still collectively shocked this works.
- Keys are tiny — 32 bytes each. Fits in a header.
- Fast to sign and verify. Sub-millisecond.
- Heavily audited. Signal uses it. SSH uses it. Tor uses it. It’s trusted.
- Seed — a 32-byte random number that generates the key pair. If you have the seed, you can always re-derive both keys.
- Signature — the 64-byte output of signing a message. Usually encoded as Base58 so it’s a readable string. If the signature verifies, you know the message really came from someone with the private key.
A note on storage. A Bindu agent doesn’t store the seed itself —
DIDAgentExtension in bindu/extensions/did/did_agent_extension.py writes a PKCS8 PEM private key to private.pem (mode 0o600) and a SubjectPublicKeyInfo PEM to public.pem (mode 0o644), under the pki_dir (default .bindu/). The PEM file can optionally be encrypted at rest with key_password. The “seed” pattern you’ll see in scripts (Postman, browser POC) is a separate convenience — it derives the same key pair without ever touching PEM.What a DID actually is
Take a deep breath. A DID is just a string. A specific shape of string that says “this identifier belongs to a specific identity system, and here’s where to look up more info about it.” Bindu DIDs look like this:DIDAgentExtension.did builds when author, agent_name, and agent_id are all set):
| Part | In the example | What it means |
|---|---|---|
| 1 | did | The literal prefix. “This is a Decentralized Identifier.” Every DID ever, in every system, starts with this. W3C standard. |
| 2 | bindu | The method. Tells you which DID system to use. Others exist: did:web, did:ethr, did:key. Here we use Bindu’s. |
| 3 | dutta_raahul_at_gmail_com | The author segment. A human-readable identifier of whoever created this DID. Sanitized by _sanitize_identifier: lowercased, spaces → _, @ → _at_, . → _. Pure metadata — helps humans know whose agent this is. |
| 4 | postman | The agent name. A short label, run through the same sanitizer. |
| 5 | ee67868d-...-ba4b29dc5e1d | The agent ID — a UUID-shaped string. By convention it’s derived from the first 16 bytes of sha256(public_key) (the Postman generator does exactly that), but DIDAgentExtension will accept whatever agent_id you pass in. |
did:key form: did:key:z<base58-multibase-public-key> (the z is the multibase prefix for Base58btc). Useful for ad-hoc identities; Bindu native agents always use the five-part did:bindu:... form.
DID string rules
A few constraints — the ones enforced byDIDValidation in bindu/extensions/did/validation.py:
- Must start with
did:(settingsdid.prefix) - Must match the basic pattern
^did:[a-z0-9]+:.+$(case-insensitive) - For
did:bindu: must match^did:bindu:[^:]+:[^:]+(:[^:]+)?$— author and agent name are mandatory, agent ID is optional in the regex but mandatory in practice
._:%-; case-sensitive; no ?, #, or spaces; under 2048 characters. Bindu doesn’t re-enforce all of these on the wire, but cross-protocol parsers will.
The DID document — the thing servers actually trust
The DID string is just a name. To trust you, a server needs your public key. That mapping — DID string → public key — lives in a JSON file called the DID document. Here’s exactly whatDIDAgentExtension.get_did_document() returns:
@context—["https://www.w3.org/ns/did/v1", "https://getbindu.com/ns/v1"]. Parsers care; you don’t.id— the DID itself.created— UTC ISO timestamp cached at extension construction time.authentication— a list of verification methods. The default one has a fragment#key-1(did.key_fragment), typeEd25519VerificationKey2020, controller equal to the DID itself, and the public key as raw 32 bytes, Base58btc-encoded.
Where does Bindu store this document? Two places, depending on who’s resolving:
- The agent advertises its own DID document inline in
/.well-known/agent.jsonand exposesPOST /did/resolve(path fromdid.resolver_endpoint) for direct lookups. - The gateway (when you authenticate against Hydra) stores client public keys in the Hydra OAuth client’s
metadata.public_keyfield. The middleware inbindu/server/middleware/auth/hydra.pypulls that key to verify theX-DID-Signatureheader.
DIDValidation.validate_did_document enforces @context and id are present, that each entry in authentication is an object with type and controller, and — if a service array is present — that every serviceEndpoint matches the configured network.default_url.Signing a request (what the client does)
You want to send a request to a Hydra-fronted Bindu agent. You want to sign it, so the agent knows the request really came from you — not a replay, not a man-in-the-middle tampering in transit. The signer and the verifier share one contract: they must agree on the bytes. The reference implementation lives inbindu/utils/did/signature.py::create_signature_payload.
Gather three inputs
- Body — the exact bytes of the HTTP request body, as they’ll go on the wire. Not a parsed object. Not “reformatted.” The exact UTF-8 bytes the server will receive. This is the single thing people get wrong most often.
- DID — your DID string.
- Timestamp — current Unix time, in seconds (an integer).
Build the signing payload
Combine the three into a small JSON object:Then serialize it using Python’s Notice the spaces after
json.dumps(sort_keys=True) convention. Two things matter:- Keys sorted alphabetically at every level. So
body→did→timestamp. - Default Python separators —
", "and": ". With a space. After the comma. After the colon.
JSON.stringify omits those spaces by default. Python doesn’t. If your client skips the spaces, the bytes you signed don’t match the bytes the server reconstructs, and the signature fails — even though you “signed the right thing.”A correct payload for a small example:: and ,. Notice body comes first. If what you produce matches json.dumps(payload, sort_keys=True), you’re good.For JS/TS, replicate it with the pythonSortedJson helper from docs/postman-did-signing.js — it walks the value, sorts object keys, and inserts the Python-style separators.Sign the bytes
Take the UTF-8 bytes of that payload string. Sign them with your Ed25519 private key. Base58-encode the resulting 64-byte signature.In Python, against a Or use the bundled
DIDAgentExtension:sign_request helper:Attach four headers
Three for the signature:Plus your bearer token from the Authentication flow:Send the request. The body on the wire has to be exactly the same bytes you used in the signing payload. If any middleware reformats the JSON between your sign-step and the network — the signature breaks.
curl with signed headers
A complete, working curl once you’ve computed the signature:--data-binary @body.json matters — --data strips newlines and breaks the signature.
Verifying a request (what the server does)
When a Hydra-fronted Bindu agent receives your request, four gates fire in order (seebindu/server/middleware/auth/hydra.py::_verify_did_signature_asgi and bindu/utils/did/signature.py::verify_signature). Fail any one, and the request is rejected with a reason telling you which gate.
Each reason in a rejection points to exactly one gate:
| Reason | Gate | What’s wrong |
|---|---|---|
missing_signature_headers | 2 | Bearer token present, client is a DID, but no X-DID-* headers |
did_mismatch | 2 | X-DID header disagrees with the token’s client_id |
public_key_unavailable | 3 | Hydra has no public key for this DID |
payload_too_large | 3 | Body exceeds MAX_BODY_SIZE_BYTES — can’t safely buffer for verify |
timestamp_out_of_window | 4a | |now - X-DID-Timestamp| > 300s (replay guard) |
malformed_input | 4b | X-DID-Signature or public key isn’t valid Base58 / wrong length |
crypto_mismatch | 4b | Signature doesn’t verify — wrong key, wrong bytes, or tampering |
The verifier reconstructs the payload from the raw ASGI body bytes plus the
X-DID and X-DID-Timestamp headers, then re-runs json.dumps(payload, sort_keys=True) and checks the signature against the result. The body bytes are buffered from the ASGI stream and replayed to downstream handlers via a proxy receiver — your handler still sees the body.Setting up your own DID from scratch
Two paths, depending on whether you’re running an agent or signing as a client.Path A: Generate a seed and derive everything (client-side)
This is what the Postman pre-script and the browser POC do. One Python command does it all:Path B: Let DIDAgentExtension manage the keypair (agent-side)
If you’re embedding the DID extension into a Bindu agent, you don’t write a script — you instantiate the extension and let it write private.pem / public.pem under pki_dir (default .bindu/):
private.pem— Ed25519 PKCS8 PEM, mode0o600(or encrypted, ifkey_passwordset)public.pem— Ed25519 SubjectPublicKeyInfo PEM, mode0o644
Register the client with Hydra
Whichever path you took, the gateway needs to know your public key. Register a Hydra OAuth client and stash the Base58 public key inmetadata.public_key:
metadata.public_key is exactly what Gate 3 reads. hybrid_auth: true signals to the middleware that this client requires DID signatures on top of a bearer token — once that’s set, unsigned requests get missing_signature_headers.
Get a bearer token
Head back to the Authentication guide — step 2 of “Getting your first token.” Same flow, your shiny new DID is theclient_id.
Sign and send a request
Usebindu/utils/did/signature.py::sign_request (Python), the Postman pre-script from docs/postman-did-signing.js (JS, browser-compat Web Crypto), or roll your own. All three produce identical bytes — they’ve been cross-tested.
Canonical fixture for cross-language testing. Rolling your own signer? Use this:If it doesn’t, your Python-compatible JSON serialization is almost certainly the culprit — either missing spaces, or keys aren’t sorted.
- seed = 32 zero bytes
- DID =
did:bindu:test - body =
{"test": "value"} - timestamp =
1000
What goes wrong in real life
This section is long because this is where people lose hours. Every one of these happened to a real person setting up Bindu. Learn from their pain.I'm getting did_mismatch and the strings look identical
I'm getting did_mismatch and the strings look identical
X-DID must be byte-identical to the client_id Hydra returns when introspecting your token. Three things to check, in this order:- Are you talking to the right Hydra? Your agent’s
HYDRA__ADMIN_URLand the Hydra that issued your token must be the same. Run:Theclient_idfield must exactly matchX-DID. - Did a character get auto-edited? Some clients (pasting from Slack, from Markdown tables) turn
-into–(en dash). Same-looking, different bytes. Spot it with: - Is Postman holding onto a stale token? Open Postman Console (Option-Cmd-C) and read the actual outgoing headers — not what you think you sent, what actually went out.
I'm getting crypto_mismatch
I'm getting crypto_mismatch
Gate 4b tried to verify your signature and it didn’t match. Four usual suspects:
- Body bytes drifted. Your signing code serialized the JSON object, then some middleware or HTTP client re-serialized the body before sending. Same object, different whitespace or key order. Fix: sign the exact string you’ll put on the wire, not the parsed object.
create_signature_payloadraisesTypeErroron dicts specifically to make this hard to do by accident. - Wrong public key in Hydra. You rotated the key locally but forgot to update
metadata.public_key. Server verifies against the old key. Fix: re-register with the new key. - Python-compat JSON mismatch. You’re probably in JavaScript, and
JSON.stringifyomits the spaces. Fix: usepythonSortedJsonfromdocs/postman-did-signing.js, or replicate it. - Actually forged / wrong seed. The key you’re signing with doesn’t match the public key in Hydra. Walk the chain:
private key → public key → metadata.public_key. One of the links is broken.
I'm getting malformed_input
I'm getting malformed_input
Verifier got either
X-DID-Signature or the registered public key, and one of them didn’t decode as valid Base58, or decoded to the wrong length. Common causes:- You URL-encoded the signature. Don’t — Base58 has no reserved characters.
- You sent the public key as hex instead of Base58.
- You concatenated/truncated bytes somewhere.
len(base58.b58decode(sig)) == 64 and len(base58.b58decode(pubkey)) == 32.I'm getting timestamp_out_of_window
I'm getting timestamp_out_of_window
Two causes:
- Your clock is wrong. Check
date -uagainst a known-good time server. Container clocks drift all the time. - Someone’s replaying a captured request. Or you’re trying to resend a request your logs captured 10 minutes ago. Sign fresh every time.
±300s by default (max_age_seconds in verify_signature).I'm getting public_key_unavailable
I'm getting public_key_unavailable
I'm getting missing_signature_headers
I'm getting missing_signature_headers
You sent
Authorization: Bearer <token> where the token’s client_id is a DID, but didn’t send the three X-DID-* headers. Once client_id starts with did:, signing is mandatory — there’s no “unsigned is fine” fallback (this used to fail-open and was closed; see bugs/2026-04-18-did-signature-fail-open.md in the repo).I'm getting payload_too_large
I'm getting payload_too_large
Body exceeds the configured
MAX_BODY_SIZE_BYTES. The middleware refuses to buffer arbitrarily large bodies for signature verification. Split the payload, or talk to whoever runs the gateway about raising the limit.Keeping your key safe
The private key — whether held as a 32-byte seed or asprivate.pem — is the single thing that gives you authority over your DID. Treat it like a password — but worse, because there’s no “forgot password” flow. Lose it and the DID is gone forever.
Storage
Secret manager (1Password, AWS Secrets Manager, Vault) for prod. Gitignored
.env for local. Never in source. Never logged in plaintext.Permissions
DIDAgentExtension writes private.pem mode 0o600 on POSIX. Verify with ls -l ~/.bindu/private.pem — should be -rw-------.At-rest encryption
Pass
key_password (or env:DID_KEY_PASSWORD) to DIDAgentExtension and the PEM is wrapped with BestAvailableEncryption. Loading the key without the password raises ValueError.Rotation
Generate new key,
PUT to /admin/clients/<did> with updated metadata.public_key, restart agent with recreate_keys=True, discard old key. Old signatures stop verifying immediately — do it in a maintenance window.If you think the key is compromised
Assume the attacker has full signing authority until you’ve:- Rotated the key.
- Revoked all outstanding bearer tokens (Hydra’s admin API can do this).
- Audited recent requests signed by this DID — what did the attacker do?
- Read your logs for unfamiliar
X-DID-Timestampvalues or weird request patterns.
Bonus: agents sign their responses too
So far we’ve talked about you signing requests. Agents sign their responses right back. Every artifact a Bindu agent produces is run throughdid_extension.sign_text(text_value) in bindu/utils/worker/artifacts.py, and the Base58 signature is stuffed into the artifact metadata under the key did.message.signature (from app_settings.did.agent_extension_metadata).
Look for this inside a task response:
publicKeyBase58, and check the signature against the message bytes using the same verify_text logic the extension uses internally:
/.well-known/agent.json, and the full DID document is at POST /did/resolve.
You don’t have to verify. The server does the heavy lifting on the way in. But if you’re building something compliance-heavy (legal, medical, financial), client-side verification gives you a proof you can put in an audit log.
Signing is not encryption
One clarification that comes up constantly:- Signing gives you authenticity (“really from this DID”) and integrity (“not modified in transit”). It does not hide the contents. Anyone who sees the traffic can read the body as plain JSON.
- Encryption hides the contents. For network transport, use HTTPS (TLS). Bindu’s public endpoints are HTTPS in production.
Why Ed25519 (optional reading)
The short version: Ed25519 is a modern elliptic-curve signature scheme that is:- Small. Keys are 32 bytes, signatures 64 bytes. Fits in headers.
- Fast. Sub-millisecond sign/verify on modern CPUs.
- Deterministic. Same input → same signature. Makes cross-language testing painless.
- Well-vetted. Signal, Tor, SSH, RFC 8032. No known practical attacks.
authentication[].type in its DID document tells you which algorithm to use. Bindu-native DIDs always use Ed25519VerificationKey2020.
Related
Authentication
The bearer-token half. Read this first if you haven’t.
Payment (x402)
Payment receipts are signed with the same DID keypair.
W3C DID Core
The governing standard for all DID methods.
RFC 8032 / Ed25519
The signature scheme itself.
The whole thing in one paragraph
A DID is a long identifier string that maps, through a public document, to an Ed25519 public key. When you make a request, you sign specific bytes —json.dumps({"body", "did", "timestamp"}, sort_keys=True) — with your private key and attach the signature as X-DID-Signature. The middleware introspects your bearer token, checks that X-DID matches the token’s client_id, looks up your public key in Hydra’s metadata.public_key, reconstructs the same bytes from the raw ASGI body, and verifies. If everything lines up, the request reaches your handler; otherwise it’s rejected with a one-word reason pointing at the exact gate that failed. Combined with the bearer token from the Authentication page, Bindu gets two independent guarantees: this request is permitted (token) and this request is authentic (signature). Lose either and the request is rejected. Get both right and you have a system where identity and access are verifiable end-to-end — without trusting any single central authority.