Skip to content
Open
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
11 changes: 9 additions & 2 deletions py_clob_client_v2/signing/hmac.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import hmac
import hashlib
import base64
import json


def build_hmac_signature(
Expand All @@ -13,8 +14,14 @@ def build_hmac_signature(
base64_secret = base64.urlsafe_b64decode(secret)
message = str(timestamp) + str(method) + str(requestPath)
if body:
# Match the canonical API signing payload format.
message += str(body).replace("'", '"')
# A pre-serialized string body is signed verbatim so it matches the exact
# bytes sent on the wire. Structured bodies are serialized with json.dumps
# so Python values like False/None become JSON false/null (str() would emit
# "False"/"None", producing a signature that mismatches the sent payload).
if isinstance(body, str):
message += body
else:
message += json.dumps(body, ensure_ascii=False)

# nosec: SHA256 is used here for API request signing (HMAC-SHA256), not password hashing
h = hmac.new(base64_secret, bytes(message, "utf-8"), hashlib.sha256)
Expand Down
38 changes: 38 additions & 0 deletions tests/signing/test_hmac.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from unittest import TestCase
import binascii
import json

from py_clob_client_v2.signing.hmac import build_hmac_signature

Expand Down Expand Up @@ -174,3 +175,40 @@ def test_string_body_preserved_verbatim(self):
self.secret, self.timestamp, self.method, self.path, '{"x":1,"y":2}'
)
self.assertNotEqual(sig_a, sig_b)

def _sig(self, body):
return build_hmac_signature(
self.secret, self.timestamp, self.method, self.path, body
)

def test_bool_value_serialized_as_json_not_python_repr(self):
# Regression for #108: str({"deferExec": False}) yields "False", not the
# JSON "false" the server actually receives. The signature must be taken
# over the json.dumps form so it matches the sent payload.
body = {"deferExec": False}
self.assertEqual(
self._sig(body), self._sig(json.dumps(body, ensure_ascii=False))
)
# The broken str()-based rendering must NOT match the fixed signature.
self.assertNotEqual(self._sig(body), self._sig(str(body).replace("'", '"')))
# Explicitly: signing the dict matches signing '{"deferExec": false}'.
self.assertEqual(self._sig(body), self._sig('{"deferExec": false}'))

def test_none_value_serialized_as_json_null(self):
# Regression for #108: str({"foo": None}) yields "None", not JSON "null".
body = {"foo": None}
self.assertEqual(
self._sig(body), self._sig(json.dumps(body, ensure_ascii=False))
)
self.assertNotEqual(self._sig(body), self._sig(str(body).replace("'", '"')))
self.assertEqual(self._sig(body), self._sig('{"foo": null}'))

def test_bool_true_and_nested_none_serialized_as_json(self):
body = {"a": True, "b": {"c": None}, "d": [False, None]}
self.assertEqual(
self._sig(body), self._sig(json.dumps(body, ensure_ascii=False))
)
self.assertEqual(
self._sig(body),
self._sig('{"a": true, "b": {"c": null}, "d": [false, null]}'),
)
Loading