Skip to content

fix: bind Tempo attribution memos to challenge IDs#111

Merged
brendanjryan merged 5 commits into
mainfrom
fix/challenge-bound-attribution-memo
Apr 3, 2026
Merged

fix: bind Tempo attribution memos to challenge IDs#111
brendanjryan merged 5 commits into
mainfrom
fix/challenge-bound-attribution-memo

Conversation

@brendanjryan
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan commented Apr 3, 2026

port of: wevm/mppx#291

@brendanjryan brendanjryan added the cyclops Trigger Cyclops PR audit label Apr 3, 2026
@tempoxyz-bot
Copy link
Copy Markdown

tempoxyz-bot commented Apr 3, 2026

👁️ Cyclops Security Review

1ac475a

🧭 Auditing · mode=normal · workers 2/3 done (1 left) · verify pending 1

Worker Engine Progress Status
pr-111-w1 gemini-3.1-pro-preview 🚨 thread-1 🚨 thread-2 🚨 thread-3 Done
pr-111-w2 amp/deep thread-1 🚨 thread-2 🚨 thread-3 Done
pr-111-w3 gpt-5.4 🚨 thread-1 🔍 thread-2 · Running

Findings

# Finding Severity Verification Threads
1 Cross-Challenge Replay via TransactionCredentialPayload Bypass High ✅ Verified audit · verify
2 Cross-Method Replay via Missing Store Update in _verify_transaction Allows Double Credit High ✅ Verified audit · verify
3 Pending Transaction Lockout via Early Store Update High ✅ Verified audit · verify
4 Challenge-bound hash rejection permanently blacklists paid transactions Medium ✅ Verified audit · verify
5 Reused explicit server memos still bypass challenge binding, allowing tx-hash replay across fresh challenges High ✅ Verified audit · verify
6 Empty-String Memo Disables Absent-Memo Challenge Binding in Custom Tempo Integrations Low ⏳ Pending audit
⚙️ Controls
  • 🚀 Keep only 1 remaining iteration per worker after the current work finishes.
  • 👀 Keep only 2 remaining iterations per worker after the current work finishes.
  • ❤️ Let only worker 1 continue; other workers skip queued iterations.
  • 😄 Let only worker 2 continue; other workers skip queued iterations.
  • 🎉 End faster by skipping queued iterations and moving toward consolidation.
  • 😕 Stop active workers/verifiers now and start consolidation immediately.

📜 34 events

🔍 pr-111-w1 iter 1/3 [audit-ripple.md]
🔍 pr-111-w2 iter 1/3 [audit-focused.md]
🔍 pr-111-w3 iter 1/3 [audit-deep-focus.md]
🚨 pr-111-w1 iter 1 — finding | Thread
🚨 Finding: Cross-Challenge Replay via TransactionCredentialPayload Bypass (High) | Thread
🔍 pr-111-w1 iter 2/3 [audit-historical.md]
pr-111-w2 iter 1 — clear | Thread
🔬 Verifying: Cross-Challenge Replay via TransactionCredentialPayload Bypass | Thread
🔍 pr-111-w2 iter 2/3 [audit-ripple.md]
📋 Verify: Cross-Challenge Replay via TransactionCredentialPayload Bypass → ✅ Verified | Thread
🚨 pr-111-w1 iter 2 — finding | Thread
🚨 Finding: Cross-Method Replay via Missing Store Update in _verify_transaction Allows Double Credit (High) | Thread
🔍 pr-111-w1 iter 3/3 [audit-focused.md]
🔬 Verifying: Cross-Method Replay via Missing Store Update in _verify_transaction Allows Double Credit | Thread
📋 Verify: Cross-Method Replay via Missing Store Update in _verify_transaction Allows Double Credit → ✅ Verified | Thread
🚨 pr-111-w1 iter 3 — finding | Thread
🚨 Finding: Pending Transaction Lockout via Early Store Update (High) | Thread
🏁 pr-111-w1 done
🔬 Verifying: Pending Transaction Lockout via Early Store Update | Thread
🚨 pr-111-w2 iter 2 — finding | Thread
🚨 Finding: Challenge-bound hash rejection permanently blacklists paid transactions (Medium) | Thread
🔍 pr-111-w2 iter 3/3 [audit-historical.md]
🔬 Verifying: Challenge-bound hash rejection permanently blacklists paid transactions | Thread
📋 Verify: Pending Transaction Lockout via Early Store Update → ✅ Verified | Thread
🚨 pr-111-w3 iter 1 — finding | Thread
🚨 Finding: Reused explicit server memos still bypass challenge binding, allowing tx-hash replay across fresh challenges (High) | Thread
🔍 pr-111-w3 iter 2/3 [audit-focused.md]
🔬 Verifying: Reused explicit server memos still bypass challenge binding, allowing tx-hash replay across fresh challenges | Thread
📋 Verify: Challenge-bound hash rejection permanently blacklists paid transactions → ✅ Verified | Thread
📋 Verify: Reused explicit server memos still bypass challenge binding, allowing tx-hash replay across fresh challenges → ✅ Verified | Thread
🚨 pr-111-w2 iter 3 — finding | Thread
🚨 Finding: Empty-String Memo Disables Absent-Memo Challenge Binding in Custom Tempo Integrations (Low) | Thread
🏁 pr-111-w2 done
🔬 Verifying: Empty-String Memo Disables Absent-Memo Challenge Binding in Custom Tempo Integrations | Thread

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@brendanjryan brendanjryan removed the cyclops Trigger Cyclops PR audit label Apr 3, 2026
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ 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() strips realm, but create_credential() now hashes challenge.realm into auto-memos. MCP clients build memos with server_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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Explicit memos skip challenge binding; deferred store check wastes RPC

Two issues at this gate:

  1. Explicit memo bypass: When request.methodDetails.memo is set (via Mpp.charge(..., memo=STATIC_MEMO)), _assert_challenge_bound_memo() is skipped. Since store is optional, a single payment hash can be replayed across multiple challenges sharing the same fixed memo/amount/recipient. Consider requiring store= when explicit memos are used, or documenting this as a mandatory constraint.

  2. Deferred replay store check (line 299): Moving put_if_absent after the RPC call means every duplicate replay of an already-used hash performs a full eth_getTransactionReceipt before being rejected. The parent commit rejected duplicates before any RPC. Consider adding a cheap early-exit if await store.get(store_key) is not None: reject before the RPC call, keeping the final put_if_absent for race safety.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noted in code / interface -- this is consistent with mppx

req,
challenge_id=credential.challenge.id,
realm=credential.challenge.realm,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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.

@brendanjryan brendanjryan merged commit 707758c into main Apr 3, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request Apr 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants