Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/tall-pigs-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: patch
---

Fixed fail-closed expiry enforcement in `ChargeIntent.verify`: requests with a missing `expires` challenge parameter are now rejected instead of silently allowed through.
8 changes: 2 additions & 6 deletions src/mpp/methods/tempo/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,7 @@ async def _build_tempo_transfer(
# (smart wallet), not the access key address.
nonce_address = self.root_account if self.root_account else self.account.address

chain_id, on_chain_nonce, gas_price = await get_tx_params(
resolved_rpc, nonce_address
)
chain_id, on_chain_nonce, gas_price = await get_tx_params(resolved_rpc, nonce_address)

if expected_chain_id is not None and chain_id != expected_chain_id:
raise TransactionError(
Expand All @@ -238,9 +236,7 @@ async def _build_tempo_transfer(

gas_limit = DEFAULT_GAS_LIMIT
try:
estimated = await estimate_gas(
resolved_rpc, nonce_address, currency, transfer_data
)
estimated = await estimate_gas(resolved_rpc, nonce_address, currency, transfer_data)
gas_limit = max(gas_limit, estimated + 5_000)
except Exception:
pass
Expand Down
11 changes: 6 additions & 5 deletions src/mpp/methods/tempo/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,13 @@ async def verify(
req = ChargeRequest.model_validate(request)

# Expiry is conveyed via the challenge-level expires auth-param,
# not inside the request body.
# not inside the request body. Fail closed: reject if missing.
challenge_expires = credential.challenge.expires
if challenge_expires:
expires = datetime.fromisoformat(challenge_expires.replace("Z", "+00:00"))
if expires < datetime.now(UTC):
raise VerificationError("Request has expired")
if not challenge_expires:
raise VerificationError("Request has expired (no expires)")
expires = datetime.fromisoformat(challenge_expires.replace("Z", "+00:00"))
if expires < datetime.now(UTC):
raise VerificationError("Request has expired")

payload_data = credential.payload
if not isinstance(payload_data, dict) or "type" not in payload_data:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_tempo.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ async def test_verify_expired_request(self) -> None:
},
)

@pytest.mark.asyncio
async def test_verify_missing_expires_rejected(self) -> None:
"""Should reject credentials with no expires (fail-closed)."""
intent = ChargeIntent(rpc_url="https://rpc.test")
credential = make_credential(payload={"type": "hash", "hash": "0x123"}, expires=None)

with pytest.raises(VerificationError, match="no expires"):
await intent.verify(
credential,
{
"amount": "1000",
"currency": "0x123",
"recipient": "0x456",
},
)

@pytest.mark.asyncio
async def test_verify_invalid_payload(self) -> None:
"""Should reject invalid credential payload."""
Expand Down Expand Up @@ -1423,6 +1439,7 @@ async def test_access_key_builds_keychain_signature(self, httpx_mock: HTTPXMock)

assert credential.payload["type"] == "transaction"
# source should be the root account, not the access key
assert credential.source is not None
assert root.lower() in credential.source.lower()
assert access_key.address.lower() not in credential.source.lower()

Expand Down Expand Up @@ -1462,6 +1479,7 @@ async def test_access_key_with_fee_payer(self, httpx_mock: HTTPXMock) -> None:

assert credential.payload["type"] == "transaction"
assert credential.payload["signature"].startswith("0x78")
assert credential.source is not None
assert root.lower() in credential.source.lower()

@pytest.mark.asyncio
Expand Down Expand Up @@ -1496,6 +1514,7 @@ async def test_no_root_account_uses_regular_signing(self, httpx_mock: HTTPXMock)
credential = await method.create_credential(challenge)

assert credential.payload["type"] == "transaction"
assert credential.source is not None
assert account.address in credential.source


Expand Down
Loading