Skip to content

Commit

Permalink
Release v0.20.0 (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
rohan-agarwal-coinbase authored Feb 25, 2025
1 parent 5ae9385 commit 792e518
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 36 deletions.
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")

0 comments on commit 792e518

Please sign in to comment.