Skip to content

perps: expose password= for encrypted private keys in client kwargs + from_env (shared with core client) #410

Description

@TexasCoding

Context

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:91KalshiAuth.from_key_path(key_id, private_key_path) (no password=)
  • kalshi/perps/client.py:94KalshiAuth.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:155from_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.
    • from_key_path(key_id, private_key_path, password=password) / from_pem(key_id, private_key, password=password).
    • 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).
  • Perps: kalshi/perps/client.py + kalshi/perps/async_client.py
    • Same __init__/PerpsClientInitKwargs additions.
    • 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.
  • Related: perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387. password-as-callable is already supported by KalshiAuth (vault-deferred secrets), so the new kwarg should accept the same union for consistency.
  • breaking label intentionally omitted — this is a purely additive optional kwarg.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions