Skip to content

feat(identity-agent): worldid.Verifier as a module + config-gated wiring into the deployable agent#374

Merged
bokelley merged 1 commit into
mainfrom
bokelley/worldid-verifier-prod
Jun 10, 2026
Merged

feat(identity-agent): worldid.Verifier as a module + config-gated wiring into the deployable agent#374
bokelley merged 1 commit into
mainfrom
bokelley/worldid-verifier-prod

Conversation

@bokelley

@bokelley bokelley commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Promotes the concrete World ID AttestationVerifier out of the reference demo (#373) into a production-grade, importable module and wires it into the deployable cmd/identity-agent — the binary that already runs the real verified-identity eligibility pipeline (#370/#371).

What

1. verify/worldid module. The World ID verifier gets its own go.mod (github.com/adcontextprotocol/adcp-go/verify/worldid). World's HTTP-client dependency surface stays out of the core adcp-go module that every consumer imports — which is exactly why targeting.AttestationVerifier is a pluggable interface with no default. verify-before-trust is unchanged: the verifier never trusts sender-asserted claims; it POSTs the proof to World's verify/{rp_id} backend and derives the nullifier + claim set from World's authoritative response.

2. Config-gated wiring into cmd/identity-agent. targeting/identityagent gains RunOption / 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 — Run only ever sees the targeting interfaces and RecipientKey data. cmd/identity-agent constructs the verifier + recipient key from env and passes them in.

Fail-closed by default

With TMP_VERIFIED_IDENTITY_ENABLED unset, no verifier is wired — every attestation is treated as absent and eligibility is unchanged. Enabling it requires TMP_RELYING_PARTY_ID, TMP_RECIPIENT_KID, and TMP_RECIPIENT_KEY (hex X25519, injected from secret storage, never logged). World backend via WORLD_VERIFY_BASE_URL / WORLD_API_KEY.

Deploy invariant (from #370): turning on a verifier is a coordinated rollout — the impression writer must emit RP-namespaced vid:<rp>:<nullifier> caps before the flag flips, or the verified path reads caps empty (fail open).

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/worldid instead of its in-package copy (or close it once the demo path is covered).

Tests

verify/worldid carries the 7 verifier unit tests (happy path, World success:false, non-200, missing nullifier/claim/proof, empty rp). Core identityagent + cmd/identity-agent build/vet/test green; Run change is backward-compatible (variadic options).

🤖 Generated with Claude Code

@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 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.go never reads att.Claims. Nullifier comes from vr.nullifier(), claims from vr.claims() mapping World's responses[].identifier through the closed identifierToClaim table. verifier_test.go:73 asserts a sender-asserted age_over_21 is dropped. security-reviewer and ad-tech-protocol-expert both confirmed.
  • Fail-closed by default. cmd/identity-agent/verified_identity.go:43-49 returns nil, nil with the flag unset → no verifier, no recipient keys → runVerifiedIdentityStage short-circuits at verified_identity_stage.go:47. Enabling without TMP_RELYING_PARTY_ID/TMP_RECIPIENT_KID/TMP_RECIPIENT_KEY errors at startup.
  • Secrets stay out of logs. The X25519 private key and WORLD_API_KEY are never logged; the enable line at verified_identity.go:81 logs only relying_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:108 records only the bounded VIDropVerifierError label). No proof material reaches a log.
  • Bounded read + RP binding. 64KB io.LimitReader (verifier.go:166), 5s client, maxSealedCredentials=8 cap the per-request surface. rp is operator-config TMP_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. The Run signature change is variadic — backward-compatible at the source level, single caller already updated. No !/BREAKING CHANGE: needed.
  • No schema surface. No adcp/schemas/**, no adcp/types_gen.go, no protocol-managed skills touched. RecipientKey.PrivateKey (*ecdh.PrivateKey) matches tmproto.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)

  • success absent is treated as success. verifier.go:182 rejects only an explicit success: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.Success is the belt-and-suspenders form.
  • No TLS-scheme guard on WORLD_VERIFY_BASE_URL. An operator setting http:// would send the proof and WORLD_API_KEY bearer in cleartext. Default is https; an https-or-loopback assertion when enabled would harden the secret path.
  • Update #373 to import verify/worldid instead of its in-package copy, per the PR description.

Minor nits (non-blocking)

  1. WithHTTPClient(nil) is a nil-panic. verifier.goNew doesn't restore the default if a nil client is passed through the option, and v.httpClient.Do would panic. No production path hits it (the only constructor never passes it), but if v.httpClient == nil after the option loop makes the public API safe.
  2. lookupBool reinvents strconv.ParseBool. verified_identity.go:96 accepts 1/true/TRUE/True only. 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>
@bokelley
bokelley force-pushed the bokelley/worldid-verifier-prod branch from bf71149 to bddb131 Compare June 9, 2026 23:19

@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 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), and ServiceConfig.{Verifier,RecipientKeys,AgeResolver} (targeting/identityagent/service.go:40,59,62). The setup.go:374-376 wiring lands in the fields NewService already consumes.
  • Fail-closed by default — confirmed. Flag unset ⇒ verifiedIdentityOptions returns (nil, nil) (cmd/identity-agent/verified_identity.go:45-49) ⇒ no verifier wired ⇒ runVerifiedIdentityStage returns early on s.verifier == nil. Default eligibility is byte-for-byte unchanged. security-reviewer confirmed.
  • Verify-before-trust — confirmed. verifier.go never reads att.Claims; nullifier and claims derive only from World's responses[]/success. verifier_test.go:73-75 asserts the sender's age_over_21 does not survive. Closed-vocab projection via identifierToClaim is fail-closed: unmapped identifier ⇒ len(claims)==0 ⇒ error ⇒ treated as absent.
  • Secret handling — clean. TMP_RECIPIENT_KEY never logged; the enabled-stage log at verified_identity.go:80-81 emits only relying_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.LimitReader bounds the read at 64 KiB.
  • Backward-compatible signature. Run(...opts RunOption) is variadic; the only in-repo caller (main.go) passes opts.... 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, or skills/** 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_bindingRequestID commitment is unimplemented. All three experts flagged it. The AttestationVerifier contract (targeting/verified_identity.go:74-77) says Verify MUST confirm signal_binding commits to vctx.RequestID; LocalPreCheck only proves the field is non-empty. verifier.go reads neither att.SignalBinding nor vctx.RequestID and forwards only att.Proof — so a captured valid proof can be replayed against a different RequestID unless World's backend binds it server-side via the forwarded signal. security-reviewer rates this no-High / pre-existing (the path is default-off), and ad-tech-protocol-expert returns sound-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) before TMP_VERIFIED_IDENTITY_ENABLED flips on for any RP.
  • Validate WORLD_VERIFY_BASE_URL at startup (security-reviewer, Medium). verified_identity.go:75-77 accepts 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. Require https (or http only 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). The vid:<rp>:<nullifier> cap correctness rests on /api/v4/verify/{rp_id} returning a per-RP pseudonym. The vid: 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 in verified_identity_stage.go).

Minor nits (non-blocking)

  1. Absent success field is treated as success. verifier.go:147 rejects only on Success != nil && !*Success. A 200 with success omitted falls through (still gated by the nullifier + recognised-claim requirements, so no live bypass). One line documenting that "absent success + valid nullifier + recognised claim" is the accepted v4 shape would close the ambiguity.
  2. lookupBool is stricter than strconv.ParseBool (verified_identity.go:96-105) — fine as a deliberate allowlist, but ParseBool is the idiomatic choice and also handles t/f.
  3. WithAgeResolver is 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.

@bokelley
bokelley merged commit c0720af into main Jun 10, 2026
19 checks passed
@bokelley
bokelley deleted the bokelley/worldid-verifier-prod branch June 10, 2026 02:06
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