Skip to content

fix(auth): sign_request strips URL fragment + PSS config module-cache#371

Merged
TexasCoding merged 1 commit into
mainfrom
r3/W3-A
May 22, 2026
Merged

fix(auth): sign_request strips URL fragment + PSS config module-cache#371
TexasCoding merged 1 commit into
mainfrom
r3/W3-A

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Summary

Two surgical fixes in kalshi/auth.py: sign_request now strips #fragment from the path before signing (httpx drops fragments before transmission, so signing them produced a guaranteed signature mismatch surfacing as a 401), and the immutable RSA-PSS / MGF1 / SHA256 algorithm descriptors are hoisted to module-level singletons so they are no longer reallocated on every request and websocket reconnect.

Issues closed

Behavioral changes

  • Paths containing a #fragment are now canonicalized the same way httpx canonicalizes them on the wire: the fragment is stripped before the signing payload is built. Previously such paths produced a 401 KalshiAuthError; they now succeed.

Tests

  • tests/test_auth.py::TestIssueRegressions::test_issue_317_sign_request_strips_url_fragment — verifies that signatures produced for /path#frag, /path?q=x#frag, and /path# all verify against the fragment-stripped canonical message (RSA-PSS is non-deterministic, so verification, not byte equality, is the right oracle).
  • tests/test_auth.py::TestIssueRegressions::test_issue_345_pss_config_cached_at_module_level — asserts _SHA256 / _PSS_PADDING exist at module scope and are not re-bound across sign_request calls, and that the cached config still produces a verifiable signature.

Source

Round-3 independent audit closure plan, wave W3.

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
@TexasCoding TexasCoding added bug Something isn't working polish Code-quality and DX improvements; non-functional performance Performance / hot-path concern labels May 22, 2026
@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Code Review

Overview

Two surgical fixes in kalshi/auth.py: fragment stripping in sign_request (issue #317) and module-level PSS/SHA256 config caching (issue #345). Both changes are correct, well-scoped, and consistent with the repo's conventions.


Fix #317 — Fragment stripping

# Before
clean_path = path.split("?")[0]

# After
clean_path = path.split("?", 1)[0].split("#", 1)[0]

Correct. The chained split handles all cases:

Input After ? split After # split
/path#frag /path#frag /path
/path?q=x#frag /path /path
/path# /path# /path
/path#frag?q=x (malformed) /path#frag?q=x /path

Adding maxsplit=1 is a nice micro-improvement to be explicit about intent — only the first delimiter matters here.

Edge case not tested (low severity): A path that is only # yields an empty clean_path, which would produce a malformed signing message. This can't happen with any real Kalshi API path, so omitting a test for it is fine.


Fix #345 — Module-level PSS/SHA256 caching

_SHA256 = hashes.SHA256()
_PSS_PADDING = padding.PSS(mgf=padding.MGF1(_SHA256), salt_length=padding.PSS.DIGEST_LENGTH)

Correct. hashes.SHA256() and padding.PSS() are pure descriptor/config value objects in cryptography — they hold no mutable state and OpenSSL reads properties from them without writing back. This makes them safe to reuse across calls and across threads (relevant given sign_request_async calls sign_request via run_in_executor).

One suggestion: Since the code already has the threading context documented elsewhere and sign_request_async uses a thread executor, it's worth adding a one-liner to the existing comment confirming thread-safety, e.g.:

# Module-level cached config for RSA-PSS signing (#345). Both objects are
# immutable descriptors (no mutable state); safe to share across threads.

This removes any future doubt for someone who sees the executor in sign_request_async and wonders whether the shared objects could race.


Tests

test_issue_317_sign_request_strips_url_fragment

  • Good: uses pub.verify() (verification) rather than byte equality — RSA-PSS is non-deterministic, so this is the correct oracle.
  • Good: covers the three meaningful cases (fragment-only, query+fragment, empty fragment).
  • Good: explicitly pins timestamp_ms=1000 to make the expected canonical message deterministic.
  • Minor: the test allocates its own pss/hashes.SHA256() instances for verification, independent of the module-level singletons. This is the right call for test isolation.

test_issue_345_pss_config_cached_at_module_level

  • Good: uses is (identity) rather than == (equality) to verify no re-allocation — exactly the right check.
  • Good: ends with a live verify() to confirm the cached config still produces valid signatures, not just that the references are preserved.
  • Nitpick: accessing auth_mod._SHA256 / auth_mod._PSS_PADDING couples the test to the private names. That's acceptable for a regression test that specifically guards the optimization, but document that updating the internal name requires updating the test too (or just note it in the test docstring).

Minor style note

CLAUDE.md says "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." The new module-level comment is 3 lines. The existing file has similar 3-line comment blocks throughout, so this is consistent in practice even if technically over the limit. Not a blocker.


Verdict

Approve. Both fixes are correct, minimal, and well-tested. The fragment-stripping logic handles every edge case including malformed URLs. The PSS caching is a legitimate performance improvement with no correctness risk. The only actionable suggestion is adding a thread-safety note to the module-level comment for future maintainers.

@TexasCoding
TexasCoding merged commit 7c4ac58 into main May 22, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the r3/W3-A branch May 22, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working performance Performance / hot-path concern polish Code-quality and DX improvements; non-functional

Projects

None yet

1 participant