Security policy¶
Reporting a vulnerability¶
If you have found a security vulnerability, please do not open a GitHub issue: an issue is public from the moment it is filed, and so is the window between filing it and a fix being released.
Report it privately instead, by opening a security advisory. Only the maintainers can see it, the discussion stays private until an advisory is published, and a CVE can be requested from it if the vulnerability warrants one.
If you have no GitHub account, or would rather not use it for this, responsible disclosure by email to security at btclib dot org is equally welcome.
A report is acknowledged within 7 days, and a fix or a published advisory follows within 90 days.
A report triaged as easy to rediscover is fixed as soon as possible, since anyone can find it in the meantime, and its advisory is published with the fix.
What belongs here, and what belongs upstream¶
secp256k1 arithmetic is delegated, where the optional bindings are installed, to btclib-secp256k1 and through it to libsecp256k1, each with its own security policy. Not every call: one predicate decides, from a process-wide dispatch switch, the curve, the hash function and the caller’s other choices, and a call site may add a condition on an input’s value. Limitations, not vulnerabilities below states each of them. Report a flaw wherever you found it, though: routing a report is the maintainers’ job, not the reporter’s, and a doubt about which project owns a flaw is not a reason to keep it to yourself.
What a refusal may print about a value that could be a key is the organization’s rule, A refusal carries no secret.
Security review¶
The latest security review is dated 2026-09-30. pmazzocchi did it against the assurance case, and issue 84 records it.
The review’s scope is the tree at commit 9121965a3d03.
What changed since
is that commit’s comparison with main.
Supported versions¶
Only the latest release is supported. Versions are calendar-based
(YYYY.M.D), a fix is published as a new release, and nothing is
backported.
Wheels and sdist are published to PyPI with PEP 740 attestations, through a workflow that no long-lived token can authenticate for (PyPI Trusted Publishing), so a distribution can be traced back to the workflow run and the commit it was built from.
The same files are attached to the GitHub release, and those copies carry a build provenance attestation of their own, signed in the run that built them:
signer=btclib-org/.github/.github/workflows/reusable-build.yml
id=$(gh api repos/btclib-org/ecc --jq .id)
cert='.[].verificationResult.signature.certificate'
gh attestation verify <file> --owner btclib-org \
--signer-workflow "${signer:?}" \
--source-ref refs/tags/v<version> --format json \
--jq "${cert:?}.sourceRepositoryIdentifier" | grep -qx "${id:?}"
The exit code is 0 only where a verified attestation names this
repository. The repository is compared by its id, which a rename keeps:
--repo compares the current name and refuses every release signed
before a rename.
<file> is a distribution file from the release.
--signer-workflow is what makes that say which workflow signed, rather
than accepting any attestation this repository has: the signing runs in
btclib-org/.github’s reusable-build.yml, which release.yml calls,
and --source-ref is what keeps a build of a branch from passing as the
release. A release made before that was signed by reusable-attest.yml,
named the same way, with the same --source-ref.
A CycloneDX bill of materials is attached beside them, generated from the built wheel and covered by the same attestation. Either distribution file can also be rebuilt from its tag and compared, the build being reproducible: RELEASING.md has that command.
Limitations, not vulnerabilities¶
These are known and inherent. They are worth stating because this package is used to teach and to prototype as much as to build:
secret material handed to this package lives in Python objects, which are immutable and not zeroized: it stays in the process memory until garbage collection, and may have been copied by the interpreter meanwhile. The constant-time properties of libsecp256k1 apply to the C side of the boundary, not to what happens before and after it
musig2.nonce_genandsignstay on this arithmetic by decision, not merely by default (btclib-org/btclib#1050). Delegating them would putmusig_nonce_gen’s secnonce – an opaque 132-byte struct the header calls “implementation defined and not guaranteed to be portable between different platforms or versions” – intoecc.musig2’s public API. What it would buy is measured rather than assumed: the point-multiplication side is regular (btclib-org/btclib#254), andsign’s own line,s = (k_1_ + values.b * k_2_ + values.e * a * d) % secp256k1.n(src/ecc/musig2.py:872), spreads 1.016x over uniform scalars in[1, n-1]– the magnitude leak that remains shows only for scalars with zero high bits, keys already lost for other reasons. The gain left is narrower than that figure suggests: delegating would only keepk_1andk_2from becoming Pythonints, and the bullet above already covers why that does not decide anything –curves.scalar_from_prv_keyproduces an unzeroizableintfrom the private key on the delegated path too.btclib_secp256k1itself takes the equivalent opaque handle formusig_keyagg_cacheandmusig_sessionwithout this reasoning landing on a different answer there: those have no octets form to begin with, where aninthere already does the job. The same reasoning answers two more questions: this package grows no private-key class, because zeroization needs exactly the delegation declined above – a class that hands out anintthe moment anything uses it has a session object’s ergonomics and none of its guarantee – and curve arithmetic stays onintrather thanbytes,bytesbeing immutable exactly likeintwhile onlybytearrayzeroizes, and bignum arithmetic on abytearraystill allocatingintintermediates at every stepthe bindings also let a caller own the buffer a secret is written into: a keyword-only
into=, on the entry points that produce one. This package passes none, and that is a decision, not an oversight.commit_nonce.commit_nonce_reads one straight into a Pythonintatint.from_bytes(tweaked, byteorder="big", signed=False)(src/ecc/commit_nonce.py:159). A caller-owned buffer can be wiped once the call that filled it returns; theintit is read into cannot be, and outlives the call regardless, so taking the buffer there would cost a public signature and buy nothing, short of this package no longer holding a private key as a Pythonint, which is a change to that representation and not to a call site.dsa.Signer.__init__atself._q.to_bytes(32, "big")(src/ecc/dsa.py:1483) crosses the same boundary the other way, once, at construction: the plainintscalar_from_prv_keyalready produced becomes a transientbyteson the way into the owned bufferwipeoverwrites afterwards. Thatbytesis dropped rather than erased, same as theintit replaces – one call rather than the buffer’s whole lifetime, which is the trade this class exists to makethe boundary is not always there, and an install decides whether it is.
pip install "ecc[secp256k1]"installs the bindings, and everything the next bullet says describes that installation.pip install eccinstalls no C at all: signing, verification and key agreement all run the Python arithmetic the last bullet describes, which is tens of times slower and not constant-time. Nothing raises to say so, andcurves.is_libsecp256k1_serving()is how a caller asks which of the two it has. The dispatch is a runtime switch besides:curves.set_libsecp256k1_serving(serving=False)turns it off for the whole process, andECC_NO_LIBSECP256K1set in the environment makes that the state from the first call – a test framework built on this package wants exactly that, having to check libsecp256k1 with something other than libsecp256k1. The variable is not a lock: any code in the process can turn the dispatch back on, so a framework that must not run on the bindings checkscurves.is_libsecp256k1_serving()itself. With the dispatch off, every operation named below is the Python arithmetic, whichever way the package was installeda call the bindings decline runs the Python arithmetic and says nothing, unless the caller asks to be refused instead:
curves.set_libsecp256k1_serving(serving=True, required=True). A call on secp256k1 that the bindings decline for something the caller chose – a hash function that is nothashlib.sha256itself, a nonce of its own, a commitment,lower_s=False, a MuSig2 session with an adaptor – then raisesEccRuntimeError. What is not a choice still runs the Python arithmetic: another curve, the point at infinity, a message length the bindings have no call for.requiredneedsserving=True, and each call of the setter sets it afresh, so it is False unless passed. Adsa.Signerbuilt while the bindings were off, or with anhfthey decline, signs on the Python arithmetic, but raises underrequiredonce they would serve it, or while they decline itshfnot every operation crosses that boundary, and one predicate decides whether it can:
curve._libsecp256k1_servesasks for the switch above, then for secp256k1 as the curve, then for a hash function that is sha256 or absent – it declineshf is not None and hf is not sha256(src/ecc/curves/curve.py:700) – with whatever further options the call site passes it. The hash function is matched by identity rather than by what it computes, sofunctools.partial(sha256), or any other wrapper a caller writes to fit an interface, is one the predicate declines, and the call it is passed to runs the Python arithmetic – conservative for the arithmetic, silent for the caller unless the bindings are required (above). The condition each operation below states as sha256 is that identity.mult,double_mult_varandmulti_mult_varreach the bindings for secp256k1 and any point of it, the point at infinity excepted – libsecp256k1 has no public key for it; a zero scalar, which it has no scalar for, is excepted by the two_varones and multiplied as one bymult, whose answer is then infinity;dsa.signfor secp256k1 with sha256, the lower-s form, no caller-imposed nonce and no commitment;ssa.signfor secp256k1 with sha256, a message of any size and no commitment;dh.diffie_hellman,ellswift.xdh,commit_nonce.commit_nonce_,curves.prv_key_tweak_addandssa.prv_key_tweak_addfor secp256k1, the shared point of a key agreement, the tweaking of a sign-to-contract nonce and the tweaking of a private key being other places a secret meets the curve. Verification crosses it whole, not only in its multiplication:dsa.verifyandssa.verifyare one libsecp256k1 call each, where the dispatch is on, for secp256k1 with sha256, a high-s signature being normalized first where the lower-s form is not being enforced. Batch verification and BIP458’s aggregate verification are the exceptions, libsecp256k1 having no call for either, sossa.batch_verifyandssa_halfagg.verifyare the Python equation over delegated multiplications.musig2.partial_sig_verify_is a narrower delegation again, of one MuSig2 round-two check rather than of every operation the module offers (btclib-org/btclib#1049): for secp256k1, sha256, a 32-byte message and a session with no adaptor.musig_nonce_processtakes a fixed 32-bytemsg32with no length parameter, so a message of any other size runs the Python equation below regardless of the bindings, as does a session carrying the adaptor extensionecc.musig2implements and the bindings do not. Underrequiredsuch a session raises instead.key_agg_var,key_sortandnonce_agg_varstay Python’s alone either way: measured too close to the delegated arithmetic they already call, or run once per session rather than once per signer, to earn a second code path. This paragraph is about a secret meeting the curve and verification holds none, which is why it is named here only to say that the sentence below is not about it. A signature the bindings decline is not all Python for that:dsa.gen_keysand the nonce point ofdsa._sign_go throughmult, and the verification equation of bothdsaandssathroughdouble_mult_var, so those multiplications are delegated whatever else the signature asks for. The rest of that signature is not – the inversion of the nonce and the arithmetic on the key around it are Python integers. That inversion is blinded, as every inversion of a secret in the package is:mod_invdraws a random factor, so that the extended Euclid’s iteration count follows the factor rather than the nonce. Unblinded it followed the nonce’s bit-length, which is the correlation the Minerva attack turns into the private key. Whatever the predicate above and a call’s own conditions decline runs the Python implementation, whose scalar multiplication is a double-and-add in Jacobian coordinates: it is validated against the bindings, which are the authority on the answer, but it is not constant-time. It tries, which is not the same claim. The Jacobian group law neither branches on the point at infinity nor lets it reach the arithmetic: a full-size stand-in takes its place and a table answers for it, because a Python integer costs what its size costs and the zero coordinates of infinity would time the case as well as a branch would. That is the case that matters, infinity being the identity and so the accumulator every multiplication starts from and the multiple a zero digit names: an addition of it costs what any other addition costs, and a multiplication takes the same time whatever the bits of the scalar. Two points that coincide, or that are opposite, do still branch – that case needs the accumulator to land on a table entry, 2^-250 on a curve with a real order, and a caller spelling outP + Pknows it did. Nor does the scalar decide how many additions there are, or how many windows:multrecodes it into signed odd digits, none of them zero and alwaysceil(nlen / w)of them, and starts the accumulator at a table entry rather than at the identity, so every scalar of the curve costs the same additions and the same doublings, and its size is hidden as its bits are. The plain fixed window is the contrast: its digits areceil(m.bit_length() / w)of them, so a scalar short of a full top window costs one window less there and its size is what the count shows. On secp256k1, whose endomorphism halves the doublings, the regular windows of its decomposition are uniform the same way. The interleaved wNAFs of that same decomposition add on a nonzero digit and so once per unit of the recoded weight of the coefficient, which is why they are not whatmultreaches for; they are whatdouble_mult_varand signature verification reach, where the coefficients are a signature and a message hash rather than a secret.What is left is out of reach from pure Python, and is enough to matter: the windowed multiplications index a table of precomputed multiples with a secret digit, which is the memory access pattern the FLUSH+RELOAD recovery of OpenSSL’s nonces read; every reduction and multiplication takes the time its operand sizes ask for, and a residue is not always the full size; the affine group law spends a modular inverse, an extended Euclid whose iteration count follows its input, and so does the conversion back from Jacobian coordinates – that one on a Z coordinate
_blinded_jachas randomized, which is why it is named here as a cost and not as a channel; andmulti_mult_varis Bos-Coster, whose shape is the scalars themselves. Thegmpy2extra hands the Jacobian group law and its inverses to GMP, and changes none of this: gmpy2 calls GMP’smpzfunctions, and the GMP manual’s Low-level functions for cryptography names only itsmpn_sec_andmpn_cnd_functions as written for side-channel resistance. Using the Python arithmetic on key material that matters is a choice, and this is the notice of itthe delegated multiplication of a point that is not the generator is constant time in its scalar where the multiplication is
mult’s, and variable time where it is a_varone’s.multreaches libsecp256k1 throughsecp256k1_ecdh, whose multiplication issecp256k1_ecmult_const, constant time in its scalar;ecdh.shared_pointof the bindings is that call answering the point rather than a hash of it. The armcurves.curve.multshares withPreparedPoint.multasks for the predicate above, with no hash function, so the switch and the curve are the whole of what the predicate asks; a zero reduced scalar is multiplied as one and the product dropped, the substitutionsecp256k1_ec_pubkey_createandsecp256k1_ecdhmake for a key they refuse, so a zero is not told apart by the arm it takes, though the Python around the call tests it (the next bullet); the generator is a different call inside that arm and infinity is not delegated at all –curve._libsecp256k1_multatlibsecp256k1_shared_point(_sec_from_point(Q), m, False)(src/ecc/curves/curve.py:976).dh.diffie_hellmanatsec = libsecp256k1_shared_point((src/ecc/dh.py:123),sec_point._mult_secatlibsecp256k1_shared_point(sec, m or 1, False)(src/ecc/curves/sec_point.py:444), undersec_point.mult_pub_key, andsec_point._mult_sec_compressedatlibsecp256k1_shared_point(sec, m, True)(src/ecc/curves/sec_point.py:466), underecies.derive_keys, make the same call on the octets they already hold.double_mult_varandmulti_mult_var, andssa.batch_verify, are the other call:keys.pubkey_tweak_mul_sum, onesecp256k1_ec_pubkey_tweak_mulper term, which runssecp256k1_ecmult, whose windowed NAF holds at most one more digit than the scalar has bits – so the work follows the scalar rather than the order of the curve, which is what their suffix says. A verification’s scalars are public; a secret handed to them is timed by them all the same, so a Pedersen commitment rG+vgen, whose two scalars are secrets, is amultof each and their sum instead –pedersen._commitatreturn _add(mult(r, ec.G, ec), mult(v, gen, ec), ec)(src/ecc/pedersen.py:384), underpedersen.commit,rangeproof.signandrangeproof.rewind. The sum iscurve._addatreturn _libsecp256k1_sum((P, Q))(src/ecc/curves/curve.py:1450):secp256k1_ec_pubkey_combine, whose group lawsecp256k1_gej_add_geand whose inversionsecp256k1_fe_invare constant time. A commitment to a zero value has a product at infinity, which_adddoes not hand over: it sums the other term with itself and drops the answer, so the call is made for a zero as for any other value. Only the call is constant time, and the next bullet names the Python around it.curves.addis that sum behind a check of its arguments, published for a caller whose points are as secret as these. Where the bindings do not serve,curves.addis the Python arithmetic the bullet above describes.pedersen.generator_from_seedtreats its seed and its blinding factor as secrets, as libsecp256k1-zkp does: its blindG ismultand its sums are_add, and the map its seed goes through is Python on either arm, forming the same roots for every seed. The other delegations the bullet above names are different calls: a multiple of the generator issecp256k1_ec_pubkey_create, which runssecp256k1_ecmult_gen; the tweaking of a sign-to-contract nonce, andcurves.prv_key_tweak_add, aresecp256k1_ec_seckey_tweak_add, which adds scalars; andssa.prv_key_tweak_addissecp256k1_keypair_xonly_tweak_add, which negates the key where its point has an odd y before adding. It does not treat the tweak as a secret, andsrc/ctime_tests.cmarks it defined before that call.ellswift.xdhissecp256k1_ellswift_xdh, whose multiplication,secp256k1_ecmult_const_xonly, is constant time in its scalar. These calls andsecp256k1_ecdhare among the entry points libsecp256k1’s ownsrc/ctime_tests.cdeclassifies a secret for.secp256k1_ec_pubkey_tweak_mulandsecp256k1_ec_pubkey_combineare named nowhere in that file, so what makes the second constant time is the group law and the inversion it calls, not a testthe Python around a crossing is not constant time, and this is what it does with the secret. A function is listed here where it is public, takes a secret, and hands that secret to libsecp256k1 through a dispatch of its own: its body, or a private function it reaches through private functions only, asks
curve._libsecp256k1_servesor calls the bindings.tests/security_census_test.pyfinds those that reach their dispatch through calls made by name, and fails on one that is not listed in the items below, unless its name is a_varone or a verification’s, or that file names it as handed public values only. A function that reaches the bindings only through a listed one, such asdsa.gen_keysthroughcurve.mult, takes that function’s steps and adds its own Python arithmetic, which is the Python arithmetic described above. Each step below runs while the bindings serve, on the secret, on a value computed from it, or, for the multiplications, on the point multiplied.Some steps are common to most of them. A private key passes through
sec_point.scalar_from_prv_key, which reads it from octets where it arrives as octets and refuses one outside 1..n-1 with a Python comparison. A scalar that is not that, the onecurve.mult,sec_point.mult_pub_key,sec_point.bytes_from_prv_key_intand thepedersenfunctions take, is read by_utils.int_from_integer, which turns a string or octets into an int. btclib-secp256k1’s own Python,_scalar.scalar, which its entry points apply to a scalar argument, checks an int against 2^256 and writes it to 32 octets before the C call. After the call, itsecdh._shared_point_, under everysecp256k1_ecdhthis package makes, assembles the octets it answers in Python: it copies the coordinates out and, where the call asks for the compressed form, asdh.diffie_hellman’s andecies.derive_keys’s do, sets the prefix from the parity of the product’s y,_EVEN_Y | (coordinates[63] & 1).curve.multandcurve.PreparedPoint.multreduce the scalar mod n and test it for zero before the call,stand_in = m or 1, and after it,product if m else INF. Both compare the point with the generator, and read the product back from octets. A point other than the generator they test for infinity and write to octets for the call, andcurve.multrunsrequire_on_curveon it first. Handing the zero to the call and answering infinity where the bindings refuse it would not remove the test: their Python makes it on the call’s result, raises, and skips the serialization a product costs, so a zero would return sooner (issue btclib-org/ecc#439)curve.addchecks the form of both points, a pair of integers whose x is in 0..p-1, and leaves the rest of the proof that a finite one is on the curve to the parse of the call:require_on_curveruns only on the path that refuses.curve._addunder it tests each point for infinity to choose the stand-in, writes the points to octets for the call, and reads the sum backpedersen.commitrefuses an r that is 0 mod n, and its commitment is acurve.multof each scalar and acurve._addof the products, with their steps.pedersen.generator_from_seedis the map of its seed, which the bullet above names, and acurve._add; given a blinding factor, it refuses one outside 0..n-1 and adds itscurve.multwith anothercurve._add.rangeproof.signandrangeproof.rewindare the Python arithmetic around their commitments, which arepedersen._commit, andrewindcompares the commitment it rebuilds with the one it was handedsec_point.mult_pub_keyreduces its scalar mod n, andsec_point._mult_secunder it tests it for zero before the call and after it, ascurve.multdoes, and reads the product back from octets.ecies.derive_keystakes its key throughscalar_from_prv_key, and hashes with sha512 the compressed octets the bindings answersec_point.bytes_from_prv_key_intreduces its scalar mod n and sends a zero tocurve.mult, whose answer at infinity it refusessec_point.prv_key_tweak_addandssa.prv_key_tweak_addrefuse a tweak outside 0..n-1, turn the bindings’ refusal of a zero sum into their own, and read the sum back into an int.commit_nonce.commit_nonce_multiplies its nonce withcurve.multfor the receipt, turns a refused zero sum into its own refusal, and reads the tweaked nonce back into an intdh.diffie_hellmanslices the x-coordinate out of the shared point’s octets and derives the key from it in Python,kdf.ansi_x9_63_kdf, one hash call per blockellswift.create,ellswift.xdh,dsa.sign_,dsa.sign_recoverable_andssa.sign_, with the spellings that reduce a message first, take the key throughscalar_from_prv_keyand do nothing else with it in Python: what comes back is a public key, a signature or, forxdh, the secret octets as the bindings answer themdsa.Signertakes the key throughscalar_from_prv_key, multiplies it withcurve.multfor the public key, and writes it to the buffer it signs from, as the bullet on caller-owned buffers above says.ssa.Signerhands the key to the bindings’ keypair. Neither does Python on the key when it signs
rangeproof.rewindon the Python arm takes time that depends on the value it recovers:rangeproof._value_encoding_varatreturn position, int.from_bytes(tmp[24:32], "big")(src/ecc/rangeproof.py:1605) returns early, at a position set by a digit of that value, which its_varsuffix saysa sign-to-contract commitment is the signer’s to open, and opening it twice over one message is safe only because the committed value reaches the nonce derivation: that is what keeps two such signatures from sharing an untweaked nonce and handing out the key. The derivation is
ecc.commit_nonce, and the property is worth knowing about for anyone building on it the anti-exfil protocol, whichdsaandssacarry asanti_exfil_*: there, the ordering matters as well – the signer must publish itsRbefore learning the host’s randomness, andsignalone cannot enforce thatrandomness comes from the operating system through the
secretsmodule: the auxiliary randomness of BIP340 signing and the private keys of the key generation helpers. Nothing here seeds a generator of its ownecc.eciesships no block cipher and takes AES-128-CBC as two callables, so the cipher’s own resistance to timing and side-channel attack is whatever the caller passed in – this package neither provides it nor can check it. That is the point of the parameter rather than a gap in it: a pure-Python AES here would be table-driven and would leak its key through cache timing, and the caveat above about the Python curve arithmetic is exactly the one this design refuses to add a second of. The MAC is verified before the cipher is called, and compared withhmac.compare_digest, so what this package does with the envelope does not depend on the secret byte by byte; a caller wanting the same of the decryption should bring a cipher that gives it