Skip to content

Commit 5a15d2c

Browse files
authored
Resolve VCSWP-19101 (#35)
Add signing secret verification util classes
1 parent b03a35e commit 5a15d2c

File tree

11 files changed

+278
-6
lines changed

11 files changed

+278
-6
lines changed

.openapi-generator/FILES

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,4 @@ requirements.txt
269269
setup.cfg
270270
setup.py
271271
test/__init__.py
272-
test/test_default_api.py
273272
tox.ini

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
99

1010
None
1111

12+
<a name="4.2.0"></a>
13+
14+
## [4.2.0] - 2023-04-03
15+
16+
### Added
17+
18+
- Introduce signing secret verification class (RequestVerifier) - https://docs.freeclimb.com/docs/validating-requests-from-freeclimb#how-to-verify-requests-manually
19+
1220
<a name="4.1.3"></a>
1321

1422
## [4.1.3] - 2023-03-13

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FreeClimb is a cloud-based application programming interface (API) that puts the
44
This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
55

66
- API version: 1.0.0
7-
- Package version: 4.1.3
7+
- Package version: 4.2.0
88
- Build package: org.openapitools.codegen.languages.PythonClientCodegen
99
For more information, please visit [https://www.freeclimb.com/support/](https://www.freeclimb.com/support/)
1010

@@ -313,7 +313,31 @@ Class | Method | HTTP request | Description
313313

314314
- **Type**: HTTP basic authentication
315315

316+
<a name="documentation-for-verify-request-signature"></a>
316317

318+
## Documentation for verifying request signature
319+
320+
- To verify the request signature, we will need to use the verify_request_signature method within the Request Verifier class
321+
322+
RequestVerifier.verify_request_signature(requestBody, requestHeader, signingSecret, tolerance)
323+
324+
This is a method that you can call directly from the request verifier class, it will throw exceptions depending on whether all parts of the request signature is valid otherwise it will throw a specific error message depending on which request signature part is causing issues
325+
326+
This method requires a requestBody of type string, a requestHeader of type string, a signingSecret of type string, and a tolerance value of type int
327+
328+
Example code down below
329+
330+
```python
331+
from freeclimb.utils.request_verifier import RequestVerifier
332+
333+
class RequestVerifierExample():
334+
def verify_request_signature_example():
335+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
336+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
337+
tolerance = 5 * 60
338+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
339+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
340+
```
317341
## Author
318342

319343

freeclimb/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313

14-
__version__ = "4.1.3"
14+
__version__ = "4.2.0"
1515

1616
# import ApiClient
1717
from freeclimb.api_client import ApiClient

freeclimb/api_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def __init__(self, configuration=None, header_name=None, header_value=None,
7777
self.default_headers[header_name] = header_value
7878
self.cookie = cookie
7979
# Set default User-Agent.
80-
self.user_agent = 'OpenAPI-Generator/4.1.3/python'
80+
self.user_agent = 'OpenAPI-Generator/4.2.0/python'
8181

8282
def __enter__(self):
8383
return self

freeclimb/configuration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def to_debug_report(self):
405405
"OS: {env}\n"\
406406
"Python Version: {pyversion}\n"\
407407
"Version of the API: 1.0.0\n"\
408-
"SDK Package Version: 4.1.3".\
408+
"SDK Package Version: 4.2.0".\
409409
format(env=sys.platform, pyversion=sys.version)
410410

411411
def get_host_settings(self):

freeclimb/utils/request_verifier.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import sys
2+
3+
from freeclimb.utils.signature_information import SignatureInformation
4+
5+
class RequestVerifier:
6+
7+
DEFAULT_TOLERANCE = 5*60*1000
8+
9+
@staticmethod
10+
def verify_request_signature(request_body:str, request_header:str, signing_secret:str, tolerance:int=DEFAULT_TOLERANCE):
11+
verifier = RequestVerifier()
12+
verifier.__check_request_body(request_body)
13+
verifier.__check_request_header(request_header)
14+
verifier.__check_signing_secret(signing_secret)
15+
verifier.__check_tolerance(tolerance)
16+
info = SignatureInformation(request_header)
17+
verifier.__verify_tolerance(info, tolerance)
18+
verifier.__verify_signature(info, request_body, signing_secret)
19+
20+
def __check_request_body(self, request_body:str):
21+
if request_body == "" or request_body == None:
22+
raise Exception("Request Body cannot be empty or null")
23+
24+
def __check_request_header(self, request_header:str):
25+
if request_header == "" or request_header == None:
26+
raise Exception("Error with request header, Request header is empty")
27+
28+
elif not("t" in request_header) :
29+
raise Exception("Error with request header, timestamp is not present")
30+
31+
elif not("v1" in request_header) :
32+
raise Exception("Error with request header, signatures are not present")
33+
34+
35+
def __check_signing_secret(self, signing_secret:str):
36+
if signing_secret == "" or signing_secret == None:
37+
raise Exception("Signing secret cannot be empty or null")
38+
39+
def __check_tolerance(self, tolerance:int):
40+
if tolerance <= 0 or tolerance >= sys.maxsize:
41+
raise Exception("Tolerance value must be a positive integer")
42+
43+
def __verify_tolerance(self, info:SignatureInformation, tolerance:int):
44+
currentTime = info.get_current_unix_time()
45+
if not(info.is_request_time_valid(tolerance)):
46+
raise Exception("Request time exceeded tolerance threshold. Request: " + str(info.request_timestamp)
47+
+ ", CurrentTime: " + str(currentTime) + ", tolerance: " + str(tolerance))
48+
49+
def __verify_signature(self, info:SignatureInformation, request_body:str, signing_secret:str):
50+
if not(info.is_signature_safe(request_body, signing_secret)):
51+
raise Exception("Unverified signature request, If this request was unexpected, it may be from a bad actor. Please proceed with caution. If the request was exepected, please check any typos or issues with the signingSecret")
52+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import time, hmac, hashlib
2+
3+
class SignatureInformation:
4+
request_timestamp:int = 0
5+
signatures = []
6+
7+
def __init__(self, request_header:str):
8+
signature_headers = request_header.split(",")
9+
for signature in signature_headers:
10+
header, value = signature.split("=")
11+
if header == "t":
12+
self.request_timestamp = int(value)
13+
elif header == "v1":
14+
self.signatures.append(value)
15+
16+
def is_request_time_valid(self, tolerance:int) -> bool:
17+
current_time = self.get_current_unix_time()
18+
time_calculation:int = self.request_timestamp + tolerance
19+
return (time_calculation) < current_time
20+
21+
def is_signature_safe(self, requestBody:str, signingSecret:str) -> bool:
22+
hashValue = self.__compute_hash(requestBody, signingSecret)
23+
return hashValue in self.signatures
24+
25+
def __compute_hash(self, requestBody:str, signingSecret:str) -> str:
26+
data = str(self.request_timestamp) + "." + requestBody
27+
return hmac.new(bytes(signingSecret, 'utf-8'), data.encode('utf-8'), digestmod=hashlib.sha256).hexdigest()
28+
29+
def get_current_unix_time(self) -> int:
30+
return int(time.time())

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from setuptools import setup, find_packages # noqa: H301
1313

1414
NAME = "FreeClimb"
15-
VERSION = "4.1.3"
15+
VERSION = "4.2.0"
1616
# To install the library, run the following
1717
#
1818
# python setup.py install

test/test_request_verifier.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import sys
2+
import time
3+
import unittest
4+
5+
from freeclimb.utils.request_verifier import RequestVerifier
6+
7+
class TestRequestVerifier(unittest.TestCase):
8+
"""RequestVerifier unit test stubs"""
9+
10+
def setUp(self):
11+
self.request_verifier = RequestVerifier()
12+
13+
def tearDown(self):
14+
pass
15+
16+
def test_check_request_body(self):
17+
request_body = ""
18+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
19+
tolerance = 5 * 60
20+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
21+
with self.assertRaises(Exception) as exc:
22+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
23+
self.assertEqual(str(exc.exception), "Request Body cannot be empty or null")
24+
25+
def test_check_request_header_no_signatures(self):
26+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
27+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
28+
tolerance = 5 * 60
29+
request_header = "t=1679944186,"
30+
with self.assertRaises(Exception) as exc:
31+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
32+
self.assertEqual(str(exc.exception), "Error with request header, signatures are not present")
33+
34+
def test_check_request_header_no_timestamp(self):
35+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
36+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
37+
tolerance = 5 * 60
38+
request_header = "v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
39+
with self.assertRaises(Exception) as exc:
40+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
41+
self.assertEqual(str(exc.exception), "Error with request header, timestamp is not present")
42+
43+
def test_check_request_header_empty_request_header(self):
44+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
45+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
46+
tolerance = 5 * 60
47+
request_header = ""
48+
with self.assertRaises(Exception) as exc:
49+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
50+
self.assertEqual(str(exc.exception), "Error with request header, Request header is empty")
51+
52+
def test_check_signing_secret(self):
53+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
54+
signing_secret = ""
55+
tolerance = 5 * 60
56+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
57+
with self.assertRaises(Exception) as exc:
58+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
59+
self.assertEqual(str(exc.exception), "Signing secret cannot be empty or null")
60+
61+
def test_check_tolerance_max_int(self):
62+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
63+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
64+
tolerance = sys.maxsize
65+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
66+
with self.assertRaises(Exception) as exc:
67+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
68+
self.assertEqual(str(exc.exception), "Tolerance value must be a positive integer")
69+
70+
def test_check_tolerance_zero_value(self):
71+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
72+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
73+
tolerance = 0
74+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
75+
with self.assertRaises(Exception) as exc:
76+
self.request_verifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
77+
self.assertEqual(str(exc.exception), "Tolerance value must be a positive integer")
78+
79+
def test_check_tolerance_negative_value(self):
80+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
81+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
82+
tolerance = -5
83+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
84+
with self.assertRaises(Exception) as exc:
85+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
86+
self.assertEqual(str(exc.exception), "Tolerance value must be a positive integer")
87+
88+
def test_verify_tolerance(self):
89+
current_time = int(time.time())
90+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
91+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
92+
tolerance = 5 * 60
93+
request_header = "t=1900871395,v1=1d798c86e977ff734dec3a8b8d67fe8621dcc1df46ef4212e0bfe2e122b01bfd,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
94+
with self.assertRaises(Exception) as exc:
95+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
96+
self.assertEqual(str(exc.exception), "Request time exceeded tolerance threshold. Request: 1900871395"
97+
+ ", CurrentTime: " + str(current_time) + ", tolerance: " + str(tolerance))
98+
99+
def test_verify_signature(self):
100+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
101+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7794"
102+
tolerance = 5 * 60
103+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
104+
with self.assertRaises(Exception) as exc:
105+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
106+
self.assertEqual(str(exc.exception), "Unverified signature request, If this request was unexpected, it may be from a bad actor. Please proceed with caution. If the request was exepected, please check any typos or issues with the signingSecret")
107+
108+
def test_verify_request_signature(self):
109+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
110+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
111+
tolerance = 5 * 60
112+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
113+
raised = False
114+
try:
115+
RequestVerifier.verify_request_signature(request_body, request_header, signing_secret, tolerance)
116+
except:
117+
raised = True
118+
self.assertFalse(raised, 'Exception has been raised')
119+
120+
121+
122+
if __name__ == '__main__':
123+
unittest.main()

test/test_signature_information.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import unittest
2+
3+
from freeclimb.utils.signature_information import SignatureInformation
4+
5+
class TestSignatureInformation(unittest.TestCase):
6+
"""SignatureInformation unit test stubs"""
7+
8+
def setUp(self):
9+
request_header = "t=1679944186,v1=c3957749baf61df4b1506802579cc69a74c77a1ae21447b930e5a704f9ec4120,v1=1ba18712726898fbbe48cd862dd096a709f7ad761a5bab14bda9ac24d963a6a8"
10+
self.signature_information = SignatureInformation(request_header)
11+
12+
def tearDown(self):
13+
pass
14+
15+
def test_is_request_time_valid_true(self):
16+
tolerance = 5 * 60
17+
self.assertEqual(self.signature_information.is_request_time_valid(tolerance), True)
18+
19+
def test_is_request_time_valid_false(self):
20+
tolerance = 5 * 60 * 10000
21+
self.assertEqual(self.signature_information.is_request_time_valid(tolerance), False)
22+
23+
def test_is_signature_safe_true(self):
24+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
25+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7793"
26+
self.assertEqual(self.signature_information.is_signature_safe(request_body, signing_secret), True)
27+
28+
def test_is_signature_safe_false(self):
29+
request_body = "{\"accountId\":\"AC1334ffb694cd8d969f51cddf5f7c9b478546d50c\",\"callId\":\"CAccb0b00506553cda09b51c5477f672a49e0b2213\",\"callStatus\":\"ringing\",\"conferenceId\":null,\"direction\":\"inbound\",\"from\":\"+13121000109\",\"parentCallId\":null,\"queueId\":null,\"requestType\":\"inboundCall\",\"to\":\"+13121000096\"}"
30+
signing_secret = "sigsec_ead6d3b6904196c60835d039e91b3341c77a7794"
31+
self.assertEqual(self.signature_information.is_signature_safe(request_body, signing_secret), False)
32+
33+
34+
35+
if __name__ == '__main__':
36+
unittest.main()

0 commit comments

Comments
 (0)