Skip to content

fix(security): SDK hardening from the pre-bounty audit; 0.6.2 - #6

Merged
SaulBuilds merged 12 commits into
mainfrom
fix/pba-r2-sdk
Sep 25, 2026
Merged

SaulBuilds merged 12 commits into
mainfrom
fix/pba-r2-sdk

Conversation

@SaulBuilds

@SaulBuilds SaulBuilds commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Pre-bounty remediation R2: SDK lane (citrate-sdk-python)

This PR makes security hardening changes from the 2026-09-24 pre-bounty audit. Finding detail, PoCs and mutation evidence are in the private audit record. The version is bumped to 0.6.2, and there is a security note in CHANGELOG.md. Publishing is the owner's call.

Finding Area Status
PBA-L6b-003 Model-encryption key sharing: holder-wrapped, delivered off-chain. The tripwire runs on the built wheel in CI and in the publish workflow. FIXED
PBA-L6b-026 / 027 Transport-gate and wallet-verification hardening FIXED (further hardening in the follow-up PR)
PBA-L6b-028 Classroom invite-code generation FIXED
PBA-L6b-029 ID-token claim validation, plus parity with the JS SDK's refresh and SIWE behaviour FIXED
PBA-L6b-030 IPFS download bounds and verification FIXED (further hardening in the follow-up PR)
PBA-L6b-031 Enum validation FIXED
PBA-L6b-040 ClassroomRegistry ABI parity FIXED
PBA-L6b-041 uv.lock regenerated and checked in CI FIXED
PBA-L6b-042 Chain-id pin on manager writes FIXED

🤖 Generated with Claude Code

BerryManifold and others added 12 commits September 25, 2026 00:14
…L6b-003)

deploy_model with threshold_shares > 0 put every share of the model AES key
into tx_data["encryption_metadata"]["key_shares"], i.e. public calldata.
Anyone reading the chain rebuilt the key and decrypted the uploaded model.

- encrypt_model refuses threshold_shares > 0; the new
  encrypt_model_with_key_shares requires share_holder_public_keys (one
  distinct secp256k1 key per share), ECDH-wraps each share to its holder
  (existing V2 envelope) and returns the envelopes APART from the public
  metadata. deploy_model hands them back on ModelDeployment
  .key_share_envelopes for off-chain delivery; the metadata carries only
  {threshold, total_shares}.
- KeyManager.unwrap_key_share (holder side, owner-pinned) and
  reconstruct_key_from_shares(shares, threshold) with the threshold taken
  from the caller, never the share.
- deploy_model runs assert_no_key_share_material over the whole tx payload
  (caller metadata included) before signing.
- Variant (PBA-L4-005 twin): finite_field validates every share (integer x
  in 1..255, distinct, equal non-empty y), verify_shares now checks extra
  shares lie on the polynomial, Lagrange is general in x.
- examples/encrypted_inference.py uses holder keys and off-chain shares.
- Tripwires: scripts/keyshare_leak_tripwire.py (independent GF(2^8));
  tests decode the signed raw tx and assert no calldata subset rebuilds the
  key; scripts/check_keyshare_wheel.py runs the same against the built
  wheel in CI and before publish (fails on the published 0.6.0 wheel).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…es in the transport gate (PBA-L6b-026)

"\u00a0http://remote" parsed with an empty scheme and passed the gate; requests then stripped the whitespace and sent plaintext to the remote host (all 6 gated clients). The gate now refuses whitespace/control/format characters anywhere, allowlists {https, http}, refuses an empty scheme or host and non-string input, and the gated clients use the URL it returns (identity client hunks land with the PBA-L6b-029 commit).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…d + factory code (PBA-L6b-027)

The verifier posted to any rpc_url (remote plaintext included) and trusted whatever came back, so a MITM or hostile RPC could echo the publicly computable prediction and "do not fund" passed. It now goes through the transport gate (allow_insecure_http opt-in), requires eth_chainId == the pinned chain (chain_id override), and requires non-empty factory code before comparing predictAddress.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
… ABI parity (PBA-L6b-028, PBA-L6b-040)

L6b-028: the default invite code was classroom-<ms timestamp>; its keccak is public at createClassroom and the block timestamp bounds the window, so a +/-60 s search recovered it in under a second. Default is now secrets.token_urlsafe(16), exposed to the caller as ClassroomManager.last_invite_code.

