Two agents that verify each other without a shared secret
A buyer agent commits to an OpenProposal offer, and a seller agent accepts it. Each agent learns who the other is from a signature it can check, with no key exchanged in advance.
Three agents run live at their own origins: a seller, a buyer, and cfbot, an agent built on Cloudflare's Web Bot Auth library. Every 15 minutes a runner drives the whole offer flow, once with a signature and once with a bearer token. It publishes what each party sent, received, and kept under Latest run.
Loading the latest runHow Web Bot Auth works
Web Bot Auth is an IETF draft protocol that signs automated HTTP traffic with HTTP Message Signatures (RFC 9421). An agent's identity is its origin, such as https://buyer.wba-demo.noetis.dev. The agent publishes its public keys in a signed key directory at a fixed path on that origin, and signs each request with its private key.
A verifier reads the origin from the request, fetches that directory over HTTPS, and checks the signature. The TLS certificate for the host ties the keys to the domain, so the verifier needs nothing in advance: no key, no secret, no registration.
The four steps in Figure 1 work as follows:
- The buyer signs the request.
Signature-Inputlists what the signature covers: the method, the target URI, the authority, andSignature-Agent, which names the buyer's directory URL. With a body,Content-Digestcarries the body's SHA-256, and the signature covers it too. - The seller takes the host from
Signature-Agentand fetcheshttps://<host>/.well-known/http-message-signatures-directory. It follows no redirects, and it keeps the directory for up to 300 seconds. - The directory is a JWK Set. Each key signs the directory response itself, and each key's
kidis its JWK thumbprint. - The seller selects the key by the pair of origin and
keyid, verifies the signature andContent-Digest, checks the time window, and records thenonce, so the same request can't be replayed.
Responses work the same way. The seller signs each answer as its own origin, and that signature also covers the request's signature. The answer is bound to that exact request, so nobody can replay it as the answer to another one.
These are the headers of a real commit the buyer sent, from a run of this demo:
content-digest: sha-256=:XYZXLHvlTPfgpTCpGuuvvlXJOIn517Wm3H5ngdw24vg=:
signature-agent: sig1="https://buyer.wba-demo.noetis.dev/.well-known/http-message-signatures-directory"
signature-input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest" "signature-agent";key="sig1");created=1794068629;keyid="ZKFm7HPxqUK7ECl0ppoCPHjrE2qTrz_bc_rJ58ippbA";alg="ed25519";expires=1794068929;nonce="Qb7KCDWAcaX2…";tag="web-bot-auth"
signature: sig1=:5kF9XH4bsuauHZoFccx+QIPx31fC1lEEETIkSXE22ZRLN8OIL1otTSDNxFNjTIxgN+hb387+KZBuexTHnt1SAA==:
OpenProposal § 2.8 profiles these rules for agent traffic: Ed25519 keys, exactly one signature per message, a lifetime of at most 300 seconds, and 60 seconds of clock skew. It adds no header, tag, key field, or error code, so a standard Web Bot Auth implementation configured with these rules interoperates. Read the OpenProposal 3.0 specification and the Web Bot Auth library both agents use.
The identity boundary
A bearer token and a Web Bot Auth signature are both transport-level credentials. The seller handles them in one place, resolve_caller in seller/identity.py. Every agent-facing route asks that function for its caller and never reads a header itself.
The boundary takes four steps:
- Read. The boundary finds which credential the request presents. A request that presents both a signature and a token gets
400. - Confirm. A signature goes to the library's
RequestVerifier, which fetches the directory and checksContent-Digestagainst the body. A token is confirmed when its SHA-256 matches a stored row. The seller never stores the token itself. - Resolve. The confirmed credential maps to the principal in the seller's own
credentialstable: by verified origin, or by token hash. A verified origin with no row is an established identity with no principal: aPublicCallerthat carries the origin. - Freeze. The boundary returns one immutable value,
ResolvedCallerorPublicCaller. The offer logic receives it and can't change it.
The offer logic is credential-blind. The boundary puts how the caller authenticated, the evidence it kept, and the attribution of § 4.1.2 into one Credential record. The offer logic hands that record to storage with the entry it writes, and never reads it, so no offer decision depends on which credential the caller used.
A credential that fails is refused at the boundary, before anything reads the body. The refusal is a transport status with no errors[] object:
| Status | When | The answer also carries |
|---|---|---|
400 | A signature field doesn't parse, or the request presents both credentials | Nothing more |
401 | A bearer token matches no row, or Authorization isn't a Bearer token | WWW-Authenticate |
403 | A signature fails verification or a profile rule | Accept-Signature |
429 | A signature's nonce was seen before | Accept-Signature |
503 | The verifier can't record the nonce | Retry-After |
Two credentials, one identity type
The same flow runs with either credential and reaches the same ResolvedCaller type, so you can add Web Bot Auth next to bearer tokens without touching the business logic. The live run proves it every 15 minutes: the bearer buyer and the signing buyer both reach agreed through the same code. Web Bot Auth adds what a bearer token can't give:
| Property | Bearer token | Web Bot Auth signature |
|---|---|---|
| Set up before the first call | The seller issues a token and sends it to the buyer | Nothing. The seller records the buyer's origin; keys come from the buyer's directory |
| Shared secret | The token. Whoever holds it is the buyer | None. The directory holds public keys only |
| Covers the body | No | Yes, through Content-Digest |
| Replay of a captured request | Works until the token is revoked | Refused: each signature has a nonce and lasts at most 300 seconds |
| Evidence a third party can check | None. Either party could have sent the request | The signed message and the directory it verified against |
| Key rotation | Both parties coordinate a new token | The signer publishes a new key, waits out caches, and switches. The verifier does nothing |
| Identity the boundary returns | ResolvedCaller, method: bearer | ResolvedCaller, method: web_bot_auth |
The offer flow
The buyer reads a proposal the seller issued to it, and commits to one line item. The seller checks the commit and accepts. Each message is signed, and each answer is bound to the request it answers.
Each side does the following:
- The seller shows an issued proposal only to its buyer. On a commit it recomputes
record_digestover the line items plus the commit's writes, runs the checks of § 5.6.1, and writes the buyer'scommitmententry and its ownacceptance. A repeated commit with the sameidempotency_keygets the stored result. - The buyer refuses an answer it can't verify: unsigned, unbound, or bound to another request. It re-sends the same
Commituntil an answer verifies, and then checks that the acceptance matches its commitment. - Both keep the counterparty's signed message as exact bytes, with the exact directory bytes it verified against. That evidence verifies again later, offline, which is what § 4.1.2 asks a party to be able to show.
One SDK on both sides
The buyer and the seller import the same generated OpenProposal SDK and the same Web Bot Auth library. Their business logic differs: one composes a commit, and the other checks it. Everything they must agree on is the same code.
An agreement holds only if both parties compute the same record_digest: the SHA-256 of the binding record in RFC 8785 canonical form. A party that parses one field differently, drops an unknown member, or writes a number another way computes a different digest, and the seller answers record_mismatch. The same holds for signatures: two verifiers that read the profile differently accept different messages.
A generated SDK removes that risk at the source. Every party, in every language, gets types generated from one schema, so each one parses and serializes the same way. The Web Bot Auth library does the same for signing and verification. When the buyer and the seller each use these packages, they agree by construction rather than by testing each pair.
Stopgap: demo_types/stack.py defines Commit, CommitResult, and AssentEntry by hand, because the SDK generator doesn't cover these three types of the specification's pull-request stack yet. Generated types replace that module, and both parties then import them from openproposal like every other type.
The setup
The live deployment
One host runs the three agents as containers, each on a port that only the host can reach. Caddy terminates TLS with Let's Encrypt certificates and passes each public name to its agent. The agents trust public certificate authorities only, and they call each other by public name, through DNS and Caddy, like any outside party.
| Name | Serves | Local port |
|---|---|---|
wba-demo.noetis.dev | This page, /latest-run.json, and /history.json | Files on the host |
seller.wba-demo.noetis.dev | The seller agent: issued proposals, commit, /whoami, and its key directory | 18081 |
buyer.wba-demo.noetis.dev | The buyer's key directory | 18082 |
cfbot.wba-demo.noetis.dev | cfbot's key directory and /verify, which checks a signature with Cloudflare's library | 18083 |
The local end-to-end test
The same images run in one Docker network for make e2e. An nginx front answers for the three agent names with a certificate from a throwaway certificate authority, which every container trusts. A pytest runner drives eleven scenarios, from first contact to key rotation, and inspects what each party stored.
| Setting | Live | Local end-to-end test |
|---|---|---|
| TLS | Let's Encrypt, through the host's Caddy | A throwaway CA, through nginx |
| Trust store | Public roots only | Public roots plus the throwaway CA |
Directory Cache-Control: max-age | 300 s | 300 s for the seller, 0 s for the buyer |
Verifier cache (max_cache_age) | 300 s | 20 s |
| Wait before a published key signs | 600 s | 20 s |
| Signature lifetime | 300 s | 10 s |
| Fault injection | Not mounted | Mounted for scenario 9 |
Try it
Each agent publishes its key directory at its own origin:
Fetch a directory
The response is a JWK Set. Its Signature-Input and Signature headers sign the response once per key, with tag="http-message-signatures-directory":
curl -si https://buyer.wba-demo.noetis.dev/.well-known/http-message-signatures-directory
Call the seller without a credential
The seller answers 200 with a PublicCaller that has no credential. The answer itself is signed: look for Signature-Agent, Signature-Input, and Signature.
curl -si https://seller.wba-demo.noetis.dev/whoami
# {"kind":"public","credential":null}
Present a failing credential
A failing credential is refused, never treated as no credential. A token the seller didn't issue gets 401, and a signature without Signature-Agent gets 403 with an Accept-Signature header that says how to sign:
curl -si -H 'Authorization: Bearer not-a-token' https://seller.wba-demo.noetis.dev/whoami
# 401, WWW-Authenticate: Bearer error="invalid_token"
curl -si -H 'Signature-Input: sig1=("@method");tag="web-bot-auth"' \
-H 'Signature: sig1=:AAAA:' https://seller.wba-demo.noetis.dev/whoami
# 403, {"refused_at":"identity-boundary","reason":"signature-agent-missing",...}
Check a directory with Cloudflare's tools
Cloudflare Research publishes tools for Web Bot Auth that know nothing about this demo:
- On the Cloudflare Research debug page, paste a directory URL into Validate key directory. To check a
kid, paste a key from the directory into Get JWK keyid and compare the thumbprint. - Install the
http-signature-directorycommand-line validator, and run it against a directory:
cargo install http-signature-directory
http-signature-directory https://buyer.wba-demo.noetis.dev/.well-known/http-message-signatures-directory
The demo also runs Cloudflare's library itself: on every run, cfbot verifies a request the buyer signs with web-bot-auth's verify(), after it checks the directory's own signatures. The verdict is the last step under Latest run.
Latest run
Every 15 minutes the runner drives the whole flow against the live agents and publishes what each party sent, received, and kept. It withholds credentials and proxy headers, and refuses to publish a report that holds a secret. The raw report is latest-run.json.