Skip to content

spec(tmp): experimental verified-identity attestation surface (World ID)#5387

Open
bokelley wants to merge 4 commits into
mainfrom
world-id-nullifier-tmp-match
Open

spec(tmp): experimental verified-identity attestation surface (World ID)#5387
bokelley wants to merge 4 commits into
mainfrom
world-id-nullifier-tmp-match

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Jun 6, 2026

What

Adds an experimental verified-identity attestation surface to TMP Identity Match (trusted_match.verified_identity): a publisher — or a network/issuer acting as the relying party — can forward a verifiable proof about a user (proof-of-personhood and/or age) so the buyer verifies the claim cryptographically instead of trusting an assertion. Issuer-agnostic; World ID is the first scheme.

Changes (additive, experimental)

  • enums/uid-type.json+world_id_nullifier (Sybil-resistant, rp-scoped, unlinkable pseudonym; asserts nothing on its own — trust comes from the attestation).
  • enums/attestation-claim.json (new) — closed claim set: unique_human, age_over_13/16/18/21.
  • tmp/identity-match-request.json — optional per-identity attestation object + top-level sealed_credentials[] (network-as-RP carrier).
  • brand.jsonidentity_relying_parties[] on house (entity) and brand (property) for rp_id provenance.
  • index.json — register the new enum.
  • docsspecification.mdx Verified Identity Attestation section (topologies, conformance invariants, router sealed_credentials handling, age-as-eligibility) + experimental-status.mdx feature id.

Contract-bearing note

identity-match-request.json is additionalProperties: false on purpose (the identity privacy boundary). These fields are a deliberate widening — proof about the identity (identity side of the boundary), not page context. Shipped x-status: experimental; not subject to deprecation cycles until 3.0.0 GA.

Validation

build:schemas, test:schemas, test:json-schema, test:build-schemas-hoist-enums, test:schema-utf8, test:schema-links all green; precommit (unit + typecheck) passed. The wire shape was also exercised end-to-end against the live World ID v4 API in a prototype (signed proof-request → verify → eligibility).

Open questions (flagged in experimental-status)

signal_binding freshness policy, issuer/scheme registry-vs-free-form, and brand.json variant placement are WG-open.

Design rationale: specs/tmp-verified-identity-attestation.md.

🤖 Generated with Claude Code

Adds a verifiable proof-of-personhood / age surface to TMP Identity Match
(trusted_match.verified_identity) so buyers verify identity claims instead of
trusting an assertion. Issuer-agnostic; World ID is the first scheme.

- uid-type: +world_id_nullifier
- new attestation-claim enum (unique_human, age_over_13/16/18/21)
- identity-match-request: optional per-identity `attestation` + top-level
  `sealed_credentials[]` (deliberate widening of the additionalProperties:false
  privacy boundary — proof about the identity, not page context)
- brand.json: identity_relying_parties[] on house + brand (rp_id provenance)
- specification.mdx: Verified Identity Attestation section (topologies,
  conformance invariants, router sealed_credentials handling, age-as-eligibility)
- experimental-status: trusted_match.verified_identity feature id

Design: specs/tmp-verified-identity-attestation.md

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

mintlify Bot commented Jun 6, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
adcp 🟢 Ready View Preview Jun 6, 2026, 10:08 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

aao-release-bot[bot]
aao-release-bot Bot previously approved these changes Jun 6, 2026
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Additive, experimental, and contract-honest — minor is the right changeset for a surface that only adds optional fields, a new enum value, and a new enum. The architectural call is right: forward the raw ZK proof so the receiver runs the crypto itself, with no trusted attester in the loop — verifiable beats asserted, and the rp_id-as-linkability-boundary framing is the correct shape.

