Skip to content

[Bug]: Verifiers never consume an accepted closed Payment Mandate; one user consent can be redeemed for multiple payments #346

Description

@FutureEnterprises

What happened?

At main (e1ea56db72a6385bce3e5c1112b3a56ce60acb43, spec v0.2), no verifier is required to remember that a closed Payment Mandate has already been accepted. Presenting the same user-signed mandate twice yields two payment credentials, two completed checkouts, and two MPP-signed Payment Receipts whose reference is the same mandate hash. No signature is forged; the second presentation is a byte-identical copy of the first.

This is distinct from #313, #326, #342 and #319, which make aud/nonce checking mandatory on KB hops. Those checks establish that a presentation was made for this verifier with the nonce it expected. They do not establish that it is the first presentation: in the reproduction below the Credential Provider verifies with expected_aud="credential-provider" and the correct expected_nonce both times, and accepts both times. In AP2 the nonce is chosen by the Shopping Agent (it constructs the OpenID4VP Authorization Request, docs/ap2/agent_authorization.md, "Delegation using OpenID4VP"), so a nonce check cannot distinguish a first presentation from a later one made by that same agent. #308 is about the used flag on the payment token; here the attacker never reuses a token, it reuses the mandate that mints tokens.

Where the specification leaves it

  • docs/ap2/security_and_privacy_considerations.md:5-8: "all LLMs and Agents MUST be considered potential attackers and are explicitly included in the threat model."
  • docs/ap2/specification.md:237-240 and security_and_privacy_considerations.md:109-111: the only mandatory double-spend control is on the Shopping Agent ("MUST NOT present any subsequent open Payment or Checkout Mandates without receiving a rejection receipt"; "MUST avoid signing multiple, overlapping closed Mandates").
  • security_and_privacy_considerations.md:113-114: "Credential Provider, Networks or MPPs MAY reject multiple overlapping Mandates, or invalidate previously issued payment tokens."
  • specification.md:292-342 (Verification): chain verification and constraint evaluation only; no rule records an accepted mandate or refuses a repeat.
  • code/sdk/schemas/ap2/payment_mandate.json: required fields are vct, transaction_id, payee, payment_amount, payment_instrument. Nothing is unique per presentation; iat/exp are optional.
  • agent_authorization.md:521-535: the Mandate Receipt error list has no "already used" code.

Where the reference implementation leaves it

  • code/samples/python/src/roles/credentials_provider_mcp/server.py:217-241: issue_payment_credential verifies the chain and then issues unconditionally, storing under both the fresh token and the deterministic mandate hash with store.update(dict.fromkeys([token, reference], token_data)). A second presentation overwrites the record with a fresh "used": False and mints a second token.
  • code/samples/python/src/roles/merchant_agent_mcp/server.py:843-861: the only duplicate check keys on the opaque payment_token, which is new on every replay.
  • code/samples/python/src/roles/merchant_payment_processor_mcp/server.py:207-302: initiate_payment requires used to be true, mints a receipt with a fresh payment_id, and writes no state, so a used token presented again is paid again.
  • code/samples/go/pkg/roles/merchant_payment_processor_agent/executor.go:56-85: no signature verification; returns SUCCESS for any parsed mandate.

Reproduction

Run from code/samples/python with uv run python repro_ap2_amplification.py (script attached). Every verification, issuance, checkout-completion and receipt step is the sample code's own function, called in-process. The only substitutions are the Merchant-to-MPP HTTP POST (direct call to initiate_payment) and the MPP-to-CP receipt POST (a recorder).

  1. Open mandates signed; Merchant assembles a cart (2500 minor units) and signs a Checkout; the Shopping Agent presents one closed Payment Mandate, SHA-256 (base64url) Qef8RJ3tG1QHlAoeiSEe-QWewmd2eVOzayPCVZ7VdlU.
  2. issue_payment_credential called twice with identical arguments: both succeed; two tokens, both "used": false, same reference.
  3. complete_checkout with token 1: status: success, order c5d78046.... Again with token 2, same checkout mandate, same nonce: status: success, order 9f3c3a2f....
  4. Two MPP-signed Payment Receipts (ES256, kid: merchant-payment-processor-key-1), both reference = Qef8RJ3t..., distinct payment_id.

A Tamarin model of the Direct payment flow (attached) gives the same result: every charge is preceded by a user consent on the exact transaction, payee and amount (the signature does its job), and "at most one charge per mandate" is falsified in 14 steps. With an atomic consume at the processor keyed on transaction_id it verifies.

Proposed fix (PR to follow)

Verification section, Credential Provider and Network: record, in durable state with atomic single-writer semantics, the hash of each accepted closed Payment Mandate (the value already used as the receipt reference), and reject a closed Payment Mandate whose hash is already recorded with a new terminal error mandate_already_used. Merchant Payment Processor: record transaction_id before initiating payment and reject repeats the same way. Double Spend section: the verifier bullet becomes MUST. Reference implementation: refuse in issue_payment_credential when reference is already present; consume transaction_id in initiate_payment before minting a receipt. With that patch applied the reproduction refuses at step 2 (mandate_already_used), and a used token replayed straight at the MPP is refused with one receipt minted.

If freshness is also wanted, the Key Binding nonce must be issued and stored by the verifier that checks it (OpenID4VP section 5.2), not by the Shopping Agent. Consumption is needed either way: checking a nonce is not remembering it.

Version

AP2 spec v0.2 (2026-04-28); repo commit e1ea56db72a6385bce3e5c1112b3a56ce60acb43. Reported through g.co/vulnz on 2026-09-01; the security team classified it as intended behavior for program purposes and invited a public issue.

Attachments

Reproduction script, transcript, Tamarin theory and prover output: https://gist.github.com/FutureEnterprises/65fc07c1c5e7c8ec6977933aa702381b

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions