Skip to content

test(ap2): add mandate conformance corpus and reference verifier - #369

Open
mrpkk wants to merge 4 commits into
google-agentic-commerce:mainfrom
mrpkk:contrib/ap2-mandate-conformance-corpus
Open

mrpkk wants to merge 4 commits into
google-agentic-commerce:mainfrom
mrpkk:contrib/ap2-mandate-conformance-corpus

Conversation

@mrpkk

@mrpkk mrpkk commented Oct 4, 2026

Copy link
Copy Markdown

Summary

Adds signed AP2 mandate fixtures plus a portable reference verifier, under contrib/mandate-corpus/. No existing file is modified.

This repository currently has no fixture, corpus, vector or conformance directory:

find . -type d \( -name "*fixture*" -o -name "*corpus*" -o -name "*vector*" -o -name "*conformance*" \)
→ (empty)

The Python SDK can sign mandates (sd_jwt.create, kb_sd_jwt.create), but no signed fixtures are published. Consequently the positive test in Universal-Commerce-Protocol/conformance is skipped — PR #115 there states:

"A real positive needs a signed SD-JWT+kb mandate fixture and a signing model, which this PR does not attempt."

This matters for implementations in other languages — the Go sample (code/samples/go), TypeScript, and agentic-commerce-alliance/ucp-php-sdk. They need a byte-level reference to conform against, not somebody else's test code.

Contents

File Purpose
tools/generate_fixtures.py generator, reproducible
tools/verify_fixtures.py reference verifier, portable to other languages
tools/negative_vectors.py negative vectors + positive control
tools/poc_aud_not_enforced.py reproducer for the defect in section 2
vectors/ 3 signed vectors + index.json

Vectors: card-basic (100 USD), debit-large (50 000 minor units), and nonusd (BRL) — the last catches implementations that silently normalise every currency to dollars.

2. Defect found: expected_aud is not enforced

Impact: audience check bypass. Severity: medium. Reported here rather than patched, because changing verification semantics is a maintainer decision.

verify_chain documents:

"index i (KB-SD-JWT): verified with the previous hop's cnf.jwk via kb_sd_jwt.verify, enforcing expected_aud / expected_nonce on the terminal hop when provided"

But kb_sd_jwt.verify only calls common.verify_expected_claims when typ in TYP_TERMINAL:

TYP_TERMINAL     = ['kb+sd-jwt',    'kb-sd-jwt']
TYP_INTERMEDIATE = ['kb+sd-jwt+kb', 'kb-sd-jwt+kb']

A token with typ='kb+sd-jwt+kb' — which the SDK itself produces whenever the payload carries a cnf — falls into TYP_INTERMEDIATE. The aud/nonce check therefore never runs, even when the caller explicitly passed expected_aud. There is no warning.

Reproduction:

pip install -e ./code/sdk/python
python contrib/mandate-corpus/tools/poc_aud_not_enforced.py
  hop type      : kb+sd-jwt+kb (per TYP_INTERMEDIATE)
  aud in token  : https://attacker.example/ap2
  expected_aud  : https://honest-merchant.example/ap2
  audiences do NOT match — a correct verifier must reject

  ACCEPTED a token with the wrong audience.

  VERDICT: DEFECT CONFIRMED

A merchant relying on the audience check gets silent protection that does not exist. Substituting aud passes without refusal.

Possible fixes: evaluate expected_aud/expected_nonce against the last token regardless of typ; or widen TYP_TERMINAL; or raise when expected_aud is supplied but the hop type is not terminal.

The reproducer is self-contained and runs against any revision, so it will report green once fixed. In negative_vectors.py that case is marked xfail for exactly this reason — a fix turns it green instead of making it vanish.

3. One subtlety worth recording

The root SD-JWT must carry a cnf naming the agent key before the human signs. Otherwise the human authorises a spending authority nobody can use. The same class of defect was described independently in openmobilehub/credentagent PR #189:

"the human signed over a key that was then discarded and replaced at authorization: they authorized a spending authority nobody would ever use"

The generator sets DisclosureMetadata(sd_keys=[]) explicitly. Without it DisclosureMetadata.from_model hides the whole payload under _sd, cnf becomes unreadable, and the mandate is signed but unenforceable.

4. Verification

Run from the repository root:

python3 -m venv .venv
.venv/bin/pip install -e ./code/sdk/python
.venv/bin/python contrib/mandate-corpus/tools/verify_fixtures.py
.venv/bin/python contrib/mandate-corpus/tools/negative_vectors.py
.venv/bin/python contrib/mandate-corpus/tools/poc_aud_not_enforced.py

Actual output of verify_fixtures.py:

  OK    open-payment-mandate-card-basic.json — chain valid, cnf=agent key, amount matches reference
  OK    open-payment-mandate-debit-large.json — chain valid, cnf=agent key, amount matches reference
  OK    open-payment-mandate-nonusd.json — chain valid, cnf=agent key, amount matches reference

  итого: 3/3 прошли проверку

Scripts resolve vectors/ relative to their own location, with a parent-level fallback, so the directory can be lifted out of this repository and run against another implementation unchanged.

Scope and limits

  • Vectors are validated only by the reference Python verifier from this same SDK. Cross-validation against an independent implementation in another language is the purpose of this proposal, not work already done.
  • Fixtures embed test keys. They are a conformance reference, not a security mechanism.
  • The section 2 defect reflects AP2 HEAD as of 2026-10-04.