Things I checked

  • Changeset semver. minor is correct. No existing field renamed, retyped, or flipped required↔optional; no enum value removed. world_id_nullifier is appended to uid-type, attestation-claim.json is net-new, and attestation / sealed_credentials[] are new optional containers. The inner required arrays (issuer/scheme/claims/proof, audience_kid/payload, issuer/rp_id) sit inside optional parents, so they impose nothing on existing senders. ad-tech-protocol-expert: sound-with-caveats.
  • No oneOf regression. Zero oneOf added anywhere in the diff; scripts/audit-oneof.mjs only walks oneOf nodes, so a scalar enum and plain objects are invisible to it. Baseline can't move.
  • The additionalProperties: false widening is on the wire's own terms. Both new keys are explicit named properties on the identity side of the boundary — not an ext/context escape hatch — so the privacy boundary the strict schema protects is intact. proof is correctly additionalProperties: true (opaque scheme material) inside an otherwise-closed envelope. x-status: experimental carries the contract-bearing note.
  • Schema↔docs coherence. specification.mdx Attestation table required-ness (issuer/scheme/claims/proof = Yes, rest = No) matches identity-match-request.json required exactly; SealedCredential table matches; verification_level enum orb|device|document matches both ways. No drift.
  • enumDescriptions parity. attestation-claim.json 5/5, uid-type addition 1:1.
  • brand.json $ref resolves. #/definitions/identity_relying_party is referenced from both house and brand and resolves as a sibling under definitions.
  • Age stays off the wire. attestation-claim.json is a closed threshold-only enum; jurisdiction tables live in the Policy Registry and resolve to eligible_package_ids; country is stripped before forwarding. No DOB/exact-age leak path. security-reviewer: confirmed clean.
  • sealed_credentials routing. Route-by-audience_kid (not broadcast) + fold into per-provider re-signature canonical bytes + sealed_credentials_hash in the dedup key closes injection, swap, and broadcast-leak. security-reviewer: strongest part of the surface, no gap.

Follow-ups (non-blocking — file as issues, must close before any party advertises trusted_match.verified_identity for production traffic)

  • Replay-binding fields are optional on the wire. signal_binding and expires_at are not in attestation.required (identity-match-request.json:563-568), so a proof with zero replay binding is schema-valid — yet the design spec concedes at specs/tmp-verified-identity-attestation.md:195 that proof verification alone does not stop replay. The MUST lives in prose (specification.mdx:105) while the wire stays permissive. Consider making signal_binding + expires_at required (or verification_level-gated) and pinning the nullifier-reuse window normatively rather than leaving it as the open "Replay window policy" question. (security-reviewer: Medium.)
  • DoS bound is prose-only. identities[] carries maxItems: 3, but sealed_credentials[] has no maxItems and attestation.proof is additionalProperties: true with no size cap. "Receivers MUST bound count and size" gives every implementer a different floor. Put a generous maxItems on sealed_credentials[] and a maxLength/maxProperties on proof/payload on the wire. (security-reviewer: Medium.)
  • rp_id provenance is one-sided until the issuer cross-check lands. The brand.json identity_relying_parties[] check stops a relay forwarding under another owner's rp_id only because brand.json is self-published — the bidirectional issuer-metadata cross-check is still an open question (specs/tmp-verified-identity-attestation.md:236). Acceptable for v1; document it as a GA precondition.

Minor nits (non-blocking)

  1. No bound on the brand.json arrays. identity_relying_parties[] on both house and brand (brand.json:382, :395) has no maxItems, in a PR whose whole security story is bounding identity-bearing arrays. Cheap to add.
  2. scope required-ness. The sketch at specs/tmp-verified-identity-attestation.md:224 shows scope populated; the schema makes it optional. No conflict, just confirm that's intended given the field sets cross-property linkability.

One dry observation: the design doc spends a paragraph arguing signal_binding is the load-bearing replay defense, then ships it optional on the wire and files the freshness policy under Open Questions. The reasoning and the schema should agree before this leaves experimental.

Approving on the strength of the additive-only wire shape plus a coherent schema/docs/changeset. Follow-ups noted; none block the merge.

- Wire sealed_credentials into the canonical Identity Match signed-fields table
  + dedup cache key (sealed_credentials_hash with a JCS preimage) and the
  router-architecture re-sign step — the prose MUSTs now match the authoritative
  tables, fixing the signing/cache split-brain (blocker).
- State that the canonical identities bytes serialize the full identity object
  including per-identity attestation, so the router signature covers it
  (strip/swap/inject breaks verification).
- Add a conditional age-eligibility clause to the IdentityMatch conformance
  predicate, gated on trusted_match.verified_identity.
- Bound DoS vectors: sealed_credentials maxItems, payload/audience_kid maxLength;
  claims minItems/uniqueItems/maxItems; proof-opacity note.
- Note the v1 replay limitation and that brand.json rp_id provenance is a
  cross-check, not the authoritative root (the issuer registry is).
- Normalize "AdCP Policy Registry" naming; mark the design doc shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bokelley
Copy link
Copy Markdown
Contributor Author

