id: adversarial title: Integration tests at the seams, not inside the components that already pass status: open crates: [didbot-serve, didbot-pds, didbot-identity, didbot-policy, didbot-policy-source, didbot-dns, didbot-tls] dependsOn: [] exitCriterion: > Adversarial integration tests exist for zone containment and DPoP binding, each exercising a request that crosses at least two components, and each fails loudly if either component's assumption about the other quietly changes. #
adversarial #
CLAUDE.md says integration tests exercising multiple system invariants are the most valuable kind. These targets already have unit tests, and unit tests are not what is missing: what is missing is a test at the seam between the components each one assumes will hold up its end.
- Zone containment — narrower than it once was, now that a zone need not
sit at or below the PDS's own hostname (see
didbot_identity::Zone's docs) — has unit coverage incrates/didbot-identity/tests/identity.rs(hostname_is_at_or_below_respects_label_boundaries,hostname_is_at_or_below_is_case_insensitive) and in vibescrobble.com's index crate (a_suffix_is_not_containment,a_handle_suffix_is_not_containment_either). Each tests containment as a standalone function against a string. Neither drives a request through a server that resolves a hostname, decides whether to serve it, and decides whether to accept a vouch about it, using the same containment check at each step. - DPoP binding has unit coverage in
crates/didbot-serve/src/oauth/dpop_seam.rs, including a replay window, a thumbprint mismatch, and a verifier that refuses by default. Each test constructs a proof and calls the verifier directly. None of them goes throughauthorize.rs,token.rsand a resource request in sequence — the seam between "a token was minted bound to key A" and "a resource server later checks a proof against key A" is the one nothing exercises end to end.
Zone containment: the seam between resolution, serving and vouching #
The containment left to test is not "is this zone under the PDS's own
hostname" — that rule is gone — but two narrower ones: a requested handle
must sit at or below the zone the deployment mints under, and the index
crate must not credit a server with an agent whose DID sits outside what a
vouch actually covers. A suffix sharing no label boundary
(agents.localhost.attacker.com against agents.localhost) is the shape
hostname_is_at_or_below_respects_label_boundaries and
a_suffix_is_not_containment already refuse in isolation, and is what the
handle-forgery tests in crates/didbot/tests/zone_containment.rs drive
through the real HTTP surface.
DPoP binding: the seam between mint and verify #
Degenerate values: the seam between a configuration path and a comparison #
The suites above test seams between components. This one tests the seam between a component and the configuration that feeds it — the class where a comparison is correct for every value except the one a failed configuration path produces, and nothing in the codebase refuses to produce that value.
Two live examples, both found by reading rather than by a test, are written up in the Done list below. What they share is the shape: a check that reads as obviously correct, a degenerate input nobody would type, and a path that supplies the degenerate input on its own without anybody typing it. The tests that missed them missed them the same way — each asserted a handful of specific values rather than the property.
Where the remaining risk is, in rough order:
The policy engine, adversarially #
plan/policy.md and docs/write-pipeline.md's whole design rests on
"adding a policy can never widen what is permitted." crates/didbot-policy,
crates/didbot-policy-regex, crates/didbot-policy-source and the gate plus
per-repository queue in crates/didbot-pds landed in one day, from four
agents working in parallel, and had never been attacked before this pass.
What held, tried adversarially and not merely read: shuffling a fixed
outcome set through every rotation (didbot-policy's
shuffling_outcomes_cannot_change_the_verdict_severity) — genuinely reaches
combine, not just a hand-built list, since evaluate builds exactly that
(PolicyId, Outcome) sequence from PolicyTree::decision_candidates; an
unusable predicate (bad syntax, an unrecognized language, an unresolved blob)
denies only its own declared applicability, never everything and never
nothing; a stale poll observation (equal or lower revision) never overwrites
a stored one, including out-of-order arrival; a failed poll tick leaves
OperatorView untouched rather than clearing it; a declared blob size over
the limit is refused before a single byte is fetched, and a streamed one that
lies about its size is caught mid-stream; KindSet::of never matches
SubjectKind::Unknown no matter which four kinds it names, so a typo'd or
future account kind falls to the narrow default rather than escaping every
rule; and the freeze-drain path never lets PolicyGate::observe see a
denial the freeze itself caused, which is exactly what would let a
metapolicy loop.
Provisioning: two writers racing one identity #
didbot-pds's provisioning sequence (Provisioner::provision) does a name
reservation, a DID mint, a DNS write, a key generation, two record writes and
a ledger append, several of them irreversible, and had never been attacked
concurrently — only sequentially, the way crates/didbot-pds/tests/naming.rs
and provisioning.rs already did before this pass. What held and what did
not, from actually racing it with real threads rather than reasoning about it:
- Held: two requests minting the same
agent_id.AccountStore::insertis a single check-and-insert under one mutex, so exactly one of two concurrentprovision()calls for the same DID gets past it. The other's rollback withdraws the DNS record it believes it just published — the dangerous case would be that rollback deleting the winner's now-live DNS record — butInMemoryDns/LoopbackDns/WildcardDnsall refuse a secondpublishfor one hostname (DnsError::AlreadyPublished) rather than overwriting, so the loser's owndns.publishfails first, before it has touched the store, the ledger or anything worth rolling back. No test added: this is a property ofRecords::insert's existingpublishing_twice_is_a_collision_not_an_overwritetest, already covering the mechanism this depends on. - Held: naming through
Naming::issue.NameRegistry::claimchecks and claims a name under one mutex, so two concurrent provisions cannot both walk away believing they hold the same generated or hinted name. - Fixed: two requests minting different
agent_ids for the same caller-asserted handle, with noNamingconfigured.check_requested_handlescans the account store and the actual claim (AccountStore::insert) happens several steps later — a mint, a keypair, a DNS publish, a ledger open — far enough apart that two concurrent requests could both pass the scan before either was stored, minting two DIDs whose documents both claim one handle: exactly the situationtwo_accounts_may_not_claim_one_handleasserts is refused, reached through concurrency instead of sequencing.handle_didcan then answer for only one of the two, permanently breaking the bidirectional handle/DID promise for whichever account it does not answer for, indistinguishably. Closed withProvisioner::handle_lock, serializing the check against the claim the wayNameRegistry::claimalready does for a deployment that names its own agents. Regression test:two_accounts_racing_for_one_handle_concurrently_still_only_one_winsincrates/didbot-pds/tests/naming.rs, real threads on aBarrier, matching the patternrecords.rs'sa_concurrent_write_between_the_check_and_the_commit_does_not_let_two_batches_winalready uses for this class of race. Mutation-tested: reverting the lock (git stashthe fix) fails the test 5/5 runs; restoring it passes 3/3. - Held: the e-stop and lifecycle gates.
docs/write-pipeline.md's ordering — e-stop before lifecycle before anything is parsed — is exactly whatcrates/didbot-serve/src/routes.rs'sprovision_agentdoes, and because both checks run beforeregistry.provision()is ever called, "a refusal leaves nothing behind" is trivial rather than tested: nothing was started. Already covered end to end incrates/didbot-serve/src/tests.rs(Revoke, Pause, and Pause-thrown-by-a-lapsed-operator-claim each refuseprovisionAgentwithHalted, and Pause is shown leaving an already-issued token alone).
Left open, in rough order of how much a deployment should care:
The AWS surface, driven without an AWS account #
Every AWS interaction this deployment makes is this repository's own code:
crates/didbot-dns/src/route53.rs signs its own SigV4 requests and reads
IMDSv2 itself, and no AWS SDK is in the dependency graph. That puts the whole
surface inside reach of a test, and didbot_dns::aws_fake — behind the
aws-fake feature, a dev-dependency in didbot-pds and didbot-tls — is
what it is driven against: a hosted zone holding record sets, applying change
batches atomically, paginating a listing, taking a change through PENDING
before INSYNC, and refusing a CREATE collision, a DELETE value
mismatch, throttling and the record-set quota the way the service does, plus
an instance metadata service issuing credentials that expire. The ACME half
of a deployment keeps its own fake, FakeAcmeServer in didbot-tls's
tests/support; tests/dns01_zone.rs uses both at once.
The suites, and the property each one holds:
crates/didbot-dns/tests/route53_zone.rs— the round trip. A record the provider writes is one a second provider reads back over the listing wire format; aPENDINGchange is not listed until it isINSYNC; a throttled call is retried into one record set rather than several; a refusal is classified into the [DnsError] a caller can act on; a 25-record zone at 7 per page is read in four calls; a request signed with a lapsed credential is refused until instance metadata is read again.crates/didbot-pds/tests/route53_seam.rs— the store, the zone and the ledger, asserted together after each of: a slow zone, a throttled one, one that refuses the write outright, a store that fails once the zone has already been written, a credential that lapses mid-run, the record-set quota reached, and a run that stops between the DNS write and the store insert — that last one reconciled bydidbot_reconcile's survey, which names the record the stopped run left.crates/didbot-tls/tests/dns01_zone.rs— a combined apex-and-wildcard order whose two DNS-01 values are live together in the hosted zone while the CA validates and gone from it after, and a zone that refuses the challenge write, whose refusal names the record and whose third failure is whatRenewalTrackerreports as a warning.
Every test in the three carries the mutation that makes it fail, in its own doc comment, and each was run.
Done #
-
A second spelling of a denied client.
DenyClientcomparesclient_idas text, sohttps://Example.com/appwould pass a rule written forhttps://example.com/app.didbot_policy::ClientIdholds only the canonical spelling,POST /oauth/parrefuses every other one asinvalid_client, and every subject carries aClientId. Tests:client_id::testsincrates/didbot-policyanda_second_spelling_of_a_client_id_is_refused_at_sign_inincrates/didbot-serve/tests/denied_app.rs. -
The AWS surface, driven without an AWS account. See the section of that name above for the fake, the three suites and what each holds.
-
Two concurrent provisions racing for one caller-asserted handle. See "Provisioning: two writers racing one identity" above for what was attacked, what held, and what
handle_lockcloses. -
A caller-asserted handle more than one label below the zone.
check_requested_handleaccepted any handlehostname_is_at_or_belowthe zone, which allows any depth —deep.quernstone.agents.examplepassed as readily asquernstone.agents.example— but a deployment answers the zone with a single-label wildcard DNS record, which matches exactly one label per RFC 1034. A multi-label handle would mint an account, publish a document claiming the handle, and pass the internaldocument.claims_handle/handle_didagreement check, while no real request for that handle could ever route to this server: unreachable, not merely unpublished. Confirmed by provisioning one and checking the well-known lookup never answers before fixing it.check_requested_handlenow refuses a handle with more than one label past the zone the same way it already refuses one outside the zone.WildcardDns::acceptsincrates/didbot-dnsmirrors the same overly permissive shape (ends_with, not "exactly one more label") and is left open — two other sessions are editingcrates/didbot-dns/andcrates/didbot-tls/concurrently. Regression test:a_requested_handle_more_than_one_label_below_the_zone_is_refusedincrates/didbot-pds/tests/naming.rs. Mutation-tested: reverting the check (git stashthe fix) fails the test, restoring it passes. -
A request naming a sibling zone's hostname, driven through the actual server, from HTTP request to whichever component decides containment, confirming the same string that would pass a unit test's call to the containment function is refused at the point a real attacker would submit it — a DID document lookup, a handle resolution, or a vouch naming a hostname just outside the zone. Also covers a hostname containing the zone as a substring rather than a suffix, an uppercase sibling (proving
validate_handlerefuses it outright rather than a silent lowercase fold), and the overlapping-zone case reached throughZoneManager::add_zonerather thanZoneRegistry::newdirectly. Seecrates/didbot/tests/zone_containment.rs. -
A proof minted for one key, replayed with a different key's signature but the original
jti. The adversarial case is the combination — replay and a key swap in the same attempt — which is what an attacker holding a captured proof but not the private key would actually try. Refused. Seecrates/didbot-serve/src/tests.rs'sdpop_bindingmodule. -
A token minted with
cnf.jktbound to key A, presented with a valid, well-formed proof signed by key B. Found first as something sharper:OAuthTokenStore::validate_accesstook no proof parameter at all, so a bare stolen token authenticated with zero proof of possession. Its signature now requires a verified thumbprint, so the unsafe call does not compile, and the token'scnf.jktis checked against it on the repo write path. -
The nonce and replay window survive a request that crosses
authorize.rsandtoken.rs. Reachable now thatOAuthStatewires the real verifier:dpop_seam.rs's note that the replay window and the clock-skew window are the same number holds across a proof that has travelled both stages, rather than one fed to a single verifier call. -
An authentication bypass on an empty operator secret. The comparison was a bare
ct_eq, andct_eqof two empty slices is true, so a deployment whose secret file came back empty authenticatedAuthorization: Operator— a header anyone can send — as the operator. A failed SSM fetch redirected at the file's path left exactly that zero-byte file. Closed by removing the operator shared secret:crates/didbot-serve/src/auth.rsholds no operator credential for anything to compare. The test that missed it asserted two specific secrets andNonerather than a property over a corpus. -
Shell operator precedence in provisioning.
dnf install -y docker || apt-get update && apt-get install -y docker.iogroups as(dnf || apt-get update) && apt-get install, so on a host wherednfsucceeded the apt install ran anyway, failed, and aborted cloud-init underset -ebefore the service was ever installed. Braced. -
A live e-stop socket could be unlinked out from under its listener.
remove_if_staletreated anyconnect(2)failure as "nothing is listening" —EACCES, a full backlog,EMFILE— and removed the file, after which a second process binds a fresh one and silently takes over e-stop administration while the first still believes it owns the path. OnlyECONNREFUSEDmeans stale now, and the path must actually be a socket. The function's own doc already said it should refuse to guess; the code did the opposite. -
The scope ceiling, as an invariant rather than a table. Eight tests in
crates/didbot-serve/src/oauth/scope.rs'sceiling_boundarymodule, over every pairing of a corpus built out of adjacent atoms: nothing granted is outside the ceiling, nothing granted is outside the request, re-applying a ceiling changes nothing, reversing a ceiling grants the same thing, an empty ceiling admits nothing, disjoint atoms are refused and overlapping ones narrow to an exactly-stated grant. Each was checked by mutating the production rule it covers and confirming a failure; two mutations survived the first corpus (intersect_sizetaking the larger cap,intersect_auddropping a bound) because every corpus pair was decided byScope::intersect's twocontainsshort-circuits and never reached the arms below them, which is its own lesson about what a corpus has to contain. -
Zone containment adversarial suite added (
crates/didbot/tests/zone_containment.rs); see that section above for what it covers. -
DPoP binding adversarial suite added (
crates/didbot-serve/src/tests.rs'sdpop_bindingmodule). It found three things — the verifier was never wired, the server'shtucould not match a conformant client's, andvalidate_accesstook no proof — and all three are closed; see that section above. -
TreePolicyGatenever told any evaluator about a freeze, an unfreeze, or a deletion.PolicyGate::froze/unfroze/deleteddefault to no-ops, andTreePolicyGate— the only implementation backed by a realPolicyTree— never overrode any of the three, so a write-tripped freeze, an operator's unfreeze, or an account deletion never reached a singleEvaluator::lifecyclecall.plan/policy.mdnames the failure mode directly: "An evaluator can never unfreeze. Only an operator, explicitly. But the evaluator must be told, or the state that triggered the freeze survives the unfreeze and slams it shut again on the next attempt" — and the same paragraph for deletion, state that "survives" is per-account memory nothing releases. Invisible today only because the one evaluator that exists (didbot-policy-regex) is stateless and itslifecycleis already a no-op; the first stateful evaluator (a rate limit, or the mention/opt-out metapolicyplan/policy.mdmotivates the whole design with) would have been unfreezable in practice. Fixed:PolicyTree::evaluators()exposes the tree's full evaluator set (lifecycle events are about an account, not a subject shape, so nothing to route through the applicability index), andTreePolicyGate::froze/unfroze/deletednow call every evaluator'slifecycle. Test:policy_tree::tests::froze_unfroze_and_deleted_reach_every_evaluator_in_the_treeincrates/didbot-pds/src/policy_tree.rs— fails against the trait defaults (confirmed by reverting the three method bodies tolet _ = ...and rerunning), passes with the forwarding in place. -
The blob sweeper cannot tell a fresh data directory from one whose log is missing.
DurableStore::open_with_limitsincrates/didbot-pds/src/durable.rsrefuses withWalError::Orphanedwhen the directory holds blob bytes and no log or checkpoint that names anything, before and after replay, and reconciles blobs only when there was state to read. Test:blobs_without_a_log_refuse_to_open_rather_than_sweeping_themselves_awayincrates/didbot-pds/tests/durability.rs. -
OwnershipPollread any HTTP 400 as "no operator claim".fetch_claim_fromincrates/didbot-serve/src/ownership_poll.rsreads a 400 as absence only when the body namesRecordNotFound; any other 400 is an unreachable repository. Test:a_400_that_is_not_record_not_found_is_unreachable_not_an_absent_claimin the same file. -
A freeze names who froze it. A freeze is a
Locktag on the account row (AgentAccount::locks,crates/didbot-pds/src/lockout.rs), and each tag carries thePartythat hung it: operator, policy, the account itself, or a parent. The tag outlives the write that tripped it. A policy outcome hangsLock::QuarantinedunderParty::Policyand records aLedgerEvent::Lockedcarrying the evaluation id (crates/didbot-pds/src/provision.rs, the gate'sfroze).