Skip to content

Commit

Permalink
#2001 - Added signature generation via hmac-sha256 (#2025)
Browse files Browse the repository at this point in the history
  • Loading branch information
k-macmillan authored Oct 1, 2024
1 parent 9b61bee commit af32073
Show file tree
Hide file tree
Showing 6 changed files with 169 additions and 39 deletions.
60 changes: 32 additions & 28 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
fileignoreconfig:
- filename: README.md
checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a
- filename: app/celery/contact_information_tasks.py
checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae
- filename: app/notifications/process_notifications.py
checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7
- filename: app/service/rest.py
checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23
- filename: app/template/rest.py
checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b
- filename: app/va/va_profile/va_profile_client.py
checksum: 6f4a0f7b8bb1fee23ae53cbcc8de6f23ca51a341a087e4910fde07ab599e5fde
- filename: ci/docker-compose-test.yml
checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424
- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py
checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037
- filename: lambda_functions/ses_callback/ses_callback_lambda.py
checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b
- filename: poetry.lock
checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112
- filename: scripts/trigger_task.py
checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778
- filename: tests/app/celery/test_process_ga4_measurement_task.py
checksum: d33a6911258922f4bd3d149c90c2ee16c021a8e59e462594e4b1cd972902d689
- filename: tests/app/conftest.py
checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d
- filename: tests/app/notifications/test_process_notifications_for_profile_v3.py
checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065
- filename: README.md
checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a
- filename: app/celery/contact_information_tasks.py
checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae
- filename: app/notifications/process_notifications.py
checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7
- filename: app/service/rest.py
checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23
- filename: app/template/rest.py
checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b
- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py
checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037
- filename: lambda_functions/ses_callback/ses_callback_lambda.py
checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b
- filename: poetry.lock
checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112
- filename: scripts/trigger_task.py
checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778
- filename: tests/app/celery/test_process_ga4_measurement_task.py
checksum: d33a6911258922f4bd3d149c90c2ee16c021a8e59e462594e4b1cd972902d689
- filename: tests/app/conftest.py
checksum: a80aa727586db82ed1b50bdb81ddfe1379e649a9dfc1ece2c36047486b41b83d
- filename: tests/app/notifications/test_process_notifications_for_profile_v3.py
checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065
- filename: tests/app/callback/test_webhook_callback_strategy.py
checksum: 288841d3209dc3ca885cd0bb08591221f7f15e5b3406fb7140505096db212554
- filename: app/callback/webhook_callback_strategy.py
checksum: 47846ab651c27512d3ac7864c08cb25d647f63bb84321953f907551fd9d2e85f
- filename: app/dao/api_key_dao.py
checksum: ab93313f306c8a3f6576141e8f32d9fc99b0de7da8d44a1ddbe6ea55d167dcdb
- filename: ci/docker-compose-test.yml
checksum: e3efec2749e8c19e60f5bfc68eafabe24eba647530a482ceccfc4e0e62cff424
version: "1.0"
36 changes: 31 additions & 5 deletions app/callback/webhook_callback_strategy.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from app.callback.service_callback_strategy_interface import ServiceCallbackStrategyInterface

import hashlib
import json
from hmac import HMAC
from urllib.parse import urlencode
from uuid import UUID

from flask import current_app

from requests.api import request
from requests.exceptions import RequestException, HTTPError
from requests.exceptions import HTTPError, RequestException

from app import statsd_client
from app.celery.exceptions import RetryableException, NonRetryableException
from app.callback.service_callback_strategy_interface import ServiceCallbackStrategyInterface
from app.celery.exceptions import NonRetryableException, RetryableException
from app.dao.api_key_dao import get_unsigned_secret
from app.models import ServiceCallback


