Assurance case

SECURITY states what a user can and cannot expect of btclib-wallet in terms of security. This page argues why those expectations hold: the threat model, the trust boundaries, how secure design principles are applied, and how common implementation weaknesses are countered. Each argument below names the file, the test or the workflow that supports it; where SECURITY.md already states a fact, this page points at it instead of repeating it. The components named here are the ones ARCHITECTURE describes.

What is claimed

  • A derivation, a parse, a signature and a broadcast agree with the reference each is defined by. BIP32, BIP39, SLIP39 and SLIP132 against the vectors their own authors publish, an output descriptor and a PSBT against BIP380 and BIP174/BIP370, and, over a real device or a real node, tests/integration/hwi_device_test.py against a pinned Trezor emulator and a pinned Ledger under Speculos (.github/workflows/integration-hwi.yml) and tests/integration/regtest_test.py against a disposable regtest bitcoind (.github/workflows/integration-bitcoind.yml), the Electrum and Esplora fetchers against a live electrs, a reorg included (.github/workflows/integration-electrs.yml), and the Electrum fetcher against a live florestad, TLS included (.github/workflows/integration-floresta.yml). The fetcher reaches florestad only through a transport that adds "jsonrpc": "2.0", which Floresta requires and the Electrum spec does not. tests/_data/README.md pins every vendored vector to the upstream commit it was copied from and says whether the two still match. .github/workflows/vendored-vectors.yml runs weekly: it hashes each vendored file against the blob recorded there, compares that blob with upstream’s at the pinned commit, and reports a pin that is no longer upstream’s tip.

  • Malformed input is refused the way the library says it is. A public function handed an argument it cannot use raises BTClibTypeError or BTClibValueError, as btclib’s own contract requires, or ecc’s class of the same kind where the argument reaches that package unchecked (tests/input_validation_test.py, tests/integer_policy_test.py).

  • Parsing octets from outside costs what the protocol allows. A length or a count read from a descriptor, a PSBT or an extended key is checked against a bound before anything is built from it (tests/parse_contract_test.py), and a parser fails the way the library says it fails on anything else, never with a bare IndexError or OverflowError (tests/fuzz_test.py).

  • Where a secret meets the curve, the protection is the one SECURITY.md’s Where constant time ends states, and no more. Signing and verification run where ecc runs them; bip32/bip32.py and silent_payments.py also call the libsecp256k1 bindings directly, as Where this package reaches past ecc’s own dispatch below says; the sum this package builds for BIP352 and the EC-multiply factor BIP38 builds are Python integer arithmetic, variable in time with the operands, whichever install is in use.

  • A published distribution is what this tree built. SECURITY.md’s Supported versions states how that is verified.

Threat model

btclib-wallet is a library in its caller’s process, like btclib, with two differences ARCHITECTURE.md’s own sections name: it runs a subprocess (hwi.py, Bitcoin Core’s HWI) when a caller asks it to reach a hardware device, and it opens client sockets of its own (fetch/) when a caller asks it to reach a node, an explorer, an Electrum server or a p2p peer. Neither happens on import, and neither happens unless the caller constructs the object or calls the function that does it, such as hwi.enumerate_devices. The command below lists the top-level name of every module src/ imports, at any depth and in any spelling of the statement.

python3 - <<'EOF'
import ast, pathlib
names = set()
for p in pathlib.Path("src").rglob("*.py"):
    for n in ast.walk(ast.parse(p.read_text(encoding="utf-8"))):
        if isinstance(n, ast.Import):
            names.update(a.name.split(".")[0] for a in n.names)
        elif isinstance(n, ast.ImportFrom) and n.level == 0:
            names.add(n.module.split(".")[0])
print(sorted(names))
EOF

Beside this package’s imports of its own modules, its dependencies btclib, ecc, bip39_slip39, bitcoin_core_rpc, cryptography and typing_extensions, and btclib’s btclib_secp256k1, what it lists is the standard library: subprocess is hwi.py’s alone, ssl is fetch.transport’s, socket is fetch.transport’s and fetch.peer_transport’s, urllib is bip21.py’s percent-encoding of a payment URI and fetch.esplora’s check of its base URL, and random is coin_selection.py’s shuffle, marked # noqa: S311 at both call sites because what it orders is which of the caller’s own utxos is spent first, never a key or a nonce.