L6b-040: the SDK still encoded enrollWithCode(bytes32 hash); ClassroomRegistry (CHAIN-B-C009) takes enrollWithCode(bytes rawCode). New parity test pins every ClassroomRegistry selector the SDK encodes to the contract source @ citrate-chain 21726055; it also caught getClassroom, which returns a Classroom struct (one tuple behind an offset) that the SDK decoded as six flat values. Two existing tests that pinned the removed bytes32 ABI are corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…h; match SIWE routes (PBA-L6b-029, PBA-L3a-011/012 twins)

L6b-029: verify_id_token accepted a missing or non-numeric exp (never expired) and ignored typ, so an at+jwt/logout+jwt token from the same key and audience passed as an ID token. exp and iat must be finite numbers (bools rejected) and typ absent or JWT. Existing fixtures gain the iat claim real tokens carry.

Variant sweep, Python twins of the JS findings:
- PBA-L3a-011: refresh(refresh_token, expected_sub) refuses a refreshed ID token naming a different sub.
- PBA-L3a-012: siwe_challenge() is GET /siwe/challenge -> {nonce}; build_siwe_message() builds the EIP-4361 message the authority enforces (40204, Expiration Time <= 24 h, uri host, EIP-55; byte-identical to the JS builder); siwe_verify returns kind=redirect|token (ID token verified) and surfaces the server reason. Contract test transcribes citrate-identity siwe-routes.ts @ 08959bf.
Also routes the identity client through the URL the transport gate returns (PBA-L6b-026).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…L6b-030)

download_bytes buffered the whole body and returned it unchecked. It now streams with max_bytes (default 1 GiB), verifies expected_sha256 when given, and verifies self-describing addresses with no hint (sha256:<hex> pointers and CIDv1 raw/sha2-256). dag-pb CIDs hash a chunked DAG and cannot be recomputed from the bytes; the docstring directs callers to pass expected_sha256 (e.g. the on-chain model_hash) for those. IPFSManager.download and download_from_ipfs thread both parameters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…ulting to index 0 (PBA-L6b-031)

create_pool(access=...) turned any unrecognised value into an Open pool, post_job(tier='zk') posted a Commitment-tier job while escrowing max_price, and an unknown pool mode became InferencePool. New abi.enum_index accepts the canonical name or a case-insensitive exact match and raises ValueError otherwise, before any transaction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…042)

The SPY-B-005 pin covered only CitrateClient. The Learning/Staking/Classroom/Compute/Treasury/Farming managers send writes with node-side eth_sendTransaction and never asked which chain the node is on. New _chain_guard.pinned_send asserts eth_chainId against the pinned chain (artifact 40204 by default; chain_id= keyword to override) once per manager, refuses garbage, and puts chainId in the tx so an honest node also rejects a mismatch. Existing manager tests now answer eth_chainId like a real node (tests/_chain_rpc.py) and read the last rpc call instead of the first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
0.6.1 exists only in the repo (PyPI has 0.6.0 alone) and already names another change set, so the fix ships as the next free version, 0.6.2. CHANGELOG carries the advisory (rotate models deployed with threshold_shares > 0; yank 0.6.0) and the breaking-change notes. Not published: release is the owner's call via the publish workflow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
uv.lock locked cryptography 46.0.7 although pyproject requires >=48.0.1, and was missing packages the dev extra pulls in; uv lock --check failed on main. Regenerated with uv 0.12.19 (cryptography now 50.0.1; existing pins kept where still compatible) and added a uv-lock-check CI job (uv lock --check) so the lock cannot drift silently again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…BA-L6-003 policy)

Rebased onto the merge-gate PR (#4), which pins every action to a full commit SHA; the two jobs this branch adds now use the same pins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
…(PBA-L6b-030)

mutmut over the functions this branch changed left survivors on boundaries (exp/nbf tolerance, share-count and threshold edges, guard depth), parameter hand-offs (IPFS manager, wallet RPC, deploy plumbing) and request shapes. tests/test_pba_r2_mutation_hardening.py pins them. One behaviour change: a sha256:<hex> pointer that is not 32 bytes of hex is now refused instead of being treated as unverifiable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYQkdsk54yob6FD24jAT8P
@SaulBuilds
SaulBuilds merged commit 11c3fe0 into main Sep 25, 2026
9 checks passed
@SaulBuilds
SaulBuilds deleted the fix/pba-r2-sdk branch September 25, 2026 14:44
@SaulBuilds SaulBuilds changed the title fix(security): key shares never on-chain (PBA-L6b-003 CRITICAL) + L6b-026..031/040..042; 0.6.2 fix(security): SDK hardening from the pre-bounty audit; 0.6.2 Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants