fix: bind Tempo attribution memos to challenge IDs#111
Conversation
👁️ Cyclops Security Review🧭 Auditing · mode=
Findings
⚙️ Controls
📜 34 events🔍 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ac475ab56
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "Transaction must contain a Transfer log matching request parameters" | ||
| ) | ||
|
|
||
| if request.methodDetails.memo is None: |
There was a problem hiding this comment.
Normalize empty memo before binding validation
The new challenge-binding guard runs only when request.methodDetails.memo is None, but _verify_transfer_logs uses truthiness (if expected_memo:) when deciding whether to enforce memo equality. If a challenge is issued with methodDetails.memo as an empty string, hash verification now accepts any TransferWithMemo memo while skipping _assert_challenge_bound_memo, so the payment is no longer tied to the challenge ID. This can happen in integrations that serialize optional memo fields as "" instead of null.
Useful? React with 👍 / 👎.
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review — PR #111
This PR binds Tempo attribution memos to challenge IDs via keccak256-derived nonces. The _verify_hash() path is correctly secured, but _verify_transaction() lacks equivalent protections, creating exploitable asymmetries between credential types.
🚨 [SECURITY] Transaction Path Missing Challenge-Bound Memo Check and Replay Store Write
Severity: High · File: src/mpp/methods/tempo/intents.py:397-503
_verify_transaction() never calls _assert_challenge_bound_memo() and never writes to the replay store. A transaction built for challenge A can be submitted under challenge B (the memo binding is ignored), and the resulting tx_hash can then be redeemed again for challenge A via HashCredentialPayload (the store was never updated). One payment satisfies two challenges.
Fix: Add _assert_challenge_bound_memo() and put_if_absent to _verify_transaction(), mirroring _verify_hash(). Pass challenge_id/realm from verify().
🚨 [SECURITY] Fee-Payer Co-Signing Accepts Extra Calls and Unbounded Gas
Severity: High · File: src/mpp/methods/tempo/intents.py:603
_validate_calls() and _validate_transaction_payload() return on the first matching payment call, ignoring additional calls. Attacker-controlled gas_limit is unbounded. An attacker can append arbitrary EVM calls to a valid payment and have the merchant co-sign the full transaction. The Rust reference (mpp-rs) validates the entire call set and caps gas_limit at 1,000,000.
Fix: Validate that calls contains only expected payment calls. Enforce an upper bound on gas_limit for sponsored transactions.
⚠️ [ISSUE] Hash Verification Rejects Spec-Compliant No-Memo Payments
Severity: Medium · File: src/mpp/methods/tempo/intents.py:292
_verify_hash() now requires a challenge-bound attribution memo for all no-memo requests, but the unchanged transaction path, the Tempo charge spec, and mpp-rs still accept plain Transfer logs when methodDetails.memo is absent. Spec-compliant payments from non-Python clients will be rejected on the hash path.
Fix: Coordinate with mpp-specs and other SDKs before enforcing challenge-bound memos, or gate behind version negotiation.
Reviewer Callouts
- ⚡ MCP Challenge Conversion:
MCPChallenge.to_core()stripsrealm, butcreate_credential()now hasheschallenge.realminto auto-memos. MCP clients build memos withserver_id="". Fixing the transaction path will surface this. - ⚡ 56-bit Challenge Nonce: 7-byte nonce may be insufficient for long-lived or heavily queried challenge endpoints.
- ⚡ Hash/Transaction Parity: The two credential types now have fundamentally different security models. Fixes should unify both paths through shared helpers.
| # Explicit memos (set by the server) are strictly matched by _verify_transfer_logs | ||
| # but are NOT challenge-bound. Callers that set explicit memos are responsible | ||
| # for ensuring memo uniqueness per challenge to prevent cross-challenge hash reuse. | ||
| if request.methodDetails.memo is None: |
There was a problem hiding this comment.
🚨 [SECURITY] Explicit memos skip challenge binding; deferred store check wastes RPC
Two issues at this gate:
-
Explicit memo bypass: When
request.methodDetails.memois set (viaMpp.charge(..., memo=STATIC_MEMO)),_assert_challenge_bound_memo()is skipped. Sincestoreis optional, a single payment hash can be replayed across multiple challenges sharing the same fixed memo/amount/recipient. Consider requiringstore=when explicit memos are used, or documenting this as a mandatory constraint. -
Deferred replay store check (line 299): Moving
put_if_absentafter the RPC call means every duplicate replay of an already-used hash performs a fulleth_getTransactionReceiptbefore being rejected. The parent commit rejected duplicates before any RPC. Consider adding a cheap early-exitif await store.get(store_key) is not None: rejectbefore the RPC call, keeping the finalput_if_absentfor race safety.
There was a problem hiding this comment.
noted in code / interface -- this is consistent with mppx
| req, | ||
| challenge_id=credential.challenge.id, | ||
| realm=credential.challenge.realm, | ||
| ) |
There was a problem hiding this comment.
🚨 [SECURITY] Transaction path missing challenge binding and replay store write
The _verify_hash() call here correctly passes challenge_id and realm, but the else branch on line 247 dispatches to _verify_transaction(payload, req) without them. _verify_transaction() consequently:
- Never calls
_assert_challenge_bound_memo()— a transaction built for challenge A is accepted under challenge B - Never calls
put_if_absent— the tx hash can be redeemed again via the hash path
This allows one on-chain payment to satisfy two different challenges.
Fix: Pass challenge_id/realm to _verify_transaction() and add both _assert_challenge_bound_memo() and put_if_absent mirroring the hash path.
port of: wevm/mppx#291