What is defended.

  • Private keys, extended private keys, mnemonics, seeds and the shared secrets BIP352 and BIP38 compute, against recovery from what this package returns or raises, and from the timing of the one call each makes into libsecp256k1 or the general dispatch reaches.

  • The correctness of every answer: a derived key, a parsed descriptor or PSBT, an address, a fee estimate, a selection of coins, and the psbt or transaction a build or a sign produces.

  • The caller’s process, against a descriptor, a PSBT, a mnemonic or an extended key built to make a parser raise an exception this package does not document, or allocate without bound.

  • A ledger’s private keys at rest, against whoever reads its file, or a backup taken after Ledger.encrypt, without the passphrase. SECURITY.md’s A ledger’s keys at rest says what stays plain.

  • A wallet’s own ledger, against a change output imported as a receiving one or the reverse — core_import.account_import_requests marks a change chain internal for that reason, a mistake there being invisible until a balance is wrong.

  • The signing boundary, against a psbt or a transaction sent to psbt_signer for a party who did not ask for it, and against a fingerprint mismatch when several devices of the same vendor are present (hwi.py’s own --fingerprint selection).

The adversaries.

  • A party handing this package a descriptor, a PSBT, a mnemonic sentence, an extended key, a BIP21 URI or a BOLT11 invoice built to exploit a parser rather than to be spent.

  • A co-signer in MuSig2 or FROST, or the other side of BIP352’s key agreement, choosing what it contributes after seeing what this package already committed to.

  • The other party of a DLC, sending a signature, an adaptor signature or a transaction other than the agreed one.

  • An oracle sending an announcement its key did not sign, or an attestation that does not match the announcement. Trust boundaries says whose key that is.

  • A backend fetch/ talks to — a node, an explorer, an Electrum server, a peer — answering a question about the chain with something false; SECURITY.md’s A backend is trusted, and not every backend alike states which answers are checked and which rest on the backend’s word.

  • A device or an HWI subprocess this package drives, returning a fingerprint, a signature or a derived key that does not belong to the request it was asked to answer — the reason psbt_signer_contract.assert_psbt_signer exists, for an implementer who has no other way to ask.

  • A party tampering with a distribution between this tree and the user.

What is not defended, each stated in SECURITY.md’s Where constant time ends, A backend is trusted, and not every backend alike and A ledger’s keys at rest:

  • side channels on the Python arithmetic btclib runs, and on the Python integer arithmetic this package computes itself for BIP352’s input-key sum and BIP38’s EC-multiply factor

  • memory disclosure: secret material in a Python object is not zeroized, and bip32._cached_base58_decode keeps a decoded extended key reachable from its own cache past whatever reference the caller released; an unlocked ledger’s master key is in a buffer it zeroes, and copies of what it decrypts are not

  • a ledger’s keys in a backup taken before its encryption, in what the file system keeps of blocks SQLite overwrote or freed, and under a passphrase short enough to guess against scrypt’s cost

  • a chosen-ciphertext attack on bip38.decrypt, which carries no MAC and cannot tell a wrong password from a right one before decrypting

  • the resistance to side channels of whatever cipher a caller passes btclib_wallet.bip38, which takes AES-256 as two callables

  • the privacy of a lookup against EsploraFetcher, BitcoinCoreRestFetcher or ElectrumFetcher: each learns every txid and outpoint asked of it, and EsploraFetcher every address get_balance asks; and against CompactFilterFetcher’s peers, whose view SECURITY.md’s A backend is trusted, and not every backend alike states

  • the operating system, the interpreter, or the HWI binary or hardware device this package’s caller names

Nor is the network transport a caller’s own Fetcher, or its own implementation of another protocol of fetch, opens: tests/fetch/ exercises the ones this package ships, and a caller’s own implementation is the caller’s to secure.

Trust boundaries