bokelley commented Jun 6, 2026

Expert review pass (protocol / security / product / docs)

Ran this through four review lenses + an adversarial synthesis that verified each finding against the diff. Verdict: mergeable as experimental, one true blocker (now fixed). Addressed in f6d3778150:

Blocker (B1) + related (N1) — fixed:

  • sealed_credentials was prescribed in prose but absent from the authoritative signing/caching definitions → wire split-brain. Added sealed_credentials_hash to the Identity Match signed-fields table + dedup cache key with a JCS preimage, and updated router-architecture step 7. The section prose now references the canonical rules instead of restating them.
  • Made explicit that the canonical identities bytes serialize the full identity object including attestation, so the router signature covers it (strip/swap/inject breaks verification).

Mediums — fixed:

  • Added a conditional age-eligibility clause to the IdentityMatch conformance predicate (gated on trusted_match.verified_identity; vacuous otherwise).
  • DoS bounds: sealed_credentials maxItems, payload/audience_kid maxLength; claims minItems/uniqueItems/maxItems; proof opacity note.
  • rp_id provenance: clarified brand.json is the v1 discovery surface and a cross-check — the issuer's relying-party registry is the authoritative root (bidirectional cross-check tracked as open).
  • Replay (v1 limitation): documented that without enforced signal_binding freshness + nullifier-reuse tracking this is at-most daily-epoch replay resistance.

Nits — fixed: normalized "AdCP Policy Registry" naming; claims/proof constraints; design-doc marked shipped.

Dropped after verification: the "property-level x-status precedent" finding — the cited precedents don't exist in source (git grep x-status finds none on those files); independent detection is via the experimental_features feature-id mechanism, which this PR already implements.

Deliberately deferred (low): reciprocal world_id cross-ref on the age-verification-method enum, and a note that match-id-type deliberately excludes world_id_nullifier from match reporting — fast-follow.

aao-release-bot[bot]
aao-release-bot Bot previously approved these changes Jun 6, 2026
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Clean additive experimental surface — attestation + sealed_credentials[] ride the Identity Match request under a separately-detectable trusted_match.verified_identity feature id. This is the right shape: it widens an additionalProperties:false schema on the identity side of the privacy boundary (proof about the identity, not page context), so the boundary the strict schema protects is intact.

Things I checked

  • Schema↔docs coherence. Attestation required: ["issuer","scheme","claims","proof"] (static/schemas/source/tmp/identity-match-request.json:612-617) matches the docs table Required column exactly (docs/trusted-match/specification.mdx:101-111); verification_level enum orb|device|document matches; SealedCredential required: ["audience_kid","payload"] matches specification.mdx:117-120. No rename, no type drift, no required-flip. ad-tech-protocol-expert: sound.
  • Changeset type. .changeset/tmp-verified-identity-attestation.md:8 is minor. Correct — every addition is optional (attestation non-required on identity items, sealed_credentials non-required top-level, both enum changes are appends, identity_relying_parties[] optional on house+brand). An old validator still accepts old payloads; no major owed.
  • Enum additions. world_id_nullifier appended before other in both enum and enumDescriptions (enums/uid-type.json) — parity holds. New attestation-claim.json is draft-07, $id matches the index.json registration path, all five members have matching enumDescriptions. The disambiguation note vs the world_id value in age-verification-method heads off a real collision.
  • No oneOf regression. IMR schema carries no oneOf/anyOf/allOf; identity_relying_party is a flat closed object (required: ["issuer","rp_id"], additionalProperties:false) referenced from two sites — both $refs resolve. Publisher-as-RP vs network-as-RP is modeled as two independent optional carriers, not a discriminated union. Right call.
  • Design doc (specs/tmp-verified-identity-attestation.md, 246 lines — largest non-generated file). The world_id_nullifier-not-publisher_first_party rationale at L250-254 is load-bearing and correct: tagging a Sybil-resistant nullifier as a churn-able first-party cookie discards the one property that makes it valuable. The control-plane-vs-data-plane split against XAA / RFC 8693 (L373-389) is the correct adjacent standard and keeps OAuth token-endpoint round trips off the per-impression path.
  • Sealed-credential tamper/cache. sealed_credentials folded into the per-provider re-signature canonical bytes and sealed_credentials_hash in the dedup cache key (specification.mdx:156,165) — swap/inject breaks the signature, a credential change repartitions the cache, route-by-audience_kid keeps payloads opaque to non-owning providers. security-reviewer: no tamper or poisoning gap.

Follow-ups (non-blocking — file as issues before trusted_match.verified_identity leaves experimental)

  • proof is unbounded in size. identity-match-request.json:602-606proof is {type:object, additionalProperties:true} with no maxProperties/maxLength. identities caps attestation count, but each proof blob is unbounded. The spec's "Receivers MUST bound attestation size" (specification.mdx conformance invariant 5) is load-bearing but prose-only — a schema-only implementer gets zero DoS protection. Contrast sealed_credentials.payload which is bounded at 8192. Bound proof the same way before GA. (security-reviewer M1, Medium.)
  • Replay fails open by schema default. signal_binding and expires_at are optional (identity-match-request.json:612-617). The fail-closed language is strong for an attestation that carries those fields and fails the check, but one that simply omits them passes validation — so the documented v1 floor is daily-epoch replay only. Disclosed honestly as a v1 limitation (specification.mdx Replay note), which is why it's a follow-up and not a block under x-status: experimental. For the personhood path where replay is the whole value proposition, make signal_binding+expires_at required before GA. (security-reviewer M2, Medium.)
  • rp_id provenance bidirectional cross-check is a tracked open item — the spec correctly states brand.json alone MUST NOT be trusted absent an issuer-side anchor (specification.mdx conformance invariant 3). Land the issuer-metadata cross-check before GA.
  • proof: additionalProperties:true as a context-smuggling vector is adequately fenced today (the object is JCS-serialized into the signed identities_hash, and the only adversary who could exploit it — a malicious publisher — already originates both sides). When scheme becomes a registry, replace the open proof with per-scheme closed shapes so the boundary is structurally enforced, not prose-enforced.

Minor nits (non-blocking)

  1. Dead maxItems. identity-match-request.json:587claims.maxItems: 16 against a 5-member uniqueItems:true enum means the effective ceiling is 5. Harmless but misleading; lower to 5 or drop it.
  2. Docs table omits a claims constraint. The Attestation table doesn't note minItems:1 on claims while the schema enforces it. Doc-completeness only.

Three experts, zero blockers. The two security findings are Medium, schema-enforceable, and gated behind the experimental flag — exactly the kind of thing the WG-open list exists to track. The PR is unusually honest about its own v1 limitations, which is the right posture for shipping a contract-bearing surface under x-status: experimental.

Approving on the strength of the tight schema↔docs mapping plus the fail-closed conformance language.

Address review on the verified-identity attestation surface.

issuer: model the identity issuer as a vendor — the same `core/brand-ref.json`
shape AdCP uses for measurement/signals vendors. `issuer: "world_id"` (opaque
string) becomes `issuer: {"domain": "world.org"}` on both wire sides. The
domain is the canonical anchor; brand.json hosting is optional. The relying
party is namespaced by the issuer: `(issuer.domain, issuer.brand_id,
relying_party_id)`, mirroring `(vendor.domain, vendor.brand_id, metric_id)`.

rp_id -> relying_party_id: spell out the OIDC/WebAuthn abbreviation to match
AdCP's id naming and its own `identity_relying_parties[]` container; not
`entity_id` (no generic entity type, and one entity runs many relying
parties). Tag `x-entity: identity_relying_party` so it obeys x-entity
discipline. The BrandRef issuer field carries no x-entity (the domain is the
identity), matching how the `vendor` field is done.

Renamed atomically across both wire surfaces, the spec conformance clauses,
the design doc's AdCP-field JSON examples, and this changeset; World ID's
native `rp_id` concept (nullifier scope) and the prototype are left as-is.

build:schemas, context-entity lint, test:schemas, and test:json-schema pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aao-release-bot[bot]
aao-release-bot Bot previously approved these changes Jun 7, 2026
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

Clean additive experimental extension to TMP Identity Match. The architecture is right: an attestation is proof about the identity, so widening the additionalProperties: false identity-match-request with optional fields stays on the identity side of the privacy boundary — it doesn't breach the boundary the strict schema protects.

