Hi — sharing four findings from a cross-language MPP/charge security audit. The Solana @solana/mpp SDK delegates its server-side HMAC issuance/verification, the challenge↔credential binding, expiry, realm handling, and the WWW-Authenticate codec to mppx, so these live here rather than in the consumer. Analyzed against mppx@0.5.5 (dist/ is the runtime source of truth). Each "required fix" is the behavior the audited reference implementation adopted.
Severities are Low–Medium; reporting publicly since the analysis is already published in the pay-kit audit notes. Happy to open a PR if a direction is preferred.
| # |
Severity |
Title |
| 1 |
Medium |
Partial expected-vs-request comparison — most payment fields never pinned |
| 2 |
Medium |
Weak HMAC secret key accepted (non-empty check only) |
| 3 |
Low |
Default realm is a shared constant across servers |
| 4 |
Low |
WWW-Authenticate parser missing size cap |
1. Partial expected-vs-request comparison (Medium)
- Where:
dist/server/Mppx.js requestBindingFields / getRequestBindingMismatch, invoked from the verify path.
- Behavior: the credential↔route binding compares only
['amount','currency','recipient','chainId','memo','splits']. Fields like network, decimals, tokenProgram, feePayer, feePayerKey, externalId, description are never compared, so a credential carrying different values for those than the route configured flows into settlement unchecked (cross-route confusion when two routes share a secret).
- Fix: exhaustive up-front comparison between the route-built request and the credential's decoded request across all payment-constraining fields (top-level
amount,currency,recipient,externalId,description and methodDetails.{network,decimals,tokenProgram,feePayer,feePayerKey,splits}, splits element-wise/order-sensitive). Exclude recentBlockhash (per-challenge state).
2. Weak HMAC secret key accepted (Medium)
- Where:
Mppx.create (if (!secretKey) throw — non-empty only), consumed at dist/Challenge.js (Bytes.fromString(options.secretKey)) with no length check.
- Behavior: any non-empty string (
"key", "a") is accepted as the HMAC-SHA256 key that binds challenge IDs; a low-entropy key enables challenge forgery.
- Fix: enforce a 32-byte minimum (NIST SP 800-107 for HMAC-SHA256) in
Mppx.create, validating both the explicit secretKey and the MPP_SECRET_KEY env path. Document openssl rand -base64 32.
3. Default realm shared across servers (Low)
- Where:
dist/server/Mppx.js const defaultRealm = 'MPP Payment', fallback in resolveRealmFromRequest; realm participates in the cross-route binding and the HMAC ID.
- Behavior: two services sharing one
MPP_SECRET_KEY and both keeping the default realm share a credential namespace — a credential paid against service A passes verification on service B. The Host-header default partially mitigates, but the explicit fallback is a fixed shared string.
- Fix: derive the default realm from a per-app identity so two services with the same secret get distinct realms; reject explicit
realm: ''; keep explicit non-empty realms verbatim.
4. WWW-Authenticate parser missing size cap (Low)
- Where:
dist/Challenge.js deserialize/deserializeList base64-decode + JSON-parse the embedded request parameter with no length guard.
- Behavior: an oversized header drives proportionally larger decode/parse work than the credential/receipt parsers allow — a client-side DoS surface.
- Fix: cap the
request parameter (e.g. 16 KiB, matching the credential/receipt parsers) before base64-decode/JSON-parse.
Hi — sharing four findings from a cross-language MPP/charge security audit. The Solana
@solana/mppSDK delegates its server-side HMAC issuance/verification, the challenge↔credential binding, expiry, realm handling, and theWWW-Authenticatecodec tomppx, so these live here rather than in the consumer. Analyzed againstmppx@0.5.5(dist/is the runtime source of truth). Each "required fix" is the behavior the audited reference implementation adopted.Severities are Low–Medium; reporting publicly since the analysis is already published in the pay-kit audit notes. Happy to open a PR if a direction is preferred.
WWW-Authenticateparser missing size cap1. Partial expected-vs-request comparison (Medium)
dist/server/Mppx.jsrequestBindingFields/getRequestBindingMismatch, invoked from the verify path.['amount','currency','recipient','chainId','memo','splits']. Fields likenetwork,decimals,tokenProgram,feePayer,feePayerKey,externalId,descriptionare never compared, so a credential carrying different values for those than the route configured flows into settlement unchecked (cross-route confusion when two routes share a secret).amount,currency,recipient,externalId,descriptionandmethodDetails.{network,decimals,tokenProgram,feePayer,feePayerKey,splits}, splits element-wise/order-sensitive). ExcluderecentBlockhash(per-challenge state).2. Weak HMAC secret key accepted (Medium)
Mppx.create(if (!secretKey) throw— non-empty only), consumed atdist/Challenge.js(Bytes.fromString(options.secretKey)) with no length check."key","a") is accepted as the HMAC-SHA256 key that binds challenge IDs; a low-entropy key enables challenge forgery.Mppx.create, validating both the explicitsecretKeyand theMPP_SECRET_KEYenv path. Documentopenssl rand -base64 32.3. Default realm shared across servers (Low)
dist/server/Mppx.jsconst defaultRealm = 'MPP Payment', fallback inresolveRealmFromRequest; realm participates in the cross-route binding and the HMAC ID.MPP_SECRET_KEYand both keeping the default realm share a credential namespace — a credential paid against service A passes verification on service B. The Host-header default partially mitigates, but the explicit fallback is a fixed shared string.realm: ''; keep explicit non-empty realms verbatim.4.
WWW-Authenticateparser missing size cap (Low)dist/Challenge.jsdeserialize/deserializeListbase64-decode + JSON-parse the embeddedrequestparameter with no length guard.requestparameter (e.g. 16 KiB, matching the credential/receipt parsers) before base64-decode/JSON-parse.