The caller and the public API. Arguments cross from the caller into btclib-wallet at every public function, and each is validated there. CONTRIBUTING.md’s The public surface states the rule and names the tests that drive it — tests/input_validation_test.py, tests/bool_contract_test.py, tests/built_object_contract_test.py, tests/curve_parameter_test.py — and tests/serialization_boundary_test.py drives it again where an object meets octets, text or json. check_validity=False is a caller’s explicit choice to defer validation on an already-built object, never an exemption from it (tests/check_validity_test.py).

btclib-wallet and btclib. Every primitive crosses this boundary rather than being reimplemented — the curve arithmetic, a signature scheme, a script, a transaction’s own serialization. This package imports btclib and re-exports none of it, which tests/all_test.py’s REEXPORTED holds, and btclib does not depend on this package. A flaw in a primitive is btclib’s to fix, and reported through its own security policy.

Descriptors and PSBTs from outside. descriptors.parse and Psbt.parse/Psbt.b64decode are where a stranger’s own text or bytes cross in — a descriptor pasted, read from a file or returned by listdescriptors; a psbt from another wallet, a coordinator or a QR code. tests/parse_contract_test.py holds every parse in the package to btclib’s own contract: a field is as long as its encoding says, a complete octet string is one whole object, a caller’s stream is the caller’s. descriptors.miniscript.parse and Psbt.parse are BIP379’s and BIP174’s own recursive grammars over that input, and fuzz/fuzz_descriptor.py and fuzz/fuzz_psbt.py drive both under ClusterFuzzLite (.github/workflows/fuzz.yml); fuzz/fuzz_bip32.py does the same for an extended key’s Base58Check and fuzz/fuzz_bip322.py for a BIP322 signature, and tests/fuzz_corpus_test.py checks that every seed in their corpora still parses.

An oracle’s key. Whose key an announcement carries is the caller’s to check, as dlc.oracle’s module docstring says; tests/dlc/dlc_oracle_refusals_test.py’s test_an_event_announced_under_another_key_verifies pins the limit.

Mnemonics and passwords. A sentence or a password only the user holds crosses in through mnemonic/ and bip38.py. A sentence is read by bip39_slip39, against the word lists that package ships, before mnemonic/ derives a key from its seed.

