2025, Dec 06 21:02
Исправляем ошибку PEM приватного ключа в Coinbase Advanced Trade API на FastAPI
Разбираем сбой cryptography при интеграции Coinbase Advanced Trade с FastAPI: ошибка PEM приватного ключа, причины и решение с генерацией корректного JWT.
Интеграция Coinbase Advanced Trade API в сервис на FastAPI может неожиданно уткнуться в проблему криптографии: приватный ключ не загружается. Типичный симптом — сервер запускается без ошибок, но первый аутентифицированный вызов падает с ошибкой разбора PEM. Ниже — краткий разбор сценария сбоя, его причин и способа починки, причём менять саму схему аутентификации не придётся.
Обзор проблемы
Приложение FastAPI считывает учетные данные API из переменных окружения и инициализирует клиент Coinbase REST. Сервер стартует нормально, но первый запрос к защищённой конечной точке падает с ошибкой криптографии, указывающей на приватный ключ. В трассировке стека выделяется путь формирования JWT внутри клиента и упоминаются проблемы разбора PEM.
Ключевая ошибка выглядит так:
Не удалось загрузить PEM-файл. Подробнее: https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file. MalformedFraming Вы уверены, что сгенерировали ключ на https://cloud.coinbase.com/access/api ?
Код для воспроизведения (что приводит к ошибке)
Приложение подключает клиент Coinbase REST, используя значения из файла .env, а затем вызывает endpoint accounts. Логика ниже в общих чертах повторяет эту схему.
# app/services/trade_api.py
import logging
from app.core.config import CB_API_KEY, CB_API_SECRET
from coinbase.rest import RESTClient
logger = logging.getLogger(__name__)
try:
cb_client = RESTClient(api_key=CB_API_KEY, api_secret=CB_API_SECRET, verbose=True)
except Exception as exc:
logger.error("Could not create RESTClient with provided credentials")
logger.exception(exc)
raise
def fetch_accounts():
logger.info(f"Key in use: {CB_API_KEY}")
try:
return cb_client.get_accounts()
except Exception as exc:
logger.error("Failed to call Coinbase accounts endpoint")
logger.exception(exc)
return {"error": str(exc)}
Когда конечная точка вызывает fetch_accounts, клиент пытается подписать запрос, внутренне собирает JWT, и затем cryptography падает на загрузке PEM. Трассировка показывает путь сбоя через coinbase.rest и coinbase.jwt_generator к функции load_pem_private_key библиотеки cryptography.
Что на самом деле идёт не так
Клиент Coinbase ожидает корректный PEM-кодированный приватный ключ EC, который cryptography может загрузить. Если в COINBASE_API_SECRET попало значение, не разбираемое как PEM, cryptography выдаёт ошибку вроде MalformedFraming. Это возможно, если ключевой материал не в формате PEM или при сохранении в окружении потерялись важные детали форматирования — переносы строк и разделители. Результат один: сборщик JWT не может десериализовать ключ, и запрос так и не подписывается.
Исправление: преобразуйте секрет в корректный PEM и подписывайте запросы
Чтобы разблокировать процесс, преобразуйте секрет в правильный SECP256K1 PEM и используйте его для генерации JWT. Сниппет ниже делает именно это: декодирует секрет из base64, нормализует длину до 32 байт под скаляр, выводит приватный ключ EC на SECP256K1, экспортирует его в PEM и передаёт в jwt_generator из Coinbase.
# app/integrations/jwt_tools.py
import os
import base64
import logging
from coinbase import jwt_generator
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
log = logging.getLogger(__name__)
API_KEY_VALUE = os.getenv("COINBASE_API_KEY")
SECRET_B64_VALUE = os.getenv("COINBASE_API_SECRET")
def wrap_secret_as_pem(b64_secret: str) -> str:
try:
decoded = base64.b64decode(b64_secret)
# Если буфер 64 байта, оставляем первые 32 байта как скаляр
if len(decoded) == 64:
decoded = decoded[:32]
if len(decoded) != 32:
raise ValueError(f"Expected 32 bytes for SECP256K1 secret, got {len(decoded)} bytes")
scalar = int.from_bytes(decoded, "big")
ec_priv = ec.derive_private_key(scalar, ec.SECP256K1())
pem_bytes = ec_priv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
return pem_bytes.decode("utf-8")
except Exception as err:
log.error(f"Failed to produce PEM from secret: {err}")
raise
PEM_MATERIAL = wrap_secret_as_pem(SECRET_B64_VALUE)
def issue_jwt(
http_method: str = "GET",
resource_path: str = "/v2/accounts"
) -> str:
log.info(f"Using API key: {API_KEY_VALUE}")
log.info(f"PEM material prepared: {PEM_MATERIAL}")
uri = jwt_generator.format_jwt_uri(http_method, resource_path)
token = jwt_generator.build_rest_jwt(uri, API_KEY_VALUE, PEM_MATERIAL)
return token
Суть проста: передайте cryptography валидный EC-ключ в формате PEM. Как только такой PEM получен, jwt_generator.build_rest_jwt его принимает, и клиент сможет успешно авторизовать вызовы.
Почему это важно
Когда вы работаете с API, где запросы подписываются, корректность формата приватного ключа — не вопрос эстетики. Слой подписи находится на критическом пути каждого защищённого вызова. Если ключ неверный, библиотека будет падать рано и снова, и вместо бизнес-логики вы будете отлаживать инфраструктуру и криптографию. В данном случае сбой исходит от строгого загрузчика ключей в cryptography — это хорошо для безопасности, но означает, что значение в окружении должно быть точным до символа.
Итоги
Убедитесь, что секрет, который вы передаёте, — это загружаемый PEM приватного ключа SECP256K1. Если значение приходит как base64‑кодированные байты, сначала преобразуйте его в корректный PEM, а уже потом передавайте сборщику JWT. Следите за целостностью ключевого материала при хранении в переменных окружения, чтобы разделители и переводы строк сохранялись на всём пути. С валидным PEM клиент Coinbase сможет собирать JWT, и конечная точка accounts будет отвечать как ожидается.