Skip to content

feat(reference): World ID verifier + runnable verified-identity agent#373

Open
bokelley wants to merge 1 commit into
mainfrom
bokelley/worldid-verifier-agent
Open

feat(reference): World ID verifier + runnable verified-identity agent#373
bokelley wants to merge 1 commit into
mainfrom
bokelley/worldid-verifier-agent

Conversation

@bokelley

@bokelley bokelley commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The concrete World ID verifier that makes the verified-identity seam (#370/#371) actually do something — plus a runnable reference buyer agent that proves the whole receiver path end to end on the shipped code.

Why a new module

Own go.mod (mirroring reference/seller-agent/), so World's HTTP verify client never enters the core adcp-go dependency graph. That's the whole reason #370 made targeting.AttestationVerifier a pluggable interface with no default (nil ⇒ every attestation absent). Core tmproto/targeting untouched.

worldid.Verifier (implements targeting.AttestationVerifier)

POSTs the attestation's proof to World's verify/{rp_id} (rp_id = the RP the SDK already bound via the audience), and derives the nullifier + claims from World's authoritative response. Verify-before-trust: the sender's asserted att.Claims are never read — only what World confirms enters the verified set. Any failure (no proof, non-2xx, success:false, missing nullifier, no recognised claim) → error → caller treats it as no attestation.

Runnable agent (main.go)

Wires the verifier + an HPKE recipient key + a static age resolver into the identityagent.Service:

  • GET /recipient{audience_kid, public_key, rp_id, seller_agent_url} (the sender seals to this).
  • POST /seal → demo helper: wraps a World ID proof in an attestation and HPKE-seals it (in production the network/publisher seals; the agent never does).
  • POST /identity → an IdentityMatchRequest with sealed_credentials; runs the real eligibility pipeline.

Tests

7 verifier unit tests against a mocked World backend + an end-to-end test through the real Service with real HPKE seal/open: World confirms only unique_human, so a sender-asserted age_over_21 stays ungranted and the 21+ package is ineligible while the open package passes — verify-before-trust, demonstrated on shipped code. go build / vet / test -race all pass; the agent boots and serves.

Deferred

World ID's age-credential identifiers (the claim map currently recognises proof_of_human → unique_human; age maps in as World exposes them), a production recipient-key/secret loader (env/generated for now), and the live browser run (wire the idkit widget → this agent).

🤖 Generated with Claude Code

A concrete World ID AttestationVerifier and a runnable reference buyer agent
that exercises the TMP verified-identity receiver path end to end on the
shipped code (#370/#371) — open the HPKE-sealed credential, verify the World
ID proof inside, gate eligibility fail-closed on the relying-party-scoped
nullifier.

Its own reference module (own go.mod, like reference/seller-agent) so World's
HTTP verify client never enters the core adcp-go dependency graph — the reason
targeting.AttestationVerifier is pluggable with no default.

- worldid.Verifier implements targeting.AttestationVerifier: POSTs the
  attestation's proof to World's `verify/{rp_id}` (rp_id = the RP the SDK
  bound) and derives the nullifier + claims from World's authoritative
  response. Verify-before-trust: the sender's asserted claims are never read;
  only what World confirms enters the verified set. Failure ⇒ error ⇒ treated
  as no attestation.
- main.go wires the verifier + an HPKE recipient key + a static age resolver
  into the identityagent.Service over HTTP (/recipient, demo /seal, /identity).

Tests: 7 verifier unit tests against a mocked World backend (happy path,
success:false, non-200, missing nullifier, unrecognised claim, no-proof/no-RP
short-circuits) + an end-to-end test through the real Service with real HPKE
seal/open proving verify-before-trust (World confirms only unique_human, so a
sender-asserted age_over_21 stays ungranted and the 21+ package is ineligible).
go build/vet/test -race all pass. Core adcp-go untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, correctly-scoped reference module — own go.mod like reference/seller-agent/ so World's HTTP verify client never enters the core dependency graph, which is the entire reason targeting.AttestationVerifier ships pluggable with no default. Verify-before-trust holds on the shipped path.

Things I checked

  • Verify-before-trust invariant holds. worldid/verifier.go:119-181 never reads att.Claims. The returned claim set comes only from verifyResponse.claims() mapping World's response identifier through the closed identifierToClaim table (verifier.go:43-45,103-111). main_test.go:86 and verifier_test.go:76 both prove a sender-asserted age_over_21 is dropped when World confirms only unique_human.
  • Fail-closed on every error path. nil/empty proof, empty RP, transport error, non-2xx, success:false, empty nullifier, zero recognised claims all return a non-nil error and the zero VerifiedIdentity — which the core stage treats as absent (targeting/identityagent/verified_identity_stage.go). The end-to-end test exercises this through the real Service with real HPKE seal/open.
  • No request-controlled SSRF. verifier.go:135 interpolates rp into the path under url.PathEscape, and rp originates from vctx.ExpectedRelyingPartyID, set server-side from rk.RelyingPartyID (audience-selected config), not from the request. Base URL is operator config.
  • Secret handling is clean. Recipient HPKE private key is never logged — main.go:110 logs only that an ephemeral key was generated; /recipient exposes the public key only (main.go:149). WORLD_API_KEY goes out as a Bearer header (verifier.go:142), never logged. World's non-2xx body is deliberately not echoed (verifier.go:154-157).
  • Bounds in place. http.MaxBytesReader 1 MiB on both POST handlers, io.LimitReader at 64 KiB on World's response, 5s verifier client timeout, ReadHeaderTimeout on the server.
  • Signatures cross-check against coreServiceConfig, RecipientKey, SealTmpx/LoadX25519PrivateKey/SealedCredential, the Verify signature, and the VerifiedIdentity/VerifyContext shapes all match targeting/verified_identity.go and tmproto/. Core adcp-go is untouched; conventional-commit type is correct (feat(reference):).
  • Test plan: primary change (verify-before-trust gating) is validated by the e2e test on shipped code; only the live browser run is deferred, and the PR says so. Honest.

Follow-ups (non-blocking — file as issues)

  • signal_binding → RequestID commitment is unenforced. targeting/verified_identity.go:75-77 assigns the verifier an explicit duty: "Verify MUST confirm the attestation's signal_binding commits to vctx.RequestID." worldid.Verifier.Verify reads neither att.SignalBinding nor vctx.RequestID, and LocalPreCheck only checks non-empty. The per-request replay window the field exists to close is therefore not closed anywhere on the shipped path. All three reviewers landed on this; security-reviewer did not rate it High (the trust boundary — verify-before-trust, RP-scoping, closed vocab — is otherwise intact and fail-closed), so it ships as a follow-up, not a block. But it is the one place this verifier diverges from the contract it implements, and the verifyResponse/identifierToClaim structs are exactly what a downstream implementer copies verbatim. Either bind vctx.RequestID into the verify call / compare att.SignalBinding, or add it to the PR's Deferred list with a loud // TODO(replay) — the Deferred section lists three items and omits the one the interface marks MUST.
  • World v4 shape is unvalidated against live World. The proof_of_human → unique_human identifier and whether v4 populates top-level nullifier vs responses[].nullifier are confirmed only against the mock this PR wrote. ad-tech-protocol-expert: confirm both against World's Developer Portal v4 /verify reference before treating the map as canonical, since it's copy-bait.

Minor nits (non-blocking)

  1. nullifier/claims derived from independent entries. verifier.go:94-111nullifier() and claims() iterate Responses separately, so a claim from one entry can pair with a nullifier from another. Harmless for World's single-credential responses today.
  2. /seal is an unauthenticated sealing oracle. main.go:160-197 — correctly documented as a demo and harmless in the trust model (asserted claims are never trusted), but consider a DEMO=1 guard or localhost-default bind so a copy-paste deploy doesn't expose it.
  3. Ephemeral recipient key silently rotates on restart when AGENT_RECIPIENT_KEY is unset (main.go:104-111) — fine for a demo, loud failure would be better outside one.

LGTM. Follow-ups noted below — the signal_binding gap is the one worth a tracked issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant