diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c19d7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# https://dart.dev/guides/libraries/private-files + +# Don't commit the following files and directories created by pub +.dart_tool/ +build/ + +# Don't commit the API documentation directory created by dart doc +doc/api/ + +# Don't commit files and directories created by other development environments +## IntelliJ +*.iml +*.ipr +*.iws +.idea/ + +## Mac +.DS_Store diff --git a/README.md b/README.md index ac65080..5e9689f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ ## Getting started -To use this SDK, you first need login into to [blockfrost.io](https://blockfrost.io) create your project to retrive your API token. +To use this SDK, you first need login into to [blockfrost.io](https://blockfrost.io) create your +project to retrieve your API token. @@ -31,29 +32,33 @@ Using the SDK is pretty straight-forward as you can see from the following examp ```dart import 'package:blockfrost_api/blockfrost_api.dart'; -void main() async { - - String projectId = ""; - - String out; - - try - { - BlockService service = BlockService(Service.networkCardanoMainnet, projectId); - - BlockContent block = await service.getLatestBlock(); - - out = block.hash; - } - - catch(e) - { - out = e.toString(); - } - - print(out); +void main() async { + String projectId = ""; + String out; + + try { + BlockService service = BlockService(Service.networkCardanoMainnet, projectId); + + BlockContent block = await service.getLatestBlock(); + + out = block.hash; + } + + catch (e) { + out = e.toString(); + } + + print(out); } ``` +# How to run the unit tests? + +## Note: Replace the placeholders by values from https://blockfrost.io/dashboard + +``` +PROJECT_ID_MAINNET="[YOUR_PROJECT_ID_MAINNET_HERE]" PROJECT_ID_IPFS="YOUR_PROJECT_IPFS_ID" dart run test test/ +``` + diff --git a/lib/blockfrost_api.dart b/lib/blockfrost_api.dart index 1e398ec..79fcb4b 100644 --- a/lib/blockfrost_api.dart +++ b/lib/blockfrost_api.dart @@ -15,5 +15,4 @@ export 'src/scripts_service.dart'; export 'src/transactions_service.dart'; export 'src/utilities_service.dart'; export 'src/ipfs_service.dart'; - - +export 'src/utils/utils_exports.dart'; diff --git a/lib/src/utils/blockfrost_signature_validator.dart b/lib/src/utils/blockfrost_signature_validator.dart new file mode 100644 index 0000000..4968764 --- /dev/null +++ b/lib/src/utils/blockfrost_signature_validator.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:blockfrost_api/src/utils/signature_validation_exception.dart'; +import 'package:blockfrost_api/src/utils/signature_validator.dart'; +import 'package:crypto/crypto.dart'; + +/// Adapter class which implements the validator interface to validate the blockfrost webhook signature. +class BlockfrostSignatureValidator implements SignatureValidator { + @override + bool validate({ + required String requestPayload, + required String signatureHeader, + required String secretAuthToken, + int maxToleranceSeconds = defaultMaxToleranceSeconds, + }) { + return _validateSignature( + requestPayload: requestPayload, + signatureHeader: signatureHeader, + secretAuthToken: secretAuthToken, + maxToleranceSeconds: maxToleranceSeconds, + ); + } +} + +bool _validateSignature({ + required String requestPayload, + required String signatureHeader, + required String secretAuthToken, + required int maxToleranceSeconds, + int? currentUnixTime, +}) { + // Parse the timestamp and signature from the header + String? timestampString; + List providedSignatures = []; + + final parts = signatureHeader.split(','); + for (final part in parts) { + final pair = part.trim().split('='); + if (pair.length == 2) { + final key = pair[0]; + final value = pair[1]; + if (key == 't') { + timestampString = value; + } else if (key == 'v1') { + providedSignatures.add(value); + } + } + } + + if (timestampString == null || providedSignatures.isEmpty) { + throw SignatureValidationException( + "Invalid signature header format.", + header: signatureHeader, + payload: requestPayload, + ); + } + + final int timestamp; + try { + timestamp = int.parse(timestampString); + } catch (e) { + throw SignatureValidationException( + "Invalid timestamp format.", + header: signatureHeader, + payload: requestPayload, + ); + } + + // Prepare the signature_payload (timestamp.payload) + final signaturePayload = '$timestampString.$requestPayload'; + + // Compute the expected signature (HMAC-SHA256) + final key = utf8.encode(secretAuthToken); + final messageBytes = utf8.encode(signaturePayload); + + final hmac = Hmac(sha256, key); + final digest = hmac.convert(messageBytes); + final expectedSignature = digest.toString(); + + // Check for matching signature + bool signatureMatch = + providedSignatures.any((sig) => sig == expectedSignature); + + if (!signatureMatch) { + throw SignatureValidationException( + "No signature matches the expected signature for the payload.", + header: signatureHeader, + payload: requestPayload, + ); + } + + // Check timestamp tolerance (prevent replay attacks) + // Note: currentUnixTime can be injected for testing purposes + final currentTimestamp = + currentUnixTime ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); + final timeDifference = (currentTimestamp - timestamp).abs(); + if (timeDifference > maxToleranceSeconds) { + throw SignatureValidationException( + "Signature's timestamp is outside of the time tolerance.", + header: signatureHeader, + payload: requestPayload, + ); + } + return true; +} + +/// TESTING ACCESSOR: used only by unit test to access the private method +class TestBlockfrostValidatorAccessor { + bool callValidateSignature({ + required String requestPayload, + required String signatureHeader, + required String secretAuthToken, + required int currentUnixTime, + int maxToleranceSeconds = defaultMaxToleranceSeconds, + }) { + return _validateSignature( + requestPayload: requestPayload, + signatureHeader: signatureHeader, + secretAuthToken: secretAuthToken, + currentUnixTime: currentUnixTime, + maxToleranceSeconds: maxToleranceSeconds, + ); + } +} diff --git a/lib/src/utils/signature_validation_exception.dart b/lib/src/utils/signature_validation_exception.dart new file mode 100644 index 0000000..6a58452 --- /dev/null +++ b/lib/src/utils/signature_validation_exception.dart @@ -0,0 +1,18 @@ +class SignatureValidationException implements Exception { + final String message; + final String header; + final String payload; + + SignatureValidationException( + this.message, { + required this.header, + required this.payload, + }); + + @override + String toString() { + return 'SignatureValidationException: $message\n' + 'Header: $header\n' + 'Payload: $payload'; + } +} diff --git a/lib/src/utils/signature_validator.dart b/lib/src/utils/signature_validator.dart new file mode 100644 index 0000000..17c028a --- /dev/null +++ b/lib/src/utils/signature_validator.dart @@ -0,0 +1,11 @@ +// The interface for the signature validation +const int defaultMaxToleranceSeconds = 600; + +abstract class SignatureValidator { + bool validate({ + required String requestPayload, + required String signatureHeader, + required String secretAuthToken, + int maxToleranceSeconds, + }); +} diff --git a/lib/src/utils/utils_exports.dart b/lib/src/utils/utils_exports.dart new file mode 100644 index 0000000..e2f36cc --- /dev/null +++ b/lib/src/utils/utils_exports.dart @@ -0,0 +1,4 @@ +// Export validator files +export 'blockfrost_signature_validator.dart'; +export 'signature_validation_exception.dart'; +export 'signature_validator.dart'; diff --git a/pubspec.lock b/pubspec.lock index 6e4c143..7ef4d24 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,330 +5,409 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - url: "https://pub.dartlang.org" + sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a + url: "https://pub.dev" source: hosted - version: "47.0.0" + version: "88.0.0" analyzer: dependency: transitive description: name: analyzer - url: "https://pub.dartlang.org" + sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" + url: "https://pub.dev" source: hosted - version: "4.7.0" + version: "8.1.1" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.7.0" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.19.1" convert: dependency: transitive description: name: convert - url: "https://pub.dartlang.org" + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.2" coverage: dependency: transitive description: name: coverage - url: "https://pub.dartlang.org" + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.15.0" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto - url: "https://pub.dartlang.org" + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.6" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.1" frontend_server_client: dependency: transitive description: name: frontend_server_client - url: "https://pub.dartlang.org" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" glob: dependency: transitive description: name: glob - url: "https://pub.dartlang.org" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.3" http: dependency: "direct main" description: name: http - url: "https://pub.dartlang.org" + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" source: hosted - version: "0.13.5" + version: "0.13.6" http_multi_server: dependency: transitive description: name: http_multi_server - url: "https://pub.dartlang.org" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.1.2" io: dependency: transitive description: name: io - url: "https://pub.dartlang.org" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.5" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.7.2" lints: dependency: "direct dev" description: name: lints - url: "https://pub.dartlang.org" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "5.1.1" logging: dependency: transitive description: name: logging - url: "https://pub.dartlang.org" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.3.0" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" source: hosted - version: "0.12.12" + version: "0.12.17" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.17.0" mime: dependency: transitive description: name: mime - url: "https://pub.dartlang.org" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.4" node_preamble: dependency: transitive description: name: node_preamble - url: "https://pub.dartlang.org" + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" package_config: dependency: transitive description: name: package_config - url: "https://pub.dartlang.org" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.8.2" + version: "1.9.1" pool: dependency: transitive description: name: pool - url: "https://pub.dartlang.org" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" pub_semver: dependency: transitive description: name: pub_semver - url: "https://pub.dartlang.org" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" shelf: dependency: transitive description: name: shelf - url: "https://pub.dartlang.org" + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler - url: "https://pub.dartlang.org" + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" shelf_static: dependency: transitive description: name: shelf_static - url: "https://pub.dartlang.org" + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - url: "https://pub.dartlang.org" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "3.0.0" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - url: "https://pub.dartlang.org" + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" source_maps: dependency: transitive description: name: source_maps - url: "https://pub.dartlang.org" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" source: hosted - version: "0.10.10" + version: "0.10.13" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: "direct dev" description: name: test - url: "https://pub.dartlang.org" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" source: hosted - version: "1.21.5" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" source: hosted - version: "0.4.13" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - url: "https://pub.dartlang.org" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" source: hosted - version: "0.4.17" + version: "0.6.12" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" vm_service: dependency: transitive description: name: vm_service - url: "https://pub.dartlang.org" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" source: hosted - version: "9.4.0" + version: "15.0.2" watcher: dependency: transitive description: name: watcher - url: "https://pub.dartlang.org" + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" source: hosted version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - url: "https://pub.dartlang.org" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.3" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol - url: "https://pub.dartlang.org" + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" yaml: dependency: transitive description: name: yaml - url: "https://pub.dartlang.org" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" sdks: - dart: ">=2.18.0 <3.0.0" + dart: ">=3.7.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 59d87f1..d49be49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,16 +4,16 @@ version: 1.0.0 # homepage: https://www.example.com environment: - sdk: '>=2.18.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' -# dependencies: -# path: ^1.8.0 - -dev_dependencies: - lints: ^2.0.0 - test: ^1.16.0 dependencies: http: ^0.13.5 + crypto: ^3.0.3 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 + mocktail: ^1.0.0 \ No newline at end of file diff --git a/test/utils/blockfrost_signature_validator_test.dart b/test/utils/blockfrost_signature_validator_test.dart new file mode 100644 index 0000000..5ffc1e7 --- /dev/null +++ b/test/utils/blockfrost_signature_validator_test.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; + +import 'package:blockfrost_api/blockfrost_api.dart'; +import 'package:crypto/crypto.dart'; +import 'package:test/test.dart'; + +const maxToleranceSeconds = 600; + +// --- Test Constants --- +const testSecret = 'TEST_SECRET'; +const testPayload = '{"event":"test_event", "data": "content"}'; + +// Helper function to generate a valid signature for tests +String generateSignature(int timestamp, String payload, String secret) { + final signaturePayload = '$timestamp.$payload'; + final key = utf8.encode(secret); + final messageBytes = utf8.encode(signaturePayload); + + final hmac = Hmac(sha256, key); + final digest = hmac.convert(messageBytes); + return digest.toString(); +} + +void main() { + group('Blockfrost Webhook Signature Validation', () { + final int standardTimestamp = 1759917705; + final validator = TestBlockfrostValidatorAccessor(); + + // Generate a valid signature string for the standard time and payload + final String validSignature = generateSignature( + standardTimestamp, + testPayload, + testSecret, + ); + + // Signature header + final String validHeader = 't=$standardTimestamp,v1=$validSignature'; + + // --- SUCCESS CASE --- + test('Should return true for a valid, recent signature', () { + final result = validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: validHeader, + secretAuthToken: testSecret, + currentUnixTime: standardTimestamp, + ); + expect(result, isTrue); + }); + + // --- FAILURE CASES --- + test( + 'Should throw SignatureValidationException if signatureHeader is malformed (missing t or v1)', + () { + // Case 1: Missing timestamp + expect( + () => validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: 'v1=$validSignature', + secretAuthToken: testSecret, + currentUnixTime: standardTimestamp, + ), + throwsA( + predicate( + (e) => + e is SignatureValidationException && + e.message.contains('Invalid signature header format.'), + 'SignatureValidationException with correct message', + ), + ), + reason: 'Should throw exception for malformed header', + ); + }); + + test( + 'Should throw SignatureValidationException if timestamp format is invalid', + () { + final invalidHeader = 't=mywrongtimestamp,v1=$validSignature'; + expect( + () => validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: invalidHeader, + secretAuthToken: testSecret, + currentUnixTime: standardTimestamp, + ), + throwsA( + predicate( + (e) => + e is SignatureValidationException && + e.message.contains('Invalid timestamp format.'), + 'SignatureValidationException with correct message', + ), + ), + reason: 'Should throw exception for invalid timestamp'); + }); + + test( + 'Should throw SignatureValidationException if the signature does not match', + () { + // 1. Wrong Secret (Use a different secret to calculate the expected signature) + final badSignature = generateSignature( + standardTimestamp, + testPayload, + 'my_wrong_secret', + ); + final badHeader = 't=$standardTimestamp,v1=$badSignature'; + + // Validate using the correct secret + expect( + () => validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: badHeader, + secretAuthToken: testSecret, + currentUnixTime: standardTimestamp, + ), + throwsA( + predicate( + (e) => + e is SignatureValidationException && + e.message.contains( + 'No signature matches the expected signature for the payload.'), + 'SignatureValidationException with correct message', + ), + ), + reason: 'Should throw exception for not matching signatures'); + }); + + test( + 'Should throw SignatureValidationException if the timestamp is too far in the future', + () { + final farFutureTime = standardTimestamp + maxToleranceSeconds + 1; + print("$farFutureTime"); + + expect( + () => validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: validHeader, + secretAuthToken: testSecret, + currentUnixTime: + farFutureTime, // Inject current time far in the future + ), + throwsA( + predicate( + (e) => + e is SignatureValidationException && + e.message.contains( + 'Signature\'s timestamp is outside of the time tolerance.'), + 'SignatureValidationException with correct message', + ), + ), + reason: 'Should throw exception for not matching signatures'); + }); + + test( + 'Should throw SignatureValidationException if the timestamp is too old (replay attack)', + () { + final farPastTime = standardTimestamp - maxToleranceSeconds - 1; + print("$farPastTime"); + expect( + () => validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: validHeader, + secretAuthToken: testSecret, + currentUnixTime: farPastTime, + ), + throwsA( + predicate( + (e) => + e is SignatureValidationException && + e.message.contains( + 'Signature\'s timestamp is outside of the time tolerance.'), + 'SignatureValidationException with correct message', + ), + ), + reason: 'Should throw exception for not matching signatures'); + }); + + test('Should return true if time difference is exactly the tolerance limit', + () { + final toleranceLimitTime = standardTimestamp + maxToleranceSeconds; + final result = validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: validHeader, + secretAuthToken: testSecret, + currentUnixTime: toleranceLimitTime, + ); + expect(result, isTrue); + }); + + test( + 'Should return true for custom maxToleranceSeconds if time difference is exactly the tolerance limit', + () { + int customMaxToleranceSeconds = 60; + final toleranceLimitTime = standardTimestamp + customMaxToleranceSeconds; + final result = validator.callValidateSignature( + requestPayload: testPayload, + signatureHeader: validHeader, + secretAuthToken: testSecret, + currentUnixTime: toleranceLimitTime, + maxToleranceSeconds: customMaxToleranceSeconds, + ); + expect(result, isTrue); + }); + }); +}