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), the Electrum and Esplora fetchers against a liveelectrs, a reorg included (.github/workflows/integration-electrs.yml), and the Electrum fetcher against a liveflorestad, TLS included (.github/workflows/integration-floresta.yml). The fetcher reachesflorestadonly through a transport that adds"jsonrpc": "2.0", which Floresta requires and the Electrum spec does not.tests/_data/README.mdpins every vendored vector to the upstream commit it was copied from and says whether the two still match..github/workflows/vendored-vectors.ymlruns 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
BTClibTypeErrororBTClibValueError, 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 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 ecc runs them;
bip32/bip32.pyandsilent_payments.pyalso 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_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.
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_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, 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_decodekeeps 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 nota 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 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 it, andEsploraFetcherevery addressget_balanceasks; and againstCompactFilterFetcher’s peers, whose view SECURITY.md’s A backend is trusted, and not every backend alike statesthe 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 whattests/imports_test.pywalks rather than whatever the package root happened to pull in first.Fail-safe defaults.
fetch’sverify_networkruns 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_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 outsidefetch, 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
BTClibTypeErrororBTClibValueError, or ecc’s class of the same kind, rather 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 codecs sit outside it, inbtclib.p2pandbtclib_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 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, or ecc’s class of the same kind, 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.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.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
wordsandlangofbip85.mnemonic_from_root_keyand a silent payment address’s hrp are quoted as_refusal.pysays. 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), whichtests/secret_free_test.pytests.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, run by.github/workflows/mutation.ymlon the modules each.github/mutation/file’smodule-pathnames, asks whether the suite notices a wrong line there. A module nomodule-pathlists 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.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, and so is every call of the organization’s own reusable workflows;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; 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.