Skip to content

Commit a860035

Browse files
committed
Validate Bitcoin Core state types
Require exact nonnegative integer types for wallet counts and unlock state, including final relock verification. This prevents booleans and malformed RPC values from passing numeric equality checks. Security: fail closed on untrusted Bitcoin Core state while preserving valid encrypted and unencrypted wallet flows. Validation: python -m pytest -q; python -O -m pytest -q; Ruff check and format; strict mypy; differential_wallet.py --verify.
1 parent 43af20d commit a860035

2 files changed

Lines changed: 74 additions & 6 deletions

File tree

‎src/codex32/_bitcoin_core.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,19 +304,27 @@ def _target(self, name: str) -> tuple[bool, bool] | None:
304304
listing, info = self._rpc("listdescriptors", wallet=name), self._rpc("getwalletinfo", wallet=name)
305305
if not isinstance(info, dict) or not isinstance(listing, dict):
306306
raise BitcoinCoreError("Unexpected Bitcoin Core wallet information.")
307+
txcount = info.get("txcount")
308+
keypoolsize = info.get("keypoolsize")
309+
internal_keypool = info.get("keypoolsize_hd_internal", 0)
310+
has_unlock_state = "unlocked_until" in info
311+
unlocked = info.get("unlocked_until")
307312
eligible = (
308313
info.get("descriptors") is True
309314
and info.get("private_keys_enabled") is True
310315
and info.get("external_signer", False) is False
311-
and info.get("txcount") == 0
312-
and info.get("keypoolsize") == 0
313-
and info.get("keypoolsize_hd_internal", 0) == 0
316+
and type(txcount) is int
317+
and txcount == 0
318+
and type(keypoolsize) is int
319+
and keypoolsize == 0
320+
and type(internal_keypool) is int
321+
and internal_keypool == 0
314322
and info.get("scanning") is False
315323
and listing.get("descriptors") == []
316324
and name.isprintable()
325+
and (not has_unlock_state or type(unlocked) is int and unlocked >= 0)
317326
)
318-
unlocked = info.get("unlocked_until")
319-
return (unlocked is not None, unlocked == 0) if eligible else None
327+
return (has_unlock_state, unlocked == 0) if eligible else None
320328

321329
def _create_account_zero(self, secret: MasterSeed, wallet: str) -> None:
322330
root = _master_xprv_from_seed(secret.seed_bytes, testnet=self.chain != "main")
@@ -510,7 +518,8 @@ def initialize(
510518
continue
511519
except BitcoinCoreError as error:
512520
raise BitcoinCoreError(warning) from error
513-
if not isinstance(info, dict) or info.get("unlocked_until") != 0:
521+
unlocked_until = info.get("unlocked_until") if isinstance(info, dict) else None
522+
if type(unlocked_until) is not int or unlocked_until != 0:
514523
raise BitcoinCoreError(warning)
515524
relock = False
516525
if waited:

‎tests/test_bitcoin_core.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ def test_candidate_filter_rejects_every_unsafe_wallet_property(
214214
"transactions": (_empty_info(txcount=1), []),
215215
"keys": (_empty_info(keypoolsize=1), []),
216216
"change": (_empty_info(keypoolsize_hd_internal=1), []),
217+
"boolean transactions": (_empty_info(txcount=False), []),
218+
"boolean keys": (_empty_info(keypoolsize=False), []),
219+
"boolean change": (_empty_info(keypoolsize_hd_internal=False), []),
220+
"invalid unlock": (_empty_info(unlocked_until="unlocked"), []),
221+
"null unlock": (_empty_info(unlocked_until=None), []),
217222
"scanning": (_empty_info(scanning={"duration": 1}), []),
218223
"descriptors": (_empty_info(), [{"desc": "public"}]),
219224
"bad\x1bname": (_empty_info(), []),
@@ -721,6 +726,60 @@ def interrupt_once(
721726
assert rpc.locked and lock_calls == 2
722727

723728

729+
def test_walletlock_failure_requires_manual_lock_confirmation(monkeypatch: pytest.MonkeyPatch) -> None:
730+
rpc = _ImportRPC(locked=False)
731+
original = rpc.__call__
732+
733+
def fail_lock(
734+
client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None
735+
) -> object:
736+
if arguments == ("walletlock",):
737+
raise BitcoinCoreError("suppressed lock failure")
738+
return original(client, *arguments, wallet=wallet, stdin=stdin)
739+
740+
monkeypatch.setattr(BitcoinCore, "_rpc", fail_lock)
741+
with pytest.raises(
742+
BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked"
743+
):
744+
BitcoinCore("bitcoin-cli", "main", 300000).initialize(
745+
_SEED,
746+
lambda _prompt: "yes",
747+
lambda _message: None,
748+
expected_fingerprint=_FINGERPRINT,
749+
)
750+
751+
752+
@pytest.mark.parametrize("unlocked_until", (100, False, "locked"))
753+
def test_failed_lock_verification_requires_manual_confirmation(
754+
monkeypatch: pytest.MonkeyPatch, unlocked_until: object
755+
) -> None:
756+
rpc = _ImportRPC(locked=False)
757+
original = rpc.__call__
758+
lock_requested = False
759+
760+
def remain_unlocked(
761+
client: BitcoinCore, *arguments: str, wallet: str | None = None, stdin: str | None = None
762+
) -> object:
763+
nonlocal lock_requested
764+
if arguments == ("walletlock",):
765+
lock_requested = True
766+
return None
767+
if lock_requested and arguments == ("getwalletinfo",):
768+
return _empty_info(unlocked_until=unlocked_until)
769+
return original(client, *arguments, wallet=wallet, stdin=stdin)
770+
771+
monkeypatch.setattr(BitcoinCore, "_rpc", remain_unlocked)
772+
with pytest.raises(
773+
BitcoinCoreError, match="Confirm immediately in Bitcoin Core that the wallet is locked"
774+
):
775+
BitcoinCore("bitcoin-cli", "main", 300000).initialize(
776+
_SEED,
777+
lambda _prompt: "yes",
778+
lambda _message: None,
779+
expected_fingerprint=_FINGERPRINT,
780+
)
781+
782+
724783
def test_unencrypted_wallet_imports_without_a_lock_call(monkeypatch: pytest.MonkeyPatch) -> None:
725784
rpc = _ImportRPC(encrypted=False, locked=False)
726785
monkeypatch.setattr(

0 commit comments

Comments
 (0)