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 run

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

How a verifier learns who signed a request The buyer signs a request. The seller reads the host from Signature-Agent, fetches the key directory at that host over HTTPS, selects the key by origin and keyid, and verifies the signature, Content-Digest, time window, and nonce. The result is the verified origin. https://buyer.wba-demo.noetis.dev https://seller.wba-demo.noetis.dev Buyer agent Signs each request with its private key Key directory /.well-known/http-message- signatures-directory JWK Set, signed by each key Seller agent Holds no key for the buyer, only its origin on file 4 Verify Key by (origin, keyid) Signature and Content-Digest Time window and nonce 1 Signed request Signature-Agent, Signature-Input, Signature, Content-Digest Host from Signature-Agent 2 GET the directory 3 Signed keys, over HTTPS Verified identity: https://buyer.wba-demo.noetis.dev
Figure 1. The verifier learns the signer's keys from the signer's own origin, at the moment it needs them. Scroll the diagram sideways to see all of it.

The four steps in Figure 1 work as follows:

  1. The buyer signs the request. Signature-Input lists what the signature covers: the method, the target URI, the authority, and Signature-Agent, which names the buyer's directory URL. With a body, Content-Digest carries the body's SHA-256, and the signature covers it too.
  2. The seller takes the host from Signature-Agent and fetches https://<host>/.well-known/http-message-signatures-directory. It follows no redirects, and it keeps the directory for up to 300 seconds.
  3. The directory is a JWK Set. Each key signs the directory response itself, and each key's kid is its JWK thumbprint.
  4. The seller selects the key by the pair of origin and keyid, verifies the signature and Content-Digest, checks the time window, and records the nonce, 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.

One resolver for both credentials A signature, a bearer token, or no credential enters resolve_caller. It reads the credential, confirms it with the verifier or the token hash, resolves it to the stored principal, and freezes the result. A frozen ResolvedCaller or PublicCaller reaches the offer logic. A failing credential is refused with 400, 401, 403, 429, or 503, and the handler never runs. Web Bot Auth A signature, checked against the signer's key directory Bearer token Authorization: Bearer No credential resolve_callerseller/identity.py 1 Read the credential 2 Confirm it Signature: the library's verifier Token: its SHA-256 on file 3 Resolve the principal By the verified origin, or by the token hash 4 Freeze the result Frozen identity ResolvedCaller principal and credential PublicCaller no principal Offer logic seller/offers.py Never reads a header or a credential Refused: 400, 401, 403, 429, 503 The handler never runs
Figure 2. Both credentials enter one resolver, and only a frozen identity leaves it. A credential that fails confirmation leaves through the refusal path, never as "no credential". Scroll the diagram sideways to see all of it.

The boundary takes four steps:

  1. Read. The boundary finds which credential the request presents. A request that presents both a signature and a token gets 400.
  2. Confirm. A signature goes to the library's RequestVerifier, which fetches the directory and checks Content-Digest against the body. A token is confirmed when its SHA-256 matches a stored row. The seller never stores the token itself.
  3. Resolve. The confirmed credential maps to the principal in the seller's own credentials table: by verified origin, or by token hash. A verified origin with no row is an established identity with no principal: a PublicCaller that carries the origin.
  4. Freeze. The boundary returns one immutable value, ResolvedCaller or PublicCaller. 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:

StatusWhenThe answer also carries
400A signature field doesn't parse, or the request presents both credentialsNothing more
401A bearer token matches no row, or Authorization isn't a Bearer tokenWWW-Authenticate
403A signature fails verification or a profile ruleAccept-Signature
429A signature's nonce was seen beforeAccept-Signature
503The verifier can't record the nonceRetry-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:

PropertyBearer tokenWeb Bot Auth signature
Set up before the first callThe seller issues a token and sends it to the buyerNothing. The seller records the buyer's origin; keys come from the buyer's directory
Shared secretThe token. Whoever holds it is the buyerNone. The directory holds public keys only
Covers the bodyNoYes, through Content-Digest
Replay of a captured requestWorks until the token is revokedRefused: each signature has a nonce and lasts at most 300 seconds
Evidence a third party can checkNone. Either party could have sent the requestThe signed message and the directory it verified against
Key rotationBoth parties coordinate a new tokenThe signer publishes a new key, waits out caches, and switches. The verifier does nothing
Identity the boundary returnsResolvedCaller, method: bearerResolvedCaller, 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.

