Skip to content

Commit 7c4ac58

Browse files
authored
fix(auth): sign_request strips URL fragment + PSS config module-cache (#317, #345) (#371)
sign_request previously stripped the query string but left any '#fragment' intact. httpx drops fragments from the request-target before transmission (RFC 3986 §3.5), so the SDK was signing /path#frag while httpx sent /path — a guaranteed signature mismatch surfacing as a 401 KalshiAuthError with no hint that a stray '#' was the cause. The canonicalization now applies the same shape as the existing '?' strip. The RSA-PSS / MGF1 / SHA256 configuration objects are pure immutable algorithm descriptors. They were being reallocated on every signature; for a high-RPS client the per-call Python-object churn is wasted work. They are now bound once at module import as _SHA256 / _PSS_PADDING and reused. The cryptography library explicitly supports reuse of these instances. Regression tests verify (a) signatures for fragment-bearing paths verify against the fragment-stripped canonical message — the right oracle since PSS uses a random salt and is non-deterministic — and (b) the cached config objects retain identity across sign_request calls. Closes #317, #345
1 parent b0861eb commit 7c4ac58

2 files changed

Lines changed: 72 additions & 8 deletions

File tree

kalshi/auth.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@
3232

3333
logger = logging.getLogger("kalshi")
3434

35+
# Module-level cached config objects for RSA-PSS signing (#345).
36+
# These are immutable algorithm descriptors and safe to reuse across
37+
# operations; hoisting them out of sign_request avoids per-call allocation.
38+
_SHA256 = hashes.SHA256()
39+
_PSS_PADDING = padding.PSS(
40+
mgf=padding.MGF1(_SHA256),
41+
salt_length=padding.PSS.DIGEST_LENGTH,
42+
)
43+
3544

3645
def _normalize_percent_encoding(path: str) -> str:
3746
"""Normalize percent-encoded characters to uppercase hex digits.
@@ -262,7 +271,8 @@ def sign_request(
262271
263272
Args:
264273
method: HTTP method (GET, POST, DELETE, etc.)
265-
path: Full API path (e.g., /trade-api/v2/markets). Query params are stripped.
274+
path: Full API path (e.g., /trade-api/v2/markets). Query strings
275+
and URL fragments are stripped.
266276
timestamp_ms: Unix timestamp in milliseconds. Auto-generated if None.
267277
268278
Returns:
@@ -271,8 +281,10 @@ def sign_request(
271281
if timestamp_ms is None:
272282
timestamp_ms = int(time.time() * 1000)
273283

274-
# Strip query parameters before signing
275-
clean_path = path.split("?")[0]
284+
# Strip query parameters and URL fragments before signing (#317).
285+
# httpx drops fragments before transmission, so signing them would
286+
# produce a guaranteed signature mismatch (401).
287+
clean_path = path.split("?", 1)[0].split("#", 1)[0]
276288

277289
# Strip trailing slash for canonical form
278290
if clean_path != "/" and clean_path.endswith("/"):
@@ -287,11 +299,8 @@ def sign_request(
287299

288300
signature = self._private_key.sign(
289301
message_bytes,
290-
padding.PSS(
291-
mgf=padding.MGF1(hashes.SHA256()),
292-
salt_length=padding.PSS.DIGEST_LENGTH,
293-
),
294-
hashes.SHA256(),
302+
_PSS_PADDING,
303+
_SHA256,
295304
)
296305

297306
sig_b64 = base64.b64encode(signature).decode("utf-8")

tests/test_auth.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,3 +795,58 @@ def handler(request: httpx.Request) -> httpx.Response:
795795
hashes.SHA256(),
796796
)
797797
transport.close()
798+
799+
800+
class TestIssueRegressions:
801+
def test_issue_317_sign_request_strips_url_fragment(
802+
self, rsa_private_key: rsa.RSAPrivateKey, test_auth: KalshiAuth
803+
) -> None:
804+
"""#317: sign_request must strip '#fragment' so signed bytes match what httpx sends."""
805+
canonical = b"1000GET/trade-api/v2/markets/EVT"
806+
pub = rsa_private_key.public_key()
807+
pss = padding.PSS(
808+
mgf=padding.MGF1(hashes.SHA256()),
809+
salt_length=padding.PSS.DIGEST_LENGTH,
810+
)
811+
812+
for input_path in (
813+
"/trade-api/v2/markets/EVT#tab=foo",
814+
"/trade-api/v2/markets/EVT?limit=5#tab=foo",
815+
"/trade-api/v2/markets/EVT#",
816+
):
817+
headers = test_auth.sign_request("GET", input_path, timestamp_ms=1000)
818+
sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"])
819+
# Should verify against the fragment-stripped canonical message.
820+
pub.verify(sig, canonical, pss, hashes.SHA256())
821+
822+
def test_issue_345_pss_config_cached_at_module_level(
823+
self, test_auth: KalshiAuth
824+
) -> None:
825+
"""#345: PSS/MGF1/SHA256 config objects are module-level singletons, not per-call."""
826+
from kalshi import auth as auth_mod
827+
828+
assert isinstance(auth_mod._SHA256, hashes.SHA256)
829+
assert isinstance(auth_mod._PSS_PADDING, padding.PSS)
830+
831+
# Identity is preserved across calls (no re-allocation in sign_request).
832+
sha_before = auth_mod._SHA256
833+
pss_before = auth_mod._PSS_PADDING
834+
test_auth.sign_request("GET", "/trade-api/v2/markets", timestamp_ms=1)
835+
test_auth.sign_request("POST", "/trade-api/v2/orders", timestamp_ms=2)
836+
assert auth_mod._SHA256 is sha_before
837+
assert auth_mod._PSS_PADDING is pss_before
838+
839+
# And the cached config still produces a verifiable signature.
840+
headers = test_auth.sign_request(
841+
"GET", "/trade-api/v2/markets", timestamp_ms=1000
842+
)
843+
sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"])
844+
test_auth._private_key.public_key().verify(
845+
sig,
846+
b"1000GET/trade-api/v2/markets",
847+
padding.PSS(
848+
mgf=padding.MGF1(hashes.SHA256()),
849+
salt_length=padding.PSS.DIGEST_LENGTH,
850+
),
851+
hashes.SHA256(),
852+
)

0 commit comments

Comments
 (0)