Skip to content
Open
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
110 changes: 110 additions & 0 deletions packages/core/jpyc_core_sdk/attestation.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions packages/core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 64 additions & 0 deletions tests/core/test_attestation.py
Original file line number Diff line number Diff line change
@@ -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}
)