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.pyagainst a pinned Trezor emulator and a pinned Ledger under Speculos (.github/workflows/integration-hwi.yml) andtests/integration/regtest_test.pyagainst a disposable regtestbitcoind(.github/workflows/integration-bitcoind.yml).tests/_data/README.mdpins every vendored vector to the upstream commit it was copied from and says whether the two still match, and.github/workflows/vendored-vectors.ymlre-checks that weekly.Malformed input is refused the way the library says it is. A public function handed an argument it cannot use raises
BTClibTypeErrororBTClibValueError, as btclib’s own contract requires (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 bareIndexErrororOverflowError(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 btclib runs them; BIP32’s private derivation and BIP352’s key agreement are the two calls this package makes into the libsecp256k1 bindings directly; 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 or an Electrum server. Neither
happens on import, and neither happens unless the caller constructs the
object that does it. 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, bitcoin_core_rpc and typing_extensions, and btclib’s
btclib_secp256k1, what it lists is the standard library: subprocess
is hwi.py’s alone, socket and ssl are fetch.transport’s,
urllib is bip21.py’s percent-encoding of a payment URI, 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 wallet’s own ledger, against a change output imported as a receiving one or the reverse —
core_import.account_import_requestsmarks a change chaininternalfor that reason, a mistake there being invisible until a balance is wrong.The signing boundary, against a psbt or a transaction sent to
psbt_signerfor 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--fingerprintselection).
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.
A backend
fetch/talks to — a node, an explorer, an Electrum server — 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_signerexists, 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 and A backend is trusted, and not every backend alike:
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_decodekeeps a decoded extended key reachable from its own cache past whatever reference the caller releaseda chosen-ciphertext attack on
bip38.decrypt, which carries no MAC and cannot tell a wrong password from a right one before decryptingthe resistance to side channels of whatever cipher a caller passes
btclib_wallet.bip38, which takes AES-256 as two callablesthe privacy of a lookup against
EsploraFetcher,BitcoinCoreRestFetcherorElectrumFetcher: each learns every txid and outpoint asked of itthe 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, Broadcaster or
FeeEstimator substitute 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.
Mnemonics and passwords. A sentence or a password only the user holds
crosses in through mnemonic/ and bip38.py, against the module-level
WORDLISTS this package ships; a wordlist for a language WORDLISTS
does not carry is the one place Files below names a caller’s own path
crossing in instead.
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 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 through ElectrumFetcher’s own merkle proof.
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. The one place a caller’s own path crosses in is
mnemonic.WordLists: its language_files constructor argument and its
load_lang method let a caller point a language at any file, which is
how electrum.py loads a wordlist that is not BIP39’s — every other
caller reaches the module-level WORDLISTS singleton, loaded from this
package’s own _data/ files. Every other read under src/ is this
package’s own package data (bip44.py’s purposes table,
mnemonic/electrum.py’s old wordlist), and nothing under src/ writes a
file at all.
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 whattests/imports_test.pywalks rather than whatever the package root happened to pull in first.Fail-safe defaults.
fetch’sverify_networkruns by default on every backend construction, so a caller who does not choose gets the check;check_validitydefaults toTruethe 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.deriveandbip32._deriveis the shape CONTRIBUTING.md names to copy.Open design. The code, the vendored vectors and the corpus under
fuzz/are published,tests/_data/README.mdsays 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 this package, and importingfetchpays 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.pybuilds the request Bitcoin Core’simportdescriptorstakes and opens no RPC connection to send it.Psychological acceptability. A parser refuses with
BTClibTypeErrororBTClibValueErrorrather than an opaque exception, andassert_psbt_signerraises 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 codec sits inbtclib.p2pandbtclib.electrum, outside this package, for the reason above.
Where this package reaches past btclib’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 btclib’s general
predicate, and each is one call for one fixed operation — a private-key
tweak, an ECDH shared secret — rather than a copy of the dispatch logic
itself, keeping the one place a caller has to read for “does this call
reach the bindings” to curves.curve._libsecp256k1_serves plus these two
named exceptions.
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 aboolwhere 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,BTClibTypeErrororBTClibRuntimeError, and nothing else — driven by Hypothesis over every declared parser and by the harnesses underfuzz/running under ClusterFuzzLite.tests/fuzz_corpus_test.pychecks that every seed of their corpus still parses.Race conditions on shared state (CWE-362). The module-level
WORDLISTSsingleton is reachable from any thread a caller runs, andWordLists’s own lock is what keeps a second thread arriving mid-load from reading the empty list the constructor put there before the words landed —tests/mnemonic/mnemonic_test.py’stest_load_lang_is_not_a_raceforces that interleaving and asserts the second reader waits rather than seeing zero words.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 inpyproject.toml, flags a barerandomcall undersrc/;coin_selection.py’s two uses are the exception, each carrying a# noqa: S311naming 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.pyandtests/integration/regtest_test.pyunder 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:decryptcannot 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.Deserialization of untrusted data (CWE-502).
descriptors.parseandPsbt.parse/Psbt.b64decodeare this package’s own parsers, not a generic deserializer; the census under Threat model lists neitherpicklenormarshalnorshelve..github/workflows/codeql.ymlanalyses the code and the workflows.OS command injection (CWE-78).
hwi.py’ssubprocess.Popentakes an argument list this package built from a caller’s own typed parameters, never a shell string interpolated from them —noqa: S603is scoped to that one call, andshell=True, the vectorS602guards, is not used anywhere undersrc/.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_underinpyproject.toml, and mutation testing, profiled per subsystem under.github/mutation/and run by.github/workflows/mutation.yml, asks whether the suite notices a wrong line —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.lockpins every dependency, and CONTRIBUTING.md’s The environment and the gates states that the suite and the lint gate install with--locked;deps-latest.ymlanddeps-oldest.ymlresolve 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;actionlint,zizmoranddetect-secretsrun 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; 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.