feat(reference): World ID verifier + runnable verified-identity agent#373
Open
bokelley wants to merge 1 commit into
Open
feat(reference): World ID verifier + runnable verified-identity agent#373bokelley wants to merge 1 commit into
bokelley wants to merge 1 commit into
Conversation
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>
bokelley
requested review from
BaiyuScope3,
EmmaLouise2018,
lockwoodscope and
ohalushchak-exadel
as code owners
June 9, 2026 17:23
There was a problem hiding this comment.
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-181never readsatt.Claims. The returned claim set comes only fromverifyResponse.claims()mapping World's responseidentifierthrough the closedidentifierToClaimtable (verifier.go:43-45,103-111).main_test.go:86andverifier_test.go:76both prove a sender-assertedage_over_21is dropped when World confirms onlyunique_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 zeroVerifiedIdentity— which the core stage treats as absent (targeting/identityagent/verified_identity_stage.go). The end-to-end test exercises this through the realServicewith real HPKE seal/open. - No request-controlled SSRF.
verifier.go:135interpolatesrpinto the path underurl.PathEscape, andrporiginates fromvctx.ExpectedRelyingPartyID, set server-side fromrk.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:110logs only that an ephemeral key was generated;/recipientexposes the public key only (main.go:149).WORLD_API_KEYgoes out as aBearerheader (verifier.go:142), never logged. World's non-2xx body is deliberately not echoed (verifier.go:154-157). - Bounds in place.
http.MaxBytesReader1 MiB on both POST handlers,io.LimitReaderat 64 KiB on World's response, 5s verifier client timeout,ReadHeaderTimeouton the server. - Signatures cross-check against core —
ServiceConfig,RecipientKey,SealTmpx/LoadX25519PrivateKey/SealedCredential, theVerifysignature, and theVerifiedIdentity/VerifyContextshapes all matchtargeting/verified_identity.goandtmproto/. 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-77assigns the verifier an explicit duty: "Verify MUST confirm the attestation'ssignal_bindingcommits tovctx.RequestID."worldid.Verifier.Verifyreads neitheratt.SignalBindingnorvctx.RequestID, andLocalPreCheckonly 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-reviewerdid 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 theverifyResponse/identifierToClaimstructs are exactly what a downstream implementer copies verbatim. Either bindvctx.RequestIDinto the verify call / compareatt.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_humanidentifier and whether v4 populates top-levelnullifiervsresponses[].nullifierare confirmed only against the mock this PR wrote.ad-tech-protocol-expert: confirm both against World's Developer Portal v4/verifyreference before treating the map as canonical, since it's copy-bait.
Minor nits (non-blocking)
- nullifier/claims derived from independent entries.
verifier.go:94-111—nullifier()andclaims()iterateResponsesseparately, so a claim from one entry can pair with a nullifier from another. Harmless for World's single-credential responses today. /sealis 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 aDEMO=1guard or localhost-default bind so a copy-paste deploy doesn't expose it.- Ephemeral recipient key silently rotates on restart when
AGENT_RECIPIENT_KEYis 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(mirroringreference/seller-agent/), so World's HTTP verify client never enters the core adcp-go dependency graph. That's the whole reason #370 madetargeting.AttestationVerifiera pluggable interface with no default (nil ⇒ every attestation absent). Coretmproto/targetinguntouched.worldid.Verifier(implementstargeting.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 assertedatt.Claimsare 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→ anIdentityMatchRequestwithsealed_credentials; runs the real eligibility pipeline.Tests
7 verifier unit tests against a mocked World backend + an end-to-end test through the real
Servicewith real HPKE seal/open: World confirms onlyunique_human, so a sender-assertedage_over_21stays ungranted and the 21+ package is ineligible while the open package passes — verify-before-trust, demonstrated on shipped code.go build/vet/test -raceall 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