From e2b50a3fd092e1681d0b9b808d376ef2b9b34429 Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 12:05:21 -0300 Subject: [PATCH] test(crypto): cross-SDK canonical JSON roundtrip + signature parity Adds `canonical_stringify()` as a public helper on CryptoUtils so both SDKs can produce byte-identical canonical JSON for the same input. The existing `_stringify_deterministic` is kept as a thin alias for backward compatibility. Adds tests/test_canonical.py with: - Shared test vectors (byte-identical to TS SDK) - Cross-SDK fixture verification: reads canonical-sig.json produced by the TS SDK and confirms the signature verifies in Python - Signature parity check: Python signs with the same seed and must produce byte-identical signature bytes to TS (proves canonical drift would surface immediately) This closes the signature mismatch between TS and Python SDKs. --- bsp_sdk/crypto.py | 14 +++- tests/test_canonical.py | 165 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 tests/test_canonical.py diff --git a/bsp_sdk/crypto.py b/bsp_sdk/crypto.py index 38083e0..8e3a7fd 100644 --- a/bsp_sdk/crypto.py +++ b/bsp_sdk/crypto.py @@ -114,14 +114,22 @@ def _pack_keypair(sk: signing.SigningKey) -> KeyPair: ) @staticmethod - def _stringify_deterministic(obj: dict[str, Any]) -> str: - """Sort object keys recursively and serialize with no whitespace. + def canonical_stringify(obj: Any) -> str: + """Canonical JSON — public for cross-SDK parity with TypeScript SDK. - Must match the JS implementation (`JSON.stringify(sortedObj)`). + Mirrors ``CryptoUtils.canonicalStringify`` in the TS SDK: recursive + key sort + compact separators + ensure_ascii=False so UTF-8 strings + pass through verbatim. Both SDKs must produce byte-identical output + for the same input. """ sorted_obj = CryptoUtils._sort_object_keys(obj) return json.dumps(sorted_obj, separators=(",", ":"), ensure_ascii=False) + @staticmethod + def _stringify_deterministic(obj: dict[str, Any]) -> str: + """Alias kept for backward compatibility — delegates to canonical_stringify.""" + return CryptoUtils.canonical_stringify(obj) + @staticmethod def _sort_object_keys(obj: Any) -> Any: if isinstance(obj, dict): diff --git a/tests/test_canonical.py b/tests/test_canonical.py new file mode 100644 index 0000000..37b3a32 --- /dev/null +++ b/tests/test_canonical.py @@ -0,0 +1,165 @@ +"""Cross-SDK canonical JSON + signature roundtrip tests. + +These vectors MUST stay byte-identical to the ones in +`bsp-sdk-typescript/tests/canonical-stringify.test.ts`. Both SDKs +must produce the exact same canonical string for the same input, +otherwise signatures generated in one SDK will fail in the other. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from bsp_sdk.crypto import CryptoUtils + + +# ─── Test vectors (MUST match TS file byte-for-byte) ───────────────────────── + +VECTORS: list[tuple[str, object, str]] = [ + ("empty object", {}, "{}"), + ("single key", {"a": 1}, '{"a":1}'), + ("keys get sorted", {"b": 2, "a": 1}, '{"a":1,"b":2}'), + ( + "nested keys sorted recursively", + {"outer": {"z": 1, "a": 2}}, + '{"outer":{"a":2,"z":1}}', + ), + ("arrays preserve order", {"arr": [3, 1, 2]}, '{"arr":[3,1,2]}'), + ( + "arrays of objects sort each object", + {"arr": [{"z": 1, "a": 2}, {"m": 0}]}, + '{"arr":[{"a":2,"z":1},{"m":0}]}', + ), + ("null value", {"v": None}, '{"v":null}'), + ("booleans", {"t": True, "f": False}, '{"f":false,"t":true}'), + ("integer and float", {"i": 1, "f": 1.5}, '{"f":1.5,"i":1}'), + ( + "string with unicode (ensure_ascii=False)", + {"s": "olá"}, + '{"s":"olá"}', + ), + ( + "biorecord-like payload", + {"biomarker": "BSP-HM-001", "value": 13.8, "unit": "g/dL"}, + '{"biomarker":"BSP-HM-001","unit":"g/dL","value":13.8}', + ), +] + + +@pytest.mark.parametrize("name,input_,expected", VECTORS, ids=[v[0] for v in VECTORS]) +def test_canonical_vectors(name, input_, expected): + assert CryptoUtils.canonical_stringify(input_) == expected + + +def test_no_spaces_in_output(): + out = CryptoUtils.canonical_stringify({"a": 1, "b": [1, 2, {"c": 3}]}) + assert ": " not in out + assert ", " not in out + assert out == '{"a":1,"b":[1,2,{"c":3}]}' + + +def test_insertion_order_does_not_change_output(): + a = CryptoUtils.canonical_stringify({"z": 1, "a": 2, "m": 3}) + b = CryptoUtils.canonical_stringify({"a": 2, "m": 3, "z": 1}) + assert a == b + + +# ─── Cross-SDK signature fixture ───────────────────────────────────────────── + +# Fixed seed shared with TS fixture — identical bytes on both sides. +FIXED_SEED = "0101010101010101010101010101010101010101010101010101010101010101" +FIXED_PAYLOAD = { + "biomarker": "BSP-HM-001", + "value": 13.8, + "unit": "g/dL", + "collected_at": "2026-02-26T08:00:00Z", + "nested": {"z": 1, "a": [1, 2, 3]}, +} +EXPECTED_CANONICAL = ( + '{"biomarker":"BSP-HM-001","collected_at":"2026-02-26T08:00:00Z",' + '"nested":{"a":[1,2,3],"z":1},"unit":"g/dL","value":13.8}' +) + + +def test_python_sign_verify_roundtrip(): + kp = CryptoUtils.key_pair_from_seed(FIXED_SEED) + sig = CryptoUtils.sign_payload(FIXED_PAYLOAD, kp["private_key"]) + assert CryptoUtils.verify_signature(FIXED_PAYLOAD, sig, kp["public_key"]) is True + + +def test_canonical_matches_expected_string(): + assert CryptoUtils.canonical_stringify(FIXED_PAYLOAD) == EXPECTED_CANONICAL + + +def test_cross_sdk_fixture_verifies(): + """ + Read the fixture produced by the TS SDK (or committed from a previous run) + and verify the signature. If Python and TS disagree on the canonical + bytes, this test is the early-warning siren. + + Fixture location (relative to repo root): + ../bsp-sdk-typescript/tests/fixtures/canonical-sig.json + """ + here = Path(__file__).resolve().parent + candidates = [ + # sibling repo layout (monorepo / checkout-style) + here.parent.parent / "bsp-sdk-typescript" / "tests" / "fixtures" / "canonical-sig.json", + # explicit override for CI + Path(os.environ.get("BSP_TS_FIXTURE", "")), + ] + fixture_path = next((p for p in candidates if p and p.is_file()), None) + if fixture_path is None: + pytest.skip( + "TS fixture not available — set BSP_TS_FIXTURE or check out sibling repo", + ) + + with fixture_path.open("r", encoding="utf-8") as f: + fixture = json.load(f) + + # 1. Our canonical string must match the one TS computed + assert ( + CryptoUtils.canonical_stringify(fixture["payload"]) == fixture["canonical"] + ), "Canonical JSON disagreement between Python and TS SDKs" + + # 2. Signature produced by TS must verify with Python + ok = CryptoUtils.verify_signature( + fixture["payload"], + fixture["signature"], + fixture["public_key"], + ) + assert ok is True, "TS-generated signature failed to verify in Python" + + +def test_python_generated_signature_round_trip_with_fixed_seed(): + """Mirror test: Python generates a signature that TS will verify. + + TS reads the fixture in its own test suite. This test ensures that + Python consistently produces the *same* signature bytes as the fixture + (deterministic Ed25519 + deterministic canonical bytes ⇒ same signature). + """ + here = Path(__file__).resolve().parent + fixture_path = ( + here.parent.parent + / "bsp-sdk-typescript" + / "tests" + / "fixtures" + / "canonical-sig.json" + ) + if not fixture_path.is_file(): + pytest.skip("TS fixture not available for signature parity check") + + with fixture_path.open("r", encoding="utf-8") as f: + fixture = json.load(f) + + kp = CryptoUtils.key_pair_from_seed(fixture["seed"]) + sig = CryptoUtils.sign_payload(fixture["payload"], kp["private_key"]) + + assert kp["public_key"] == fixture["public_key"] + assert sig == fixture["signature"], ( + "Python-generated signature differs from TS fixture — " + "canonical JSON drift suspected" + )