From 62c604b108724ebd7b6f098b9ad5ca1f1c4f644e Mon Sep 17 00:00:00 2001 From: Di Mei Date: Tue, 25 Feb 2025 13:49:34 -0500 Subject: [PATCH 1/2] feat: Support Ed25519 key (#111) * support Edwards key * create an api key helper * lint error fix * addressing comments * addressing comments * add an entry to CHANGELOG --- CHANGELOG.md | 5 ++++ cdp/api_key_utils.py | 35 +++++++++++++++++++++++++++ cdp/cdp.py | 2 +- cdp/cdp_api_client.py | 39 ++++++++++++------------------ cdp/wallet.py | 30 ++++++++++++++++------- tests/factories/api_key_factory.py | 33 +++++++++++++++++++++++++ tests/test_api_key_utils.py | 28 +++++++++++++++++++++ 7 files changed, 139 insertions(+), 33 deletions(-) create mode 100644 cdp/api_key_utils.py create mode 100644 tests/factories/api_key_factory.py create mode 100644 tests/test_api_key_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf66b68..350d843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +## [0.20.0] - 2025-02-25 + +### Added +- Support for Ed25519 API keys. + ## [0.19.0] - 2025-02-21 ### Added diff --git a/cdp/api_key_utils.py b/cdp/api_key_utils.py new file mode 100644 index 0000000..b3c9ff7 --- /dev/null +++ b/cdp/api_key_utils.py @@ -0,0 +1,35 @@ +import base64 + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +def _parse_private_key(key_str: str): + """Parse a private key from a given string representation. + + Args: + key_str (str): A string representing the private key. It should be either a PEM-encoded + key (for ECDSA keys) or a base64-encoded string (for Ed25519 keys). + + Returns: + An instance of a private key. Specifically: + + Raises: + ValueError: If the key cannot be parsed as a valid PEM-encoded key or a base64-encoded + Ed25519 private key. + + """ + key_data = key_str.encode() + try: + return serialization.load_pem_private_key(key_data, password=None) + except Exception: + try: + decoded_key = base64.b64decode(key_str) + if len(decoded_key) == 32: + return ed25519.Ed25519PrivateKey.from_private_bytes(decoded_key) + elif len(decoded_key) == 64: + return ed25519.Ed25519PrivateKey.from_private_bytes(decoded_key[:32]) + else: + raise ValueError("Ed25519 private key must be 32 or 64 bytes after base64 decoding") + except Exception as e: + raise ValueError("Could not parse the private key") from e diff --git a/cdp/cdp.py b/cdp/cdp.py index 84c2416..f7a62d9 100644 --- a/cdp/cdp.py +++ b/cdp/cdp.py @@ -118,7 +118,7 @@ def configure_from_json( """ with open(os.path.expanduser(file_path)) as file: data = json.load(file) - api_key_name = data.get("name") + api_key_name = data.get("name") or data.get("id") private_key = data.get("privateKey") if not api_key_name: raise InvalidConfigurationError("Invalid JSON format: Missing 'api_key_name'") diff --git a/cdp/cdp_api_client.py b/cdp/cdp_api_client.py index 15a7d2f..02a6a82 100644 --- a/cdp/cdp_api_client.py +++ b/cdp/cdp_api_client.py @@ -3,11 +3,11 @@ from urllib.parse import urlparse import jwt -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from urllib3.util import Retry from cdp import __version__ +from cdp.api_key_utils import _parse_private_key from cdp.client import rest from cdp.client.api_client import ApiClient from cdp.client.api_response import ApiResponse @@ -162,27 +162,20 @@ def _apply_headers(self, url: str, method: str, header_params: dict[str, str]) - header_params["Correlation-Context"] = self._get_correlation_data() def _build_jwt(self, url: str, method: str = "GET") -> str: - """Build the JWT for the given API endpoint URL. - - Args: - url (str): The URL to authenticate. - method (str): The HTTP method to use. - - Returns: - str: The JWT for the given API endpoint URL. - - """ - try: - private_key = serialization.load_pem_private_key( - self.private_key.encode(), password=None - ) - if not isinstance(private_key, ec.EllipticCurvePrivateKey): - raise InvalidAPIKeyFormatError("Invalid key type") - except Exception as e: - raise InvalidAPIKeyFormatError("Could not parse the private key") from e + """Build the JWT for the given API endpoint URL.""" + # Parse the private key using our helper function. + private_key_obj = _parse_private_key(self.private_key) + + # Determine signing algorithm based on the key type. + if isinstance(private_key_obj, ec.EllipticCurvePrivateKey): + alg = "ES256" + elif isinstance(private_key_obj, ed25519.Ed25519PrivateKey): + alg = "EdDSA" + else: + raise InvalidAPIKeyFormatError("Unsupported key type") header = { - "alg": "ES256", + "alg": alg, "kid": self.api_key, "typ": "JWT", "nonce": self._nonce(), @@ -195,12 +188,12 @@ def _build_jwt(self, url: str, method: str = "GET") -> str: "iss": "cdp", "aud": ["cdp_service"], "nbf": int(time.time()), - "exp": int(time.time()) + 60, # +1 minute + "exp": int(time.time()) + 60, # Token valid for 1 minute "uris": [uri], } try: - return jwt.encode(claims, private_key, algorithm="ES256", headers=header) + return jwt.encode(claims, private_key_obj, algorithm=alg, headers=header) except Exception as e: print(f"Error during JWT signing: {e!s}") raise InvalidAPIKeyFormatError("Could not sign the JWT") from e diff --git a/cdp/wallet.py b/cdp/wallet.py index 5707197..1ceacbd 100644 --- a/cdp/wallet.py +++ b/cdp/wallet.py @@ -12,10 +12,11 @@ from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicValidator, Bip39SeedGenerator from Crypto.Cipher import AES from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from eth_account import Account from cdp.address import Address +from cdp.api_key_utils import _parse_private_key from cdp.balance_map import BalanceMap from cdp.cdp import Cdp from cdp.client.models.address import Address as AddressModel @@ -686,19 +687,30 @@ def load_seed_from_file(self, file_path: str) -> None: self._master = self._set_master_node() def _encryption_key(self) -> bytes: - """Generate an encryption key based on the private key. + """Generate an encryption key derived from the configured private key. Returns: - bytes: The generated encryption key. + bytes: A 32-byte encryption key derived via SHA-256 hashing. - """ - private_key = serialization.load_pem_private_key(Cdp.private_key.encode(), password=None) - - public_key = private_key.public_key() + Raises: + ValueError: If the private key type is not supported for encryption key derivation. - shared_secret = private_key.exchange(ec.ECDH(), public_key) + """ + key_obj = _parse_private_key(Cdp.private_key) - return hashlib.sha256(shared_secret).digest() + if isinstance(key_obj, ec.EllipticCurvePrivateKey): + public_key = key_obj.public_key() + shared_secret = key_obj.exchange(ec.ECDH(), public_key) + return hashlib.sha256(shared_secret).digest() + elif isinstance(key_obj, ed25519.Ed25519PrivateKey): + raw_bytes = key_obj.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + return hashlib.sha256(raw_bytes).digest() + else: + raise ValueError("Unsupported key type for encryption key derivation") def _existing_seeds(self, file_path: str) -> dict[str, Any]: """Load existing seeds from a file. diff --git a/tests/factories/api_key_factory.py b/tests/factories/api_key_factory.py new file mode 100644 index 0000000..1d54679 --- /dev/null +++ b/tests/factories/api_key_factory.py @@ -0,0 +1,33 @@ +import base64 + +import pytest + + +@pytest.fixture +def dummy_key_factory(): + """Create and return a factory function for generating dummy keys for testing. + + The factory accepts a `key_type` parameter with the following options: + - "ecdsa": Returns a PEM-encoded ECDSA private key. + - "ed25519-32": Returns a base64-encoded 32-byte Ed25519 private key. + - "ed25519-64": Returns a base64-encoded 64-byte dummy Ed25519 key (the first 32 bytes will be used). + """ + def _create_dummy(key_type: str = "ecdsa") -> str: + if key_type == "ecdsa": + return ( + "-----BEGIN EC PRIVATE KEY-----\n" + "MHcCAQEEIMM75bm9WZCYPkfjXSUWNU5eHx47fWM2IpG8ki90BhRDoAoGCCqGSM49\n" + "AwEHoUQDQgAEicwlaAqy7Z4SS7lvrEYoy6qR9Kf0n0jFzg+XExcXKU1JMr18z47W\n" + "5mrftEqWIqPCLQ16ByoKW2Bsup5V3q9P4g==\n" + "-----END EC PRIVATE KEY-----\n" + ) + elif key_type == "ed25519-32": + return "BXyKC+eFINc/6ztE/3neSaPGgeiU9aDRpaDnAbaA/vyTrUNgtuh/1oX6Vp+OEObV3SLWF+OkF2EQNPtpl0pbfA==" + elif key_type == "ed25519-64": + # Create a 64-byte dummy by concatenating a 32-byte sequence with itself. + dummy_32 = b'\x01' * 32 + dummy_64 = dummy_32 + dummy_32 + return base64.b64encode(dummy_64).decode("utf-8") + else: + raise ValueError("Unsupported key type for dummy key creation") + return _create_dummy diff --git a/tests/test_api_key_utils.py b/tests/test_api_key_utils.py new file mode 100644 index 0000000..30f347a --- /dev/null +++ b/tests/test_api_key_utils.py @@ -0,0 +1,28 @@ +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from cdp.api_key_utils import _parse_private_key + + +def test_parse_private_key_pem_ec(dummy_key_factory): + """Test that a PEM-encoded ECDSA key is parsed correctly using a dummy key from the factory.""" + dummy_key = dummy_key_factory("ecdsa") + parsed_key = _parse_private_key(dummy_key) + assert isinstance(parsed_key, ec.EllipticCurvePrivateKey) + +def test_parse_private_key_ed25519_32(dummy_key_factory): + """Test that a base64-encoded 32-byte Ed25519 key is parsed correctly using a dummy key from the factory.""" + dummy_key = dummy_key_factory("ed25519-32") + parsed_key = _parse_private_key(dummy_key) + assert isinstance(parsed_key, ed25519.Ed25519PrivateKey) + +def test_parse_private_key_ed25519_64(dummy_key_factory): + """Test that a base64-encoded 64-byte input is parsed correctly by taking the first 32 bytes using a dummy key from the factory.""" + dummy_key = dummy_key_factory("ed25519-64") + parsed_key = _parse_private_key(dummy_key) + assert isinstance(parsed_key, ed25519.Ed25519PrivateKey) + +def test_parse_private_key_invalid(): + """Test that an invalid key string raises a ValueError.""" + with pytest.raises(ValueError, match="Could not parse the private key"): + _parse_private_key("invalid_key") From 5232ea5f204ce03e55719780bbbfc9109e5c56ea Mon Sep 17 00:00:00 2001 From: rohan-agarwal-coinbase Date: Tue, 25 Feb 2025 14:05:30 -0700 Subject: [PATCH 2/2] Prep release (#112) --- cdp/__version__.py | 2 +- docs/cdp.rst | 8 ++++++++ docs/conf.py | 2 +- pyproject.toml | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cdp/__version__.py b/cdp/__version__.py index 11ac8e1..5f4bb0b 100644 --- a/cdp/__version__.py +++ b/cdp/__version__.py @@ -1 +1 @@ -__version__ = "0.19.0" +__version__ = "0.20.0" diff --git a/docs/cdp.rst b/docs/cdp.rst index ddbf2c2..ebd740d 100644 --- a/docs/cdp.rst +++ b/docs/cdp.rst @@ -36,6 +36,14 @@ cdp.api\_clients module :undoc-members: :show-inheritance: +cdp.api\_key\_utils module +-------------------------- + +.. automodule:: cdp.api_key_utils + :members: + :undoc-members: + :show-inheritance: + cdp.asset module ---------------- diff --git a/docs/conf.py b/docs/conf.py index ce99321..184f31c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ project = 'CDP SDK' author = 'Coinbase Developer Platform' -release = '0.19.0' +release = '0.20.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 959a03c..a37afce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cdp-sdk" -version = "0.19.0" +version = "0.20.0" description = "CDP Python SDK" authors = ["John Peterson "] license = "LICENSE.md"