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¶
This package is built on btclib,
which holds the primitives — the curve arithmetic, the signature
schemes, the codecs other than Base58Check and bech32, scripts and
transactions — and on
base58-bech32, which
holds the Base58Check and bech32 codecs, and on
bitcoin-core-rpc, which
is the client the Bitcoin Core backends of btclib_wallet.fetch talk
through, and on
bip39-slip39, which
holds the BIP39, SLIP39 and Electrum mnemonic schemes up to the seed.
secp256k1 arithmetic is delegated further down, through btclib,
to btclib-secp256k1 and
libsecp256k1. A flaw in a
signature, in the curve arithmetic, in how rpc credentials are handled
or in how a sentence becomes a seed most likely belongs to one of those,
each with its own security policy.
What belongs here is everything this package does on top of them:
the key derivation paths: BIP32, the master key of a BIP39, SLIP39 or Electrum seed, SLIP132, BIP85, BIP38
the parsing and serialization of what comes from outside — extended keys, output descriptors, PSBTs, payment requests — and the validation that decides what is accepted
the signing boundary: the PSBT signer, the wallets, and the HWI bridge
the node and indexer clients of
btclib_wallet.fetch, and what each does with a replythe distributions published to PyPI and their provenance
A refusal that prints a value that may be secret belongs here: A refusal carries no secret says what a refusal may print.
Report it 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.
Security review¶
The latest security review is dated 2026-09-30. pmazzocchi did it against the assurance case, and issue 169 records it.
The review’s scope is the tree at commit 7d02fb4dc6ee.
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/btclib-wallet --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, --source-ref included.
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.
Where constant time ends¶
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, and worth stating because this package is used to teach and to prototype as much as to build.
The arithmetic is ecc’s, and so is its notice. Signing, verification, BIP32’s derivation and the silent-payment operations reach libsecp256k1 where ecc’s own predicate lets them, through ecc or through the bindings
bip32andsilent_paymentscall directly, and run ecc’s Python arithmetic otherwise — which is validated against the bindings but is not constant-time. Which operations cross the boundary, under which conditions, and what the Python path leaks is ecc’s SECURITY.md, and this file does not restate it.An install decides whether the boundary is there.
pip install "btclib-wallet[secp256k1]"installs the bindings, through btclib’s own extra and by name;pip install btclib-walletinstalls no C, and every secret then meets the Python arithmetic. Nothing raises to say so, andecc.curves.is_libsecp256k1_serving()is how a caller asks which of the two it has.Where this package combines secret scalars itself, the arithmetic is on Python integers, variable in time with the operands, whether or not the bindings are installed: BIP352’s sum of input keys and BIP38’s EC-multiply factor are computed that way before the result reaches a btclib call. With the bindings serving, two sums are libsecp256k1’s instead. BIP32’s child private key is
secp256k1_ec_seckey_tweak_add’s. A found silent payment output’s private tweak, (t_k + label) mod n, is the same function’s, throughecc.curves.prv_key_tweak_add, where the scan runs in Python, and the bindings’ scan computes it where they scan.Secret material lives in Python objects, which are immutable and not zeroized: an extended private key, a mnemonic or a seed stays in the process memory until garbage collection, and may have been copied by the interpreter meanwhile.
bip32._cached_base58_decodeextends this by one step for an xprv string handed to a derivation: the decoded key stays reachable from that cache, bounded by itsmaxsize, past whatever reference the caller itself still holds. A ledger’s master key is held in a buffer it zeroes, as A ledger’s keys at rest says.btclib_wallet.bip38has no MAC. It takes AES-256 as two callables, for the reason btclib’seciestakes its cipher that way, and its cipher’s resistance to side channels is whatever the caller passed in. BIP38 carries no MAC, sodecryptcalls that cipher before it can tell a wrong password from a right one — the check is a re-derived address, compared against the record’s four-byte hash only after decryption.scrypt’s cost is what BIP38 relies on to make each guess expensive.Randomness comes from the operating system through the
secretsmodule, and nothing here seeds a generator of its own.
A backend is trusted, and not every backend alike¶
Each
btclib_wallet.fetchbackend asks by default which chain it is talking to, throughverify_network, and they do not ask the same question. The Bitcoin Core backends talk to a node that validated the chain it reports, andsignet_challengeholds them to one signet;-restauthenticates nobody who reaches it, so that endpoint is trusted on whoever handed it over.EsploraFetcherandElectrumFetchertalk to a host that says it validated, and compare the genesis block against the one the network names, which cannot tell two signets apart.CompactFilterFetchercompares the parent of the first block itstransportpeer serves with that genesis block too, and refuses a message that does not start with the network’s message start bytes. For signet those are the default signet’s, so a peer on a signet of its own is refused.The answers a fetch makes are checked to different degrees. A transaction’s id is recomputed from the bytes that came back and refused unless it matches, and an output’s amount is derived from that same transaction. A block header is parsed and its proof of work checked: its hash within the target its bits claim, and that target within the limit of the fetcher’s network. Neither check says it is the header at the height asked for. A height rests on the backend’s word except from
CompactFilterFetcher, and so does the tip hash except fromElectrumFetcherandCompactFilterFetcher. An address’s balance rests on the backend’s word too, and so does a script’s history, except the ids of the transactions it lists and, fromElectrumFetcher, that each confirmed one is in the header at its height. The work each network’s limit stands for, in bits, is:uv run python -c " from math import log2 from btclib.block.proof_of_work import target_from_bits from btclib.network import NETWORKS for name, net in NETWORKS.items(): limit = int.from_bytes(target_from_bits(net.consensus.pow_limit_bits)) print(name, round(256 - log2(limit + 1))) "
ElectrumFetchercan prove an answer.get_tx_merkleandverify_txcheck a transaction’s branch against a header the fetcher fetched itself, so a caller who runs the check learns that the transaction is in the block that header names. That is the trade the Esplora backend’s fallback makes: it cannot say a transaction is confirmed.TlsLineTransportverifies the server’s certificate and host name by default.CompactFilterFetcherchecks the chain it follows and trusts its peers for the rest. Every header connects to the one before it, carries the work its bits claim and carries the bits the retarget rules require (btclib.block.header_context.next_bits_required). Its time is after the median time past of its parent, no more than two hours ahead of the clock, and where the network enforces BIP94 not far behind its parent at a retarget. No chain with less work than the network’sminimum_chain_workis followed: a call that ends on one fails, and a later synchronization completes it or drops it. Until a chain has the minimum work, its headers are downloaded without being kept, as Bitcoin Core’s headers presync does: the memory is a bit for every few hundred headers, and the longest chain the rules allow at the time of the clock bounds those bits. The chain is then downloaded again, and a header is kept only once enough later ones have matched the commitments of the first download, or once the chain has the work the first download needed. A peer can still make the fetcher download as many low-work headers as it has mined, up to that bound. A header’s version is checked only in a block that is downloaded. A fork is followed only if it ends with more work than the chain it displaces; otherwise the chain held is put back if it has the minimum work, and dropped if not. Everycfheadersanswers the request it came from and chains from the filter header held. Everycfilteris the filter of the block asked for and hashes, chained to the filter header before it, to the filter header held. Every block downloaded is one that was asked for, whose header is the one in the chain and which passesBlock.assert_valid_structure(its merkle root and witness commitment among the checks) andBlock.assert_valid_contextualat its height. The filter headers are compared with those of eachcross_checkpeer: itscfcheckptup to the tip, then itscfheadersfrom the last checkpoint it agrees on to the first it does not, or to the tip. At the first height where they differ, the block is downloaded, from thetransportpeer or, where it does not serve it, from the other, and both peers are asked for its filter. A peer whose filter does not hash to the filter header it served, or leaves out one of the block’s output scripts, is dropped, unless that would drop every peer, which is aFetchError. Across_checkpeer whosecfheadersagree with ours at a checkpoint height where its owncfcheckptdoes not is dropped too. A filter also holds the scripts the block’s inputs spend, which the block does not carry, so a disagreement that neither filter fails that way is aFetchError, and so is a peer that does not answer. TheFetchErrorof a disagreement gives thecross_checkpeer’s index incross_checkas the error leaves it. Where every peer lies alike, or there is nocross_checkpeer, the filter headers are the word of the peer that served them, and a peer that lies in them can hide a block that pays the wallet. A peer can hold back the network’s newer blocks and serve blocks of its own on the tip it showed, and invent a payment in them; that costs the work of those blocks, not a majority of the network’s. On signet nothing checks the signature that makes a block authoritative, so the work of those blocks is signet’s low difficulty. A block with a witness changed or removed is refused, one with every witness removed included: its coinbase commits to witnesses and carries no nonce for them. Thetransportpeer also decides which blocks a scan downloads, up to themax_blocksof the call, and sees which they are; it is given no script. Settling a disagreement keeps one block more, the one at the height where two peers’ filter headers part, and asks for it at most twice: of a second peer where the first does not serve it. Across_checkpeer sees the tip and, where its filter headers part from thetransportpeer’s, acfilterrequest at that height and perhaps a request for that block; nothing it sees comes from the wallet’s scripts. Use a peer you trust, or cross-check it with peers run by others.An explorer learns every txid, outpoint and address you look up, which is a good deal of what a wallet is. A public deployment is named as a constant and never as a default, so nothing here contacts anyone until a caller writes the endpoint down.
Broadcaster.broadcastchecks that the backend named the txid this code computed, and nothing checks that the transaction went on to propagate.A
Ledgerverifies a backend’s unconfirmed spends of its own coins with btclib’s script engine.btclib_wallet.ledger.ledger’s docstring says when, and what stays unverified.
A ledger’s keys at rest¶
btclib_wallet.ledger writes each source’s extended private keys into
its SQLite file in plain text, as an unencrypted Bitcoin Core wallet
does, until Ledger.encrypt encrypts them under a passphrase. So does
every file Ledger.backup writes and every copy Ledger.restore makes.
Whoever reads one of them before the encryption can spend the coins.
Each is made readable by its owner alone where the system has POSIX
permissions.
What is encrypted is the private keys, and only them, under a master key the passphrase encrypts:
btclib_wallet.ledger.cryptosays with what, and how, and README’s Installing names the extra the encryption needs. The descriptors, which name the extended public keys, the addresses handed out, every transaction with the commentsLedger.spendwas given, and the tip stay plain, as in an encrypted Core wallet: whoever reads the file learns the wallet’s history and balance, and can watch it, but cannot spend from it.A backup taken before the encryption keeps the keys in plain text. Encrypting a ledger encrypts none of the files
backupwrote before, so those are to be destroyed or kept as safe as the keys. A backup taken after holds only their ciphertext.The file and a backup taken after the encryption hold no byte of a key in plain text, through SQLite’s
secure_deleteand its rollback journal.btclib_wallet.ledger.storesays how, and what becomes of a file in WAL mode (tests/ledger/encryption_test.pysearches every file for them). SQLite’s rollback journal holds the pages a transaction changes, as they were, and SQLite deletes it once the transaction ends. The disk blocks a deleted journal used, and what a copy-on-write file system or an SSD keeps of overwritten blocks, are outside the file and can keep a key written before the encryption. A ledger loaded before another encrypted the file writes no key into it in plain text, and a file holding plain keys beside a master key is refused at load.While unlocked, the master key is in the process’s memory.
btclib_wallet.ledger.ledger’s docstring says what holds it and the keys it decrypts, when each is zeroed, and why the master key outlives its timeout. The parsed keys are Python objects that nothing zeroes, as Where constant time ends says of every secret.The passphrase is what protects the keys. scrypt makes each guess cost what
cryptosets, and a passphrase short enough to guess is guessed offline from a copy of the file. A wrong passphrase and a damaged master key both fail its tag, so neither is told from the other.A new passphrase keeps the master key.
change_passphraseencrypts the same master key again, as Core’sChangeWalletPassphrasedoes (src/wallet/wallet.cpp:647-677 at bitcoin/bitcoin@f0de87f040, the v32.1 tag). So a backup taken before the change, with the passphrase it had, decrypts the keys of every source a later copy of the file holds, those added since included.