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
30 changes: 22 additions & 8 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ jobs:
- name: Install package
run: |
python -m pip install --upgrade pip
# Pre-generation the repo has no installable package yet; the verify
# tests import from src/ directly (PYTHONPATH below).
pip install -e . || echo "editable install unavailable (pre-generation); continuing"
# pyproject.toml arrives with the first Speakeasy generation. Once
# it exists, installation must succeed — a packaging regression
# (e.g. wheel omitting webhook verify) has to fail CI, not hide.
if [ -f pyproject.toml ]; then
pip install -e .
else
echo "pre-generation: no installable package yet; tests import from src/"
fi
pip install pytest

- name: Verify hand-written modules are present
Expand All @@ -32,9 +37,18 @@ jobs:
test -f test/signature-vectors.json
test -f test/test_shared_vectors.py

- name: Verify installed package exposes webhook verify
if: hashFiles('pyproject.toml') != ''
# No PYTHONPATH: this must resolve from the installed distribution.
run: python -c "from convoy.utils.webhook import Webhook"

- name: Execute verify + shared vector tests
env:
# PEP 420 namespace packages: `convoy.utils.webhook` resolves from
# src/ before generation adds real __init__.py files.
PYTHONPATH: src
run: pytest test/test_webhook.py test/test_shared_vectors.py -q
run: |
if [ -f pyproject.toml ]; then
# Import from the installed distribution so packaging bugs surface.
pytest test/test_webhook.py test/test_shared_vectors.py -q
else
# PEP 420 namespace packages: resolve convoy.utils.webhook from
# src/ before generation adds real __init__.py files.
PYTHONPATH=src pytest test/test_webhook.py test/test_shared_vectors.py -q
fi
34 changes: 0 additions & 34 deletions setup.py

This file was deleted.

18 changes: 12 additions & 6 deletions src/convoy/utils/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json

class InvalidTimestampError(Exception):
def __init__(self, *args: list) -> None:
def __init__(self, *args: str) -> None:
self.message = args[0]

@property
Expand All @@ -14,7 +14,7 @@ def response(self):


class InvalidSignature(Exception):
def __init__(self, *args: list) -> None:
def __init__(self, *args: str) -> None:
self.message = args[0]

@property
Expand Down Expand Up @@ -59,9 +59,9 @@ def compare_hashes(self, hash1: str, hash2: str) -> bool:
try:
decoded1 = base64.b64decode(hash1)
decoded2 = base64.b64decode(hash2)
except (ValueError, TypeError):
except (ValueError, TypeError) as exc:
# A malformed signature is a mismatch, not a crash.
raise InvalidSignature("Invalid signature.")
raise InvalidSignature("Invalid signature.") from exc
valid = hmac.compare_digest(decoded1, decoded2)
if valid is False:
raise InvalidSignature("Invalid signature.")
Expand Down Expand Up @@ -100,6 +100,9 @@ def create_signature(self, payload: str) -> str:
if self.encoding == "base64":
sig = hmac.new(bytes(self.secret, "utf-8"), msg=bytes(encoded_payload, "utf-8"), digestmod=self.hash).digest()
return base64.b64encode(sig).decode()

# Fail closed instead of implicitly returning None.
raise InvalidSignature("Invalid encoding.")

def create_advanced_signature(self, payload, timestamp=None) -> str:
"""Create signature for advanced webhooks (timestamp + payload) like Convoy does"""
Expand All @@ -119,15 +122,18 @@ def create_advanced_signature(self, payload, timestamp=None) -> str:
if self.encoding == "base64":
sig = hmac.new(bytes(self.secret, "utf-8"), msg=bytes(signed_payload, "utf-8"), digestmod=self.hash).digest()
return base64.b64encode(sig).decode()

# Fail closed instead of implicitly returning None.
raise InvalidSignature("Invalid encoding.")

def get_timestamp_and_signatures(self, signature):
pairs = [sig.split("=", 1) for sig in signature.split(",")]

timestamp_pair = next((p for p in pairs if p[0].strip() == "t"), None)
try:
timestamp_int = int(timestamp_pair[1])
except (TypeError, ValueError):
raise InvalidTimestampError("Invalid timestamp format")
except (TypeError, ValueError) as exc:
raise InvalidTimestampError("Invalid timestamp format") from exc

timestamp = datetime.fromtimestamp(timestamp_int)
signatures = [p[1] for p in pairs if p[0].strip() != "t" and len(p) == 2]
Expand Down