Skip to content

Client re-encodes the challenge opaque instead of echoing it unchanged, so issuers that bind the raw string reject the credential #370

Closed

Description

@erkancamli

Existing issues

Description

parse_www_authenticate decodes opaque into a dict and keeps no copy of the issuer's string (src/mpp/_parsing.py L214 to L217). Challenge.to_echo() (src/mpp/__init__.py L326 to L328) and format_www_authenticate then encode that dict again with canonical_b64url. request does not have this problem, because request_b64 keeps the original string.

So whenever the server's opaque is not byte-for-byte pympp's canonical JCS encoding (different member order or whitespace), the credential carries a different opaque than the challenge did. The spec says clients MUST return opaque unchanged, and the issuer's challenge-ID check runs over the exact string it sent: mppx's idBindingInput uses challenge.opaque as received (src/Challenge.ts L727), and so does mpp-rs (src/server/mpp/verify.rs L167). The issuer then answers that the challenge was not issued by it and the payment is refused.

A realistic trigger is an mppx server that sets the documented raw opaque parameter ("Optional raw base64url-encoded server-defined correlation data. Clients MUST NOT modify.") instead of meta; mppx sends that string as is. Every pympp client method builds its credential through to_echo(), so this affects tempo charge and session, evm, stripe and x402 alike.

mpp-go had the same bug and fixed it on 24 Oct in tempoxyz/mpp-go#229 (fixes tempoxyz/mpp-go#182): keep the issuer's encoded opaque and echo it while it still decodes to the same value, otherwise fall back to the canonical encoding.

Expected: to_echo() and format_www_authenticate return the opaque string exactly as the issuer sent it.

Reproduction

import base64
from mpp import Challenge

raw = base64.urlsafe_b64encode(b'{"route":"/v1/search","plan":"pro"}').rstrip(b"=").decode()
header = (
    'Payment id="abc", realm="api.example.com", method="tempo", intent="charge", '
    f'request="e30", opaque="{raw}"'
)
echo = Challenge.from_www_authenticate(header).to_echo()
print(echo.opaque == raw)

On main (f742b95) this prints False: the echo is eyJwbGFuIjoicHJvIiwicm91dGUiOiIvdjEvc2VhcmNoIn0 (keys sorted) instead of the eyJyb3V0ZSI6Ii92MS9zZWFyY2giLCJwbGFuIjoicHJvIn0 the server sent. Whitespace inside the JSON ({"plan": "pro", "route": "/v1/search"}) is changed the same way.

End to end, I issued challenges with mpp-rs core (PaymentChallenge::with_secret_key_full) for the three opaque values below, built the credential with pympp exactly as the client transport does (Challenge.from_www_authenticate then to_echo()), and ran the mpp-rs server check on it:

issuer opaque JSON result
{"plan":"pro","route":"/v1/search"} (already canonical) accepted
{"route":"/v1/search","plan":"pro"} rejected, challenge was not issued by this server
{"plan": "pro", "route": "/v1/search"} rejected, challenge was not issued by this server

I have a patch along the lines of the mpp-go fix (the original string kept in a private field of Challenge, reused while it decodes to the same opaque), with tests; IDs of challenges pympp issues itself are unchanged. Happy to open a PR if that is welcome.

Version

0.18.0 (main f742b95)

Environment

Python 3.13, no extras needed for the snippet above.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions