Security policy¶
Reporting a vulnerability¶
Please do not open a GitHub issue. Provide responsible disclosure either privately through GitHub, from the Security tab of this repository (“Report a vulnerability”), or by emailing security at btclib dot org — the organization’s own address, kept in section 2 of the standard in btclib-org/.github, rather than repeated here as a claim about every other repository this file does not read.
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¶
This project is a thin binding layer: the cryptography is libsecp256k1, vendored as a submodule and built from source. A flaw in the library itself is not ours to fix, and it has its own security policy and its own address, secp256k1-security at bitcoincore dot org.
One namespace answers from somewhere else, and a report has to say
which: what answers under btclib_secp256k1.zkp is
secp256k1-zkp,
Blockstream Research’s fork of libsecp256k1, vendored as a submodule of
its own and pinned where README.md’s Versioning section says. It is
beta: the fork cuts no tag, so a vendored-source review has no
release to anchor its pin against, and every entry under the fork’s own
README heading Added features is an experimental module, “the APIs of
these features should not be considered stable”. No published wheel
carries the extension behind that namespace: it is built only under
BTCLIB_LIBSECP256K1_ZKP=true, the literal scripts/cffi_build.py
compares that variable against, so a report about anything under zkp
is a report about a build somebody made from source. The fork’s own
security policy
names the same address mainline’s does.
Report there anything affecting the C library. Report here anything affecting how these bindings drive it:
the validation of the arguments crossing the cffi boundary. Every wrapper checks lengths and ranges before a pointer reaches C, which reads a fixed number of bytes from it and cannot know it was handed less
the libsecp256k1 default callbacks, which report two different kinds of news and are treated differently here. The illegal-argument one this project replaces with a do-nothing stub, so that a violated precondition does not
abort()the hosting Python process; the consequence is that nothing catches it on the caller’s behalf:context.check()reports what was recorded, and calling it is the caller’s to do. The private halves the README’s Parsing the key once names are the exception. Code reaching the rawlibbindings directly is on its own, and so is code calling another private_foo_half with a libsecp256k1 object of its own — an object no argument check can vouch for, where a refusal can reach the caller as aFalseor an ordering rather than as an exception. The entry points taking octets are not in that position: they parse what they are given. The error one keeps upstream’s own behaviour — write the message to stderr andabort()— because it reports an internal error, a hardware failure, a miscompilation, a memory corruption or a bug in the library rather than a caller mistake, and upstream’s own header says anything may happen once the callback reporting it returns: this package does not turn that into a Python exception and let the process go on running long enough to raise itthe build: which optional modules are compiled in, and the commit each vendored submodule is pinned to
the distributions published to PyPI and their provenance
an exception, a repr or a log line that prints a secret, which the standard’s A refusal carries no secret forbids
Security review¶
The latest security review is dated 2026-09-30. pmazzocchi did it against the assurance case, and issue 1089 records it.
The review’s scope is the tree at commit 41d35c3252a1, without the libraries
its submodules vendor.
What changed since
is that commit’s comparison with main.
Supported versions¶
Only the latest release is supported. Version numbers track the wrapped libsecp256k1 (release M.N.P wraps libsecp256k1 vM.N.P, with a fourth number appended for binding-only releases); a fix is published as a new release, and nothing is backported.
Wheels and sdist are published with PEP 740 attestations, so that a distribution can be traced back to the workflow run and the commit it was built from.
The sdist is also attached to the GitHub release, and that copy carries a
build provenance attestation of its own, signed at SLSA Build L3 by the
organization’s reusable-build.yml, which built it in the release’s own
run. The wheels are built by this repository’s own test.yml, and that
attestation does not cover them: their provenance is the PEP 740
attestation alone, signed by release.yml, so they do not reach SLSA
Build L3.
The version takes a fence of its own with nothing under it to reach:
written into the file name, btclib_secp256k1-<version>.tar.gz is a
<version redirection that creates a file named .tar.gz wherever the
reader’s directory holds a file called version. ${version:?} answers
the other paste, the last fence alone, where the value is merely unset:
it stops the check before it asks about a file nobody named.
version=<version>
signer=btclib-org/.github/.github/workflows/reusable-build.yml
id=$(gh api repos/btclib-org/btclib-secp256k1 --jq .id)
cert='.[].verificationResult.signature.certificate'
gh attestation verify "btclib_secp256k1-${version:?}.tar.gz" \
--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.
--signer-workflow names the workflow that signed, and narrows what
passes to it. Without it, --owner and the id check hold only the
source repository: an attestation from any workflow this repository ran,
its own or one it calls from btclib-org/.github, passes.
For a release built by the organization’s reusable-build.yml, which
release.yml calls, the signer is that workflow: an attestation made
inside a called workflow names the callee as its signer, while the
certificate still names this repository as the source. --source-ref is
what keeps a build of a branch from passing as the release. From 0.8.0.7
to 0.8.0.9 the signer is reusable-attest.yml, named the same way.
From 0.8.0 to 0.8.0.6 the signer is release.yml itself, so for those
releases signer is
btclib-org/btclib-secp256k1/.github/workflows/release.yml. All three take
--source-ref "refs/tags/v${version:?}". No path verifies a release
another signed. The PEP 740
attestations on PyPI name release.yml, the job that uploads there
being its own rather than a called workflow’s.
The signed statement
is attached to the release as well, as <tag>.intoto.jsonl, or
as <tag>.attestation.jsonl on a release that carries that name instead,
so --bundle <that file> runs the same check reading it from disk instead
of asking GitHub for it — the form for whoever mirrors the releases page
rather than trusting it live. The wheels are on PyPI and
nowhere else, so what verifies them is their PEP 740 attestation there.
A CycloneDX 1.6 bill of materials is attached beside the sdist,
btclib_secp256k1-<version>.cdx.json: the archive with its SHA-256, the
licence, the dependencies its metadata declares, and each vendored C
library at the commit the submodule gitlink pins it to. That pin is what
the package’s own metadata cannot state — Requires-Dist names cffi
and says nothing of libsecp256k1 — so a reader asking which
libsecp256k1 a release carries has the answer in a signed document
rather than in prose. One attestation covers the sdist and the document,
that command run over the .cdx.json verifying it the same way; the
bundle is that attestation and is not among its subjects. Every wheel
from v0.8.0.10 on carries its own document, at
.dist-info/sboms/btclib_secp256k1.cdx.json, naming the vendored
libraries that wheel compiled and how it links them, and its PEP 740
attestation on PyPI covers it with the rest of the wheel.
Limitations of the binding layer¶
The assurance case is the threat model these are written against, and the argument for what this file does promise.
These are known and inherent, not vulnerabilities:
secret material handed to these bindings lives in Python objects, which are immutable and not zeroized: it stays in the process memory until garbage collection, and may be copied by the interpreter. The constant-time properties of libsecp256k1 apply to the C side of the boundary, not to what the caller does before and after it. The copy in the middle is not a Python object and is overwritten: a private key or a shared secret libsecp256k1 or secp256k1-zkp writes into a cffi buffer — the output of a tweak or a negation, an ECDH secret, the coordinates of the point
ecdh.shared_pointanswers, thesecp256k1_keypaira BIP340 signature is made with, the noncedsa.nonce_rfc6979andssa.nonce_bip340answer with, the adaptorzkp.musig.extract_adaptorrecovers, the decryption keyzkp.ecdsa_adaptor.recoverrecovers, a blinding factorzkp.generator.pedersen_blind_sumorzkp.generator.pedersen_blind_generator_blind_sumanswers, or the onezkp.rangeproof.rewindrecovers and the message and value beside it — is read out and the buffer zeroed before it is dropped. That is one copy taken back, not safety: thebyteshanded to the caller holds the same secret and cannot be overwrittenThe amounts
zkp.generator.pedersen_blind_generator_blind_sumcopies into auint64_tarray of its own are not zeroed, and that is deliberate: zeroing it would not take back the last copy. A value reaches that call only as a Pythonint—valuesis aSequence[int], and anything else is refused — so the object the limitation this bullet opens with is about exists before the call and nothing can overwrite it. A blinding factor beside it can be handed in as a cffi array the caller wipes instead, which is what makes taking that copy back worth doing, andzkp.generator.pedersen_committakes its ownvalueas a plain C argument, with no buffer to wipe at allintomoves that last copy somewhere the caller can overwrite, and is the whole of what it does. The entry points that take a keyword-onlyinto— a writable buffer of exactly the secret’s length, contiguous and one octet an item,bytearrayandmemoryviewandmmapandarray.array("B")alike — write the secret there and return None, so nobytesof it is ever made. That breadth is the run time’s: the annotation is abytearrayor amemoryview,collections.abc.Bufferbeing python 3.12, above the floor here, so a caller runningmypy --strictpasses anything else asmemoryview(x), which copies nothing. They arekeys.prvkey_negate,keys.prvkey_tweak_add,keys.prvkey_tweak_mul,xonly.prvkey_tweak_add,ecdh.shared_secret,ecdh.shared_point,ellswift.xdh,dsa.nonce_rfc6979,ssa.nonce_bip340,zkp.musig.extract_adaptor,zkp.ecdsa_adaptor.recover,zkp.generator.pedersen_blind_sumandzkp.generator.pedersen_blind_generator_blind_sum. Some secrets do not, each being one member of a returned tuple, where an argument could not say which: the tweak ofsilentpayments.label, the per-output tweaksilentpayments.scan_outputshands back, and the blinding factor, message and valuezkp.rangeproof.rewindrecovers. None of them can be zeroed, which is the limitation above and not this narrowing of it. What the caller then does with the buffer is theirs: this does not wipe it for them, and a buffer they never overwrite is exactly the un-zeroizable copyintowas reached for to avoid. The obligation is stated rather than enforced, for the reasonssa.Signerhas no finalizer: a guarantee nothing keeps is worse than noneprvkey = bytearray(32) keys.prvkey_tweak_add(parent, tweak, into=prvkey) try: ... # use it finally: prvkey[:] = bytes(32) # the caller's wipe, and only theirs
the buffers whose zeroing is the caller’s to ask for are
ssa.Signer’s keypair andmusig.SecretNonce’s secret nonce. Everything above is wiped inside the call that made it — read out and zeroed in the one operation, or wiped in afinally; a signer holds itssecp256k1_keypairacross calls, which is what it is for, and aSecretNonceholds asecp256k1_musig_secnoncebetweenmusig.nonce_genandpartial_signfor the same reason.partial_signwipes it as the last thing it does with it, whatever that call answers; short of that, both wipe when told —wipe, or thewithstatement that calls it on the way out of the block. Either one told neither is dropped with the secret still in it, and cffi frees that memory without overwriting it. There is no finalizer behind either on purpose: one would run at a time nothing specifies, which reads as a guarantee and is not one.withis the guaranteea nonce is the private key, given the signature it made. The nonce entry points exist so that an implementation of RFC6979 or of BIP340’s derivation can be checked against libsecp256k1’s own, and what they answer is not a value to publish, log or store beside a signature. Each scheme has its own equation: ECDSA signs
s = k⁻¹(h + r·d), sod = (s·k − h)/r mod n, and BIP340 signss = k + e·dwithe = H(R ‖ P ‖ m), sod = (s − k)/e mod n— for thekanddBIP340 negates to an even-y point, which is a parity whoever holds the nonce computes rather than a step that stops them. Reading a nonce also takes it out of constant-time code, which is the limit above; this is the one that has the arithmetic to show for itthe session randomness of
musig.nonce_genandzkp.musig.nonce_genpasses through abytes. It is made withsecrets.token_bytesand copied into a cffi buffer, which is wiped; thebytesis freed without being overwritten. Python has no call that fills a buffer from the operating system on every platform this package ships for, so the copy is not avoided. Withoutprvkey, libsecp256k1 derives the secret nonce from that randomness and the other inputs alone, so a copy of it gives the nonce even afterSecretNonce.wipe(), and the private key once the partial signature is published. Withprvkeythe randomness is hashed and XORed with the key before it is used (secp256k1_nonce_function_musig), and the copy alone gives nothing: passprvkeya scalar passed as an
intleaks its magnitude. A Python integer is a variable-length object, so serializing one — and any arithmetic that produced it — takes a time that depends on the value; the argument checks in front of the call add no leak of their own, branching on a type, a length or a magnitude and never on the content of a secret. Everywhere anintis acceptedbytesis too, and for a secret that is the form to usekeys.pubkey_tweak_mulandkeys.pubkey_tweak_mul_sumpass a scalar into a variable-time multiplication. Both runsecp256k1_ec_pubkey_tweak_mul, which callssecp256k1_ecmult— the wNAF point multiplication, timed by the scalar’s bits — rather than the constant-timesecp256k1_ecmult_constsecp256k1_ecdhuses for the same kind of point, so a private key passed in as that scalar is one an attacker able to time the call may recover bits of.ecdh.shared_pointis the same product throughsecp256k1_ecdh, and is where a secret scalar belongs.keys.prvkey_tweak_muldoes not share this: it multiplies two scalars mod n throughsecp256k1_ec_seckey_tweak_mul, a different callsecp256k1-zkp calls Valgrind reports as not constant time: it finds a branch or a memory access that depends on their secrets, in
tests/zkp_ctime_tests.casupstream-tests.ymlruns it.zkp.generator.pedersen_blind_sumandzkp.generator.pedersen_blind_generator_blind_sumreturn early when a secret blinding factor is not below the group order.zkp.generator.generate_blindednormalizes the blinded generator it outputs in variable time.zkp.rangeproof.signderives the proof’s shape and its ring indices from the value, and the forged ring members are computed in variable time from the nonce, as a comment in secp256k1-zkp’s Borromean signer says.zkp.rangeproof.rewinddecodes the value from the nonce in variable timethe entry side takes one form that is not a copy: where a scalar is accepted, so is a cffi array of 32 octets —
ffi.new("unsigned char[32]", ...), or any other item type an octet wide — and where the call only reads it, it reaches libsecp256k1 as it stands. That is the whole of the difference: abytesor abytearrayis copied at the boundary, deliberately, so that nothing can change what libsecp256k1 is about to read, and the copy of a secret is an immutable object nothing can overwrite. A caller signing again and again under one key can therefore hold it in memory it owns, wipe that memory when done, and have nobytesof the key made per call. What this does not change is what came before: the value the buffer was filled from existed already, and the arithmetic that produced it happened where this package cannot see — which is why filling one from anintbuys nothing the bullet above does not already refuse. And the wipe stays the caller’s, as it is forinto: a buffer never overwritten is the same un-zeroizable copy under another name.What the caller takes on is what
_scalarnames where it refuses the shapes it cannot take. The octets must stay put for the whole call, which is more than one read — libsecp256k1 loads the scalar and then derives the nonce from the same pointer, and grinding and the check read it again — so a write in between yields a nonce and a signature under two different keys, reported as the fault it is indistinguishable from. The memory must outlive the call, which no python argument has had to promise: a cffi view, a slice or a cast, does not keep its owner alive, and a dangling one reads freed memory as a private key. And the length is the declaration’s word:ffi.cast("unsigned char[32]", ...)over 8 octets is accepted, cffi having no way to report what was really allocated.The calls that copy the octets into a buffer of their own instead, and have to, are these.
keys.prvkey_negate,keys.prvkey_tweak_addandkeys.prvkey_tweak_mulhave libsecp256k1 write the answer through that pointer, andzkp.generator.pedersen_blind_generator_blind_sumhas secp256k1-zkp write the correction through the last element of itsblinding_factorarray, which the header declares In/Out. The sender side ofsilentpayments,zkp.generator.pedersen_blind_sumand that same call wipe what they copied on the way out — so passing the caller’s memory would negate, overwrite or zero the secret they handed in. Those of them that answer a new secret take theintoof the bullet above, which is where that secret comes back into a buffer instead of abytes, and that bullet’s own list is which they are. The sender side ofsilentpaymentstakes none: whatsilentpayments.create_outputsanswers is the x-only public keys of the outputs, no entry point of that module has anintoat all, and the copy it takes is owed for the wiping reason aloneprvkey = ffi.new("unsigned char[32]", secret_octets) try: sig = dsa.sign(msg, prvkey) # no bytes of the key is made finally: ffi.buffer(prvkey)[:] = bytes(32) # the caller's wipe
randomness comes from
secrets.token_bytes, i.e. from the operating system, both for the context randomization and for the auxiliary randomness of BIP340 signing and of ElligatorSwift encoding