Things I checked

  • The signing/cache split-brain commit 2 claims to fix is genuinely closed. All three sealed_credentials_hash references are mutually consistent: signed-fields table (specification.mdx:541), canonical-bytes definition (:547 — sort by audience_kid UTF-8, JCS, SHA-256, null when absent), and dedup cache key (:730), plus the router re-sign step (router-architecture.mdx:46). Per-identity attestation is folded into identities_hash via full-object JCS, so it rides the existing signature without a parallel row. No normative MUST lives in prose without a backing canonical-table entry — which was the exact bug class.
  • rp_idrelying_party_id rename is atomic. Zero stray rp_id in any AdCP-field context across brand.json, identity-match-request.json, x-entity-types.json, and the docs. The remaining rp_id occurrences are confined to specs/tmp-verified-identity-attestation.md narrative and refer to World ID's native nullifier-scope concept — correctly left as-is.
  • x-entity wiring complete on both halves. relying_party_id carries x-entity: identity_relying_party on both wire surfaces and is registered in x-entity-types.json enum + definitions. issuer as a BrandRef correctly carries no x-entity (the domain is the identity), matching the vendor pattern. Identity tuple (issuer.domain, issuer.brand_id, relying_party_id) mirrors vendor_metric's (vendor.domain, vendor.brand_id, metric_id)ad-tech-protocol-expert: sound, no new vendor-reference primitive invented.
  • Enum parity. attestation-claim.json is 1:1 — all five members have matching enumDescriptions, no orphans; registered in index.json; world_id_nullifier has a substantive description in uid-type.json and is appended before the other sentinel (non-breaking append position). Distinct from the unrelated world_id value in age-verification-method (a method, not an identifier) — the schema calls that out explicitly.
  • Docs↔schema coherence. docs-expert: no drift. The Attestation and SealedCredential tables in specification.mdx match the schema field-for-field on type and required/optional; anchors resolve; the experimental-status.mdx feature-surface row matches what shipped.
  • Fail-closed posture. security-reviewer: no path where an unverified attestation reads as asserted-true — invariants 1–2 plus the age-eligibility clause ("unverified or absent attestation does not satisfy this clause"; vacuously true when the feature is off) leave no silent upgrade. Sealed routing is route-by-audience_kid, not broadcast; inject/swap/strip breaks the re-signature. Age crosses the wire only as a threshold claim, never as cleartext DOB.

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

  • attestation.proof is the one unbounded field amid carefully-bounded neighbors. security-reviewer Medium: proof is type: object, additionalProperties: true with no maxProperties or byte cap, while sealed_credentials (maxItems 8), payload (maxLength 8192), audience_kid (maxLength 128), and identities (maxItems 3) are all explicitly bounded. The design doc mandates it in prose — specs/tmp-verified-identity-attestation.md:285: "Receivers MUST bound attestation size and count to prevent DoS amplification" — but the schema lets an oversized proof through. An implementer trusting the schema for input bounds (the normal assumption on a strict additionalProperties:false surface) ships unbounded on the verify path. Add a maxProperties or serialized-size cap to match the prose MUST. Experimental, so a follow-up, not a block.
  • Provenance: the load-bearing MUST is phrased as an open item. Invariant 3 says brand.json is the v1 discovery surface and the issuer's own registry (e.g. World ID on-chain) is the authoritative root — but the issuer-anchor requirement is deferred as a "tracked open item" while the field table says relying_party_id is "Checked against ... brand.json." An implementer reading only invariant 3 + the table may ship brand.json-only provenance, which is exactly the replay-under-a-different-owner gap the design doc raises. When the freshness/registry policy lands, promote "issuer-registry anchor required before trusting a cross-entity relying_party_id" to a hard normative MUST.
  • signal_binding and expires_at are optional in schema. Not in the attestation required set, so a freshness-free attestation parses valid. The replay limitation is honestly disclosed ("at most daily-epoch replay resistance"), so no over-promise — but when the WG-open freshness policy resolves, make both required for any attestation used to satisfy the eligibility clause.
  • Changeset type. ad-tech-protocol-expert reads the experimental-surface downgrade as making this patch rather than minor. The diff also touches stable schemas (uid-type.json, brand.json, index.json) additively, so minor is defensible — but it's a categorization nuance worth a second look against the triage matrix. Nothing breaking here either way, so not a block.

Minor nits (non-blocking)

  1. claims.maxItems: 16 can never bind. identity-match-request.json — the attestation-claim enum has five values and uniqueItems: true already caps the array at five. A bound that can't bind is harmless but misleading; set it to 5 or drop it.

Approving on the strength of the closed signing/cache split-brain plus clean schema↔docs parity. Follow-ups noted — the unbounded proof is the one worth filing.

…ation