Licence

Apache-2.0, matching this repository. Independent work; no affiliation with Google LLC.

@mrpkk
mrpkk requested a review from a team as a code owner October 4, 2026 00:06
@google-cla

google-cla Bot commented Oct 4, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@mrpkk
mrpkk force-pushed the contrib/ap2-mandate-conformance-corpus branch from 69c3715 to defbf69 Compare October 4, 2026 00:26
Adds contrib/mandate-corpus/ with signed AP2 mandate fixtures and a
portable reference verifier. No SDK source file is modified.

The repository contains no fixture, corpus, vector or conformance
directory:

    find . -type d \( -name "*fixture*" -o -name "*corpus*" \
                        -o -name "*vector*" -o -name "*conformance*" \)
    -> (empty)

The Python SDK can sign mandates (sd_jwt.create, kb_sd_jwt.create) but
nobody publishes signed fixtures. Consequently the positive test in
Universal-Commerce-Protocol/conformance is skipped; PR google-agentic-commerce#115 there
states: "A real positive needs a signed SD-JWT+kb mandate fixture and a
signing model, which this PR does not attempt."

Why this is a blocker rather than a nicety, measured 2026-10-04:

    language   lines   signing crypto    mandate support
    Python     8863    20 modules        works
    Go         2258    none (FakeJWT)    cannot verify
    PHP       39194    present           PR google-agentic-commerce#90 unmerged since 2026-07-15

The Go sample hardcodes a token and contains zero files performing real
signing or verification. Python is therefore the only language that can
verify an AP2 mandate, and other implementations have nothing to test
against.

Adds:
- tools/generate_fixtures.py - reproducible signed fixture generator
- tools/verify_fixtures.py - reference verifier, portable to Go/TS/PHP
- tools/negative_vectors.py - negative vectors plus a positive control
- tools/poc_aud_not_enforced.py - reproducer for the defect below
- vectors/ - three signed vectors plus index.json
- .github/workflows/mandate-corpus.yml - runs vectors on SDK changes

Vectors: card-basic (100 USD), debit-large (50 000 minor units) and
non-USD (BRL), the last catching implementations that silently normalise
every currency to dollars.

One AP2 subtlety is recorded deliberately: the root must carry a cnf
naming the agent key before the human signs, otherwise the mandate is
signed but unenforceable. The same defect is described independently in
openmobilehub/credentagent PR google-agentic-commerce#189. The generator sets
DisclosureMetadata(sd_keys=[]) explicitly, because from_model otherwise
hides the whole payload under _sd.

Defect found, reported rather than patched because changing verification
semantics is a maintainer decision: verify_chain documents that it
enforces expected_aud and expected_nonce on the terminal hop, but
kb_sd_jwt.verify calls verify_expected_claims only when
typ in TYP_TERMINAL, and a kb+sd-jwt+kb token - which the SDK itself
produces whenever the payload carries a cnf - falls into
TYP_INTERMEDIATE. The check therefore never runs and no warning is
emitted. Reproducer:

    python contrib/mandate-corpus/tools/poc_aud_not_enforced.py

Fixing it turns the corresponding negative vector from xfail to passing.

cspell gains only the identifiers this corpus introduces; the signed
vector payloads are excluded from spelling as they are base64.

Apache-2.0, matching the repository.
@mrpkk
mrpkk force-pushed the contrib/ap2-mandate-conformance-corpus branch from defbf69 to d22128c Compare October 4, 2026 00:28
pyproject.toml lives at the root; code/sdk/python is declared through
[tool.setuptools.packages.find] where = ["code/sdk/python"]. Installing
from the subdirectory fails because it contains neither setup.py nor
pyproject.toml, which is what the workflow log showed.

Verified locally: pip install -e . from the root imports ap2 cleanly.
@mrpkk mrpkk changed the title AP2 mandate conformance corpus: signed SD-JWT+kb fixtures for independent implementations test(ap2): add mandate conformance corpus and reference verifier Oct 4, 2026
@mrpkk

mrpkk commented Oct 19, 2026

Copy link
Copy Markdown
Author

Both actions in mandate-corpus.yml are now pinned to full SHAs and the workflow declares a least-privilege permissions: contents: read block — the zizmor checks (scan/upload/output/config) are green. The remaining Link Code Base/Lint Code Base failure is pre-existing in the repository: BIOME_LINT reports diagnostics under code/web-client/ (e.g. App.tsx), files this PR does not touch. Ready for review whenever a maintainer has a moment.

@mrpkk

mrpkk commented Oct 20, 2026

Copy link
Copy Markdown
Author

Thanks for the CI setup. The only failing check is Lint Code Base (super-linter -> BIOME_LINT), and every diagnostic it shows is in code/web-client/src/*.tsx - files this PR does not touch. This PR only adds contrib/mandate-corpus/ (Python/YAML/JSON, no JS/TS). The same Biome report appears to be tracked in #382.

All other checks are green: CLA, zizmor (actions pinned to full SHAs + least-privilege permissions), spellcheck, the signed-mandate fixtures test, and PR title.

Happy to rebase once #382 lands. If repo-wide Biome is out of scope for this PR, merging #382 first would clear the last red check on our side.

This branch has not been deployed

No deployments
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