Aller au contenu
ecdsa.com
Python

coincurve

coincurve d’Ofek Lev, des bindings Python pour la libsecp256k1 de Bitcoin Core : signatures DER sur SHA-256 par défaut, RFC 6979, low-S strict à la signature comme à la vérification, signatures récupérables et signatures Schnorr BIP-340.
Licence
MIT OR Apache-2.0
Version vérifiée
21.0.0 ·
Code source
github.com/ofek/coincurve
Documentation
ofek.dev/coincurve/

Compatibilité en un coup d’œil

ECDSA

Courbes ECDSA
secp256k1
EdDSA
none
Formats de clé publique
sec1-uncompressedsec1-compressed
Formats de clé privée
pkcs8-pempkcs8-derraw-scalar

coincurve enveloppe libsecp256k1 avec CFFI et distribue des paquets wheel précompilés, si bien que le code Python obtient, sans compilateur, la même implémentation de secp256k1 en temps constant que celle de Bitcoin Core. La version 21.0.0 ne publie des wheels que pour CPython 3.10 à 3.13 ; sous Python 3.14, pip se rabat sur un build depuis les sources, qui échoue dans les images slim dépourvues de chaîne de compilation C. C’est pourquoi les essais ont été menés sous Python 3.12.

Les valeurs par défaut conviennent à Bitcoin plutôt qu’à Ethereum. sign() hache le message avec SHA-256, utilise un nonce RFC 6979 (le résultat des essais correspondait à celui d’un signataire de référence indépendant) et renvoie une signature DER dont s est déjà dans la moitié inférieure. Ethereum, Nostr et la plupart des portefeuilles hachent avec keccak-256 ou signent un condensat de 32 octets : passez donc un hasher ou hasher=None ; conserver la valeur par défaut produit des signatures d’apparence valide, mais calculées sur le mauvais condensat.

La vérification hérite de la rigueur de libsecp256k1. verify() a renvoyé False pour la forme high-S symétrique d’une signature valide : une signature issue d’OpenSSL, de Java ou d’un KMS cloud échoue environ une fois sur deux si s n’est pas normalisé au préalable. Une entrée qui n’est pas en DER lève ValueError: The DER-encoded signature could not be parsed. au lieu de renvoyer False ; les signatures brutes de 64 octets doivent être converties en encodage DER avant d’être passées à verify(), et le convertisseur DER ⇄ brut montre les deux formes.

Au-delà de l’ECDSA simple, sign_recoverable() renvoie la forme r ‖ s ‖ identifiant de récupération de 65 octets et PublicKey.from_signature_and_message() récupère la clé, ce qui permet de dériver des adresses Ethereum à partir de signatures ; sign_schnorr() produit des signatures BIP-340 pour Taproot et Nostr. Les clés publiques sont des points SEC1, compressés par défaut ; les clés privées sont des scalaires bruts de 32 octets, et to_pem()/to_der() écrivent du PKCS#8.

L’exemple montre les deux modes : SHA-256 par défaut, et un condensat keccak signé de façon récupérable, comme l’attend Ethereum. Contrôlez les résultats avec le vérificateur de messages Bitcoin ou le vérificateur de signatures Ethereum.

Les faits et les vérifications qui les fondent

