From 6fa0b1cd2fa94cf01538e479b2c49c189bfe0072 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 08:55:46 -0500 Subject: [PATCH] fix(auth): strip URL fragment from sign payload; cache PSS config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kalshi/auth.py | 25 ++++++++++++++------- tests/test_auth.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/kalshi/auth.py b/kalshi/auth.py index f5923b8..0f56e59 100644 --- a/kalshi/auth.py +++ b/kalshi/auth.py @@ -32,6 +32,15 @@ logger = logging.getLogger("kalshi") +# Module-level cached config objects for RSA-PSS signing (#345). +# These are immutable algorithm descriptors and safe to reuse across +# operations; hoisting them out of sign_request avoids per-call allocation. +_SHA256 = hashes.SHA256() +_PSS_PADDING = padding.PSS( + mgf=padding.MGF1(_SHA256), + salt_length=padding.PSS.DIGEST_LENGTH, +) + def _normalize_percent_encoding(path: str) -> str: """Normalize percent-encoded characters to uppercase hex digits. @@ -262,7 +271,8 @@ def sign_request( Args: method: HTTP method (GET, POST, DELETE, etc.) - path: Full API path (e.g., /trade-api/v2/markets). Query params are stripped. + path: Full API path (e.g., /trade-api/v2/markets). Query strings + and URL fragments are stripped. timestamp_ms: Unix timestamp in milliseconds. Auto-generated if None. Returns: @@ -271,8 +281,10 @@ def sign_request( if timestamp_ms is None: timestamp_ms = int(time.time() * 1000) - # Strip query parameters before signing - clean_path = path.split("?")[0] + # Strip query parameters and URL fragments before signing (#317). + # httpx drops fragments before transmission, so signing them would + # produce a guaranteed signature mismatch (401). + clean_path = path.split("?", 1)[0].split("#", 1)[0] # Strip trailing slash for canonical form if clean_path != "/" and clean_path.endswith("/"): @@ -287,11 +299,8 @@ def sign_request( signature = self._private_key.sign( message_bytes, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.DIGEST_LENGTH, - ), - hashes.SHA256(), + _PSS_PADDING, + _SHA256, ) sig_b64 = base64.b64encode(signature).decode("utf-8") diff --git a/tests/test_auth.py b/tests/test_auth.py index 01902f4..55c881a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -795,3 +795,58 @@ def handler(request: httpx.Request) -> httpx.Response: hashes.SHA256(), ) transport.close() + + +class TestIssueRegressions: + def test_issue_317_sign_request_strips_url_fragment( + self, rsa_private_key: rsa.RSAPrivateKey, test_auth: KalshiAuth + ) -> None: + """#317: sign_request must strip '#fragment' so signed bytes match what httpx sends.""" + canonical = b"1000GET/trade-api/v2/markets/EVT" + pub = rsa_private_key.public_key() + pss = padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.DIGEST_LENGTH, + ) + + for input_path in ( + "/trade-api/v2/markets/EVT#tab=foo", + "/trade-api/v2/markets/EVT?limit=5#tab=foo", + "/trade-api/v2/markets/EVT#", + ): + headers = test_auth.sign_request("GET", input_path, timestamp_ms=1000) + sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) + # Should verify against the fragment-stripped canonical message. + pub.verify(sig, canonical, pss, hashes.SHA256()) + + def test_issue_345_pss_config_cached_at_module_level( + self, test_auth: KalshiAuth + ) -> None: + """#345: PSS/MGF1/SHA256 config objects are module-level singletons, not per-call.""" + from kalshi import auth as auth_mod + + assert isinstance(auth_mod._SHA256, hashes.SHA256) + assert isinstance(auth_mod._PSS_PADDING, padding.PSS) + + # Identity is preserved across calls (no re-allocation in sign_request). + sha_before = auth_mod._SHA256 + pss_before = auth_mod._PSS_PADDING + test_auth.sign_request("GET", "/trade-api/v2/markets", timestamp_ms=1) + test_auth.sign_request("POST", "/trade-api/v2/orders", timestamp_ms=2) + assert auth_mod._SHA256 is sha_before + assert auth_mod._PSS_PADDING is pss_before + + # And the cached config still produces a verifiable signature. + headers = test_auth.sign_request( + "GET", "/trade-api/v2/markets", timestamp_ms=1000 + ) + sig = base64.b64decode(headers["KALSHI-ACCESS-SIGNATURE"]) + test_auth._private_key.public_key().verify( + sig, + b"1000GET/trade-api/v2/markets", + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.DIGEST_LENGTH, + ), + hashes.SHA256(), + )