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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to `citrate-labs-sdk` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [0.6.3] - 2026-09-25 — Hardening

### Changed

- `deploy_model` serialises the transaction payload once, guards those exact
bytes (duplicate object keys are refused), and sends the same bytes.
`_send_transaction` accepts an already-serialised JSON payload.
- The key-share guard recognises byte values and their JSON renderings.

### Tests

- A differential property test runs the share guard against the strict share
parser and other known hex decoders over generated near-hex input.

## [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).**
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.2"
__version__ = "0.6.3"
__author__ = "Citrate Team"

__all__ = [
Expand Down
27 changes: 19 additions & 8 deletions citrate_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@

from ._generated import contract as _contract
from ._url_security import enforce_transport_security
from .crypto import EncryptionConfig, KeyManager, assert_no_key_share_material
from .crypto import (
EncryptionConfig,
KeyManager,
assert_payload_has_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 @@ -230,16 +234,18 @@ 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)
# PBA-L6b-003: this calldata is public. Serialise once, guard the parsed
# payload (duplicate keys refused), and send that exact payload. json.dumps
# only emits JSON types, so the parsed payload is the complete view.
payload = json.dumps(tx_data)
assert_payload_has_no_key_share_material(payload)

# 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
# (`0x01`-prefixed), so every deploy dispatched to an address with no
# precompile entry and the state change never happened.
tx_hash = self._send_transaction(self._precompile("ModelDeploy"), tx_data)
tx_hash = self._send_transaction(self._precompile("ModelDeploy"), payload)

# Wait for confirmation
receipt = self._wait_for_receipt(tx_hash)
Expand Down Expand Up @@ -465,11 +471,16 @@ def _eip155_chain_id(self) -> int:
def _send_transaction(
self,
to_address: str,
data: dict[str, Any],
data: dict[str, Any] | str,
value: int = 0,
gas_limit: int = 500000
) -> str:
"""Send transaction to blockchain"""
"""Send transaction to blockchain.

``data`` is either a dict (serialised here) or an already-serialised
JSON string, which is sent byte-for-byte (see ``deploy_model``).
"""
encoded = data if isinstance(data, str) else json.dumps(data)
if not self.key_manager:
raise CitrateError("Private key required for transactions")

Expand All @@ -487,7 +498,7 @@ def _send_transaction(
"gas": hex(gas_limit),
"gasPrice": hex(20_000_000_000), # 20 gwei
"nonce": hex(nonce),
"data": "0x" + json.dumps(data).encode().hex(),
"data": "0x" + encoded.encode().hex(),
"chainId": self._eip155_chain_id(),
}

Expand Down
63 changes: 61 additions & 2 deletions citrate_sdk/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,70 @@ def _share_x(x: Any) -> bool:
return isinstance(x, str) and x.isascii() and x.isdigit() and 1 <= int(x) <= 255


def _is_byte_int(v: Any) -> bool:
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255


def _bytes_like_len(y: Any) -> int:
"""Length of ``y`` if it is bytes or a JSON rendering of bytes (an integer
list, the ``{"type": "Buffer", "data": [...]}`` shape, or an object keyed
"0".."n-1" with byte values); otherwise 0."""
if isinstance(y, (bytes, bytearray)):
return len(y)
if isinstance(y, (list, tuple)):
return len(y) if all(_is_byte_int(v) for v in y) else 0
if isinstance(y, dict):
if set(y) == {"type", "data"} and y.get("type") == "Buffer":
return _bytes_like_len(list(y["data"])) if isinstance(y.get("data"), list) else 0
n = len(y)
if n and all(isinstance(k, str) for k in y) and set(y) == {str(i) for i in range(n)}:
return n if all(_is_byte_int(y[str(i)]) for i in range(n)) else 0
return 0


class _DuplicateKeyError(ValueError):
pass


def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in pairs:
if k in out:
raise _DuplicateKeyError(k)
out[k] = v
return out


def _loads_strict(text: str) -> Any:
"""json.loads that rejects duplicate object keys (raises _DuplicateKeyError)."""
return json.loads(text, object_pairs_hook=_no_duplicate_keys)


_DUP_MSG = (
"deploy_model: refusing to publish JSON with duplicate object keys; decoders disagree on "
"which value wins, so the content cannot be checked for key-share material (PBA-L6b-003)."
)


def assert_payload_has_no_key_share_material(payload: str) -> None:
"""Guard a serialised JSON payload exactly as it will be sent: parse it
(refusing duplicate keys) and run :func:`assert_no_key_share_material`."""
try:
decoded = _loads_strict(payload)
except _DuplicateKeyError:
raise CitrateError(_DUP_MSG)
except (ValueError, RecursionError):
raise CitrateError("deploy_model: payload is not valid JSON; refusing to publish it.")
assert_no_key_share_material(decoded)


def _looks_like_share(d: dict[Any, Any]) -> bool:
"""A raw Shamir share ({x in 1..255, y of share length as hex or bytes})
or a holder-wrapped share record ({holder_public_key/holderPublicKey,
envelope}). Short or coordinate-like values are not treated as shares."""
y = d.get("y")
if "x" in d and _share_x(d["x"]):
if isinstance(y, (bytes, bytearray)) and len(y) >= _MIN_SHARE_BYTES:
if _bytes_like_len(y) >= _MIN_SHARE_BYTES:
return True
if isinstance(y, str) and _share_y_like(y):
return True
Expand All @@ -89,7 +146,9 @@ def assert_no_key_share_material(value: Any, _depth: int = 0) -> None:
# Any string that parses as JSON is checked too (no size cap: a padded
# blob must not slip through).
try:
decoded = json.loads(value)
decoded = _loads_strict(value)
except _DuplicateKeyError:
raise CitrateError(_DUP_MSG)
except (ValueError, RecursionError):
return
assert_no_key_share_material(decoded, _depth + 1)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "citrate-labs-sdk"
version = "0.6.2"
version = "0.6.3"
description = "Python SDK for the Citrate distributed AI network (chain 40204). The canonical TypeScript SDK is @citratelabs/sdk on npm; this Python client is opt-in and may lag it."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
Loading
Loading