diff --git a/packages/core/jpyc_core_sdk/attestation.py b/packages/core/jpyc_core_sdk/attestation.py new file mode 100644 index 0000000..8fb32e1 --- /dev/null +++ b/packages/core/jpyc_core_sdk/attestation.py @@ -0,0 +1,110 @@ +"""Post quantum attestation of JPYC operations. + +Signs a canonical payload such as a transfer or a mint request with FIPS 204 +ML-DSA so a holder can confirm the operation is authentic and has not been +altered, using a signature scheme that stays safe once large quantum computers +exist. + +The attestation is self contained. It carries the public key and the signature, +so a verifier needs only the attestation and the payload. Integrity is always +covered by the payload digest and authenticity is covered when the verifier +pins the expected public key. + +The scheme comes from the optional pq extra. Install it with +``pip install jpyc-core-sdk[pq]``. +""" + +import base64 +import hashlib +import json +from typing import Any, TypedDict + +SCHEME = "ML-DSA-65" +ISSUER = "Quantova" + + +class Attestation(TypedDict): + """A self contained post quantum attestation over a payload. + + Attributes: + scheme (str): The FIPS 204 signature scheme used. + issuer (str): The party that built the attestation. + payload_sha256 (str): Hex digest of the signed payload. + public_key (str): Base64 encoded public key. + signature (str): Base64 encoded signature. + """ + + scheme: str + issuer: str + payload_sha256: str + public_key: str + signature: str + + +def _backend() -> Any: + """Return the ML-DSA backend or raise if the pq extra is not installed.""" + try: + from dilithium_py.ml_dsa import ML_DSA_65 # type: ignore[import-untyped] + except ImportError as error: + raise RuntimeError( + "Post quantum attestation needs the pq extra. " + "Install it with pip install jpyc-core-sdk[pq]." + ) from error + return ML_DSA_65 + + +def canonicalize(payload: dict[str, Any]) -> bytes: + """Return the canonical byte encoding an attestation is computed over. + + Keys are sorted and whitespace is fixed so the same payload always yields + the same bytes on any machine. + """ + return json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + +def generate_keypair() -> tuple[bytes, bytes]: + """Return a fresh (public_key, secret_key) pair for the signer.""" + public_key, secret_key = _backend().keygen() + return public_key, secret_key + + +def build_attestation( + payload: bytes, public_key: bytes, secret_key: bytes +) -> Attestation: + """Return a self contained attestation over payload.""" + signature = _backend().sign(secret_key, payload, deterministic=True) + return Attestation( + scheme=SCHEME, + issuer=ISSUER, + payload_sha256=hashlib.sha256(payload).hexdigest(), + public_key=base64.b64encode(public_key).decode("ascii"), + signature=base64.b64encode(signature).decode("ascii"), + ) + + +def verify_attestation( + payload: bytes, + attestation: Attestation, + expected_public_key: bytes | None = None, +) -> bool: + """Return True when attestation matches payload. + + The payload digest is always checked. When expected_public_key is given the + signer is pinned too, so a valid signature made under a different key is + rejected. + """ + if attestation["payload_sha256"] != hashlib.sha256(payload).hexdigest(): + return False + try: + public_key = base64.b64decode(attestation["public_key"], validate=True) + signature = base64.b64decode(attestation["signature"], validate=True) + except ValueError: + return False + if expected_public_key is not None and public_key != expected_public_key: + return False + try: + return bool(_backend().verify(public_key, payload, signature)) + except (ValueError, TypeError): + return False diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 5ae7a50..7e62db1 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -41,6 +41,13 @@ dependencies = [ "web3>=7.11.0", ] +[project.optional-dependencies] +# Optional post quantum attestation (jpyc_core_sdk/attestation.py). +# Install with `pip install jpyc-core-sdk[pq]`. +pq = [ + "dilithium-py>=1.0.1", +] + [project.urls] repository = "https://github.com/jcam1/python-sdk" documentation = "https://github.com/jcam1/python-sdk/tree/main/docs/core" diff --git a/tests/core/test_attestation.py b/tests/core/test_attestation.py new file mode 100644 index 0000000..aa84852 --- /dev/null +++ b/tests/core/test_attestation.py @@ -0,0 +1,64 @@ +import base64 + +import pytest + +pytest.importorskip("dilithium_py") + +from packages.core.jpyc_core_sdk import attestation # noqa: E402 + +TRANSFER = { + "chain_id": 1, + "operation": "transfer", + "from": "0xC8CD2BE653759aed7B0996315821AAe71e1FEAdF", + "to": "0x1111111111111111111111111111111111111111", + "value": "1000000000000000000", + "nonce": 7, +} + + +def test_build_and_verify_roundtrip() -> None: + payload = attestation.canonicalize(TRANSFER) + public_key, secret_key = attestation.generate_keypair() + att = attestation.build_attestation(payload, public_key, secret_key) + assert att["scheme"] == "ML-DSA-65" + assert att["issuer"] == "Quantova" + assert attestation.verify_attestation(payload, att) is True + assert attestation.verify_attestation(payload, att, public_key) is True + + +def test_tampered_payload_is_rejected() -> None: + payload = attestation.canonicalize(TRANSFER) + public_key, secret_key = attestation.generate_keypair() + att = attestation.build_attestation(payload, public_key, secret_key) + altered = attestation.canonicalize({**TRANSFER, "value": "9" * 18}) + assert attestation.verify_attestation(altered, att) is False + + +def test_wrong_signer_is_rejected_when_pinned() -> None: + payload = attestation.canonicalize(TRANSFER) + public_key, secret_key = attestation.generate_keypair() + other_public_key, _ = attestation.generate_keypair() + att = attestation.build_attestation(payload, public_key, secret_key) + assert attestation.verify_attestation(payload, att, other_public_key) is False + + +def test_forged_signature_is_rejected() -> None: + payload = attestation.canonicalize(TRANSFER) + public_key, secret_key = attestation.generate_keypair() + att = attestation.build_attestation(payload, public_key, secret_key) + att["signature"] = base64.b64encode(b"\x00" * 64).decode("ascii") + assert attestation.verify_attestation(payload, att) is False + + +def test_malformed_field_is_rejected() -> None: + payload = attestation.canonicalize(TRANSFER) + public_key, secret_key = attestation.generate_keypair() + att = attestation.build_attestation(payload, public_key, secret_key) + att["public_key"] = "!!!not base64!!!" + assert attestation.verify_attestation(payload, att) is False + + +def test_canonicalize_is_order_independent() -> None: + assert attestation.canonicalize({"a": 1, "b": 2}) == attestation.canonicalize( + {"b": 2, "a": 1} + )