feat(identity-agent): worldid.Verifier as a module + config-gated wiring into the deployable agent#374
Conversation
There was a problem hiding this comment.
Clean extraction. The verifier's internal mechanics are right; the one open item is the external World contract, which the diff can't prove. Holding at comment, not approve, on that single question.
The architecture is the right shape: World's HTTP surface stays in its own module, targeting.AttestationVerifier keeps no default, and Run gains the verifier through variadic options so trust-without-verify is unreachable by omission rather than by config.
Things I checked
- Verify-before-trust holds.
verify/worldid/verifier.gonever readsatt.Claims. Nullifier comes fromvr.nullifier(), claims fromvr.claims()mapping World'sresponses[].identifierthrough the closedidentifierToClaimtable.verifier_test.go:73asserts a sender-assertedage_over_21is dropped.security-reviewerandad-tech-protocol-expertboth confirmed. - Fail-closed by default.
cmd/identity-agent/verified_identity.go:43-49returnsnil, nilwith the flag unset → no verifier, no recipient keys →runVerifiedIdentityStageshort-circuits atverified_identity_stage.go:47. Enabling withoutTMP_RELYING_PARTY_ID/TMP_RECIPIENT_KID/TMP_RECIPIENT_KEYerrors at startup. - Secrets stay out of logs. The X25519 private key and
WORLD_API_KEYare never logged; the enable line atverified_identity.go:81logs onlyrelying_party_id,recipient_kid,world_verify_base_url. Non-200 bodies are not echoed (verifier.go:154), and the verifier's error is discarded at the call site (verified_identity_stage.go:108records only the boundedVIDropVerifierErrorlabel). No proof material reaches a log. - Bounded read + RP binding. 64KB
io.LimitReader(verifier.go:166), 5s client,maxSealedCredentials=8cap the per-request surface.rpis operator-configTMP_RELYING_PARTY_ID,url.PathEscape'd into the verify URL — never attacker-controlled, no injection, no cross-RP replay. - Conventional-commit story.
feat(identity-agent):is correct. TheRunsignature change is variadic — backward-compatible at the source level, single caller already updated. No!/BREAKING CHANGE:needed. - No schema surface. No
adcp/schemas/**, noadcp/types_gen.go, no protocol-managed skills touched.RecipientKey.PrivateKey(*ecdh.PrivateKey) matchestmproto.LoadX25519PrivateKey's return.
The question that's holding this at comment
The verifier POSTs att.Proof verbatim to {baseURL}/api/v4/verify/{rp_id} and reads top-level success/nullifier plus responses[]{nullifier,identifier} (verifier.go:118, :165). ad-tech-protocol-expert (sound-with-caveats) flags that the long-documented World cloud-verify contract is /api/v2/verify/{app_id} with a flat nullifier_hash/verification_level and no responses[] array — so either v4 is a real newer endpoint (plausible; the expert's knowledge may lag World's 2026 API) or the shape is conflated. The PR's 7 tests all run against a self-authored mock of this assumed shape; the real World round-trip is unvalidated. The code fails closed if the shape is wrong (a flat response yields no recognized claim → "confirmed no recognised claims" error), so there's no fail-open risk — which is why this is a question, not a block.
Flips to approve: confirm against World's live docs (docs.world.org/world-id/reference/api) that (a) POST /api/v4/verify/{rp_id} is the real endpoint, (b) the {rp_id} path segment is what World scopes the nullifier to — if it's actually app_id and rp_id != app_id in a deployment, the nullifier scope and the vid:<rp>:<nullifier> CapKey scope silently diverge, and (c) att.Proof carries the field names (and action/signal_hash) World's endpoint expects, since it's forwarded with no envelope. A one-line smoke against a real signed proof would close all three.
Follow-ups (non-blocking — file as issues)
successabsent is treated as success.verifier.go:182rejects only an explicitsuccess:false. Sound today (the response still must carry a nullifier + mapped claim over TLS from the configured host), but once the v4 contract is confirmed,vr.Success != nil && *vr.Successis the belt-and-suspenders form.- No TLS-scheme guard on
WORLD_VERIFY_BASE_URL. An operator settinghttp://would send the proof andWORLD_API_KEYbearer in cleartext. Default ishttps; anhttps-or-loopback assertion when enabled would harden the secret path. - Update #373 to import
verify/worldidinstead of its in-package copy, per the PR description.
Minor nits (non-blocking)
WithHTTPClient(nil)is a nil-panic.verifier.go—Newdoesn't restore the default if a nil client is passed through the option, andv.httpClient.Dowould panic. No production path hits it (the only constructor never passes it), butif v.httpClient == nilafter the option loop makes the public API safe.lookupBoolreinventsstrconv.ParseBool.verified_identity.go:96accepts1/true/TRUE/Trueonly. The intentional-rejection-with-clear-error behavior is defensible; just flagging the idiom.
ad-tech-protocol-expert: sound-with-caveats. security-reviewer: ship, no High/Medium. code-reviewer: no blockers. Confirm the World v4 endpoint and I'll approve.
…wire into deployable agent Promote the concrete World ID AttestationVerifier out of the reference demo into an importable module, github.com/adcontextprotocol/adcp-go/verify/worldid, with its own go.mod. World's HTTP-client dependency surface stays out of the core adcp-go module that every consumer imports — the reason targeting.AttestationVerifier is a pluggable interface with no default. Wire it into the deployable cmd/identity-agent via Run options: - targeting/identityagent gains RunOption / WithAttestationVerifier / WithRecipientKeys / WithAgeResolver. These carry constructed objects that cannot come from string env config, and keep World out of the core package's dependency graph — Run only ever sees the targeting interfaces and the RecipientKey data. - cmd/identity-agent constructs the worldid.Verifier and recipient key from environment and passes them in, config-gated on TMP_VERIFIED_IDENTITY_ENABLED. Fail-closed by default: with the flag unset, no verifier is wired, every attestation is treated as absent, and eligibility is unchanged. Age gating (AgeResolver) is intentionally left unset — production resolves required-age policy via the AdCP Policy Registry, which is not yet wired, so age-restricted packages are not served through this path rather than gated on an unverified claim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bf71149 to
bddb131
Compare
There was a problem hiding this comment.
Clean extraction. The verifier lives in its own module so World's HTTP-client dependency surface stays out of the core adcp-go graph every consumer imports — the right shape for a pluggable AttestationVerifier with no default.
Things I checked
- Build coherence. Every referenced symbol resolves:
targeting.AttestationVerifier/VerifyContext/VerifiedIdentity(targeting/verified_identity.go),tmproto.LoadX25519PrivateKey(tmproto/tmpx_open.go:192),AttestationClaimUniqueHuman(tmproto/identity.go:20), andServiceConfig.{Verifier,RecipientKeys,AgeResolver}(targeting/identityagent/service.go:40,59,62). Thesetup.go:374-376wiring lands in the fieldsNewServicealready consumes. - Fail-closed by default — confirmed. Flag unset ⇒
verifiedIdentityOptionsreturns(nil, nil)(cmd/identity-agent/verified_identity.go:45-49) ⇒ no verifier wired ⇒runVerifiedIdentityStagereturns early ons.verifier == nil. Default eligibility is byte-for-byte unchanged.security-reviewerconfirmed. - Verify-before-trust — confirmed.
verifier.gonever readsatt.Claims; nullifier and claims derive only from World'sresponses[]/success.verifier_test.go:73-75asserts the sender'sage_over_21does not survive. Closed-vocab projection viaidentifierToClaimis fail-closed: unmapped identifier ⇒len(claims)==0⇒ error ⇒ treated as absent. - Secret handling — clean.
TMP_RECIPIENT_KEYnever logged; the enabled-stage log atverified_identity.go:80-81emits onlyrelying_party_id/recipient_kid/base_url. Key-parse errors wrap stdlib length/format errors, not bytes. Non-200 returns status code only — proof-bearing body not echoed (verifier.go:135-138).io.LimitReaderbounds the read at 64 KiB. - Backward-compatible signature.
Run(...opts RunOption)is variadic; the only in-repo caller (main.go) passesopts....feat(identity-agent):is the correct conventional-commit type — additive, no wire-path removal, not a breaking change. - No
adcp/schemas/**,adcp/types_gen.go, orskills/**changes — schema-coherence and protocol-skill gates N/A.
Follow-ups (non-blocking — file as issues, close before the flag is ever enabled in production)
signal_binding→RequestIDcommitment is unimplemented. All three experts flagged it. TheAttestationVerifiercontract (targeting/verified_identity.go:74-77) saysVerifyMUST confirmsignal_bindingcommits tovctx.RequestID;LocalPreCheckonly proves the field is non-empty.verifier.goreads neitheratt.SignalBindingnorvctx.RequestIDand forwards onlyatt.Proof— so a captured valid proof can be replayed against a differentRequestIDunless World's backend binds it server-side via the forwarded signal.security-reviewerrates this no-High / pre-existing (the path is default-off), andad-tech-protocol-expertreturnssound-with-caveats— so it does not block this staged, fail-closed PR. But it is the one invariant this verifier is documented to own, and it must be closed (or have its server-side delegation to World asserted in a code comment) beforeTMP_VERIFIED_IDENTITY_ENABLEDflips on for any RP.- Validate
WORLD_VERIFY_BASE_URLat startup (security-reviewer, Medium).verified_identity.go:75-77accepts any base URL verbatim; a misconfigured/compromised config store could point the verifier — carrying the proof body and bearer API key — at an internal metadata endpoint. Requirehttps(orhttponly for a documented local-forwarder mode) and reject an empty host. Operator-set, not request-controlled, so not a live external exploit. - Confirm World v4 nullifiers are rp_id-scoped (
ad-tech-protocol-expert). Thevid:<rp>:<nullifier>cap correctness rests on/api/v4/verify/{rp_id}returning a per-RP pseudonym. Thevid:prefix prevents collisions regardless; cross-RP linkability is the open question. Verify against World's v4 docs. - Per-stage verifier sub-timeout. Worst case is
maxSealedCredentials× the 5s client timeout against the parent request budget (already acknowledged inverified_identity_stage.go).
Minor nits (non-blocking)
- Absent
successfield is treated as success.verifier.go:147rejects only onSuccess != nil && !*Success. A 200 withsuccessomitted falls through (still gated by the nullifier + recognised-claim requirements, so no live bypass). One line documenting that "absentsuccess+ valid nullifier + recognised claim" is the accepted v4 shape would close the ambiguity. lookupBoolis stricter thanstrconv.ParseBool(verified_identity.go:96-105) — fine as a deliberate allowlist, butParseBoolis the idiomatic choice and also handlest/f.WithAgeResolveris unreachable in this binary — intentional and documented (age policy deferred to the Policy Registry); flagging only so the age gate's dead-by-omission state is on the record.
Notable that the headline replay finding sits in the interface contract this PR is the first to implement — but the fail-closed, default-off framing earns it the follow-up lane rather than a block. Track it before enable.
Approving on the strength of the fail-closed default plus the clean verify-before-trust derivation. Follow-ups noted above — the signal_binding binding is a must-close-before-enable, not a must-fix-before-merge.
Promotes the concrete World ID
AttestationVerifierout of the reference demo (#373) into a production-grade, importable module and wires it into the deployablecmd/identity-agent— the binary that already runs the real verified-identity eligibility pipeline (#370/#371).What
1.
verify/worldidmodule. The World ID verifier gets its owngo.mod(github.com/adcontextprotocol/adcp-go/verify/worldid). World's HTTP-client dependency surface stays out of the coreadcp-gomodule that every consumer imports — which is exactly whytargeting.AttestationVerifieris a pluggable interface with no default.verify-before-trustis unchanged: the verifier never trusts sender-asserted claims; it POSTs the proof to World'sverify/{rp_id}backend and derives the nullifier + claim set from World's authoritative response.2. Config-gated wiring into
cmd/identity-agent.targeting/identityagentgainsRunOption/WithAttestationVerifier/WithRecipientKeys/WithAgeResolver. These inject constructed objects that can't come from string env config and keep World out of the core package's dependency graph —Runonly ever sees thetargetinginterfaces andRecipientKeydata.cmd/identity-agentconstructs the verifier + recipient key from env and passes them in.Fail-closed by default
With
TMP_VERIFIED_IDENTITY_ENABLEDunset, no verifier is wired — every attestation is treated as absent and eligibility is unchanged. Enabling it requiresTMP_RELYING_PARTY_ID,TMP_RECIPIENT_KID, andTMP_RECIPIENT_KEY(hex X25519, injected from secret storage, never logged). World backend viaWORLD_VERIFY_BASE_URL/WORLD_API_KEY.Deferred
Age gating (
AgeResolver) is intentionally left unset in the deployable binary: production resolves required-age policy via the AdCP Policy Registry, which isn't wired yet. With no resolver, age-restricted packages are simply not served through this path rather than gated on an unverified claim. (The reference demo's static age map stays in #373, where it belongs.)Relationship to #373
#373 is the runnable reference/demo agent that prototyped this verifier in-package. This PR extracts the production-quality half (the verifier) into a shared module and wires it into the real binary. Follow-up: update #373 to import
verify/worldidinstead of its in-package copy (or close it once the demo path is covered).Tests
verify/worldidcarries the 7 verifier unit tests (happy path, Worldsuccess:false, non-200, missing nullifier/claim/proof, empty rp). Coreidentityagent+cmd/identity-agentbuild/vet/test green;Runchange is backward-compatible (variadic options).🤖 Generated with Claude Code