-
Notifications
You must be signed in to change notification settings - Fork 12
fix: bind Tempo attribution memos to challenge IDs #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
43284c6
1ac475a
2172e92
23342b4
01acd67
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| pympp: patch | ||
| --- | ||
|
|
||
| Fixed Tempo attribution memos to be deterministically bound to challenge IDs using a keccak256-derived nonce instead of random bytes. Added `verify_challenge_binding` to enforce that verified payments carry a memo matching the specific challenge being redeemed. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,9 @@ | |
|
|
||
| import asyncio | ||
| import time | ||
| from dataclasses import dataclass | ||
| from datetime import UTC, datetime | ||
| from typing import TYPE_CHECKING, Any | ||
| from typing import TYPE_CHECKING, Any, Literal | ||
|
|
||
| import attrs | ||
|
|
||
|
|
@@ -44,6 +45,12 @@ | |
| ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class MatchedTransferLog: | ||
| kind: Literal["memo", "transfer"] | ||
| memo: str | None = None | ||
|
|
||
|
|
||
| def _rpc_error_msg(result: dict) -> str: | ||
| """Extract error message from a JSON-RPC error response.""" | ||
| error_obj = result["error"] | ||
|
|
@@ -230,21 +237,23 @@ async def verify( | |
| raise VerificationError(f"Invalid credential type: {payload_data['type']}") | ||
|
|
||
| if isinstance(payload, HashCredentialPayload): | ||
| return await self._verify_hash(payload, req) | ||
| return await self._verify_hash( | ||
| payload, | ||
| req, | ||
| challenge_id=credential.challenge.id, | ||
| realm=credential.challenge.realm, | ||
| ) | ||
| else: | ||
| return await self._verify_transaction(payload, req) | ||
|
|
||
| async def _verify_hash( | ||
| self, | ||
| payload: HashCredentialPayload, | ||
| request: ChargeRequest, | ||
| challenge_id: str, | ||
| realm: str, | ||
| ) -> Receipt: | ||
| """Verify a credential with a transaction hash.""" | ||
| if self._store is not None: | ||
| store_key = f"mpp:charge:{payload.hash.lower()}" | ||
| if not await self._store.put_if_absent(store_key, payload.hash): | ||
| raise VerificationError("Transaction hash already used") | ||
|
|
||
| client = await self._get_client() | ||
|
|
||
| rpc_url = self._get_rpc_url() | ||
|
|
@@ -270,19 +279,56 @@ async def _verify_hash( | |
| if receipt_data.get("status") != "0x1": | ||
| raise VerificationError("Transaction reverted") | ||
|
|
||
| if not self._verify_transfer_logs(receipt_data, request): | ||
| matched_logs = self._verify_transfer_logs(receipt_data, request) | ||
| if not matched_logs: | ||
| raise VerificationError( | ||
| "Transaction must contain a Transfer log matching request parameters" | ||
| ) | ||
|
|
||
| # Only verify challenge binding when using auto-generated attribution memos. | ||
| # 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new challenge-binding guard runs only when Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. noted in code / interface -- this is consistent with mppx |
||
| self._assert_challenge_bound_memo( | ||
| matched_logs, | ||
| challenge_id=challenge_id, | ||
| realm=realm, | ||
| ) | ||
|
|
||
| if self._store is not None: | ||
| store_key = f"mpp:charge:{payload.hash.lower()}" | ||
| if not await self._store.put_if_absent(store_key, payload.hash): | ||
| raise VerificationError("Transaction hash already used") | ||
|
|
||
| return Receipt.success(payload.hash) | ||
|
|
||
| def _assert_challenge_bound_memo( | ||
| self, | ||
| matched_logs: list[MatchedTransferLog], | ||
| challenge_id: str, | ||
| realm: str, | ||
| ) -> None: | ||
| from mpp.methods.tempo._attribution import verify_challenge_binding, verify_server | ||
|
|
||
| bound = any( | ||
| matched_log.kind == "memo" | ||
| and matched_log.memo is not None | ||
| and verify_server(matched_log.memo, realm) | ||
| and verify_challenge_binding(matched_log.memo, challenge_id) | ||
| for matched_log in matched_logs | ||
| ) | ||
| if not bound: | ||
| raise VerificationError( | ||
| "Payment verification failed: memo is not bound to this challenge." | ||
| ) | ||
|
|
||
| def _verify_transfer_logs( | ||
| self, | ||
| receipt: dict[str, Any], | ||
| request: ChargeRequest, | ||
| expected_sender: str | None = None, | ||
| ) -> bool: | ||
| ) -> list[MatchedTransferLog]: | ||
| """Check if receipt contains matching Transfer or TransferWithMemo logs. | ||
|
|
||
| Args: | ||
|
|
@@ -292,10 +338,13 @@ def _verify_transfer_logs( | |
| Transfer log matches this address (for payer identity verification). | ||
|
|
||
| Returns: | ||
| True if a matching Transfer/TransferWithMemo log is found, | ||
| False otherwise. | ||
| Matched logs in priority order, with memo logs before plain | ||
| transfers so downstream verification can inspect the memo that | ||
| actually satisfied the payment. | ||
| """ | ||
| expected_memo = request.methodDetails.memo | ||
| memo_matches: list[MatchedTransferLog] = [] | ||
| transfer_matches: list[MatchedTransferLog] = [] | ||
|
|
||
| for log in receipt.get("logs", []): | ||
| if log.get("address", "").lower() != request.currency.lower(): | ||
|
|
@@ -315,9 +364,7 @@ def _verify_transfer_logs( | |
| if expected_sender and from_address.lower() != expected_sender.lower(): | ||
| continue | ||
|
|
||
| if expected_memo: | ||
| if event_topic != TRANSFER_WITH_MEMO_TOPIC: | ||
| continue | ||
| if event_topic == TRANSFER_WITH_MEMO_TOPIC: | ||
| # TransferWithMemo has 3 indexed params (from, to, memo) | ||
| # so memo is in topics[3] and only amount is in data | ||
| if len(topics) < 4: | ||
|
|
@@ -326,22 +373,26 @@ def _verify_transfer_logs( | |
| if len(data) < 66: | ||
| continue | ||
| amount = int(data[2:66], 16) | ||
| memo = topics[3] | ||
| memo_clean = expected_memo.lower() | ||
| if not memo_clean.startswith("0x"): | ||
| memo_clean = "0x" + memo_clean | ||
| if amount == int(request.amount) and memo.lower() == memo_clean: | ||
| return True | ||
| else: | ||
| if event_topic != TRANSFER_TOPIC: | ||
| if amount != int(request.amount): | ||
| continue | ||
| memo = topics[3] | ||
| if expected_memo: | ||
| memo_clean = expected_memo.lower() | ||
| if not memo_clean.startswith("0x"): | ||
| memo_clean = "0x" + memo_clean | ||
| if memo.lower() != memo_clean: | ||
| continue | ||
| memo_matches.append(MatchedTransferLog(kind="memo", memo=memo)) | ||
| continue | ||
|
|
||
| if event_topic == TRANSFER_TOPIC and expected_memo is None: | ||
| data = log.get("data", "0x") | ||
| if len(data) >= 66: | ||
| amount = int(data, 16) | ||
| if amount == int(request.amount): | ||
| return True | ||
| transfer_matches.append(MatchedTransferLog(kind="transfer")) | ||
|
|
||
| return False | ||
| return memo_matches + transfer_matches | ||
|
|
||
| async def _verify_transaction( | ||
| self, | ||
|
|
@@ -449,6 +500,11 @@ async def _verify_transaction( | |
| "Transaction must contain a Transfer log matching request parameters" | ||
| ) | ||
|
|
||
| if self._store is not None: | ||
| store_key = f"mpp:charge:{tx_hash.lower()}" | ||
| if not await self._store.put_if_absent(store_key, tx_hash): | ||
| raise VerificationError("Transaction hash already used") | ||
|
|
||
| return Receipt.success(tx_hash) | ||
|
|
||
| def _cosign_as_fee_payer( | ||
|
|
||
There was a problem hiding this comment.
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 passeschallenge_idandrealm, but theelsebranch on line 247 dispatches to_verify_transaction(payload, req)without them._verify_transaction()consequently:_assert_challenge_bound_memo()— a transaction built for challenge A is accepted under challenge Bput_if_absent— the tx hash can be redeemed again via the hash pathThis allows one on-chain payment to satisfy two different challenges.
Fix: Pass
challenge_id/realmto_verify_transaction()and add both_assert_challenge_bound_memo()andput_if_absentmirroring the hash path.