diff --git a/.changelog/tall-pigs-sing.md b/.changelog/tall-pigs-sing.md new file mode 100644 index 0000000..1e2432f --- /dev/null +++ b/.changelog/tall-pigs-sing.md @@ -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. diff --git a/src/mpp/methods/tempo/client.py b/src/mpp/methods/tempo/client.py index 2562baa..4f14cbc 100644 --- a/src/mpp/methods/tempo/client.py +++ b/src/mpp/methods/tempo/client.py @@ -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( @@ -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 diff --git a/src/mpp/methods/tempo/intents.py b/src/mpp/methods/tempo/intents.py index 8d582ab..57cb111 100644 --- a/src/mpp/methods/tempo/intents.py +++ b/src/mpp/methods/tempo/intents.py @@ -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: diff --git a/tests/test_tempo.py b/tests/test_tempo.py index 479c602..492c9b9 100644 --- a/tests/test_tempo.py +++ b/tests/test_tempo.py @@ -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.""" @@ -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() @@ -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 @@ -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