Rien de ce qui suit n’est copié d’une documentation. La vérification content/libraries/verify/coincurve/run.sh a exécuté coincurve 21.0.0 dans l’image de conteneur python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 le 20 octobre 2026 ; la page doit correspondre à ce que la vérification a affiché, et chaque fait cite les lignes en question.

  • Encodage des signatures ECDSA

    der

    sign() renvoie du DER ; sign_recoverable() renvoie 65 octets de r ‖ s ‖ identifiant de récupération.

    Relevé par la vérification
    fact ecdsa-signature-encoding = der
    evidence ecdsa-signature-encoding: sign_recoverable() returns 65 bytes (r||s||recovery id)
    evidence ecdsa-signature-encoding: verify_signature helper on the DER signature: True
  • Fonction de hachage par défaut

    sha256

    La fonction de hachage par défaut est SHA-256 : sign(message) a donné la même signature qu’une signature RFC 6979 de référence calculée sur le SHA-256 du message.

    Relevé par la vérification
    fact ecdsa-default-hash = sha256
  • Ce que l’appel de signature hache

    hashes-message-or-digest

    sign(digest, hasher=None) signe tel quel un condensat de 32 octets et a donné, pour le SHA-256 du message, la même signature que sign(message).

    Relevé par la vérification
    fact ecdsa-prehash = hashes-message-or-digest
    evidence ecdsa-prehash: sign(sha256(m), hasher=None) equals sign(m): True
  • Nonce ECDSA

    rfc6979

    RFC 6979 : la signature correspondait à celle d’une implémentation indépendante de la RFC 6979 (elle-même contrôlée avec le vecteur de test de la RFC), avec s normalisé.

    Relevé par la vérification
    fact ecdsa-nonce = rfc6979
    evidence ecdsa-nonce: sign(message) equals the reference RFC 6979 signature over SHA-256(message) with s normalized: True
  • Low-S à la signature

    low-s

    Aucune valeur high-S parmi 64 signatures.

    Relevé par la vérification
    fact ecdsa-low-s-sign = low-s
  • High-S à la vérification

    rejects-high-s

    verify() a renvoyé False pour la même signature dont s a été remplacé par n − s.

    Relevé par la vérification
    fact ecdsa-low-s-verify = rejects-high-s
    evidence ecdsa-low-s-verify: verify() on (r, n - s): False
  • Signature ECDSA mal formée

    throws

    Une signature brute de 64 octets ou un bloc de 3 octets lève ValueError: The DER-encoded signature could not be parsed. ; une signature valide portant sur un autre message renvoie False.

    Relevé par la vérification
    fact ecdsa-malformed-signature = throws
    evidence ecdsa-malformed-signature: raw 64 bytes: ValueError: The DER-encoded signature could not be parsed.
    evidence ecdsa-malformed-signature: 3 bytes: ValueError: The DER-encoded signature could not be parsed.
    evidence ecdsa-malformed-signature: wrong message: False
  • Courbes ECDSA

    secp256k1

    secp256k1 uniquement.

    Relevé par la vérification
    fact ecdsa-curves = secp256k1
  • Signatures Schnorr

    BIP-340

    sign_schnorr() produit des signatures BIP-340 de 64 octets qui se vérifient avec la clé publique x-only.

    Relevé par la vérification
    fact schnorr = BIP-340
    evidence schnorr: PrivateKey.sign_schnorr available: True
  • EdDSA

    none

    Pas d’EdDSA.

    Relevé par la vérification
    fact eddsa = none
  • Algorithmes post-quantiques

    none

    Aucun algorithme post-quantique.

    Relevé par la vérification
    fact pqc = none
  • Formats de clé publique

    sec1-uncompressedsec1-compressed

    Les clés publiques se chargent depuis un point compressé ou un point non compressé au format SEC1 ; un x‖y de 64 octets a été rejeté avec The public key could not be parsed or is invalid.

    Relevé par la vérification
    fact public-key-formats = sec1-uncompressed, sec1-compressed
    evidence public-key-formats: 64-byte x||y: ValueError: The public key could not be parsed or is invalid.
  • Format de clé publique par défaut

    sec1-compressed

    format() renvoie le point compressé de 33 octets, sauf si compressed=False est passé.

    Relevé par la vérification
    fact default-public-key = sec1-compressed
  • Formats de clé privée

    pkcs8-pempkcs8-derraw-scalar

    Les clés privées sont des scalaires bruts de 32 octets ; to_pem() et to_der() écrivent du PKCS#8 (-----BEGIN PRIVATE KEY-----), que from_pem() et from_der() relisent.

    Relevé par la vérification
    fact private-key-formats = pkcs8-pem, pkcs8-der, raw-scalar
    evidence private-key-formats: to_pem() writes -----BEGIN PRIVATE KEY-----; to_der() structure: pkcs8

Utilisation

Signer avec SHA-256 par défaut et vérifier, puis signer un condensat keccak-256 de façon récupérable et récupérer la clé publique.
from coincurve import PrivateKey, PublicKey
from Crypto.Hash import keccak  # pycryptodome, for Ethereum-style hashing

key = PrivateKey()
message = b"order #1042 approved"

# Default: SHA-256 of the message, RFC 6979 nonce, low-S, DER output.
der = key.sign(message)
print("verified:", PublicKey(key.public_key.format()).verify(der, message))

# Ethereum and other keccak users must pass the hasher, or sign a ready digest with hasher=None.
digest = keccak.new(data=message, digest_bits=256).digest()
recoverable = key.sign_recoverable(digest, hasher=None)  # r || s || recovery id, 65 bytes
recovered = PublicKey.from_signature_and_message(recoverable, digest, hasher=None)
print("recovered matches:", recovered.format() == key.public_key.format(), len(recoverable))

Le banc de test des extraits de code exécute chaque exemple tel quel dans l’image de conteneur python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1, et l’exécution échoue si la sortie ne correspond pas.

Mise en place
pip install --quiet --root-user-action=ignore coincurve==21.0.0 pycryptodome==4.1.0
Commande
python usage.py
La sortie doit correspondre à cette expression régulière
verified: True\nrecovered matches: True 65

Bibliothèques