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.
What belongs here, and what belongs upstream¶
Signing, verification and generator multiplication on secp256k1 are delegated to btclib-secp256k1, the Python bindings, and through them to libsecp256k1 itself, which has its own security policy and its own address. A flaw in the elliptic curve arithmetic, or in how the bindings drive it, most likely belongs to one of those.
What belongs here is everything btclib does around them:
the parsing and serialization of what comes from outside — keys, addresses, signatures, scripts, transactions, PSBTs — and the validation that decides what is accepted
the derivation paths: BIP32, BIP39, Electrum mnemonics, SLIP132
the script engine, and the taproot construction it validates against
the pure Python implementations, which are what runs whenever the conditions below are not met
the JSON-RPC client of
btclib.fetch: how it authenticates, and what it does with a replythe distributions published to PyPI and their provenance
Report it wherever you found it, though: routing a report is the maintainers’ job, not the reporter’s, and a doubt about which of three projects owns a flaw is not a reason to keep it to yourself.
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:
gh attestation verify btclib-<version>-py3-none-any.whl \
--repo btclib-org/btclib \
--signer-workflow btclib-org/btclib/.github/workflows/release.yml
--signer-workflow is what makes that say which workflow signed, rather
than accepting any attestation this repository has. The signed statement
is attached to the release as well, as <tag>.attestation.jsonl, so
--bundle <tag>.attestation.jsonl runs the same check reading it from
disk instead of asking GitHub for it; one attestation covers every asset
of the release. Either file can also be rebuilt from its tag and
verified without being downloaded at all, the build being reproducible:
RELEASING.md has that command and the bounds on it.
A CycloneDX 1.6 bill of materials is attached beside them,
btclib-<version>.cdx.json: the two files with their SHA-256, the
licence, and one component per dependency the wheel’s metadata declares.
It is generated from the built wheel rather than from the source tree, so
it describes the files it is attached to, and it is covered by the same
attestation — a bill of materials whose provenance nobody can check says
only what whoever wrote it wanted said. What it records of a dependency is
the requirement as published, so a component carries a version only where
that requirement is an exact pin.
What may be inside those two files is stated in the package-content policy, and a build carrying anything else is refused before it can be published: an allowlist of the members of a wheel and an sdist, the suffixes and names neither may ever hold, and — named as policy, because no list of members can show them — the rules about what runs while the package is built and installed.
Limitations, not vulnerabilities¶
These are known and inherent. They are worth stating because btclib is used to teach and to prototype as much as to build:
secret material handed to btclib 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.
bip32._cached_base58_decodeextends this by one step for an xprv or xpub string handed toderiveorderive_from_account: the decoded key stays reachable from that cache, bounded by itsmaxsize, past whatever reference the caller itself still holdsmusig2.nonce_genandsignstay on this arithmetic by decision, not merely by default (issue #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” – intobtclib.ecc.musig2’s public API, againstsrc/btclib/psbt/musig2.py’s own decision to hold no session state at all. What it would buy is measured rather than assumed: the point-multiplication side has been regular since #254, andsign’s own line,s = (k_1_ + values.b * k_2_ + values.e * a * d) % secp256k1.n(src/btclib/ecc/musig2.py:813), 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 –int_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 library has never separately decided: it 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 every entry point that produces one —keys.prvkey_negate,keys.prvkey_tweak_add,keys.prvkey_tweak_mul,xonly.prvkey_tweak_add,ecdh.shared_secret,ellswift.xdh,dsa.nonce_rfc6979andssa.nonce_bip340. btclib passes none of them, and that is a decision, not an oversight. These call sites read one of those straight into a Pythonint:bip32.derive(src/btclib/bip32/bip32.py:666),commit_nonce.commit_nonce_(src/btclib/ecc/commit_nonce.py:155) andtaproot._tweaked_prvkey(src/btclib/script/taproot.py:474). 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 —bip32.derivekeepsprv_key_intfor the life of the key object — so taking the buffer at these three would cost a public signature and buy nothing, short of btclib no longer holding a private key as a Pythonint, which is a change to that representation and not to a call site.ellswift.xdh(src/btclib/ecc/ellswift.py:362) is the one of them that returns octets rather than anint, so a caller-owned buffer there would hold what it wiped: taking it means growingxdh’s public signature withinto=and owning the contract that comes with it — a buffer too short, a buffer that is not writable, what the function then returns. btclib declines that too, for the reason the bullet above already gives: no Python object holding a secret is zeroized, on either path, and this one is no exception to it.dsa.Signer.__init__(src/btclib/ecc/dsa.py:1347) crosses the same boundary the other way, once, at construction: the plainintint_from_prv_keyalready produced becomes a transientbytesviaself._q.to_bytes(32, "big")on 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 make, and stated here for the same reason the other three call sites arethe boundary is not always there, and an install decides whether it is.
pip install "btclib[secp256k1]"– the spelling README.md and the guide give – installs the bindings, and everything the next bullet says describes that installation.pip install btclibinstalls no C at all: signing, verification, BIP32 derivation 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, andBTCLIB_NO_LIBSECP256K1set in the environment makes that the state from the first call — a test framework built on btclib wants exactly that, having to check libsecp256k1 with something other than libsecp256k1. With the dispatch off, every operation named below is the Python arithmetic, whichever way the library was installednot every operation crosses that boundary.
mult,double_mult_varandmulti_mult_varreach the bindings for secp256k1 and any point of it, a zero scalar and the point at infinity excepted — libsecp256k1 has no scalar for the one and no public key for the other;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;taproot.output_prvkey,dh.diffie_hellman,commit_nonce.commit_nonce_and the private half ofbip32.derivefor secp256k1, the tweaking of a key, the shared point of a key agreement, the tweaking of a sign-to-contract nonce and the offsetting of a parent key by the left half of an hmac being four 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 is the exception, libsecp256k1 having no call for it, sossa.batch_verifyis 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 (issue #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 extensionbtclib.ecc.musig2implements and the bindings do not.key_agg,key_sortandnonce_aggstay 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, and is the one place in the library where a secret is inverted at all: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 — 8.8 us for a 256-bit scalar against 4.3 for a 128-bit one on secp256k1’s order — which is the correlation the Minerva attack turns into the private key.bms.signis delegated outright,recovery.signsigning and naming the recovery flag in one call: message signing is defined for secp256k1 alone, so there is no argument that sends it down the Python path — the switch above is what reaches it, and nothing a caller passes does. BIP32 derivation is the same case, and for the same reason:bip32.deriveadds the offset withkeys.prvkey_tweak_addprivately andkeys.PubkeyTweakChainpublicly, and the Python arm behind them — BIP32’s two sums, on integers and on a point — is one no argument selects either.silent_payments.output_keysis the sender’s half of BIP352, which is the same case a third time: it reachessilentpayments.create_outputsfor every private key an eligible input carries, a keypair built and wiped per taproot input and a scalar buffer per other one, exactly asdsa.signandssa.signbuild and wipe theirs.scan_outputs, BIP352’s light-client scan, is not delegated — it takes the shared secret already reduced, which is the shape a light client has and the bindings’ ownscan_outputsdoes not accept, wanting the raw inputs to derive it from instead — so every secret that function meets is still Python’s alone.scan_transaction_outputs, the full-node sibling a wallet holding the transaction itself can call, is a fourth case: where the bindings serve secp256k1 it reachessilentpayments.scan_outputswithb_scan, the recipient’s scan private key, built once for a whole transaction rather than once per input; off that path it falls back toscan_outputsabove and stays Python’s alone. Anything else — another curve, another hash function, a nonce of your own — 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. Measured over 200 random scalars: 71 additions and 253 doublings for every one of them, where the plain fixed window makes 68 to 70 and 251 to 259; and on secp256k1, whose endomorphism halves the doublings, 79 and 126 for every one of them, where the interleaved wNAFs of the same decomposition make 51 to 64 and 124 to 131. Those wNAFs 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. Using it on key material that matters is a choice, and this is the notice of ita 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
btclib.ecc.commit_nonce, and the property is worth knowing about for anyone building the anti-exfil protocol on top, which btclib does not yet offer: there, the ordering matters as well — the signer must publish itsRbefore learning the host’s randomness, andsignalone cannot enforce thata
btclib.ecc.borromeanring signature made before issue #1053’s fix names its own signer. The real signer’s s-value was the only one computed rather than drawn, and the only one left unreduced: it ran to about twice the bit length of the forged values beside it, so the longest s in a ring was the real signer’s position, read off a signature that was otherwise valid and without breaking anything cryptographically. The fix reduces that value modec.n, the same reduction every verifier already applied to it, and draws the forged values from the same range rather thansecrets.randbits(256); no signature changes, so a signature made before the fix still verifies after it, and this bullet is about what it already disclosed, not about needing a new one. A ring signature published under the old code is not repaired by upgrading btclib: whatever bit length its published s-values carry is public already, and nothing library-side can withdraw thatrandomness comes from the operating system through the
secretsmodule: the auxiliary randomness of BIP340 signing, the entropy of a generated mnemonic, and the private keys of the key generation helpers. Nothing here seeds a generator of its ownbtclib.ecc.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 — btclib 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 btclib 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 ita
btclib.fetchbackend is trusted, and the two are trusted differently.BitcoinCoreFetchertalks to a node that validated the chain it reports;EsploraFetchertalks to a host that says it did. Only one answer of the four is checked at all — the transaction, whose id is recomputed from the bytes that came back — so a height, a tip hash and the amount of an output all rest on the backend’s word. An explorer also learns every txid and outpoint you look up, which is a good deal of what a wallet is; btclib names a public deployment as a constant and never as a default, so nothing here contacts anyone until a caller writes the endpoint downrpc credentials. They are passed as arguments and refused in the url, so that a password is not carried in a string that ends up in config files, tracebacks and logs. bitcoind’s
.cookieneeds none at all and is the default. The transport is plain HTTP against127.0.0.1, which is what bitcoind serves: a node on another host is reached over an ssh tunnel or a TLS proxy, not by trusting the network in between — the basic authentication this sends is a base64 of the credential and nothing more. With the defaulturlopen_transportit reaches the url the caller wrote down and no other: no redirect is followed, so a 30x is a status the backend reports rather than a second request carrying the sameAuthorizationto wherever aLocationheader named. A transport of the caller’s own is supported and does its own I/O, so there the same guarantee is theirs to provide — a client that follows redirects is one that decides where the credential goes