Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,35 @@ jobs:
python:
uses: CitrateNetwork/.github/.github/workflows/reusable-python-ci.yml@db5f1a6ddaec28275708265b56762eb1dfc3571b # main

# PBA-L6b-041: uv.lock drifted from pyproject (it locked cryptography 46.0.7
# while pyproject requires >=48.0.1) because nothing checked it. Fail when the
# lock no longer matches pyproject.toml.
uv-lock-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- run: python -m pip install "uv==0.12.19"
- name: uv.lock matches pyproject.toml
run: uv lock --check

# PBA-L6b-003 tripwire on the BUILT wheel: build it the way a release does,
# import citrate_sdk from the unpacked wheel only, and assert that no subset
# of the deploy_model calldata reconstructs the model key. The source-tree
# twin is tests/test_pba_r2_l6b_keyshares.py.
keyshare-wheel-tripwire:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade pip && pip install -e .
- name: Key-share leak tripwire (built wheel)
run: python scripts/check_keyshare_wheel.py

# CL-C2 / SW-092: fail the build if the vendored federation_contract artifact
# drifts from the canonical federation intermediate (which federation's
# contract-artifact-drift gate keeps fresh vs citrate-chain). The reusable
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ jobs:
- name: Check package
run: twine check dist/*

# PBA-L6b-003: refuse to publish a wheel whose deploy_model calldata can
# rebuild the model key. Runs against the built wheel, not the source tree.
- name: Key-share leak tripwire (built wheel)
run: |
pip install -e .
python scripts/check_keyshare_wheel.py dist/*.whl

- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
Expand Down
68 changes: 67 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,73 @@
All notable changes to `citrate-labs-sdk` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [0.6.1] - 2026-08-02 — SECREM-02 K3 envelope hardening
## [0.6.2] - 2026-09-25 — Pre-bounty audit remediation (SECURITY)

> **Security advisory: upgrade from 0.6.0 (and any unreleased 0.6.1 build).**
> `CitrateClient.deploy_model` with `encrypted=True` and
> `EncryptionConfig(threshold_shares > 0)` wrote **every Shamir share of the
> model AES key** into the public deploy calldata (`encryption_metadata.key_shares`).
> Anyone reading the chain could rebuild the key and decrypt the uploaded model
> with no private key (PBA-L6b-003, CRITICAL; same design flaw as the JS SDK's
> PBA-L4-001). Models deployed that way should be treated as disclosed: rotate
> (re-encrypt under a new key) and redeploy. Deploys with `threshold_shares=0`
> (the default) were not affected. 0.6.0 is to be yanked on PyPI.

### Security

- **PBA-L6b-003 (CRITICAL):** key shares never enter metadata or calldata.
`encrypt_model` refuses `threshold_shares > 0`; the new
`KeyManager.encrypt_model_with_key_shares` requires
`EncryptionConfig.share_holder_public_keys` (one distinct secp256k1 key per
share), ECDH-wraps each share to its holder (V2 envelope) and returns the
envelopes separately. `deploy_model` returns them on
`ModelDeployment.key_share_envelopes` for **off-chain** delivery; holders open
theirs with `KeyManager.unwrap_key_share(envelope, owner_public_key)`.
`deploy_model` also refuses any payload that carries a key-share field.
Tripwires: a calldata decoder asserts no subset of the deploy calldata
rebuilds the key, in the test suite and against the built wheel in CI and in
the publish workflow.
- Shamir hardening (variant of the JS PBA-L4-005): every share is validated
(integer x in 1..255, distinct, equal non-empty y);
`reconstruct_key_from_shares(shares, threshold)` takes the threshold from the
caller; `verify_shares` now checks consistency.
- **PBA-L6b-026:** the transport gate refused plain remote `http://` but passed
`"\u00a0http://..."` (empty parsed scheme). It now refuses whitespace and
control characters anywhere, allows only https/http, and refuses an empty
scheme or host.
- **PBA-L6b-027:** `verify_wallet_address_on_chain` goes through the transport
gate, asserts `eth_chainId`, and requires factory code.
- **PBA-L6b-028:** the default classroom invite code is
`secrets.token_urlsafe(16)` (was a millisecond timestamp); it is available as
`ClassroomManager.last_invite_code`.
- **PBA-L6b-029:** `verify_id_token` requires numeric `exp` and `iat` and
refuses a `typ` other than `JWT`.
- **PBA-L6b-030:** IPFS downloads stream under `max_bytes` (default 1 GiB) and
verify `expected_sha256` and self-describing CIDs.
- **PBA-L6b-042:** manager writes assert `eth_chainId` against the pinned chain.

### Changed (breaking)

- `IdentityClient.refresh(refresh_token, expected_sub)`: `expected_sub` is
required; a refreshed token naming another `sub` is refused.
- `IdentityClient.siwe_challenge()` takes no argument and returns `{"nonce"}`
(GET, as the authority serves it); build the message with
`build_siwe_message(...)`. `siwe_verify` returns
`{"kind": "redirect" | "token", ...}` (the authority never returned the
access/refresh tokens the old client expected).
- `reconstruct_key_from_shares(shares, threshold)`: threshold is required.
- Unknown `access` / `tier` / `mode` strings raise `ValueError` (PBA-L6b-031)
instead of silently becoming option 0.

### Fixed

- **PBA-L6b-040:** `ClassroomManager.enroll` encodes `enrollWithCode(bytes)`
(the contract hashes the raw code); `get_classroom` decodes the struct
return. A parity test pins every ClassroomRegistry selector.
- **PBA-L6b-041:** `uv.lock` regenerated (cryptography 50.0.1) and checked in
CI with `uv lock --check`.

## [0.6.1] - 2026-08-02 (never published) — SECREM-02 K3 envelope hardening

**Package metadata (2026-09-24):** Repository, Bug Tracker and Changelog now point at
`github.com/CitrateNetwork/citrate-sdk-python`. The published 0.6.0 pointed at a private
Expand Down
2 changes: 1 addition & 1 deletion citrate_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
StakingInfo,
)

__version__ = "0.6.1"
__version__ = "0.6.2"
__author__ = "Citrate Team"

__all__ = [
Expand Down
39 changes: 39 additions & 0 deletions citrate_sdk/_chain_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Chain-id pin for the node-signed manager write paths (PBA-L6b-042).

SPY-B-005 pinned the chain id for ``CitrateClient`` (which signs locally), but
the Learning/Staking/Classroom/Compute/Treasury/Farming managers send writes
with node-side ``eth_sendTransaction`` and never asked the node which chain it
is on. A manager pointed at another network's node that holds the same account
moved funds there. ``pinned_send`` asserts ``eth_chainId`` against the expected
chain once per manager, then sends with ``chainId`` in the tx so an honest node
also rejects a mismatch itself.
"""
from __future__ import annotations

from typing import Any, cast

from ._generated import contract as _contract
from .errors import CitrateError


def expected_chain_id(chain_id: int | None) -> int:
return int(chain_id) if chain_id is not None else _contract.chain_id()


def pinned_send(manager: Any, tx: dict[str, Any]) -> str:
"""Assert the node's chain (cached per manager) and send ``tx``."""
expected = cast(int, manager._expected_chain_id)
if not getattr(manager, "_chain_verified", False):
raw = manager._rpc_call("eth_chainId", [])
try:
reported = int(raw, 16) if isinstance(raw, str) else int(raw)
except (TypeError, ValueError):
raise CitrateError(f"RPC returned an unparseable eth_chainId {raw!r}; refusing to send (PBA-L6b-042).")
if reported != expected:
raise CitrateError(
f"RPC chain-id mismatch: node reports {reported} but the configured/vendored "
f"chain-id is {expected}. Refusing to send a transaction to another network "
"(PBA-L6b-042)."
)
manager._chain_verified = True
return cast(str, manager._rpc_call("eth_sendTransaction", [dict(tx, chainId=hex(expected))]))
35 changes: 31 additions & 4 deletions citrate_sdk/_url_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
"""

import ipaddress
import unicodedata
from urllib.parse import urlparse

# PBA-L6b-026: only these schemes are ever accepted.
_ALLOWED_SCHEMES = frozenset({"https", "http"})

# Hostnames that are always local to the calling machine — plaintext to these
# never traverses an untrusted network, so http:// is silently allowed.
_LOCAL_HOSTNAMES = frozenset({"localhost", "ip6-localhost", "ip6-loopback"})
Expand Down Expand Up @@ -49,6 +53,8 @@ def enforce_transport_security(url: str, *, allow_insecure_http: bool = False) -
"""
Validate the transport security of ``url`` and return it unchanged.

- Anything but ``https``/``http``, an empty scheme or host, and any URL
containing whitespace or control characters RAISES (PBA-L6b-026).
- ``https://`` URLs pass silently.
- ``http://`` to a loopback/localhost host passes silently (local traffic).
- ``http://`` to a *remote* host RAISES :class:`InsecureTransportError`
Expand All @@ -64,15 +70,36 @@ def enforce_transport_security(url: str, *, allow_insecure_http: bool = False) -
implicitly — an automatic http→https upgrade to a host that doesn't serve TLS
would fail confusingly.
"""
# PBA-L6b-026: this used to return non-strings, empty strings and any URL
# whose parsed scheme was not "http" unchecked. "\u00a0http://remote"
# parses with an EMPTY scheme, so it passed; requests then strips the
# whitespace and sends plaintext to the remote host. Now: refuse
# whitespace/control/format characters anywhere (they have no business in
# an endpoint URL and different parsers treat them differently), allow
# only https/http, and refuse an empty scheme or host.
if not isinstance(url, str) or not url:
return url
raise InsecureTransportError(f"Refusing an empty or non-string endpoint URL: {url!r}")
for ch in url:
if ch.isspace() or unicodedata.category(ch)[0] in ("C", "Z"):
raise InsecureTransportError(
f"Refusing endpoint URL {url!r}: it contains whitespace or a control "
f"character (U+{ord(ch):04X}). Parsers disagree on such URLs, which "
"lets an http:// endpoint slip past this check (PBA-L6b-026)."
)

parsed = urlparse(url)
if parsed.scheme != "http":
# https, or a non-http scheme we don't police here.
scheme = parsed.scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
raise InsecureTransportError(
f"Refusing endpoint URL {url!r}: scheme {parsed.scheme!r} is not https or "
"http (PBA-L6b-026)."
)
if not parsed.hostname:
raise InsecureTransportError(f"Refusing endpoint URL {url!r}: no host (PBA-L6b-026).")
if scheme == "https":
return url

if _is_local_host(parsed.hostname or ""):
if _is_local_host(parsed.hostname):
return url

if not allow_insecure_http:
Expand Down
15 changes: 15 additions & 0 deletions citrate_sdk/abi.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ def keccak256_text(text: str) -> str:
# Wei / Ether conversion
# ---------------------------------------------------------------------------

def enum_index(choices: Sequence[str], value: str, what: str) -> int:
"""Index of ``value`` in ``choices`` (exact, else case-insensitive exact).

PBA-L6b-031: callers used ``choices.index(v) if v in choices else 0``, so an
unknown or mis-cased value silently became option 0 (an Open pool, a
Commitment-tier job). Unknown values now raise ValueError.
"""
if value in choices:
return list(choices).index(value)
folded = [c.lower() for c in choices]
if isinstance(value, str) and value.lower() in folded:
return folded.index(value.lower())
raise ValueError(f"unknown {what} {value!r}; expected one of {', '.join(choices)}")


def to_wei(ether: str) -> int:
"""Convert an ether-denominated decimal string to wei (int)."""
return Web3.to_wei(ether, "ether")
Expand Down
23 changes: 18 additions & 5 deletions citrate_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from ._generated import contract as _contract
from ._url_security import enforce_transport_security
from .crypto import EncryptionConfig, KeyManager
from .crypto import EncryptionConfig, KeyManager, assert_no_key_share_material
from .errors import CitrateError, ModelNotFoundError
from .ipfs import upload_to_ipfs
from .models import InferenceRequest, InferenceResult, ModelConfig, ModelDeployment
Expand Down Expand Up @@ -197,14 +197,22 @@ def deploy_model(
# Encrypt model if requested
encrypted_data = None
encryption_metadata = None
key_share_envelopes: list[dict[str, Any]] | None = None

if config.encrypted:
if not config.encryption_config:
config.encryption_config = EncryptionConfig()

encrypted_data, encryption_metadata = self.key_manager.encrypt_model(
model_data, config.encryption_config
)
if config.encryption_config.threshold_shares:
# PBA-L6b-003: shares come back wrapped to their holders and
# separate from the public metadata; they never enter calldata.
encrypted_data, encryption_metadata, key_share_envelopes = (
self.key_manager.encrypt_model_with_key_shares(model_data, config.encryption_config)
)
else:
encrypted_data, encryption_metadata = self.key_manager.encrypt_model(
model_data, config.encryption_config
)

# Upload to IPFS
ipfs_hash = self._upload_to_ipfs(encrypted_data or model_data)
Expand All @@ -222,6 +230,10 @@ def deploy_model(
if encryption_metadata:
tx_data["encryption_metadata"] = encryption_metadata

# PBA-L6b-003: this calldata is public. Refuse to send if anything in it,
# including caller-supplied metadata, carries a key-share field.
assert_no_key_share_material(tx_data)

# Call model deployment precompile. SPY-B-007: the address is read from
# the vendored canonical table (ModelDeploy = 0x..0100), NOT a hardcoded
# `0x0100..0100` literal. The pre-fix literals were wrong in the high byte
Expand All @@ -241,7 +253,8 @@ def deploy_model(
ipfs_hash=ipfs_hash,
encrypted=config.encrypted,
access_price=config.access_price,
deployment_time=int(time.time())
deployment_time=int(time.time()),
key_share_envelopes=key_share_envelopes,
)

def inference(
Expand Down
14 changes: 10 additions & 4 deletions citrate_sdk/compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@

from typing import Any, cast

from .abi import AbiInterface, from_wei, to_wei
from ._chain_guard import expected_chain_id, pinned_send
from .abi import AbiInterface, enum_index, from_wei, to_wei
from .errors import ConfigurationError
from .types import ComputeJob, ComputePool, Dispute, ProviderInfo

Expand Down Expand Up @@ -86,8 +87,13 @@ def __init__(
gas_limit: int = 500_000,
gas_price: str = "0x3b9aca00",
contract_addresses: dict[str, str] | None = None,
*,
chain_id: int | None = None,
) -> None:
self._rpc_call = rpc_call
# PBA-L6b-042: writes assert eth_chainId against this before sending.
self._expected_chain_id = expected_chain_id(chain_id)
self._chain_verified = False
self._default_account = default_account
self._gas_limit = gas_limit
self._gas_price = gas_price
Expand Down Expand Up @@ -130,7 +136,7 @@ def _send_transaction(self, to: str, data: str, value: str = "0x0") -> str:
"gas": hex(self._gas_limit),
"gasPrice": self._gas_price,
}
return cast(str, self._rpc_call("eth_sendTransaction", [tx]))
return pinned_send(self, tx)

# -------------------------------------------------------------------
# Job Lifecycle
Expand Down Expand Up @@ -159,7 +165,7 @@ def post_job(
addr = self._require_compute_address()
h = model_hash if model_hash.startswith("0x") else f"0x{model_hash}"
hash_bytes = bytes.fromhex(h[2:])
tier_num = JOB_TIERS.index(tier) if tier in JOB_TIERS else 0
tier_num = enum_index(JOB_TIERS, tier, "tier")
max_price_wei = to_wei(max_price)
input_bytes = input_data.encode("utf-8")
data = self._compute_iface.encode_function_data("postJob", [
Expand Down Expand Up @@ -363,7 +369,7 @@ def create_pool(
Transaction hash (pool ID emitted in PoolCreated event).
"""
addr = self._require_compute_address()
mode_num = POOL_MODES.index(mode) if mode in POOL_MODES else 0
mode_num = enum_index(POOL_MODES, mode, "pool mode")
price_wei = to_wei(price)
data = self._compute_iface.encode_function_data("createPool", [
name, mode_num, min_providers, throughput, price_wei,
Expand Down
Loading
Loading