Skip to content
ecdsa.com
Python

coincurve

Ofek Lev's coincurve, Python bindings to Bitcoin Core's libsecp256k1: DER signatures over SHA-256 by default, RFC 6979, strict low-S on both sides, recoverable and BIP-340 Schnorr signatures.
License
MIT OR Apache-2.0
Checked version
21.0.0 ·
Source
github.com/ofek/coincurve
Documentation
ofek.dev/coincurve/

Compatibility at a glance

ECDSA

ECDSA curves
secp256k1
EdDSA
none
Public key formats
sec1-uncompressedsec1-compressed
Private key formats
pkcs8-pempkcs8-derraw-scalar

coincurve wraps libsecp256k1 with CFFI and ships prebuilt wheels, so Python code gets the same constant-time secp256k1 implementation Bitcoin Core uses without a compiler. Version 21.0.0 publishes wheels for CPython 3.10 to 3.13 only; on Python 3.14 pip falls back to a source build, which fails in slim images without a C toolchain — that is why the check ran on Python 3.12.

The defaults suit Bitcoin rather than Ethereum. sign() hashes the message with SHA-256, uses an RFC 6979 nonce (the check matched an independent reference signer) and returns a DER signature with s already in the low half. Ethereum, Nostr and most wallets hash with keccak-256 or sign a 32-byte digest, so pass a hasher or hasher=None; leaving the default in place produces valid-looking signatures over the wrong hash.

Verification inherits libsecp256k1's strictness. verify() returned False for the mirrored high-S form of a valid signature — a signature from OpenSSL, Java or a cloud KMS fails about half the time unless s is normalized first. Input that is not DER raises ValueError: The DER-encoded signature could not be parsed. instead of returning False; raw 64-byte signatures need verify() on a DER re-encoding, and the DER ⇄ raw converter shows both forms.

Beyond plain ECDSA, sign_recoverable() returns the 65-byte r ‖ s ‖ recovery-id form and PublicKey.from_signature_and_message() recovers the key, which is how Ethereum addresses are derived from signatures; sign_schnorr() produces BIP-340 signatures for Taproot and Nostr. Public keys are SEC1 points, compressed by default; private keys are raw 32-byte scalars, and to_pem()/to_der() write PKCS#8.

The sample shows both modes: the SHA-256 default, and a keccak digest signed recoverably as Ethereum expects. Check results against the Bitcoin message verifier or the Ethereum signature verifier.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/coincurve/run.sh ran coincurve 21.0.0 in the container image python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1 on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • ECDSA signature encoding

    der

    sign() returns DER; sign_recoverable() returns 65 bytes of r ‖ s ‖ recovery id.

    Recorded by the check
    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
  • Default hash

    sha256

    The default hasher is SHA-256: sign(message) matched a reference RFC 6979 signature over SHA-256 of the message.

    Recorded by the check
    fact ecdsa-default-hash = sha256
  • What the signing call hashes

    hashes-message-or-digest

    sign(digest, hasher=None) signs a 32-byte digest unchanged and gave the same signature as sign(message) for its SHA-256.

    Recorded by the check
    fact ecdsa-prehash = hashes-message-or-digest
    evidence ecdsa-prehash: sign(sha256(m), hasher=None) equals sign(m): True
  • ECDSA nonce

    rfc6979

    RFC 6979: the signature matched an independent RFC 6979 implementation (itself checked against the RFC's test vector), with s normalized.

    Recorded by the check
    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 when signing

    low-s

    No high-S value among 64 signatures.

    Recorded by the check
    fact ecdsa-low-s-sign = low-s
  • High-S when verifying

    rejects-high-s

    verify() returned False for the same signature with s replaced by n − s.

    Recorded by the check
    fact ecdsa-low-s-verify = rejects-high-s
    evidence ecdsa-low-s-verify: verify() on (r, n - s): False
  • Malformed ECDSA signature

    throws

    A raw 64-byte signature or a 3-byte blob raises ValueError: The DER-encoded signature could not be parsed.; a valid signature over another message returns False.

    Recorded by the check
    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
  • ECDSA curves

    secp256k1

    secp256k1 only.

    Recorded by the check
    fact ecdsa-curves = secp256k1
  • Schnorr signatures

    BIP-340

    sign_schnorr() produces 64-byte BIP-340 signatures that verify against the x-only public key.

    Recorded by the check
    fact schnorr = BIP-340
    evidence schnorr: PrivateKey.sign_schnorr available: True
  • EdDSA

    none

    No EdDSA.

    Recorded by the check
    fact eddsa = none
  • Post-quantum algorithms

    none

    No post-quantum algorithms.

    Recorded by the check
    fact pqc = none
  • Public key formats

    sec1-uncompressedsec1-compressed

    Public keys load from compressed or uncompressed SEC1 points; a 64-byte x‖y was rejected with The public key could not be parsed or is invalid.

    Recorded by the check
    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.
  • Default public key format

    sec1-compressed

    format() returns the 33-byte compressed point unless compressed=False is passed.

    Recorded by the check
    fact default-public-key = sec1-compressed
  • Private key formats

    pkcs8-pempkcs8-derraw-scalar

    Private keys are raw 32-byte scalars; to_pem() and to_der() write PKCS#8 (-----BEGIN PRIVATE KEY-----), which from_pem() and from_der() read back.

    Recorded by the check
    fact private-key-formats = pkcs8-pem, pkcs8-der, raw-scalar
    evidence private-key-formats: to_pem() writes -----BEGIN PRIVATE KEY-----; to_der() structure: pkcs8

Usage

Sign with the SHA-256 default and verify, then sign a keccak-256 digest recoverably and recover the public key.
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))

The snippet harness runs every sample verbatim in the container image python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1, and the run fails unless the output matches.

Setup
pip install --quiet --root-user-action=ignore coincurve==21.0.0 pycryptodome==4.1.0
Command
python usage.py
Output must match (regular expression)
verified: True\nrecovered matches: True 65

Libraries