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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ jobs:
- name: uv.lock matches pyproject.toml
run: uv lock --check

# The shared python job installs with an unpinned `pip install -e .[dev]`,
# which can resolve newer dependencies than uv.lock. This job runs the same
# lint, type and test steps inside the frozen lockfile environment, so CI
# and a local `uv sync --frozen` see the same dependency versions.
locked-env:
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: Install from uv.lock (frozen)
run: uv sync --frozen --all-extras --python 3.11
- name: Lint (ruff)
run: uv run --frozen ruff check .
- name: Type-check (mypy)
run: uv run --frozen mypy .
- name: Test (pytest, locked dependencies)
run: uv run --frozen pytest -q
- name: Dependency audit of the locked set (pip-audit)
run: uv run --frozen --with "pip-audit==2.10.0" pip-audit --skip-editable

# 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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe
- The deploy guard also refuses values shaped like shares, not only the
known field names.

- **Round 3 (still 0.6.2, unreleased):**
- `ClassroomManager` now follows the invite-key flow of ClassroomRegistry
(citrate-chain #222). `create()` and `rotate_invite_code()` return the
invite secret in `last_invite_code`. `enroll_with_invite(secret)` signs
the enrolment. `enroll()` is deprecated.
- `verify_id_token` refuses an `iat` beyond the clock tolerance in the
future (parity with the JS SDK).
- The share guard matches only share-shaped values (x in 1..255 and a y of
at least 16 bytes), so coordinate-like metadata is no longer refused.

### Changed (breaking)

- `IdentityClient.refresh(refresh_token, expected_sub)`: `expected_sub` is
Expand Down
51 changes: 44 additions & 7 deletions citrate_sdk/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,52 @@
_MAX_GUARD_DEPTH = 32


_HEX_RE = re.compile(r"^(0x)?[0-9a-fA-F]+$")
#: A share's y is at least 16 bytes (the SDK shares 32-byte keys), as
#: even-length hex, optionally 0x-prefixed.
_MIN_SHARE_BYTES = 16
_STRICT_HEX_RE = re.compile(r"(?:0[xX])?((?:[0-9a-fA-F]{2})+)")
_HEX_RUN_RE = re.compile(r"[0-9a-fA-F]{%d,}" % (2 * _MIN_SHARE_BYTES))
_WS_RE = re.compile(r"\s+")


def parse_share_y(y: str) -> bytes:
"""The SDK's one share-value parser: optional 0x/0X, then an even number of
hex digits and nothing else (no whitespace, no trailing junk). Raises
ValueError otherwise. ``reconstruct_key_from_shares`` uses it."""
m = _STRICT_HEX_RE.fullmatch(y) if isinstance(y, str) else None
if m is None:
raise ValueError("share y must be an even-length hex string")
return bytes.fromhex(m.group(1))


def _share_y_like(y: str) -> bool:
"""Deliberately LENIENT (a superset of ``parse_share_y`` and of the lenient
parsers other consumers may use): after removing whitespace, any run of
>= 16 bytes of hex digits counts (a 0x/0X prefix cannot join the run)."""
return _HEX_RUN_RE.search(_WS_RE.sub("", y)) is not None


def _share_x(x: Any) -> bool:
if isinstance(x, bool):
return False
if isinstance(x, int):
return 1 <= x <= 255
if isinstance(x, float):
# JSON "1.0" is the Number 1 in JS: treat an integral float as an integer.
return x.is_integer() and 1 <= x <= 255
return isinstance(x, str) and x.isascii() and x.isdigit() and 1 <= int(x) <= 255


def _looks_like_share(d: dict[Any, Any]) -> bool:
"""A raw Shamir share ({x, y} with y as hex/bytes) or a holder-wrapped
share record ({holder_public_key/holderPublicKey, envelope})."""
"""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 (isinstance(y, (bytes, bytearray)) or (isinstance(y, str) and _HEX_RE.match(y))):
return True
if "x" in d and _share_x(d["x"]):
if isinstance(y, (bytes, bytearray)) and len(y) >= _MIN_SHARE_BYTES:
return True
if isinstance(y, str) and _share_y_like(y):
return True
return "envelope" in d and ("holder_public_key" in d or "holderPublicKey" in d)


Expand Down Expand Up @@ -742,9 +779,9 @@ def reconstruct_key_from_shares(self, shares: list[dict[str, str]], threshold: i
if not (x_text.isascii() and x_text.isdigit() and 1 <= len(x_text) <= 3):
raise CitrateError(f"Invalid share: x must be an integer in 1..255, got {x_text!r}")
try:
y = bytes.fromhex(share["y"])
y = parse_share_y(share["y"])
except ValueError:
raise CitrateError("Invalid share: y is not hex")
raise CitrateError("Invalid share: y is not hex (an even-length hex string is required)")
shares_tuples.append((int(x_text), y))

try:
Expand Down
2 changes: 2 additions & 0 deletions citrate_sdk/identity/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ def verify_id_token(
raise IdTokenError("token has no numeric exp claim")
if not _is_number(payload.get("iat")):
raise IdTokenError("token has no numeric iat claim")
if cast(float, payload["iat"]) > now + tol:
raise IdTokenError("token issued in the future (iat)")
if now > cast(float, exp) + tol:
raise IdTokenError("token expired")
nbf = payload.get("nbf")
Expand Down
141 changes: 107 additions & 34 deletions citrate_sdk/learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@

from __future__ import annotations

import re
import secrets
import warnings
from typing import Any, cast

from eth_abi import decode as abi_decode
from eth_abi import encode as abi_encode
from eth_account import Account
from eth_account.messages import encode_defunct
from eth_account.signers.local import LocalAccount
from eth_utils import keccak

from ._chain_guard import expected_chain_id, pinned_send
from .abi import AbiInterface, enum_index, from_wei, keccak256_text, to_wei
from .abi import AbiInterface, enum_index, from_wei, to_wei
from .errors import ConfigurationError
from .types import (
ClassroomInfo,
Expand Down Expand Up @@ -95,13 +102,48 @@
"function nextWithdrawalId() view returns (uint256)",
]

#: Domain tag of the enrolment proof (ClassroomRegistry.ENROLL_TAG).
_ENROLL_TAG = keccak(b"CitrateClassroomRegistry.Enroll.v1")
_ZERO_ADDRESS = "0x" + "00" * 20


#: secp256k1 group order; a private key must be in [1, n).
_SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141


def _invite_account(invite_secret: str) -> LocalAccount:
"""Parse an invite secret (a 32-byte secp256k1 key as 0x-hex).

The range is checked here rather than left to the installed eth-keys
version, and any parse failure is reported as ValueError.
"""
if not isinstance(invite_secret, str) or not re.fullmatch(r"0x[0-9a-fA-F]{64}", invite_secret):
raise ValueError(
"invite secret must be a 0x-prefixed 32-byte key (the classroom invite is a key pair; "
"use the value ClassroomManager.create / rotate_invite_code returned)"
)
if not 1 <= int(invite_secret, 16) < _SECP256K1_N:
raise ValueError("invite secret is out of range for a secp256k1 key")
try:
return cast(LocalAccount, Account.from_key(invite_secret))
except Exception as e: # eth-keys raises different types across versions
raise ValueError(f"invite secret is not a valid secp256k1 key: {e}") from None


def _invite_commitment(invite_key: str) -> bytes:
"""keccak256(abi.encodePacked(inviteKey)): the on-chain invite commitment."""
return keccak(bytes.fromhex(invite_key[2:]))


_CLASSROOM_TUPLE = "(address,string,uint256,uint256,uint256,bool)"

CLASSROOM_REGISTRY_ABI = [
"function createClassroom(string name, uint256 maxStudents, bytes32 inviteCodeHash)",
# PBA-L6b-040: CHAIN-B-C009 changed this to take the raw code and hash it
# on-chain; the old bytes32 selector no longer exists on the contract.
"function enrollWithCode(bytes inviteCode)",
# citrate-chain #222 (d89200c2): invites are key pairs. The student submits
# the invite key (an address) and the invite secret's signature over
# enrollmentDigest(teacher, student, inviteCodeHash); enrollWithCode is gone.
"function enrollWithInvite(address inviteKey, bytes signature)",
"function codeToTeacher(bytes32 inviteCodeHash) view returns (address)",
"function unenroll()",
"function removeStudent(address student)",
"function whitelistModel(bytes32 modelHash)",
Expand Down Expand Up @@ -668,60 +710,90 @@ def __init__(
self._gas_price = gas_price
self._classroom_address = classroom_address
self._iface = AbiInterface(CLASSROOM_REGISTRY_ABI)
#: The invite code used by the most recent ``create`` call (generated
#: when none was passed). Only its hash goes on-chain; share the code
#: with students out of band.
#: The invite secret from the most recent ``create`` /
#: ``rotate_invite_code`` call. Only the invite key's commitment goes
#: on-chain; share the secret with students out of band.
self.last_invite_code: str | None = None

def _require_address(self) -> str:
if not self._classroom_address:
raise ConfigurationError("ClassroomRegistry contract address not configured.")
return self._classroom_address

def _new_invite(self, invite_code: str | None) -> bytes:
secret = invite_code if invite_code is not None else "0x" + secrets.token_bytes(32).hex()
account = _invite_account(secret)
self.last_invite_code = secret
return _invite_commitment(account.address)

def create(self, name: str, max_students: int, invite_code: str | None = None) -> str:
"""Create a new classroom.

Data source: ClassroomRegistry.createClassroom(string, uint256, bytes32) via eth_sendTransaction.

The invite is a key pair (citrate-chain #222): only the commitment
``keccak256(abi.encodePacked(inviteKey))`` goes on-chain. The invite
secret is left in ``last_invite_code``; share it with students out of
band.

Args:
name: Classroom display name.
max_students: Maximum enrollment capacity.
invite_code: Plain-text invite code (only its keccak256 goes on-chain).
If omitted, a random 128-bit code is generated
(``secrets.token_urlsafe(16)``) and left in ``last_invite_code``.
invite_code: Optional invite secret (0x-prefixed 32-byte key). A
fresh random one is generated if omitted. Plain-text codes are
no longer accepted.

Returns:
Transaction hash.
"""
addr = self._require_address()
# PBA-L6b-028: the old default was ``classroom-<ms timestamp>``; its hash
# is public at createClassroom and the block timestamp bounds the
# window, so it fell to a sub-second brute force.
code = invite_code or secrets.token_urlsafe(16)
self.last_invite_code = code
code_hash = keccak256_text(code)
code_hash_bytes = bytes.fromhex(code_hash[2:])
commitment = self._new_invite(invite_code)
data = self._iface.encode_function_data("createClassroom", [
name, max_students, code_hash_bytes,
name, max_students, commitment,
])
return self._send_transaction(addr, data)

def enroll(self, invite_code: str) -> str:
"""Enroll as a student in a classroom using an invite code.

Data source: ClassroomRegistry.enrollWithCode(bytes) via eth_sendTransaction.
The contract hashes the raw code itself (CHAIN-B-C009, PBA-L6b-040).
def enroll_with_invite(self, invite_secret: str) -> str:
"""Enroll the configured account using the classroom's invite secret.

Args:
invite_code: Plain-text invite code provided by teacher.
Data source: ClassroomRegistry.enrollWithInvite(address, bytes) via eth_sendTransaction.

Returns:
Transaction hash.
Signs ``enrollmentDigest(teacher, student, inviteCodeHash)`` (EIP-191)
with the invite secret locally. Only the invite key (an address) and the
signature go on-chain, and the signature is bound to this student,
this registry and the pinned chain id.
"""
addr = self._require_address()
data = self._iface.encode_function_data("enrollWithCode", [invite_code.encode("utf-8")])
if not self._default_account:
raise ConfigurationError("defaultAccount not configured; required for write operations.")
account = _invite_account(invite_secret)
commitment = _invite_commitment(account.address)
raw = self._eth_call(addr, self._iface.encode_function_data("codeToTeacher", [commitment]))
(teacher,) = self._iface.decode_function_result("codeToTeacher", raw)
if not teacher or str(teacher).lower() == _ZERO_ADDRESS:
raise ValueError("this invite secret is not an active invite on the registry (unknown or rotated)")
digest = keccak(abi_encode(
["bytes32", "uint256", "address", "address", "address", "bytes32"],
[_ENROLL_TAG, self._expected_chain_id, addr, teacher, self._default_account, commitment],
))
signature = account.sign_message(encode_defunct(primitive=digest)).signature
data = self._iface.encode_function_data("enrollWithInvite", [account.address, bytes(signature)])
return self._send_transaction(addr, data)

def enroll(self, invite_code: str) -> str:
"""Deprecated: use :meth:`enroll_with_invite`.

The registry no longer accepts a raw invite code (citrate-chain #222).
This forwards to ``enroll_with_invite`` when given an invite secret and
raises ``ValueError`` for anything else.
"""
warnings.warn(
"ClassroomManager.enroll is deprecated; use enroll_with_invite(invite_secret)",
DeprecationWarning,
stacklevel=2,
)
return self.enroll_with_invite(invite_code)

def unenroll(self) -> str:
"""Unenroll from current classroom (student-initiated).

Expand Down Expand Up @@ -768,21 +840,22 @@ def remove_model(self, model_hash: str) -> str:
data = self._iface.encode_function_data("removeModel", [hash_bytes])
return self._send_transaction(addr, data)

def rotate_invite_code(self, new_invite_code: str) -> str:
"""Rotate the classroom's invite code (teacher only).
def rotate_invite_code(self, new_invite_code: str | None = None) -> str:
"""Rotate the classroom's invite (teacher only).

Data source: ClassroomRegistry.rotateInviteCode(bytes32) via eth_sendTransaction.

Args:
new_invite_code: New plain-text invite code.
new_invite_code: Optional new invite secret (0x-prefixed 32-byte
key); a fresh one is generated if omitted and left in
``last_invite_code``.

Returns:
Transaction hash.
"""
addr = self._require_address()
code_hash = keccak256_text(new_invite_code)
code_hash_bytes = bytes.fromhex(code_hash[2:])
data = self._iface.encode_function_data("rotateInviteCode", [code_hash_bytes])
commitment = self._new_invite(new_invite_code)
data = self._iface.encode_function_data("rotateInviteCode", [commitment])
return self._send_transaction(addr, data)

def get_classroom(self, teacher_address: str) -> ClassroomInfo:
Expand Down
Loading
Loading