Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#2001 - Added signature generation via hmac-sha256 #2025

Merged
merged 5 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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]:
"""Encrypt and return the delivery status message.

Args:
notification (Notification): Notification object

Returns:
dict[str, str]: Data for callbacks
"""

from app import DATETIME_FORMAT # Circular import

data = {
'notification_id': str(notification.id),
'reference': notification.client_reference,
'to': notification.to,
'status': notification.status,
'created_at': notification.created_at.strftime(DATETIME_FORMAT),
'updated_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,
'callback_url': notification.callback_url,
'provider': notification.sent_by,
'status_reason': notification.status_reason,
'provider_payload': None,
}

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
23 changes: 23 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,25 @@ 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()
with pytest.raises(AttributeError):
# Remove with statement and reduce indent when callback_url is implemented
data = create_delivery_status_callback_data_v3(notification)

assert data['notification_id'] == 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['updated_at'] is None
assert data['sent_at'] is None
assert data['notification_type'] == notification.notification_type
assert data['callback_url'] == notification.callback_url
assert data['provider'] == notification.sent_by
assert data['status_reason'] == notification.status_reason
assert data['provider_payload'] is None