The signing boundary. psbt_signer.PsbtSigner is where a signer’s own answer crosses back in: a fingerprint, a derived key, a signed psbt or transaction, checked by psbt_signer_contract.assert_psbt_signer against what the request asked for before a caller trusts it. hwi.py is the one place this boundary is also a process boundary: subprocess.Popen runs hwi with an argument list this package built, never a caller-supplied string passed to a shell (hwi.py’s own # noqa: S603, the exec vector S602’s shell=True would open, not taken).

The sig_hash type a signature commits to is the caller’s, not the psbt author’s. psbt.sign, SoftwareSigner.sign_psbt and the MuSig2 and FROST partial_sign sign SIGHASH_ALL, or SIGHASH_DEFAULT for taproot, unless allowed_sig_hash_types names another type. request_signatures sends a psbt asking for another type only where its own allowed_sig_hash_types names it (issue #233). HwiSigner.sign_psbt checks against its own, before hwi runs (issue #243). psbt.sign refuses SIGHASH_SINGLE on an input it signs with no output at its index, and request_signatures and HwiSigner.sign_psbt do not send it, whatever is allowed. The MuSig2 and FROST partial_sign refuse it too, in btclib’s taproot hash. A returned ECDSA signature of a type other than SIGHASH_ALL is refused where the input names none (GHSA-qq38-77mp-j6wr), and so is a taproot one of a type other than SIGHASH_DEFAULT or SIGHASH_ALL. psbt.sign refuses SIGHASH_DEFAULT on an input it signs that is not known to be taproot, and request_signatures and HwiSigner.sign_psbt refuse to send it: no ECDSA signature has that type, and a device would sign SIGHASH_ALL.

The network. fetch/ is where a backend’s own reply crosses in — a transaction, a block header, a fee estimate, a UTXO’s existence. SECURITY.md’s A backend is trusted, and not every backend alike is the full statement of what is checked at that boundary and what is not: a transaction id is recomputed and refused unless it matches, a header’s proof of work is checked, and a height or a tip hash rests on the backend’s word except where that section names a check the fetcher makes itself. TlsLineTransport verifies the server’s certificate and host name by default. Broadcaster.broadcast checks that the backend named the txid this package computed and nothing checks that the transaction went on to propagate.

Files. No caller’s own path crosses in: every read under src/ is this package’s own package data, bip44.py’s purposes table. The one write under src/ is hwi.py’s, into the anonymous temporary files that carry a child process’s standard input, output and error.

Secure design principles

Saltzer and Schroeder’s principles, over the layering ARCHITECTURE.md describes.

  • Economy of mechanism. One protocol, psbt_signer.PsbtSigner, is what every signer implementation is checked against rather than a test suite per adapter, and one function, psbt_signer_contract.assert_psbt_signer, is where that check lives. btclib_wallet/__init__.py’s __all__ is the one place the public tree is declared, and nothing is imported eagerly, so a module’s own import graph is what tests/imports_test.py walks rather than whatever the package root happened to pull in first.

  • Fail-safe defaults. fetch’s verify_network runs by default, before a backend’s first answer, so a caller who does not choose gets the check. EsploraFetcher.text, a plain GET of a path, answers without it. check_validity defaults to True the same way btclib’s own dataclasses do (tests/check_validity_test.py). A hardware device is selected by fingerprint, never by “the first one found” (hwi.py’s own argument against HWI’s --device-type).

  • Complete mediation. Every public function validates its inputs, and where it defers the work to a private twin, the twin trusts its inputs because its callers checked them — bip32.derive and bip32._derive is the shape CONTRIBUTING.md names to copy.

  • Open design. The code, the vendored vectors and the corpus under fuzz/ are published, tests/_data/README.md says where every vendored file came from, and SECURITY.md states what is not defended.

  • Least privilege. fetch/__init__.py’s own docstring is the argument in full: the codec a server would need sits outside fetch, and importing fetch pays for a client’s dependencies — urllib, ssl, socket — only because a caller asked for one, never because a caller only wanted to parse. core_import.py builds the request Bitcoin Core’s importdescriptors takes and opens no RPC connection to send it.

  • Psychological acceptability. A parser refuses with BTClibTypeError or BTClibValueError, or ecc’s class of the same kind, rather than an opaque exception, and assert_psbt_signer raises on the first breach with what was expected and what came back, because the first breach is the one an implementer has to fix.

  • Layering. btclib does not import this package, and this package does not reimplement a primitive btclib already defines (tests/imports_test.py). descriptors/’s own three modules import only the ones before them; fetch’s own codecs sit outside it, in btclib.p2p and btclib_wallet.electrum, for the reason above.

Where this package reaches past ecc’s own dispatch, it says so. bip32/bip32.py and silent_payments.py are the two modules calling the libsecp256k1 bindings directly rather than through ecc’s general dispatch. Each call is one fixed operation behind is_libsecp256k1_serving, not a copy of the dispatch logic: prvkey_tweak_add and PubkeyTweakChain in bip32.py; create_outputs, prevouts_summary and scan_outputs in silent_payments.py, where create_outputs is switched off while _DELEGATE_OUTPUT_KEYS is False (issue #591). That keeps the one place a caller has to read for “does this call reach the bindings” to ecc.curves.curve._libsecp256k1_serves plus these two modules.

Common implementation weaknesses

Weaknesses from MITRE’s CWE list that a package of this kind is exposed to, and what counters each.

  • Improper input validation (CWE-20). The trust boundaries above, and tests/integer_policy_test.py, which refuses a bool where an integer field — a derivation index, an account number — is expected.

  • Uncaught exceptions on hostile input (CWE-248, CWE-755). tests/fuzz_test.py’s contract — BTClibValueError, BTClibTypeError or BTClibRuntimeError, or ecc’s class of the same kind, and nothing else — driven by Hypothesis over every declared parser and by the harnesses under fuzz/ running under ClusterFuzzLite. tests/fuzz_corpus_test.py checks that every seed of their corpus still parses.

  • Uncontrolled resource consumption (CWE-400, CWE-770). tests/parse_contract_test.py’s bound, read before anything is built, covers a descriptor, a PSBT map and an extended key the same way btclib’s own parsers are covered.

  • Observable timing (CWE-208). SECURITY.md’s Where constant time ends states which of this package’s own operations run on Python integers regardless of which arithmetic btclib itself is using.

  • Weak randomness (CWE-330, CWE-338). Randomness for a key, a seed or a nonce comes from secrets (SECURITY.md), and ruff’s flake8-bandit family, selected whole in pyproject.toml, flags a bare random call under src/; coin_selection.py’s two uses are the exception, each carrying a # noqa: S311 naming why the choice need not be unpredictable to an adversary — which of the caller’s own utxos is spent first, not a secret.

  • Improper verification of a signature or a derivation (CWE-347). Checked against the BIPs’ and SLIPs’ own vectors, and, over a real device or a real node rather than only this package’s own suite, tests/integration/hwi_device_test.py and tests/integration/regtest_test.py under the workflows named in What is claimed.

  • Exposure of sensitive information (CWE-209). bip38.py’s own design is the sharpest instance and SECURITY.md states it: decrypt cannot tell a wrong password from a right one before running the cipher, so nothing here reports which of the two happened beyond the final address check.

    A value in the wrong place may be a private key, so the refusals of a duplicated psbt map key, of a MuSig2 key that is no point, and of a partial signature and its key print at most one byte of the value, which the format defines (a type byte, a key prefix), with a length or an index. The words and lang of bip85.mnemonic_from_root_key and a silent payment address’s hrp are quoted as _refusal.py says. Each of these refusals has a test that feeds a secret and finds none of it in the exception or its causes.

    The rule is section 7’s A refusal carries no secret. The refusal tests call the organization’s helper in tests/secret_free/ (git grep -l assert_secret_free -- tests), which tests/secret_free_test.py tests.

  • Deserialization of untrusted data (CWE-502). descriptors.parse and Psbt.parse/Psbt.b64decode are this package’s own parsers, not a generic deserializer; the census under Threat model lists neither pickle nor marshal nor shelve. .github/workflows/codeql.yml analyses the code and the workflows.

  • OS command injection (CWE-78). hwi.py’s subprocess.Popen takes an argument list this package built from a caller’s own typed parameters, never a shell string interpolated from them — noqa: S603 is scoped to that one call, and shell=True, the vector S602 guards, is not used anywhere under src/.

  • Type confusion (CWE-843). mypy runs with strict = true (pyproject.toml) over the package and the suite, as a hook of the lint gate in .pre-commit-config.yaml.

  • Code that is wrong and still passes. Line and branch coverage of the package and of the suite is held at 100% by fail_under in pyproject.toml, and mutation testing, run by .github/workflows/mutation.yml on the modules each .github/mutation/ file’s module-path names, asks whether the suite notices a wrong line there. A module no module-path lists rests on coverage alone. signer.toml’s own header states why the signing boundary is mutated apart from parsing or encoding: a wrong decision there is a signature over somebody else’s transaction.

  • Supply chain. SECURITY.md’s Supported versions describes the attestations and the bill of materials. uv.lock pins every dependency, and CONTRIBUTING.md’s The environment and the gates states that the suite and the lint gate install with --locked; deps-latest.yml and deps-oldest.yml resolve fresh on a schedule instead, so a break in what the pin hides is caught rather than never seen. Every third-party action is pinned to a commit sha, and so is every call of the organization’s own reusable workflows; actionlint, zizmor and detect-secrets run as hooks in .pre-commit-config.yaml.

Upstream. tests/_data/README.md pins every vendored file to the upstream commit it was copied from and to a git blob hash, and states whether the two still match; the weekly vendored-vectors.yml run fails where a vendored file’s bytes are not the recorded blob. A vector that disagrees with the rule it tests is kept byte for byte, so that its pin still compares, rather than edited to pass.