Skip to content

spec(brand): add response signing envelope#5192

Merged
bokelley merged 2 commits into
mainfrom
prioritize-spec-backlog
May 30, 2026
Merged

spec(brand): add response signing envelope#5192
bokelley merged 2 commits into
mainfrom
prioritize-spec-backlog

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Adds the core decoded-payload JWS envelope schema and requires signed_response on verify_brand_claim and verify_brand_claims success responses.

Documents the designated-task response-signing profile, request_hash binding, JCS/JWS signing input, per-brand response-signing key separation, freshness handling, and bulk audit retention.

Includes a changeset and was validated by the precommit suite, schema/docs checks, and git diff --check.

@bokelley bokelley changed the title [codex] Add brand response signing envelope spec(brand): add response signing envelope May 30, 2026
@bokelley bokelley marked this pull request as ready for review May 30, 2026 14:32

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The envelope shape, tenant binding, request-hash composition, and iat/exp freshness rules are coherent with the prior #4703 consistency PR and the shared #adcp-jws-profile, and minor is the right changeset bump because verify_brand_claim/verify_brand_claims are pre-release 3.1 — adding a required field before GA is not a breaking flip.

Things I checked

  • Per-task const on signed_response.payload.task matches the envelope-level task enum: verify_brand_claim and verify_brand_claims agree on both sides. static/schemas/source/brand/verify-brand-claim-response.json:117 and verify-brand-claims-response.json:99.
  • signed_success_payload.not.anyOf correctly excludes both errors and signed_response — the envelope cannot recurse under its own attested payload and the success arm cannot impersonate the error arm. Load-bearing.
  • request_hash pattern ^sha256:[A-Za-z0-9_-]{43}$ matches unpadded base64url of a 32-byte digest. Right shape.
  • static/schemas/source/index.json:349 correctly registers the new envelope under core with a descriptive title.
  • signed_success_payload field list mirrors the outer success arm minus protocol/version envelope fields and signed_response itself, on both single-target and bulk variants.
  • #adcp-jws-profile anchor exists at docs/building/by-layer/L1/security.mdx:589; the new normative paragraphs after L892 correctly defer alg allowlist, JWKS discovery, SSRF, and duplicate-key rejection to it.
  • Designated-task list (verify_brand_claim, verify_brand_claims) matches the envelope task enum exactly. Closed-list invariant intact.
  • No new undiscriminated oneOf introduced. scripts/audit-oneof.mjs --check will not regress.
  • Pre-existing result_entry in the bulk schema still uses status on per-result success while the single-target schema renamed to verification_status. Internally consistent, but the signed payload now locks the divergence in. Pre-existing, not introduced here — worth a follow-up to align the bulk per-result field name before 3.1 GA.

Follow-ups (non-blocking — file as issues before 3.1 GA)

  1. JWS Signing Input prose has variable-name shadowing. security.mdx (new paragraph after L893) writes BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload))) where protected denotes the decoded JSON header (the next clause says "protected decodes to..."), but the envelope schema also calls the wire field protected — which holds the already-base64url-encoded value. A careful reader gets it right. A literal one double-encodes. ad-tech-protocol-expert flagged this Critical; I'm treating it as a Major follow-up rather than a block because the "decodes to" qualifier resolves the ambiguity, and RFC 7515 §5.1 is the conventional reading. Rename the formula variable (e.g., BASE64URL(UTF8(protected_header))) before 3.1 GA so no implementer has to triangulate.
  2. No verifier-side mechanism enforces per-brand_domain JWK provenance. The spec MUSTs distinct response-signing JWK material and kid per brand_domain, but a JWK in a shared multi-brand fleet's JWKS carries no brand_domain tag, so a misconfigured signer using brand-A's kid to sign a brand_domain: brand-b envelope produces a signature any verifier accepts. security-reviewer Medium. Closest fix: add an adcp_brand_domain JWK member (or a per-brand_domain JWKS path) so verifiers can MUST-reject when the JWK's asserted brand does not byte-equal the payload's brand_domain.
  3. caller_identity: null is replayable within the freshness window. Spec says "weaker evidence." That's prose, not a programmatic rule. Online verifiers consuming not_ours as direction-asymmetric exclusion evidence should be required to refuse caller_identity: null envelopes as the sole basis for online trust decisions; audit verifiers can still accept them as historical evidence.
  4. Clock-skew tolerance is unspecified. "Small clock-skew tolerance" interoperates badly. The shared profile uses ±60 s at security.mdx:664. Pin the same value on online verification of exp/iat.
  5. Error arm is silently unsigned. The schema requires signed_response only on the success arm. That's a defensible scope choice — rejections (disputed/not_ours) ride the success arm with a verification_status, not errors[] — but say so in the normative text. Otherwise a reader can't tell whether an unsigned error response is conformant or a downgrade attack.
  6. Bulk extraction MUST belongs in security.mdx, not only verify_brand_claims.mdx. The "audit stores extracting one result MUST also retain the original batch request and result index" rule is normative for any verifier reading bulk evidence; lift it into the designated-task section so audit-store implementers reading the L1 page see it.
  7. context_note is now signed, durable, audit-retained free-form attacker-influenceable text. Existed pre-PR, but this PR turns it into stable LLM-injection surface for downstream classification pipelines. Add a non-normative implementer note mirroring the brand_json_url "MUST be HTML-escaped" treatment at security.mdx:1105, but framed for prompt-injection.
  8. Credit the resolved issues. This PR resolves #4716 (replay protection), #4717 (per-brand JWK uniqueness), and #4718 (tenant binding) — all deferred to 3.2 by .changeset/4703-response-signing-internal-consistency.md. Add Closes #4716 #4717 #4718 to the PR body and prune the now-stale "3.2 hardening" disclaimer in verify_brand_claim.mdx (the diff already replaces that paragraph — confirm no other doc still references those issues as open).

Minor nits (non-blocking)

  1. Schema can't express exp >= iat. response-payload-jws-envelope.json:46/:50 lean on prose. Draft-07 can't, but a $comment would help schema-only SDK readers.
  2. brand_domain pattern omits RFC 1035 length caps. Single-label hostnames (localhost) and >253-char strings validate. Permissive for a tenant identifier.
  3. agent_url uses format: "uri". Allows non-HTTPS and non-http(s) schemes. The narrative mandates HTTPS; the schema should reflect it.
  4. request_hash.description does not list the canonical pre-image fields. SDK generators that consume only the schema can't reconstruct {task, brand_domain, agent_url, caller_identity, request} from prose elsewhere. Inline the binding object in the description.

Notable that this PR quietly pulls all three 3.2-deferred hardenings forward — the prior changeset's "3.2 hardening" disclaimer is now obsolete, which is good news the PR body undersells.

Safe to merge.

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