From 63e41d986481692216dbf90d545866665b586b41 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 20:44:10 +0700 Subject: [PATCH 1/4] feat: add Ed25519 (EdDSA) signing and Solana example; bump to 1.0.5 Add Ed25519 signing support for applet v2.0 (KeyType.ED25519) and an examples/solana_transaction/ demo. Rename the BasicG1 card class to Basic (BasicG1 kept as an alias), pad the SIGN PIN to the spec's 9-byte field with named constants, and guard against silent ECDSA fallback for unknown key types. Bump __version__ to 1.0.5 and update CHANGELOG.rst. --- CHANGELOG.rst | 21 + cryptnox_sdk_py/__init__.py | 2 +- cryptnox_sdk_py/card/__init__.py | 4 +- cryptnox_sdk_py/card/base.py | 5 +- cryptnox_sdk_py/card/basic_g1.py | 148 +++++-- cryptnox_sdk_py/card/nft.py | 2 +- cryptnox_sdk_py/enums.py | 1 + cryptnox_sdk_py/exceptions.py | 4 +- cryptnox_sdk_py/factory.py | 2 +- docs/class_diagrams.rst | 2 - examples/solana_transaction/README.md | 173 ++++++++ examples/solana_transaction/__init__.py | 42 ++ .../solana_transaction/solana_transaction.py | 407 ++++++++++++++++++ examples/xrp_transaction/README.md | 5 +- 14 files changed, 770 insertions(+), 48 deletions(-) create mode 100644 examples/solana_transaction/README.md create mode 100644 examples/solana_transaction/__init__.py create mode 100644 examples/solana_transaction/solana_transaction.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5417447..fcda400 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,27 @@ Changelog ========= +Version 1.0.5 - 2026-07-14 +------------------------------------------------------------------------------------------------ + +Added +^^^^^^^ + +- Added Ed25519 (EdDSA) signing support for applet v2.0 (``KeyType.ED25519``) +- Added ``examples/solana_transaction/`` demonstrating Solana signing and transaction building + +Changed +^^^^^^^ + +- Renamed ``BasicG1`` card class to ``Basic`` (``BasicG1`` kept as a backwards-compatible alias) +- Padded the SIGN PIN to the spec's fixed 9-byte field and replaced SIGN magic numbers with named constants +- Replaced hardcoded PINs in examples with a named PIN variable + +Fixed +^^^^^^^ + +- Guarded Ed25519 key handling against silently falling through to the ECDSA path (unknown key types now raise) + Version 1.0.4 - 2026-04-10 ------------------------------------------------------------------------------------------------ diff --git a/cryptnox_sdk_py/__init__.py b/cryptnox_sdk_py/__init__.py index 8dc9500..01551d2 100644 --- a/cryptnox_sdk_py/__init__.py +++ b/cryptnox_sdk_py/__init__.py @@ -10,7 +10,7 @@ from . import enums, exceptions from .enums import SlotIndex, Derivation, KeyType, AuthType, SeedSource, Origin -__version__ = "1.0.4" +__version__ = "1.0.5" __all__ = [ 'Card', diff --git a/cryptnox_sdk_py/card/__init__.py b/cryptnox_sdk_py/card/__init__.py index e9e63a9..9d5ab0f 100644 --- a/cryptnox_sdk_py/card/__init__.py +++ b/cryptnox_sdk_py/card/__init__.py @@ -3,7 +3,7 @@ Module that contains classes for various Cryptnox cards. """ from .base import Base -from .basic_g1 import BasicG1 +from .basic_g1 import Basic, BasicG1 from .nft import Nft -__all__ = ["Base", "BasicG1", "Nft"] +__all__ = ["Base", "Basic", "BasicG1", "Nft"] diff --git a/cryptnox_sdk_py/card/base.py b/cryptnox_sdk_py/card/base.py index 7b072d4..559ed81 100644 --- a/cryptnox_sdk_py/card/base.py +++ b/cryptnox_sdk_py/card/base.py @@ -600,7 +600,7 @@ def set_extended_public_key(self, status: bool, puk: str) -> None: @abc.abstractmethod def sign(self, data: bytes, derivation: Derivation, key_type: KeyType = KeyType.K1, - path: str = "", pin: str = "", filter_eos: bool = False) -> bytes: + path: str = "", pin: str = "") -> bytes: """ Sign the message using given derivation. @@ -617,9 +617,6 @@ def sign(self, data: bytes, derivation: Derivation, key_type: KeyType = KeyType. :type path: str, optional :param pin: PIN code of the card :type pin: str, optional - :param bool filter_eos: Filter signature so it is valid for EOS network, - might take longer. Defaults to False - :type filter_eos: str, optional :return: The signature generated by the card in DER common format. :rtype: bytes diff --git a/cryptnox_sdk_py/card/basic_g1.py b/cryptnox_sdk_py/card/basic_g1.py index 3b3a1c5..0203874 100644 --- a/cryptnox_sdk_py/card/basic_g1.py +++ b/cryptnox_sdk_py/card/basic_g1.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- """ -Module containing class for Basic card of 1st generation +Module containing the class for the Basic card family. + +The class is named ``Basic``; ``BasicG1`` is kept as a backwards-compatible +alias. Both card generations share the same applet identity (``select_apdu``), +so a single class handles them and version-specific capabilities (e.g. Ed25519 +on applet v2.0+) are gated by a runtime applet-version check. """ from collections import namedtuple from typing import ( @@ -25,9 +30,9 @@ ) -class BasicG1(base.Base): +class Basic(base.Base): """ - Class containing functionality for Basic cards of the 1st generation + Class containing functionality for the Basic card family (all generations). """ select_apdu = [0xA0, 0x00, 0x00, 0x10, 0x00, 0x01, 0x12] puk_rule = "12 ASCII characters" @@ -42,6 +47,12 @@ class BasicG1(base.Base): _PINLESS_FLAG = int("00001000", 2) _EXTENDED_PUBLIC_KEY = int("00000100", 2) + # SIGN command (docs.cryptnox.com v2.0): P2 selects the signing scheme. + _SIGN_P2_ECDSA = 0x00 + _SIGN_P2_EDDSA = 0x03 + _DER_SEQUENCE_TAG = 0x30 # first byte of a DER-encoded ECDSA signature + _ED25519_SIGNATURE_LENGTH = 64 # raw R|S EdDSA signature + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.user_data = UserData(self, reading_index_offset=1) @@ -91,15 +102,15 @@ def dual_seed_load(self, data: bytes, pin: str = "") -> None: @property def extended_public_key(self) -> bool: - return bool(self._data[1] & BasicG1._EXTENDED_PUBLIC_KEY) + return bool(self._data[1] & Basic._EXTENDED_PUBLIC_KEY) def generate_random_number(self, size: int) -> bytes: try: size = int(size) except ValueError as error: - raise exceptions.DataValidationException("Checksum has to be an integer") from error - if 16 > size > 64 or size % 4: - raise exceptions.DataValidationException("Checksum value must be between 4 and 8.") + raise exceptions.DataValidationException("Random size has to be an integer") from error + if not 16 <= size <= 64 or size % 4: + raise exceptions.DataValidationException("Random size must be between 16 and 64 bytes, multiple of 4.") return self.connection.send_encrypted([0x80, 0xD3, size, 0x00], b"") @@ -117,7 +128,7 @@ def generate_seed(self, pin: str = "") -> bytes: "generate another one without resetting the card") from error raise - self._data[1] |= BasicG1._SEED_FLAG + self._data[1] |= Basic._SEED_FLAG if result and not self.open: self.auth_type = base.AuthType.PIN @@ -144,6 +155,23 @@ def get_manufacturer_certificate(self, hexed: bool = True): cert = mnft_cert_resp[2:] return "".join([f"{x:02x}" for x in cert]) + def _require_ed25519_support(self, key_type: KeyType) -> None: + """ + Ensure the card applet is recent enough to perform Ed25519 (EdDSA) + operations. Ed25519 support was introduced in applet v2.0. + + :param KeyType key_type: Key type requested for the operation + :raises AppletVersionException: If Ed25519 is requested on a pre-2.0 applet + """ + if key_type != KeyType.ED25519: + return + if not self.applet_version or self.applet_version[0] < 2: + version = ".".join(map(str, self.applet_version)) if self.applet_version else "unknown" + raise exceptions.AppletVersionException( + f"Ed25519 (Solana) operations require applet v2.0 or later, " + f"but the card reports v{version}" + ) + def get_public_key(self, derivation: Derivation, key_type: KeyType = KeyType.K1, path: str = "", compressed: bool = True, hexed: bool = True) -> str: if derivation == Derivation.CURRENT_KEY and path: @@ -158,6 +186,8 @@ def get_public_key(self, derivation: Derivation, key_type: KeyType = KeyType.K1, key_type = KeyType(key_type) derivation = Derivation(derivation) + self._require_ed25519_support(key_type) + if derivation in (Derivation.PINLESS_PATH, Derivation.DERIVE_AND_MAKE_CURRENT): raise exceptions.DerivationSelectionException("This operation doesn't support this derivation form") @@ -167,7 +197,10 @@ def get_public_key(self, derivation: Derivation, key_type: KeyType = KeyType.K1, data = self.connection.send_encrypted(message, binary_path) result = data.hex() if hexed else data - if compressed: + # Compression only applies to the secp256k1/secp256r1 encoding. Allowlist + # the ECDSA key types so a raw key (Ed25519) or any future key type is + # never silently mangled by encode_pubkey. + if compressed and key_type in (KeyType.K1, KeyType.R1): result = encode_pubkey(result, "bin_compressed").hex() return result @@ -177,6 +210,16 @@ def get_public_key_extended(self, key_type: KeyType = KeyType.K1, puk: str = "") if self.seed_source == SeedSource.NO_SEED: raise exceptions.SeedException("There is no seed on the card") + # BIP32 extended public keys are defined for secp256k1 only; the card + # rejects any P1 != 0 for the EXTPUB export. Normalize first so an unknown + # int raises KeySelectionException (not a bare ValueError), then allowlist k1. + try: + key_type = KeyType(key_type) + except ValueError as error: + raise exceptions.KeySelectionException(f"Unknown key type: {key_type!r}") from error + if key_type not in (KeyType.K1,): + raise exceptions.KeySelectionException("Extended public key is only available for k1 keys") + # Step 1: Try to enable XPUB capability if PUK provided if puk: try: @@ -187,8 +230,8 @@ def get_public_key_extended(self, key_type: KeyType = KeyType.K1, puk: str = "") # If enabling fails, continue anyway - it might already be enabled pass - # Step 2: Build APDU to get extended public key - p1 = 0x00 if key_type == KeyType.K1 else 0x10 + # Step 2: Build APDU to get extended public key (k1-only, so P1=0x00) + p1 = 0x00 p2 = 0x02 # extended public key (BIP32) get_apdu = [0x80, 0xC2, p1, p2] @@ -325,7 +368,7 @@ def history(self, index: int = 0) -> NamedTuple: @property def initialized(self) -> bool: - return bool(self._data[1] & BasicG1._INITIALIZATION_FLAG) + return bool(self._data[1] & Basic._INITIALIZATION_FLAG) def load_wrapped_seed(self, seed: bytes, pin: str = "") -> None: if self.auth_type == base.AuthType.PIN: @@ -339,7 +382,7 @@ def load_wrapped_seed(self, seed: bytes, pin: str = "") -> None: "generate another one without resetting the card") from error raise - self._data[1] |= BasicG1._SEED_FLAG + self._data[1] |= Basic._SEED_FLAG if not self.open: self.auth_type = base.AuthType.PIN @@ -356,18 +399,18 @@ def load_seed(self, seed: bytes, pin: str = "") -> None: "generate another one without resetting the card") from error raise - self._data[1] |= BasicG1._SEED_FLAG + self._data[1] |= Basic._SEED_FLAG if not self.open: self.auth_type = base.AuthType.PIN @property def pin_authentication(self) -> bool: - return bool(self._data[1] & BasicG1._PIN_AUTH_FLAG) + return bool(self._data[1] & Basic._PIN_AUTH_FLAG) @property def pinless_enabled(self) -> bool: - return bool(self._data[1] & BasicG1._PINLESS_FLAG) + return bool(self._data[1] & Basic._PINLESS_FLAG) def reset(self, puk: str) -> None: puk = self.valid_puk(puk) @@ -399,7 +442,7 @@ def set_pin_authentication(self, status: bool, puk: str) -> None: raise exceptions.PinAuthenticationException("PIN can't be set without user key.") raise - self._data[1] |= BasicG1._PIN_AUTH_FLAG + self._data[1] |= Basic._PIN_AUTH_FLAG def set_pinless_path(self, path: str, puk: str) -> None: """ @@ -439,7 +482,7 @@ def set_pinless_path(self, path: str, puk: str) -> None: raise - self._data[1] |= BasicG1._PINLESS_FLAG + self._data[1] |= Basic._PINLESS_FLAG def set_extended_public_key(self, status: bool, puk: str) -> None: """ @@ -487,7 +530,7 @@ def user_key_add(self, slot: SlotIndex, data_info: str, public_key: bytes, puk_c if error.status == 0x6700: raise exceptions.DataValidationException("Invalid data length") raise - self._data[3] = BasicG1._set_bit(self._data[3], slot - 1) + self._data[3] = Basic._set_bit(self._data[3], slot - 1) def user_key_delete(self, slot: SlotIndex, puk_code: str) -> None: puk_code = self.valid_puk(puk_code) @@ -505,7 +548,7 @@ def user_key_delete(self, slot: SlotIndex, puk_code: str) -> None: raise exceptions.DataValidationException("Slot empty") raise - self._data[3] = BasicG1._clear_bit(self._data[3], slot - 1) + self._data[3] = Basic._clear_bit(self._data[3], slot - 1) def user_key_info(self, slot: SlotIndex) -> Tuple[str, str]: try: @@ -567,9 +610,15 @@ def sign_public(self, key_type: KeyType = KeyType.K1) -> bytes: if self.seed_source == "NO_SEED": raise exceptions.SeedException("There is no key on the card") + key_type = KeyType(key_type) + # Ed25519 (P1=0x20) needs applet v2.0+; raise a clean AppletVersionException + # on older cards instead of an opaque card status, matching get_public_key/sign. + self._require_ed25519_support(key_type) + CLA = 0x80 INS = 0xC6 - P1 = key_type.value if key_type == KeyType.R1 else KeyType.K1.value + # P1 selects the curve: k1=0x00, r1=0x10, ed25519=0x20 (all supported). + P1 = key_type.value P2 = 0x00 DATA = b"" @@ -579,7 +628,7 @@ def sign_public(self, key_type: KeyType = KeyType.K1) -> bytes: return response def sign(self, data: bytes, derivation: Derivation = Derivation.CURRENT_KEY, key_type: KeyType = KeyType.K1, - path: str = "", pin: str = "", filter_eos: bool = False) -> bytes: + path: str = "", pin: str = "") -> bytes: if self.seed_source == SeedSource.NO_SEED: raise exceptions.SeedException("There is no key on the card") @@ -587,22 +636,48 @@ def sign(self, data: bytes, derivation: Derivation = Derivation.CURRENT_KEY, key derivation = Derivation(derivation) key_type = KeyType(key_type) + self._require_ed25519_support(key_type) + if derivation == Derivation.DERIVE_AND_MAKE_CURRENT or \ (derivation == Derivation.PINLESS_PATH and key_type == KeyType.R1): raise exceptions.DerivationSelectionException("This operation doesn't support this derivation form") - signal = [0x80, 0xC0, derivation + key_type, 0x01 if filter_eos else 0x00] + # P2 selects the signing scheme. Allowlist each supported key type so an + # unknown/future one raises instead of silently taking the ECDSA path. + if key_type == KeyType.ED25519: + p2 = Basic._SIGN_P2_EDDSA + elif key_type in (KeyType.K1, KeyType.R1): + p2 = Basic._SIGN_P2_ECDSA + else: + raise exceptions.KeySelectionException(f"Unsupported key type for signing: {key_type!r}") + signal = [0x80, 0xC0, derivation + key_type, p2] derivation_base = (derivation + key_type) & 0x0F - if derivation_base in (1, 2): + include_path = derivation_base in (1, 2) + + # SIGN data field (docs.cryptnox.com v2.0, SIGN command). Across all + # variants the PIN is a trailing fixed 9-byte field right-padded with + # 0x00, and is omitted when user-key auth was already performed. + # ECDSA: [32-byte hash][path?][PIN] + # EdDSA: [2-byte big-endian message length][raw message][path?][PIN] + # Ed25519 is pure - the card hashes the raw message internally - so the + # length prefix delimits the variable-length message from the path/PIN. + if key_type == KeyType.ED25519: + data = len(data).to_bytes(2, "big") + data + if include_path: data += path_to_bytes(path) - if pin: - data += bytes(pin, 'ascii') + # valid_pin() already right-pads the PIN to the fixed 9-byte field. + data += pin.encode("ascii") result = self.connection.send_encrypted(signal, data) - if not result or result[0] != 0x30: + # Ed25519 returns a raw R|S signature; k1/r1 return a DER sequence. + if key_type == KeyType.ED25519: + valid = bool(result) and len(result) == Basic._ED25519_SIGNATURE_LENGTH + else: + valid = bool(result) and result[0] == Basic._DER_SEQUENCE_TAG + if not valid: self.auth_type = base.AuthType.NO_AUTH raise exceptions.DataException("Invalid data received during signature") @@ -610,14 +685,14 @@ def sign(self, data: bytes, derivation: Derivation = Derivation.CURRENT_KEY, key @property def valid_key(self) -> bool: - return bool(self._data[1] & BasicG1._SEED_FLAG) + return bool(self._data[1] & Basic._SEED_FLAG) @staticmethod def valid_puk(puk: str, puk_name: str = "puk") -> str: - if len(puk) != BasicG1.PUK_LENGTH: - raise exceptions.DataValidationException(f"The {puk_name} must have {BasicG1.PUK_LENGTH} " + if len(puk) != Basic.PUK_LENGTH: + raise exceptions.DataValidationException(f"The {puk_name} must have {Basic.PUK_LENGTH} " f"ASCII characters") - if not all(ord(c) < BasicG1.MAX_ASCII_LENGTH for c in puk): + if not all(ord(c) < Basic.MAX_ASCII_LENGTH for c in puk): raise exceptions.DataValidationException(f"The {puk_name} must contain only ASCII characters.") return puk @@ -626,11 +701,11 @@ def signature_check(self, nonce: bytes) -> base.SignatureCheckResult: """ Sign random 32 bytes for validation that private key of public key is on the card. - BasicG1 cards do not support this operation. Use NFT card instead. + Basic cards do not support this operation. Use NFT card instead. - :raises NotImplementedError: BasicG1 cards do not support signature_check + :raises NotImplementedError: Basic cards do not support signature_check """ - raise NotImplementedError("BasicG1 cards do not support signature_check") + raise NotImplementedError("Basic cards do not support signature_check") def verify_pin(self, pin: Optional[str] = None) -> Optional[int]: apdu = [0x80, 0x20, 0x00, 0x00] @@ -735,3 +810,8 @@ def set_clearpubkey(self, status: bool, puk: str) -> None: self.set_pubexport(status, 1, puk) self.clearpubrd = status + + +# Backwards-compatible alias: this class used to be named BasicG1, but it always +# handled the whole Basic card family regardless of generation. +BasicG1 = Basic diff --git a/cryptnox_sdk_py/card/nft.py b/cryptnox_sdk_py/card/nft.py index 91b361f..467e32d 100644 --- a/cryptnox_sdk_py/card/nft.py +++ b/cryptnox_sdk_py/card/nft.py @@ -15,7 +15,7 @@ ) -class Nft(basic_g1.BasicG1): +class Nft(basic_g1.Basic): """ Class containing functionality for NFT card which has limited capabilities """ diff --git a/cryptnox_sdk_py/enums.py b/cryptnox_sdk_py/enums.py index 7de72c6..d76e88c 100644 --- a/cryptnox_sdk_py/enums.py +++ b/cryptnox_sdk_py/enums.py @@ -37,6 +37,7 @@ class KeyType(IntEnum): """ K1 = 0x00 R1 = 0x10 + ED25519 = 0x20 class Origin(Enum): diff --git a/cryptnox_sdk_py/exceptions.py b/cryptnox_sdk_py/exceptions.py index 174037a..e77ea52 100644 --- a/cryptnox_sdk_py/exceptions.py +++ b/cryptnox_sdk_py/exceptions.py @@ -44,8 +44,8 @@ class KeySelectionException(CryptnoxException): """Not a valid key type selection""" -class EOSKeyError(CryptnoxException): - """The signature wasn't compatible with EOS standard after 10 tries""" +class AppletVersionException(CryptnoxException): + """The card applet version is too old for the requested operation.""" class FirmwareException(CryptnoxException): diff --git a/cryptnox_sdk_py/factory.py b/cryptnox_sdk_py/factory.py index a8e315c..20562dd 100644 --- a/cryptnox_sdk_py/factory.py +++ b/cryptnox_sdk_py/factory.py @@ -11,7 +11,7 @@ from .card import ( # noqa: F401 authenticity, Base, - BasicG1, # Required to register with Base for _all_subclasses() + Basic, # Required to register with Base for _all_subclasses() Nft, # Required to register with Base for _all_subclasses() ) # pylint: enable=unused-import diff --git a/docs/class_diagrams.rst b/docs/class_diagrams.rst index 61262db..b8e742b 100644 --- a/docs/class_diagrams.rst +++ b/docs/class_diagrams.rst @@ -97,7 +97,6 @@ The SDK defines a custom exception hierarchy for different error scenarios: DataException; DataValidationException; DerivationSelectionException; - EOSKeyError; FirmwareException; GenericException; GenuineCheckException; @@ -124,7 +123,6 @@ The SDK defines a custom exception hierarchy for different error scenarios: CryptnoxException -> DataException; CryptnoxException -> DataValidationException; CryptnoxException -> DerivationSelectionException; - CryptnoxException -> EOSKeyError; CryptnoxException -> FirmwareException; CryptnoxException -> GenericException; CryptnoxException -> GenuineCheckException; diff --git a/examples/solana_transaction/README.md b/examples/solana_transaction/README.md new file mode 100644 index 0000000..0d31834 --- /dev/null +++ b/examples/solana_transaction/README.md @@ -0,0 +1,173 @@ +# Solana Transaction Signing with Cryptnox Card + +Sign Solana native-SOL transfers using a Cryptnox hardware card for secure key storage and **Ed25519 (EdDSA)** signing. + +> **Requires applet v2.0+.** Ed25519 signing is only available on Cryptnox applet v2.0 and later. On older cards the SDK raises `AppletVersionException`. + +## Requirements + +| Component | Details | +|-----------|---------| +| **Hardware** | Cryptnox card with **applet v2.0+** + PC/SC smart card reader | +| **Python** | `pip install cryptnox-sdk-py solders base58 requests` | +| **Optional** | `pip install pynacl` (for local signature verification) | + +## Quick Start + +```bash +# Send 0.001 SOL on devnet (default) +python solana_transaction.py --pin 000000000 --destination + +# Custom amount on testnet +python solana_transaction.py --pin 000000000 --destination --amount 0.01 --testnet +``` + +Airdrop test SOL to the shown address first at [faucet.solana.com](https://faucet.solana.com). + +## How It Works + +### Signing Flow + +``` + 1. Get Public Key Cryptnox card, Ed25519, SLIP-0010 m/44'/501'/0'/0' + | + 2. Derive Address base58(raw 32-byte key) — NO hashing (unlike BTC/ETH/XRP) + | + 3. Fetch Blockhash JSON-RPC getLatestBlockhash + | + 4. Build Message System-Program transfer, fee-payer = sender, recent blockhash + | + 5. Sign with Card Ed25519 (P2=0x03), returns raw 64-byte R|S signature + | (Ed25519 is *pure* — the card hashes the FULL message internally) + | + 6. Verify (optional) PyNaCl verifies the signature over the original message bytes + | + 7. Assemble + Broadcast Transaction.populate(message, [signature]) -> sendTransaction +``` + +### Key Implementation Details + +**Address is the raw key, base58-encoded.** A Solana address is *not* a hash of the public key — it is simply `base58(public_key_bytes)`. Call `get_public_key(..., compressed=False)` to get the raw 32-byte Ed25519 key. + +**Ed25519 is a pure signature scheme.** Unlike ECDSA (where the host hashes the message and sends a 32-byte digest), Ed25519 hashes internally. The **entire transaction message** is sent to the card, which returns a **raw 64-byte `R|S`** signature (no DER, no `0x30` prefix). The SIGN command uses **P2 = 0x03** to select EdDSA. + +**Applet version gate.** Ed25519 operations require applet v2.0+. The SDK checks `card.applet_version` and raises `AppletVersionException` on older cards before touching the wire. + +## Step-by-Step Code + +### 1. Connect and get the Ed25519 public key + +```python +import cryptnox_sdk_py +from cryptnox_sdk_py import exceptions +from cryptnox_sdk_py.enums import Derivation, KeyType + +connection = None +try: + connection = cryptnox_sdk_py.Connection(0) + card = cryptnox_sdk_py.factory.get_card(connection) + + # Replace with your card's PIN (this is the factory default, for demo only) + PIN = "000000000" + card.verify_pin(PIN) + + # Raw 32-byte Ed25519 key (compressed=False is required) + public_key = card.get_public_key( + derivation=Derivation.DERIVE, + key_type=KeyType.ED25519, + path="m/44'/501'/0'/0'", + compressed=False, + ) +except exceptions.AppletVersionException: + raise SystemExit("Ed25519 requires a Cryptnox card with applet v2.0+") +except exceptions.CryptnoxException as error: + raise SystemExit(f"Card error: {error}") +finally: + if connection: + connection.disconnect() +``` + +> The card calls (`verify_pin`, `get_public_key`, `sign`) all raise +> `exceptions.CryptnoxException` subclasses — wrap them as above. See +> `solana_transaction.py` for the full end-to-end handling. + +### 2. Derive the Solana address + +```python +import base58 + +address = base58.b58encode(bytes.fromhex(public_key)).decode() +``` + +### 3. Build the transfer message + +```python +from solders.hash import Hash +from solders.message import Message +from solders.pubkey import Pubkey +from solders.system_program import TransferParams, transfer + +from_pubkey = Pubkey.from_bytes(bytes.fromhex(public_key)) +to_pubkey = Pubkey.from_string("RECIPIENT_ADDRESS") +ix = transfer(TransferParams(from_pubkey=from_pubkey, to_pubkey=to_pubkey, lamports=1_000_000)) +message = Message.new_with_blockhash([ix], from_pubkey, Hash.from_string(recent_blockhash)) +message_bytes = bytes(message) # <-- exactly what the card signs +``` + +### 4. Sign with the card (full message, not a hash) + +```python +signature = card.sign( + data=message_bytes, + derivation=Derivation.DERIVE, + key_type=KeyType.ED25519, + path="m/44'/501'/0'/0'", +) +# signature is a raw 64-byte R|S value +``` + +### 5. Assemble and broadcast + +```python +from solders.signature import Signature +from solders.transaction import Transaction + +tx = Transaction.populate(message, [Signature.from_bytes(signature)]) +# base64-encode bytes(tx) and submit via JSON-RPC sendTransaction +``` + +## Command Line Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--pin` | (empty) | Card PIN code | +| `--destination` | (required) | Recipient Solana address (base58) | +| `--amount` | 0.001 | Amount in SOL | +| `--testnet` | false | Use testnet instead of devnet | +| `--mainnet` | false | Use mainnet-beta instead of devnet | +| `--debug` | false | Verbose output | + +## Verification + +Step 6 of the example verifies the card's signature locally with PyNaCl: + +1. Loads the raw 32-byte Ed25519 public key as a `VerifyKey` +2. Verifies the 64-byte signature over the **original message bytes** (not a hash) + +Output: `[PASS] Signature verifies over the original message bytes!` + +This also confirms the DERIVE-mode path handling: the card must sign the original message, not the message with the derivation path bytes appended. + +## Error Reference + +| Error | Cause | Fix | +|-------|-------|-----| +| `AppletVersionException` | Card applet older than v2.0 | Use a v2.0+ card; Ed25519 is unavailable on older applets | +| `Not enough funds` | Balance below amount + fee | Airdrop at [faucet.solana.com](https://faucet.solana.com) | +| `Invalid data received during signature` | Card did not return a 64-byte signature | Confirm applet v2.0 EdDSA support (P2=0x03) | +| `ReaderException` | No card reader | Connect a PC/SC reader | +| `SeedException` | No seed on card | Initialize card with a seed | + +## License + +Part of the Cryptnox SDK. See repository root for license terms. diff --git a/examples/solana_transaction/__init__.py b/examples/solana_transaction/__init__.py new file mode 100644 index 0000000..530a4f7 --- /dev/null +++ b/examples/solana_transaction/__init__.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +Solana Transaction Signing Example + +Demonstrates how to sign Solana (Ed25519) native-SOL transfers using a Cryptnox +hardware card (applet v2.0+) for secure key storage and signing. + +Usage: + python solana_transaction.py --pin YOUR_PIN --destination +""" + +from .solana_transaction import ( + # Address derivation + public_key_to_solana_address, + # Network queries + get_balance, + get_latest_blockhash, + send_transaction, + # Transaction building + build_transfer_message, + assemble_transaction, + # Card interaction + get_public_key_from_card, + sign_with_card, + # Verification + verify_signature, + # Entry point + run_solana_transaction_example, +) + +__all__ = [ + "public_key_to_solana_address", + "get_balance", + "get_latest_blockhash", + "send_transaction", + "build_transfer_message", + "assemble_transaction", + "get_public_key_from_card", + "sign_with_card", + "verify_signature", + "run_solana_transaction_example", +] diff --git a/examples/solana_transaction/solana_transaction.py b/examples/solana_transaction/solana_transaction.py new file mode 100644 index 0000000..4ce1e02 --- /dev/null +++ b/examples/solana_transaction/solana_transaction.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Solana Transaction Signing Example with Cryptnox Card + +This example demonstrates how to: +1. Derive the Ed25519 public key from the card (applet v2.0+) +2. Compute the Solana address (base58 of the raw 32-byte key - no hashing) +3. Fetch a recent blockhash from a Solana JSON-RPC endpoint +4. Build a System-Program transfer message with solders +5. Send the raw message bytes to the card for signing (Ed25519 is *pure* - the + card hashes internally, so the full message is signed, not a pre-hash) +6. Assemble the 64-byte signature onto the message and broadcast it + +Requirements: + pip install solders base58 requests cryptnox-sdk-py + +Usage: + python solana_transaction.py --pin 1234 --destination --amount 0.001 + +Note: This example requires a Cryptnox card with applet v2.0+ and a seed loaded. + It defaults to devnet - airdrop test SOL to the shown address first: + https://faucet.solana.com +""" + +import base64 +import sys +from typing import Any, Dict, Optional + +try: + import base58 + import requests + from solders.hash import Hash + from solders.message import Message + from solders.pubkey import Pubkey + from solders.signature import Signature + from solders.system_program import TransferParams, transfer + from solders.transaction import Transaction + SOLANA_AVAILABLE = True +except ImportError: + SOLANA_AVAILABLE = False + print("Warning: solders/base58 not installed. Install with: " + "pip install solders base58 requests") + +# Cryptnox SDK +try: + import cryptnox_sdk_py + from cryptnox_sdk_py import exceptions + from cryptnox_sdk_py.enums import Derivation, KeyType + CRYPTNOX_AVAILABLE = True +except ImportError: + CRYPTNOX_AVAILABLE = False + print("Warning: cryptnox-sdk-py not installed. Install with: pip install cryptnox-sdk-py") + + +# ============================================================================= +# Solana Constants +# ============================================================================= + +# Solana derivation path used by Phantom and most wallets (all-hardened SLIP-0010) +SOLANA_DERIVATION_PATH = "m/44'/501'/0'/0'" + +# 1 SOL = 1 000 000 000 lamports +LAMPORTS_PER_SOL = 1_000_000_000 + +DEVNET_URL = "https://api.devnet.solana.com" +TESTNET_URL = "https://api.testnet.solana.com" +MAINNET_URL = "https://api.mainnet-beta.solana.com" + + +# ============================================================================= +# Address Derivation +# ============================================================================= + +def public_key_to_solana_address(public_key_hex: str) -> str: + """ + Derive a Solana address from a raw Ed25519 public key. + + Unlike Bitcoin/Ethereum/XRP, a Solana address is *not* a hash of the public + key: it is simply the base58 encoding of the raw 32-byte Ed25519 key. + + Args: + public_key_hex: Raw 32-byte Ed25519 public key as hex (64 chars) + + Returns: + Base58 Solana address + """ + return base58.b58encode(bytes.fromhex(public_key_hex)).decode() + + +# ============================================================================= +# Solana JSON-RPC Helpers +# ============================================================================= + +def _rpc(url: str, method: str, params: list) -> Any: + payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + response = requests.post(url, json=payload, timeout=20) + response.raise_for_status() + data = response.json() + if "error" in data: + raise RuntimeError(data["error"].get("message", "Solana RPC error")) + return data["result"] + + +def get_balance(url: str, address: str) -> int: + """Return the balance of *address* in lamports.""" + result = _rpc(url, "getBalance", [address]) + return result["value"] + + +def get_latest_blockhash(url: str) -> str: + """Return a recent blockhash as a base58 string.""" + result = _rpc(url, "getLatestBlockhash", [{"commitment": "finalized"}]) + return result["value"]["blockhash"] + + +def send_transaction(url: str, transaction: "Transaction") -> str: + """Broadcast a signed transaction (base64) and return its signature string.""" + encoded = base64.b64encode(bytes(transaction)).decode() + return _rpc(url, "sendTransaction", [encoded, {"encoding": "base64"}]) + + +# ============================================================================= +# Transaction Building +# ============================================================================= + +def build_transfer_message(from_public_key_hex: str, to_address: str, lamports: int, + recent_blockhash: str) -> "Message": + """ + Build a System-Program transfer message with the sender as fee-payer. + + ``bytes(message)`` are exactly what the card signs. + """ + from_pubkey = Pubkey.from_bytes(bytes.fromhex(from_public_key_hex)) + to_pubkey = Pubkey.from_string(to_address) + instruction = transfer(TransferParams(from_pubkey=from_pubkey, to_pubkey=to_pubkey, + lamports=lamports)) + blockhash = Hash.from_string(recent_blockhash) + return Message.new_with_blockhash([instruction], from_pubkey, blockhash) + + +def assemble_transaction(message: "Message", signature: bytes) -> "Transaction": + """Graft a card-produced 64-byte signature onto a message.""" + return Transaction.populate(message, [Signature.from_bytes(signature)]) + + +# ============================================================================= +# Cryptnox Card Helpers +# ============================================================================= + +def get_public_key_from_card(card, derivation_path: str = SOLANA_DERIVATION_PATH) -> str: + """ + Get the raw 32-byte Ed25519 public key from the card as hex. + + ``compressed=False`` is required: Ed25519 keys must never be run through the + secp256k1/r1 compression path. + """ + return card.get_public_key( + derivation=Derivation.DERIVE, + key_type=KeyType.ED25519, + path=derivation_path, + compressed=False + ) + + +def sign_with_card(card, message_bytes: bytes, path: str = SOLANA_DERIVATION_PATH, + pin: str = "") -> bytes: + """ + Sign the raw message bytes with the card, returning a 64-byte Ed25519 + signature. Ed25519 is pure: the *entire* message is sent, not a pre-hash. + """ + return card.sign( + data=message_bytes, + derivation=Derivation.DERIVE, + key_type=KeyType.ED25519, + path=path, + pin=pin + ) + + +def verify_signature(public_key_hex: str, message_bytes: bytes, signature: bytes) -> bool: + """ + Verify the card's Ed25519 signature over the original message bytes using + PyNaCl, when available. Returns False if PyNaCl is not installed. + """ + try: + from nacl.signing import VerifyKey + from nacl.exceptions import BadSignatureError + except ImportError: + return False + + try: + VerifyKey(bytes.fromhex(public_key_hex)).verify(message_bytes, signature) + return True + except BadSignatureError: + return False + + +# ============================================================================= +# Main Example +# ============================================================================= + +def run_solana_transaction_example( + pin: str = "", + destination: str = "", + amount_sol: float = 0.001, + debug: bool = False, + url: str = DEVNET_URL +) -> Optional[Dict[str, Any]]: + """Run a complete Solana native-SOL transfer signing example.""" + if not SOLANA_AVAILABLE: + print("Error: solders/base58 libraries are required. " + "Install with: pip install solders base58 requests") + return None + + if not CRYPTNOX_AVAILABLE: + print("Error: cryptnox-sdk-py library is required.") + return None + + if not destination: + print("Error: --destination is required.") + return None + + connection = None + + try: + print("=" * 60) + print("Solana Transaction Signing with Cryptnox Card") + print("=" * 60) + print("\n[Step 1] Connecting to Cryptnox card...") + + connection = cryptnox_sdk_py.Connection(0, debug=debug) + card = cryptnox_sdk_py.factory.get_card(connection, debug=debug) + print(f" ✓ Connected to card (Serial: {card.serial_number})") + print(f" → Applet version: {'.'.join(map(str, card.applet_version))}") + + if pin: + print(" → Verifying PIN...") + card.verify_pin(pin) + print(" ✓ PIN verified") + + if not card.valid_key: + print(" ✗ Error: Card does not have a seed loaded") + return None + + # ===================================================================== + # Step 2: Get Ed25519 public key + Solana address + # ===================================================================== + print("\n[Step 2] Getting Ed25519 public key from card...") + print(f" → Derivation path: {SOLANA_DERIVATION_PATH}") + + public_key_hex = get_public_key_from_card(card, SOLANA_DERIVATION_PATH) + print(f" ✓ Public key (32 bytes): {public_key_hex}") + + from_address = public_key_to_solana_address(public_key_hex) + print(f" ✓ Solana address: {from_address}") + + # ===================================================================== + # Step 3: Check balance + # ===================================================================== + print(f"\n[Step 3] Fetching balance from {url}...") + balance_lamports = get_balance(url, from_address) + print(f" → Balance: {balance_lamports / LAMPORTS_PER_SOL} SOL") + + lamports = int(amount_sol * LAMPORTS_PER_SOL) + if balance_lamports < lamports + 5_000: + print(" ✗ Error: Not enough funds (need amount + ~5000 lamports fee).") + print(" ! Airdrop devnet SOL at: https://faucet.solana.com") + return None + + # ===================================================================== + # Step 4: Build transfer message + # ===================================================================== + print("\n[Step 4] Building System-Program transfer message...") + blockhash = get_latest_blockhash(url) + print(f" → Recent blockhash: {blockhash}") + + message = build_transfer_message(public_key_hex, destination, lamports, blockhash) + message_bytes = bytes(message) + print(f" ✓ Message ({len(message_bytes)} bytes)") + if debug: + print(f" Message (hex): {message_bytes.hex()}") + + # ===================================================================== + # Step 5: Sign with the card (Ed25519, pure - full message) + # ===================================================================== + print("\n[Step 5] Signing with Cryptnox card (Ed25519)...") + signature = sign_with_card(card, message_bytes, SOLANA_DERIVATION_PATH, pin) + print(f" ✓ Received signature ({len(signature)} bytes)") + if debug: + print(f" Signature (hex): {signature.hex()}") + + # ===================================================================== + # Step 6: Verify the signature locally (optional, PyNaCl) + # ===================================================================== + print("\n[Step 6] Verifying signature against the public key...") + if verify_signature(public_key_hex, message_bytes, signature): + print(" [PASS] Signature verifies over the original message bytes!") + else: + print(" [SKIP/FAIL] PyNaCl not installed or signature invalid " + "(install with: pip install pynacl)") + + # ===================================================================== + # Step 7: Assemble + broadcast + # ===================================================================== + print("\n[Step 7] Assembling and broadcasting transaction...") + transaction = assemble_transaction(message, signature) + tx_signature = send_transaction(url, transaction) + + cluster = "devnet" if url == DEVNET_URL else \ + "testnet" if url == TESTNET_URL else "mainnet-beta" + print("\n" + "=" * 60) + print("TRANSACTION BROADCAST") + print("=" * 60) + print(f"\nSignature: {tx_signature}") + print(f"Explorer: https://explorer.solana.com/tx/{tx_signature}?cluster={cluster}") + + return { + "public_key": public_key_hex, + "from_address": from_address, + "destination": destination, + "amount_sol": amount_sol, + "signature": signature.hex(), + "tx_signature": tx_signature, + } + + except exceptions.AppletVersionException as e: + print(f"\n✗ Error: {e}") + print(" ! Solana signing requires a Cryptnox card with applet v2.0 or later.") + return None + except exceptions.ReaderException: + print("\n✗ Error: Card reader not found") + return None + except exceptions.CardException as e: + print(f"\n✗ Error: Card error - {e}") + return None + except exceptions.PinException: + print("\n✗ Error: Invalid PIN code") + return None + except exceptions.SeedException: + print("\n✗ Error: No seed on card. Please load a seed first.") + return None + except exceptions.CryptnoxException as e: + print(f"\n✗ Error: {e}") + return None + except (ValueError, TypeError, RuntimeError, ImportError, requests.RequestException) as e: + print(f"\n✗ Unexpected error: {e}") + if debug: + import traceback + traceback.print_exc() + return None + finally: + if connection: + print("\n[Cleanup] Disconnecting from card...") + connection.disconnect() + print(" ✓ Disconnected") + + +# ============================================================================= +# Entry Point +# ============================================================================= + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="Solana Transaction Signing with Cryptnox Card", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Send 0.001 SOL on devnet (default) + python solana_transaction.py --pin 1234 --destination + + # Custom amount on testnet + python solana_transaction.py --pin 1234 --destination --amount 0.01 --testnet + +For more information, see README.md + """ + ) + parser.add_argument("--pin", type=str, default="", help="Card PIN code") + parser.add_argument("--destination", type=str, required=True, + help="Recipient's Solana address (base58, required)") + parser.add_argument("--amount", type=float, default=0.001, + help="Amount to send in SOL (default: 0.001)") + parser.add_argument("--testnet", action="store_true", + help="Use testnet instead of devnet") + parser.add_argument("--mainnet", action="store_true", + help="Use mainnet-beta instead of devnet") + parser.add_argument("--debug", action="store_true", help="Enable debug output") + args = parser.parse_args() + + endpoint = DEVNET_URL + if args.testnet: + endpoint = TESTNET_URL + elif args.mainnet: + endpoint = MAINNET_URL + + result = run_solana_transaction_example( + pin=args.pin, + destination=args.destination, + amount_sol=args.amount, + debug=args.debug, + url=endpoint + ) + + if not result: + sys.exit(1) diff --git a/examples/xrp_transaction/README.md b/examples/xrp_transaction/README.md index dd20d38..4a08e52 100644 --- a/examples/xrp_transaction/README.md +++ b/examples/xrp_transaction/README.md @@ -63,7 +63,10 @@ from cryptnox_sdk_py.enums import Derivation, KeyType connection = cryptnox_sdk_py.Connection(0) card = cryptnox_sdk_py.factory.get_card(connection) -card.verify_pin("000000000") + +# Replace with your card's PIN (this is the factory default, for demo only) +PIN = "000000000" +card.verify_pin(PIN) # Derive to XRP path and get compressed public key card.derive(key_type=KeyType.K1, path="m/44'/144'/0'/0/0") From e5edb788419f868b8beb554926be4eb939ded543 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 20:48:45 +0700 Subject: [PATCH 2/4] Potential fix for pull request finding 'Use of a print statement at module level' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/solana_transaction/solana_transaction.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/solana_transaction/solana_transaction.py b/examples/solana_transaction/solana_transaction.py index 4ce1e02..05d48b6 100644 --- a/examples/solana_transaction/solana_transaction.py +++ b/examples/solana_transaction/solana_transaction.py @@ -37,10 +37,11 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction SOLANA_AVAILABLE = True + SOLANA_IMPORT_WARNING: Optional[str] = None except ImportError: SOLANA_AVAILABLE = False - print("Warning: solders/base58 not installed. Install with: " - "pip install solders base58 requests") + SOLANA_IMPORT_WARNING = ("Warning: solders/base58 not installed. Install with: " + "pip install solders base58 requests") # Cryptnox SDK try: @@ -48,9 +49,11 @@ from cryptnox_sdk_py import exceptions from cryptnox_sdk_py.enums import Derivation, KeyType CRYPTNOX_AVAILABLE = True + CRYPTNOX_IMPORT_WARNING: Optional[str] = None except ImportError: CRYPTNOX_AVAILABLE = False - print("Warning: cryptnox-sdk-py not installed. Install with: pip install cryptnox-sdk-py") + CRYPTNOX_IMPORT_WARNING = ("Warning: cryptnox-sdk-py not installed. " + "Install with: pip install cryptnox-sdk-py") # ============================================================================= From 13f590aaf3d1bab28070791df02786f53a72431e Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 20:49:17 +0700 Subject: [PATCH 3/4] Potential fix for pull request finding 'Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- examples/solana_transaction/solana_transaction.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/solana_transaction/solana_transaction.py b/examples/solana_transaction/solana_transaction.py index 05d48b6..d680a80 100644 --- a/examples/solana_transaction/solana_transaction.py +++ b/examples/solana_transaction/solana_transaction.py @@ -46,7 +46,6 @@ # Cryptnox SDK try: import cryptnox_sdk_py - from cryptnox_sdk_py import exceptions from cryptnox_sdk_py.enums import Derivation, KeyType CRYPTNOX_AVAILABLE = True CRYPTNOX_IMPORT_WARNING: Optional[str] = None From e4237c45d4ce33affbca49983da22cbfadd3cc44 Mon Sep 17 00:00:00 2001 From: YanNaingWinn Date: Tue, 14 Jul 2026 20:53:36 +0700 Subject: [PATCH 4/4] fix: qualify exceptions refs in solana example to clear flake8 F821 --- examples/solana_transaction/solana_transaction.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/solana_transaction/solana_transaction.py b/examples/solana_transaction/solana_transaction.py index d680a80..1ab385a 100644 --- a/examples/solana_transaction/solana_transaction.py +++ b/examples/solana_transaction/solana_transaction.py @@ -326,23 +326,23 @@ def run_solana_transaction_example( "tx_signature": tx_signature, } - except exceptions.AppletVersionException as e: + except cryptnox_sdk_py.exceptions.AppletVersionException as e: print(f"\n✗ Error: {e}") print(" ! Solana signing requires a Cryptnox card with applet v2.0 or later.") return None - except exceptions.ReaderException: + except cryptnox_sdk_py.exceptions.ReaderException: print("\n✗ Error: Card reader not found") return None - except exceptions.CardException as e: + except cryptnox_sdk_py.exceptions.CardException as e: print(f"\n✗ Error: Card error - {e}") return None - except exceptions.PinException: + except cryptnox_sdk_py.exceptions.PinException: print("\n✗ Error: Invalid PIN code") return None - except exceptions.SeedException: + except cryptnox_sdk_py.exceptions.SeedException: print("\n✗ Error: No seed on card. Please load a seed first.") return None - except exceptions.CryptnoxException as e: + except cryptnox_sdk_py.exceptions.CryptnoxException as e: print(f"\n✗ Error: {e}") return None except (ValueError, TypeError, RuntimeError, ImportError, requests.RequestException) as e: