Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v0.20.0 #113

Merged
merged 2 commits into from
Feb 25, 2025
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

## [0.20.0] - 2025-02-25

### Added
- Support for Ed25519 API keys.

## [0.19.0] - 2025-02-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion cdp/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.19.0"
__version__ = "0.20.0"
35 changes: 35 additions & 0 deletions cdp/api_key_utils.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion cdp/cdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'")
Expand Down
39 changes: 16 additions & 23 deletions cdp/cdp_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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
Expand Down
30 changes: 21 additions & 9 deletions cdp/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/cdp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cdp-sdk"
version = "0.19.0"
version = "0.20.0"
description = "CDP Python SDK"
authors = ["John Peterson <[email protected]>"]
license = "LICENSE.md"
Expand Down
33 changes: 33 additions & 0 deletions tests/factories/api_key_factory.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_api_key_utils.py
Original file line number Diff line number Diff line change
@@ -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")
Loading