From dbefc9a14d3ec404a65fda7a009b4b17a1dbbfd6 Mon Sep 17 00:00:00 2001 From: SaulBuilds Date: Fri, 25 Sep 2026 20:37:21 -0700 Subject: [PATCH] hardening(crypto): broaden byte-value and key matching in the share guard; shared vectors v3 - Byte values may be whole-number floats and signed values (-128..255), including inside JSON strings. - The Buffer JSON shape matches on type == "Buffer" plus a data list, whatever other keys are present. - x/X and y/Y are all checked. - The shared vectors file is v3 (sha256 pinned), byte-identical in citrate-sdk-js. - README and CHANGELOG say that the guard is a safety net against accidental inclusion and that the SDK never places key shares in metadata itself. Hand mutants: all 6 killed. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + README.md | 4 + citrate_sdk/crypto.py | 24 +- tests/fixtures/share_guard_vectors.json | 296 +++++++++++++++++++++++- tests/test_hardening_round4.py | 20 +- 5 files changed, 338 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a7901..d76fe78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe 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. +- The key-share guard also accepts whole-number floats and signed values as + byte values, matches the Buffer JSON shape whatever other keys are present, + and treats uppercase `X`/`Y` like `x`/`y`. It is a safety net against + accidental inclusion; the SDK never places key shares in metadata itself. ### Tests diff --git a/README.md b/README.md index d133aec..6f1d39d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ print(gw.chat_completions( Runnable examples live in `examples/` (`basic_usage.py`, `encrypted_inference.py`, `marketplace_demo.py`). Verify it's up: `get_chain_id()` returning `40204` confirms the RPC. +`deploy_model` never places key shares in deployment metadata; its key-share check on +caller-supplied metadata is a safety net against accidental inclusion, not a guarantee +against deliberately re-encoded data. + > Security: the client warns/fails on a remote plaintext `http://` RPC (keys and signed > transactions would go out in cleartext). Loopback `http://` is always allowed; pass > `allow_insecure_http=True` for a trusted TLS-less internal host. diff --git a/citrate_sdk/crypto.py b/citrate_sdk/crypto.py index b029538..17b2916 100644 --- a/citrate_sdk/crypto.py +++ b/citrate_sdk/crypto.py @@ -65,7 +65,13 @@ def _share_x(x: Any) -> bool: def _is_byte_int(v: Any) -> bool: - return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 255 + """A byte value as it may appear in JSON: a whole number in -128..255 + (signed or unsigned), as an int or a whole-number float.""" + if isinstance(v, bool): + return False + if isinstance(v, float): + return v.is_integer() and -128 <= v <= 255 + return isinstance(v, int) and -128 <= v <= 255 def _bytes_like_len(y: Any) -> int: @@ -77,7 +83,7 @@ def _bytes_like_len(y: Any) -> int: 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": + if y.get("type") == "Buffer" and "data" in y: 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)}: @@ -125,12 +131,14 @@ 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 _bytes_like_len(y) >= _MIN_SHARE_BYTES: - return True - if isinstance(y, str) and _share_y_like(y): - return True + xs = [d[k] for k in ("x", "X") if k in d] + ys = [d[k] for k in ("y", "Y") if k in d] + if any(_share_x(x) for x in xs): + for y in ys: + if _bytes_like_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) diff --git a/tests/fixtures/share_guard_vectors.json b/tests/fixtures/share_guard_vectors.json index 2a083c7..777941b 100644 --- a/tests/fixtures/share_guard_vectors.json +++ b/tests/fixtures/share_guard_vectors.json @@ -1,6 +1,6 @@ { "_comment": "Shared share-guard test vectors. The SAME file is committed to citrate-sdk-js and citrate-sdk-python (tests/fixtures/share_guard_vectors.json); each SDK's guard must refuse every refuse:true entry and accept every refuse:false entry. Keep the two copies byte-identical. raw_payloads are serialised payload texts for each SDK's payload guard (strict parse, then guard).", - "version": 2, + "version": 3, "vectors": [ { "name": "int x, 64-hex y", @@ -752,6 +752,300 @@ 2 ] } + }, + { + "name": "y as whole-number float list", + "refuse": true, + "meta": { + "a": { + "x": 1, + "y": [ + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0, + 171.0 + ] + } + } + }, + { + "name": "JSON string with float byte list", + "refuse": true, + "meta": { + "blob": "{\"x\": 1, \"y\": [1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2, 1.71e2]}" + } + }, + { + "name": "Buffer shape with an extra key", + "refuse": true, + "meta": { + "a": { + "x": 1, + "y": { + "type": "Buffer", + "data": [ + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171 + ], + "extra": 1 + } + } + } + }, + { + "name": "y as signed byte list", + "refuse": true, + "meta": { + "a": { + "x": 1, + "y": [ + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85, + -85 + ] + } + } + }, + { + "name": "uppercase X and Y", + "refuse": true, + "meta": { + "a": { + "X": 1, + "Y": "abababababababababababababababababababababababababababababababab" + } + } + }, + { + "name": "uppercase X, lowercase y", + "refuse": true, + "meta": { + "a": { + "X": "3", + "y": "abababababababababababababababababababababababababababababababab" + } + } + }, + { + "name": "list with values below -128", + "refuse": false, + "meta": { + "x": 1, + "y": [ + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129, + -129 + ] + } + }, + { + "name": "list with fractional floats", + "refuse": false, + "meta": { + "x": 1, + "y": [ + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5, + 171.5 + ] + } + }, + { + "name": "uppercase X and Y coordinates", + "refuse": false, + "meta": { + "X": 1, + "Y": "10" + } + }, + { + "name": "Buffer shape with non-Buffer type", + "refuse": false, + "meta": { + "x": 1, + "y": { + "type": "Other", + "data": [ + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171, + 171 + ] + } + } } ], "raw_payloads": [ diff --git a/tests/test_hardening_round4.py b/tests/test_hardening_round4.py index 412a253..dcc70c3 100644 --- a/tests/test_hardening_round4.py +++ b/tests/test_hardening_round4.py @@ -26,7 +26,7 @@ SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 VECTORS = Path(__file__).parent / "fixtures" / "share_guard_vectors.json" -VECTORS_SHA256 = "8f336f469be58046e7984bf61756d513df9ba497aca8feb9fc8641e2c6a4b805" +VECTORS_SHA256 = "674d35d72f4fca132e59e325efec3a61fcb4cbe7d6cfc5afd85d1821afcf884f" def _mgr() -> tuple[ClassroomManager, list[Any]]: @@ -135,3 +135,21 @@ def test_shared_raw_payloads(vec: dict[str, Any]) -> None: crypto.assert_payload_has_no_key_share_material(vec["text"]) else: crypto.assert_payload_has_no_key_share_material(vec["text"]) + + +@pytest.mark.parametrize("y", [[171.0] * 32, [-85] * 32, {"type": "Buffer", "data": [171] * 32, "k": 1}]) +def test_byte_forms_refused_in_json_strings_too(y: Any) -> None: + with pytest.raises(CitrateError): + assert_no_key_share_material({"blob": json.dumps({"x": 1, "y": y})}) + with pytest.raises(CitrateError): + assert_no_key_share_material({"blob": json.dumps({"X": 1, "Y": y})}) + + +@pytest.mark.parametrize("meta", [ + {"x": "junk", "X": 1, "y": "ab" * 32}, + {"x": 1, "y": "10", "Y": "ab" * 32}, + {"X": 2, "y": "10", "Y": [171] * 32}, +]) +def test_mixed_case_keys_are_all_checked(meta: dict[str, Any]) -> None: + with pytest.raises(CitrateError): + assert_no_key_share_material({"a": meta})