Expand Down Expand Up @@ -43,3 +46,26 @@ def send_callback(
raise NonRetryableException(e)
else:
statsd_client.incr(f'callback.webhook.{callback.callback_type}.success')


def generate_callback_signature(
api_key_id: UUID,
callback_params: dict[str, str],
) -> str:
"""Generate a signature based on key and params
Args:
api_key_id (UUID): ID of the key to generate the signature
callback_params (dict[str, str]): Parameters being sent to the client
Returns:
str: The signature for this callback
"""
signature = HMAC(
get_unsigned_secret(api_key_id).encode(),
urlencode(callback_params).encode(),
digestmod=hashlib.sha256,
).hexdigest()

current_app.logger.debug('Generated signature: %s with params: %s', signature, callback_params)
return signature
30 changes: 30 additions & 0 deletions app/celery/service_callback_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,36 @@ def create_delivery_status_callback_data(
return encryption.encrypt(data)


def create_delivery_status_callback_data_v3(notification: Notification) -> dict[str, str]:
"""Create all data that will be sent to a callback url specified in the Notification object.
Args:
notification (Notification): Notification object
Returns:
dict[str, str]: Data for callbacks
"""

from app import DATETIME_FORMAT # Circular import

data = {
'id': str(notification.id),
'reference': notification.client_reference,
'to': notification.to,
'status': notification.status,
'created_at': notification.created_at.strftime(DATETIME_FORMAT),
'completed_at': notification.updated_at.strftime(DATETIME_FORMAT) if notification.updated_at else None,
'sent_at': notification.sent_at.strftime(DATETIME_FORMAT) if notification.sent_at else None,
'notification_type': notification.notification_type,
'status_reason': notification.status_reason,
'provider': notification.sent_by,
'provider_payload': None,
}

current_app.logger.debug('Created notification callback data: %s', data)
return data


def create_complaint_callback_data(
complaint,
notification,
Expand Down
11 changes: 8 additions & 3 deletions app/dao/api_key_dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ def get_unsigned_secrets(service_id):
return keys


def get_unsigned_secret(key_id):
"""
This method can only be exposed to the Authentication of the api calls.
def get_unsigned_secret(key_id: uuid.UUID) -> str:
"""Retrieve the secret for a given key.
Args:
key_id (uuid.UUID): The id related to the secret being looked up
Returns:
str: The secret
"""
stmt = select(ApiKey).where(ApiKey.id == key_id, ApiKey.expiry_date.is_(None))
api_key = db.session.scalars(stmt).one()
Expand Down
51 changes: 48 additions & 3 deletions tests/app/callback/test_webhook_callback_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@
import requests_mock
from requests import RequestException

from app.callback.webhook_callback_strategy import WebhookCallbackStrategy
from app.celery.exceptions import RetryableException, NonRetryableException
from app.models import ServiceCallback
from app.callback.webhook_callback_strategy import WebhookCallbackStrategy, generate_callback_signature
from app.celery.exceptions import NonRetryableException, RetryableException
from app.models import ApiKey, ServiceCallback


@pytest.fixture
def sample_callback_data_v3():
return {
'notification_id': '342d2432-6a79-4e18-afef-8c254751969b',
'reference': 'some client reference',
'to': '+16502532222',
'status': 'created',
'created_at': '2024-10-01T00:00:00.000000Z',
'updated_at': None,
'sent_at': None,
'notification_type': 'sms',
'provider': 'pinpoint',
'status_reason': None,
'provider_payload': None,
}


@pytest.fixture
Expand Down Expand Up @@ -111,3 +128,31 @@ def test_send_callback_increments_statsd_client_with_non_retryable_error_for_sta
)

mock_statsd_client.incr.assert_called_with(f'callback.webhook.{mock_callback.callback_type}.non_retryable_error')


def test_generate_callback_signature(
sample_callback_data_v3,
sample_api_key,
mocker,
) -> None:
mocker.patch(
'app.callback.webhook_callback_strategy.get_unsigned_secret',
return_value='test_generate_callback_signature',
)
api_key: ApiKey = sample_api_key()

signature = generate_callback_signature(
api_key.id,
sample_callback_data_v3,
)
assert signature == '18689cf9fb9c6a9dc1e0840245d48c666d97499d3894deb0e4cf3a5ba82f3d6e'


def test_callback_signature_length(
sample_api_key,
) -> None:
signature = generate_callback_signature(
sample_api_key().id,
{'data': 'test'},
)
assert len(signature) == 64 # Expected length from HMAC-SHA256
20 changes: 20 additions & 0 deletions tests/app/celery/test_service_callback_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
publish_complaint,
send_inbound_sms_to_service,
create_delivery_status_callback_data,
create_delivery_status_callback_data_v3,
)

from app.config import QueueNames
Expand Down Expand Up @@ -536,3 +537,22 @@ def test_create_delivery_status_callback_data(
]
else:
assert 'provider_payload' not in decrypted_message


def test_create_delivery_status_callback_data_v3(
sample_notification,
):
notification: Notification = sample_notification()
data = create_delivery_status_callback_data_v3(notification)

assert data['id'] == str(notification.id)
assert data['reference'] == notification.client_reference
assert data['to'] == notification.to
assert data['status'] == notification.status
assert data['created_at'] == notification.created_at.strftime(DATETIME_FORMAT)
assert data['completed_at'] is None
assert data['sent_at'] is None
assert data['notification_type'] == notification.notification_type
assert data['status_reason'] == notification.status_reason
assert data['provider'] == notification.sent_by
assert data['provider_payload'] is None

0 comments on commit af32073

Please sign in to comment.