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 reply

  • the 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 bip32 and silent_payments call 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-wallet installs no C, and every secret then meets the Python arithmetic. Nothing raises to say so, and ecc.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, through ecc.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_decode extends this by one step for an xprv string handed to a derivation: the decoded key stays reachable from that cache, bounded by its maxsize, past whatever reference the caller itself still holds.

  • btclib_wallet.bip38 has no MAC. It takes AES-256 as two callables, for the reason btclib’s ecies takes its cipher that way, and its cipher’s resistance to side channels is whatever the caller passed in. BIP38 carries no MAC, so decrypt calls 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 secrets module, and nothing here seeds a generator of its own.

A backend is trusted, and not every backend alike

  • Each btclib_wallet.fetch backend asks by default which chain it is talking to, through verify_network, and they do not ask the same question. The Bitcoin Core backends talk to a node that validated the chain it reports, and signet_challenge holds them to one signet; -rest authenticates nobody who reaches it, so that endpoint is trusted on whoever handed it over. EsploraFetcher and ElectrumFetcher talk to a host that says it validated, and compare the genesis block against the one the network names, which cannot tell two signets apart. CompactFilterFetcher compares the parent of the first block its transport peer 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 from ElectrumFetcher and CompactFilterFetcher. 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, from ElectrumFetcher, 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)))
    "
    
  • ElectrumFetcher can prove an answer. get_tx_merkle and verify_tx check 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. TlsLineTransport verifies the server’s certificate and host name by default.

  • CompactFilterFetcher checks 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’s minimum_chain_work is 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. Every cfheaders answers the request it came from and chains from the filter header held. Every cfilter is 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 passes Block.assert_valid_structure (its merkle root and witness commitment among the checks) and Block.assert_valid_contextual at its height. The filter headers are compared with those of each cross_check peer: its cfcheckpt up to the tip, then its cfheaders from 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 the transport peer 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 a FetchError. A cross_check peer whose cfheaders agree with ours at a checkpoint height where its own cfcheckpt does 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 a FetchError, and so is a peer that does not answer. The FetchError of a disagreement gives the cross_check peer’s index in cross_check as the error leaves it. Where every peer lies alike, or there is no cross_check peer, 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. The transport peer also decides which blocks a scan downloads, up to the max_blocks of 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. A cross_check peer sees the tip and, where its filter headers part from the transport peer’s, a cfilter request 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.broadcast checks that the backend named the txid this code computed, and nothing checks that the transaction went on to propagate.

  • A Ledger verifies 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 file holds its keys in plain text

btclib_wallet.ledger writes each source’s extended private keys into its SQLite file unencrypted, as an unencrypted Bitcoin Core wallet does, until ISS btclib-org/btclib-wallet#717 encrypts them. So does every file Ledger.backup writes and every copy Ledger.restore makes. Whoever reads one of them can spend the coins. Each is made readable by its owner alone where the system has POSIX permissions.