diff --git a/src/shade/__init__.py b/src/shade/__init__.py index eab2a06..d766de8 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -14,6 +14,7 @@ HTTPError, RateLimitError, ShadeError, + SignatureVerificationError, ) from .models import Merchant, ShadeObject @@ -35,6 +36,7 @@ "RateLimitError", "ShadeClient", "ShadeError", + "SignatureVerificationError", "ShadeObject", "SyncHTTPClient", "config", diff --git a/src/shade/errors.py b/src/shade/errors.py index 4805f9c..c9feff5 100644 --- a/src/shade/errors.py +++ b/src/shade/errors.py @@ -163,6 +163,40 @@ def from_response( return cls(message, status_code=404, response_body=response_body) +class SignatureVerificationError(ShadeError): + """ + Raised by ``Webhook.construct_event()`` when the HMAC-SHA256 signature in + the ``Shade-Signature`` header does not match the signature computed for + the payload. + + Attributes: + header: The raw ``Shade-Signature`` header value as received. + """ + + def __init__( + self, + message: str, + header: Optional[str] = None, + ) -> None: + super().__init__(message) + self.header = header + + @classmethod + def from_mismatch(cls, header: Optional[str] = None) -> "SignatureVerificationError": + """Construct the error for a computed/received signature mismatch. + + The expected signature is deliberately not accepted as an argument + here, so it can never end up in the exception message. + """ + message = ( + "Webhook signature verification failed: the received signature " + "does not match the signature computed for this payload. This " + "usually means the webhook secret is incorrect, or the payload " + "was modified in transit." + ) + return cls(message, header=header) + + class NetworkError(ShadeError): """Raised when the SDK cannot complete a network request.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 2155fef..2bbe956 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -7,6 +7,7 @@ NotFoundError, RateLimitError, ShadeError, + SignatureVerificationError, ) from shade.errors import raise_for_invalid_request @@ -69,6 +70,7 @@ def test_package_root_exports_error_classes(): assert shade.NetworkError is NetworkError assert shade.NotFoundError is NotFoundError assert shade.RateLimitError is RateLimitError + assert shade.SignatureVerificationError is SignatureVerificationError def test_invalid_request_error_parses_param_from_body(): @@ -211,3 +213,40 @@ def test_not_found_error_invalid_json_body(): assert error.resource_type is None assert error.resource_id is None + + +def test_signature_verification_error_is_shade_error(): + error = SignatureVerificationError("bad signature", header="t=1,v1=abc") + + assert isinstance(error, ShadeError) + assert str(error) == "bad signature" + assert error.header == "t=1,v1=abc" + + +def test_signature_verification_error_header_optional(): + error = SignatureVerificationError("bad signature") + + assert error.header is None + + +def test_signature_verification_error_from_mismatch_stores_header(): + error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef") + + assert isinstance(error, ShadeError) + assert error.header == "t=1,v1=deadbeef" + + +def test_signature_verification_error_from_mismatch_explains_causes(): + error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef") + + message = str(error) + assert "secret" in message + assert "payload" in message + + +def test_signature_verification_error_never_includes_expected_signature(): + expected_signature = "supersecrethmacvalue" + error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef") + + assert expected_signature not in str(error) + assert not hasattr(error, "expected_signature")