The brand-canonical-tools validator compiles brand.json with a hand-rolled
ajv ref preloader that walked only brand.json's own $refs. That held while
every brand.json ref pointed at leaf enum schemas, but the verified-identity
work made `identity_relying_party.issuer` a `core/brand-ref.json` BrandRef,
and brand-ref.json itself refs brand-id.json / image-asset.json / ext.json —
none of which got registered, so ajv threw "can't resolve reference
/schemas/core/brand-id.json from id /schemas/core/brand-ref.json" and the
8 brand-canonical-tools tests failed.

Recurse into each loaded schema so its own cross-file $refs register
transitively; the existing `seen` set dedupes and breaks cycles.

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

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

Choose a reason for hiding this comment

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

Approving. Additive, experimental, and the widening of the additionalProperties:false identity boundary is justified on the boundary's own terms — proof about the identity is on the identity side of it, not page context.

Things I checked

  • Changeset is correct. minor is the right bump. Every new field is optional (attestation is not in identities[].items.required, sealed_credentials is not in the top-level required), the uid-type enum gains world_id_nullifier without removing or renaming a value, and widening an additionalProperties:false schema is strictly permissive — every payload valid under the old schema is still valid. No major owed. ad-tech-protocol-expert: sound.
  • Schema-vs-docs coherence is exact. Field-by-field, the spec.mdx Attestation table (required issuer, scheme, claims, proof; optional relying_party_id, action, verification_level, signal_binding, expires_at) matches identity-match-request.json attestation.required. SealedCredential (audience_kid, payload both required) matches. world_id_nullifier, the attestation-claim enum, and identity_relying_parties[] are all documented in experimental-status.mdx. No drift either direction.
  • enumDescriptions parity. All 5 values in enums/attestation-claim.json described; the new uid-type value described. No undiscriminated oneOf introduced — the publisher-as-RP / network-as-RP split is two independent optional properties, not a discriminated union.
  • Entity registration is consistent. relying_party_id carries x-entity: identity_relying_party in both brand.json and identity-match-request.json, and core/x-entity-types.json registers it in both the type list and x-entity-definitions. Both ends covered.
  • The recursion fix is safe. brand-canonical-tools.ts:102visit(referencedSchema) cannot infinite-loop: seen.add(ref) runs before the recursive walk and the load branch is gated on !seen.has(ref), so depth is bounded by the count of distinct $ref strings. Necessary because brand.json now reaches core/brand-ref.json and its transitive refs. code-reviewer: clean, no blockers.
  • DoS bounds are present on the sealed path. sealed_credentials maxItems 8, audience_kid maxLength 128, payload maxLength 8192; identities maxItems 3; claims maxItems 16. Fail-closed conformance is normative (specification.mdx:124-132): unverifiable attestation → treated as absent, never asserted-true; reject on failed signal_binding / relying_party_id provenance / expires_at.
  • Provenance is honestly fenced. specs/tmp-verified-identity-attestation.md:299 and specification.mdx:128 both state self-published brand.json is insufficient, the issuer registry is the authoritative root, and the bidirectional cross-check is a tracked open item. The rp_id-spoofing path is named, not papered over.

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

  • Cap proof and the free-string fields at the schema, not just in prose. security-reviewer flagged this Medium: proof is additionalProperties: true with no maxProperties/byte cap, and scheme / relying_party_id / action / signal_binding have no maxLength. The sealed path got a byte budget (payload maxLength 8192); the in-band attestation path is still asking for one in prose ("Receivers MUST bound attestation size"). Same opaque-blob concern, two different enforcement tiers. Mirror the sealed-credential discipline. Also closes the residual smuggling channel — an uncapped opaque proof is where a non-conformant publisher could stuff a context-correlation tag back across the boundary the strict schema advertises.
  • signal_binding freshness, scheme registry-vs-free-form, and brand.json variant placement are all correctly flagged WG-open in experimental-status.mdx — leaving them under experimental is the right call.

Minor nits (non-blocking)

  1. Changeset wording. .changeset/tmp-verified-identity-attestation.md and the PR body describe brand-ref.json as $ref-ing image-asset.json / ext.json directly; it actually refs core/assets/image-asset.json and reaches ext only transitively. Wording only — no schema or code impact.

Test plan is honest — build/schema suites green and the wire shape exercised against live World ID v4 in a prototype; no unchecked manual box against the path this changes.

LGTM. Follow-ups noted below.

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