Offer acceptance between the buyer and the seller The buyer sends a signed GET for its proposal. The seller fetches the buyer's key directory, verifies, resolves the principal, and answers with the issued proposal, signed and bound. The buyer fetches the seller's directory, verifies the answer, composes its writes, and computes record_digest. It sends a signed POST commit. The seller verifies, recomputes record_digest, runs the section 5.6.1 checks, writes the entries, and answers with a signed, bound CommitResult. The buyer checks the matching pair and keeps the signed answer. Buyer agent buyer.wba-demo.noetis.dev Seller agent seller.wba-demo.noetis.dev 1 GET /proposals/{id} signed by the buyer Fetch the buyer's key directory first contact; kept for up to 300 s Verify; resolve_caller finds the stored principal 2 200, the issued proposal signed by the seller, bound to request 1 Fetch the seller's key directory Verify the answer and its binding Compose the writes; compute record_digest 3 POST /proposals/{id}/commit signed; Content-Digest covers the Commit Verify; the same principal Recompute record_digest Check § 5.6.1; write the entries 4 200, the CommitResult signed by the seller, bound to request 3 Check the matching pair Keep the signed answer
Figure 3. Solid arrows are signed requests, dashed arrows are signed answers, and blue arrows are directory fetches. Each party fetches the other's directory on first contact and keeps it for up to 300 seconds. Scroll the diagram sideways to see all of it.

Each side does the following:

  • The seller shows an issued proposal only to its buyer. On a commit it recomputes record_digest over the line items plus the commit's writes, runs the checks of § 5.6.1, and writes the buyer's commitment entry and its own acceptance. A repeated commit with the same idempotency_key gets the stored result.
  • The buyer refuses an answer it can't verify: unsigned, unbound, or bound to another request. It re-sends the same Commit until 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.

The same packages in the buyer and the seller The buyer and the seller each run their own logic on top of the same layers: the generated OpenProposal SDK, the stopgap stack types in demo_types/stack.py, the record rules in shared/record.py, and the Web Bot Auth library. Both compute the same record_digest from the same bytes. cfbot, on Cloudflare's library, is a second implementation that proves interop rather than shared code. Buyer Seller buyer/flow.py Composes the Commit seller/offers.py Checks the Commit own logic OpenProposal SDK openproposal, generated OpenProposal SDK openproposal, generated same types Stack types, a stopgap demo_types/stack.py Stack types, a stopgap demo_types/stack.py same, by hand Record rules (§ 4.1.1) shared/record.py Record rules (§ 4.1.1) shared/record.py same digest rule Web Bot Auth library iab_agentic_primitives Web Bot Auth library iab_agentic_primitives same checks Both compute the same record_digest from the same bytes cfbotCloudflare's web-bot-auth, in Node A second implementation of the same profile. It proves interop, not shared code.
Figure 4. Each party runs its own copy of the same packages. The dashed layer is hand-written and replaced by generated types. Scroll the diagram sideways to see all of it.

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.

Live topology Public DNS points four names at the host. Clients reach Caddy on port 443, which holds Let's Encrypt certificates and proxies seller, buyer, and cfbot to local ports 18081, 18082, and 18083, and serves the site files. Caddy never routes the operator controls. A runner container calls the agents' operator controls with the operator key, reads both databases read-only, and writes the report that Caddy serves. Public DNS Four A records point at the host Any client A browser, curl, another agent, a validator The host Caddy, port 443 Let's Encrypt certificates; passes each name to a local port Never routes /operator/, /run/, or /test/ docker compose project wba-demo-live seller :18081 key, SQLite buyer :18082 keys, SQLite cfbot :18083 key outbound runner, every 15 minutes Runs the flow with a signature, then a token Calls /operator/ with the operator key Reads both databases, read-only Writes a report with no secrets Site files site/ run/ latest-run.json history.json writes
Figure 5. Each agent serves its key directory at its own public name. The dashed arrow is an agent's outbound call: it leaves by public name and comes back in through Caddy. The runner holds no agent's signing key. Scroll the diagram sideways to see all of it.
NameServesLocal port
wba-demo.noetis.devThis page, /latest-run.json, and /history.jsonFiles on the host
seller.wba-demo.noetis.devThe seller agent: issued proposals, commit, /whoami, and its key directory18081
buyer.wba-demo.noetis.devThe buyer's key directory18082
cfbot.wba-demo.noetis.devcfbot's key directory and /verify, which checks a signature with Cloudflare's library18083

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.

Local end-to-end topology Inside one Docker network, an nginx front answers for the three agent names with a throwaway certificate and passes requests to the seller, the buyer, and cfbot. A pytest runner calls the agents through the front, calls their controls directly, and reads both databases read-only. Docker network demo, from e2e/docker-compose.yml nginx TLS front Answers for seller., buyer., and cfbot.wba-demo.noetis.dev with a certificate from a throwaway CA seller trusts the demo CA buyer trusts the demo CA cfbot trusts the demo CA runner (pytest) Scenarios 1 to 11 Calls the agents through the front, and their controls directly; reads both databases read-only The same code as the live deployment. Shorter cache lifetimes (20 s) keep the key rotation scenario short.
Figure 6. The local stack swaps public DNS and Let's Encrypt for network aliases and a throwaway CA. Scroll the diagram sideways to see all of it.
SettingLiveLocal end-to-end test
TLSLet's Encrypt, through the host's CaddyA throwaway CA, through nginx
Trust storePublic roots onlyPublic roots plus the throwaway CA
Directory Cache-Control: max-age300 s300 s for the seller, 0 s for the buyer
Verifier cache (max_cache_age)300 s20 s
Wait before a published key signs600 s20 s
Signature lifetime300 s10 s
Fault injectionNot mountedMounted 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:

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.