You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Surfaced by the multi-LLM review of PR #403 (perps/margin API). KalshiAuth fully supports passphrase-encrypted PEM keys (from_key_path/from_pem/from_env/try_from_env all take password=), but the high-level clients do not thread a password through their explicit-credential kwarg path, so a user with an encrypted PEM cannot use the standard key_id/private_key(_path) constructor or from_env(password=...).
Problem
The explicit-credential path drops password on the floor in both perps and the core client:
kalshi/perps/client.py:91 — KalshiAuth.from_key_path(key_id, private_key_path) (no password=)
kalshi/perps/client.py:94 — KalshiAuth.from_pem(key_id, private_key) (no password=)
kalshi/perps/async_client.py:82 / :85 — same two calls, no password=
kalshi/perps/client.py:30-47 (PerpsClientInitKwargs) and :67-79 (__init__) — no password field/param; ditto kalshi/perps/async_client.py:30-42 / :58-70
kalshi/perps/client.py:171 / kalshi/perps/async_client.py:155 — from_env calls try_perps_auth_from_env() with no args, so an explicit from_env(password=...) cannot be honored (and password isn't in the kwargs TypedDict anyway)
This is not a perps regression — the core client has the identical limitation: kalshi/client.py:107/:110 call from_key_path/from_pem with no password=, ClientInitKwargs (kalshi/client.py:36-53) has no password field, and from_env (kalshi/client.py:198) calls KalshiAuth.try_from_env() with no args. So the right framing is a shared enhancement, with a perps parity guarantee.
Concrete failure: a user with an encrypted PEM who calls PerpsClient(key_id=..., private_key_path="~/.kalshi/perps.pem") (or the core KalshiClient equivalent) gets KalshiAuthError: Passphrase-protected private key requires a password... (raised at kalshi/auth.py:149). The only workaround is to build KalshiAuth.from_key_path(..., password=...) themselves and pass auth=.
Note the env-var route is already covered: try_perps_auth_from_env reads KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE (kalshi/perps/_env.py:42-46) and core try_from_env reads KALSHI_PRIVATE_KEY_PASSPHRASE (kalshi/auth.py:237-239). Only the explicit-kwarg path is the gap.
Proposed fix
Add a password: bytes | str | Callable[[], bytes | str] | None = None parameter to both __init__s and forward it to the auth constructors, plus add it to the *InitKwargs TypedDicts and thread it through from_env. Minimal, symmetric change across four files:
Core: kalshi/client.py + kalshi/async_client.py
Add password to __init__ signature and ClientInitKwargs.
In from_env, pass KalshiAuth.try_from_env(password=kwargs.pop("password", None)) (env passphrase still used when kwarg is None — try_from_env already implements that precedence).
In from_env, call try_perps_auth_from_env(password=kwargs.pop("password", None)).
Keep the auth is not None branch untouched (caller-built auth already carries its own key).
Acceptance criteria
PerpsClient/AsyncPerpsClient and KalshiClient/AsyncKalshiClient__init__ accept password= and forward it to from_key_path/from_pem.
password is added to ClientInitKwargs and PerpsClientInitKwargs (so mypy --strict accepts from_env(password=...) and rejects typos).
from_env(password=...) is honored on all four clients; when omitted, the existing KALSHI[_PERPS]_PRIVATE_KEY_PASSPHRASE env var still works and an explicit kwarg overrides the env var.
Regression test: construct each client with an encrypted PEM fixture + password= (kwarg path) and assert is_authenticated / a signed request succeeds (reuse the _encrypted_pem helper already in tests/test_auth.py).
Regression test: from_env(password=...) with an encrypted PEM in KALSHI_PERPS_PRIVATE_KEY / KALSHI_PRIVATE_KEY builds an authenticated client; explicit password= beats the *_PASSPHRASE env var (mirror test_from_env_explicit_password_beats_env_passphrase).
uv run mypy kalshi/ passes (note: use bytes | str | Callable[[], bytes | str] | None to match the KalshiAuth signature exactly).
Notes
Deferred from the PR perps: full Perps (margin) API — REST + WebSocket + SCM/Klear (#387) #403 fix stack because it is a pre-existing limitation shared with the core client, not a perps-specific defect introduced by the branch — the perps env-var passphrase path already works, so encrypted-key users are not fully blocked (workaround: build KalshiAuth and pass auth=).
Scope decision to make in the issue: ship as a single shared enhancement touching all four clients (recommended, keeps perps at parity with core), or split core vs. perps. Doing only perps would leave the core client inconsistent.
Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API).
KalshiAuthfully supports passphrase-encrypted PEM keys (from_key_path/from_pem/from_env/try_from_envall takepassword=), but the high-level clients do not thread apasswordthrough their explicit-credential kwarg path, so a user with an encrypted PEM cannot use the standardkey_id/private_key(_path)constructor orfrom_env(password=...).Problem
The explicit-credential path drops
passwordon the floor in both perps and the core client:kalshi/perps/client.py:91—KalshiAuth.from_key_path(key_id, private_key_path)(nopassword=)kalshi/perps/client.py:94—KalshiAuth.from_pem(key_id, private_key)(nopassword=)kalshi/perps/async_client.py:82/:85— same two calls, nopassword=kalshi/perps/client.py:30-47(PerpsClientInitKwargs) and:67-79(__init__) — nopasswordfield/param; dittokalshi/perps/async_client.py:30-42/:58-70kalshi/perps/client.py:171/kalshi/perps/async_client.py:155—from_envcallstry_perps_auth_from_env()with no args, so an explicitfrom_env(password=...)cannot be honored (andpasswordisn't in the kwargs TypedDict anyway)This is not a perps regression — the core client has the identical limitation:
kalshi/client.py:107/:110callfrom_key_path/from_pemwith nopassword=,ClientInitKwargs(kalshi/client.py:36-53) has nopasswordfield, andfrom_env(kalshi/client.py:198) callsKalshiAuth.try_from_env()with no args. So the right framing is a shared enhancement, with a perps parity guarantee.Concrete failure: a user with an encrypted PEM who calls
PerpsClient(key_id=..., private_key_path="~/.kalshi/perps.pem")(or the coreKalshiClientequivalent) getsKalshiAuthError: Passphrase-protected private key requires a password...(raised atkalshi/auth.py:149). The only workaround is to buildKalshiAuth.from_key_path(..., password=...)themselves and passauth=.Note the env-var route is already covered:
try_perps_auth_from_envreadsKALSHI_PERPS_PRIVATE_KEY_PASSPHRASE(kalshi/perps/_env.py:42-46) and coretry_from_envreadsKALSHI_PRIVATE_KEY_PASSPHRASE(kalshi/auth.py:237-239). Only the explicit-kwarg path is the gap.Proposed fix
Add a
password: bytes | str | Callable[[], bytes | str] | None = Noneparameter to both__init__s and forward it to the auth constructors, plus add it to the*InitKwargsTypedDicts and thread it throughfrom_env. Minimal, symmetric change across four files:kalshi/client.py+kalshi/async_client.pypasswordto__init__signature andClientInitKwargs.from_key_path(key_id, private_key_path, password=password)/from_pem(key_id, private_key, password=password).from_env, passKalshiAuth.try_from_env(password=kwargs.pop("password", None))(env passphrase still used when kwarg is None —try_from_envalready implements that precedence).kalshi/perps/client.py+kalshi/perps/async_client.py__init__/PerpsClientInitKwargsadditions.from_env, calltry_perps_auth_from_env(password=kwargs.pop("password", None)).Keep the
auth is not Nonebranch untouched (caller-built auth already carries its own key).Acceptance criteria
PerpsClient/AsyncPerpsClientandKalshiClient/AsyncKalshiClient__init__acceptpassword=and forward it tofrom_key_path/from_pem.passwordis added toClientInitKwargsandPerpsClientInitKwargs(somypy --strictacceptsfrom_env(password=...)and rejects typos).from_env(password=...)is honored on all four clients; when omitted, the existingKALSHI[_PERPS]_PRIVATE_KEY_PASSPHRASEenv var still works and an explicit kwarg overrides the env var.password=(kwarg path) and assertis_authenticated/ a signed request succeeds (reuse the_encrypted_pemhelper already intests/test_auth.py).from_env(password=...)with an encrypted PEM inKALSHI_PERPS_PRIVATE_KEY/KALSHI_PRIVATE_KEYbuilds an authenticated client; explicitpassword=beats the*_PASSPHRASEenv var (mirrortest_from_env_explicit_password_beats_env_passphrase).uv run mypy kalshi/passes (note: usebytes | str | Callable[[], bytes | str] | Noneto match theKalshiAuthsignature exactly).Notes
KalshiAuthand passauth=).password-as-callable is already supported byKalshiAuth(vault-deferred secrets), so the new kwarg should accept the same union for consistency.breakinglabel intentionally omitted — this is a purely additive optional kwarg.