Repository navigation
Description
Existing issues
- I searched the existing issues and this bug is not already reported.
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.
Activity