Explain how MAX_CACHED_DERIVATIONS was chosen and test the cap
What changed, and why it matters
This commit is a documentation and testing improvement for an existing safety cap in the PSBT parser. It does not change the cap's value or behavior; it explains why the cap exists (to stop a malicious PSBT from consuming unbounded memory), adds a test proving that hitting the cap does not corrupt the parse result, and clarifies that the cap was chosen based on a realistic large multisig transaction. There is no new vulnerability being fixed here, but the change makes the existing defense easier to understand and verify.
No immediate action required. Treat as routine hardening/quality improvement. Reviewers may want to confirm that the new test actually exercises the cap by running tests and checking that removing the cap causes the assertion to fail as claimed.
Security signals we found
Existing resource-limiting cap is documented as a defense against maliciously crafted PSBTs causing unbounded memory growth
New regression test verifies that cache exhaustion does not alter parse output
No change to cap value or derivation logic; behavior-preserving documentation/test commit
Evidence from the diff
The commit updates PSBTParser.MAX_CACHED_DERIVATIONS comments and adds test coverage. The cap (1000 cached derivation levels) already existed; the patch documents it as a realistic upper bound for a 3-of-5 multisig consolidating 200 inputs and notes that exceeding it only causes redundant derivation, not parse failure. A new test patches the cap to 3, parses multisig and singlesig PSBTs, and asserts identical parser state versus an unconstrained parse. A helper compares parser fields and serialized PSBT output. No functional code path changes.
Changed components
src/seedsigner/models/psbt_parser.pytests/test_psbt_parser.pyInspect captured patch +91 / −6
### src/seedsigner/models/psbt_parser.py
@@ -20,10 +20,29 @@ class OPCODES:
class PSBTParser():
- # Upper bound on how many derived levels a single parse will hold on to; see
- # _derive_with_cache.
+ """
+ Reads a psbt on behalf of one seed and works out everything the signing flow shows
+ the user before they approve: the wallet policy (script type, plus m-of-n and the
+ cosigners for multisig), the amount coming in, what is being spent, what comes back
+ as change, the fee, where the spend is going, and any OP_RETURN payload.
+
+ Constructing it with a seed parses immediately. The results are read off the instance
+ attributes, with per-change-output detail in change_data.
+
+ has_matching_input_fingerprint answers the earlier question of which seed a psbt is
+ for and needs no parse.
+ """
+
+ # Upper bound on how many levels of derivation a single parse will cache in
+ # _child_key_derivation_cache. 1000 is just slightly under a 3-of-5 multisig
+ # consolidating 200 inputs and holds the cache to a max of about half a megabyte. A
+ # psbt that requires more levels will still parse correctly, but may have to derive
+ # some levels more than once. Capping the cache at a realistic upper bound protects
+ # against a maliciously crafted psbt that would otherwise consume unbounded memory
+ # while still providing cache wins for even atypically large real-world psbts.
MAX_CACHED_DERIVATIONS = 1000
+
def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
self.psbt: PSBT = p
self.seed = seed
@@ -376,10 +395,7 @@ def _derive_with_cache(parent_key: bip32.HDKey, derivation_path: List[int], cach
malicious coordinator to grind a deliberate collision, and the cosigner xpubs come
from the psbt.
- The cache stops accepting new levels at MAX_CACHED_DERIVATIONS. A real wallet
- needs orders of magnitude fewer, but a hostile psbt can name any number of
- derivation paths, and every level of every one of them would otherwise be held
- in memory at once. Past the limit each level is simply derived as needed.
+ The cache stops accepting new levels at MAX_CACHED_DERIVATIONS.
"""
if cache is None:
return parent_key.derive(derivation_path)
### tests/test_psbt_parser.py
@@ -3,6 +3,7 @@
from binascii import a2b_base64
from copy import deepcopy
+from unittest.mock import patch
from embit import bip32
from embit.networks import NETWORKS
from embit.psbt import PSBT, DerivationPath
@@ -508,6 +509,27 @@ def _root(self, seed: Seed = None) -> bip32.HDKey:
seed.seed_bytes, version=NETWORKS["main"]["xprv"])
+ def assert_same_parse_result(self, parser_a: PSBTParser, parser_b: PSBTParser):
+ """
+ Asserts that two parses produced the same result, field by field so that a failure
+ names the exact field that differs.
+
+ The fill path writes recovered fingerprints back into the psbt, so the serialized
+ psbt is compared too, not just the parser's own attributes.
+ """
+ assert parser_a.policy == parser_b.policy
+ assert parser_a.input_amount == parser_b.input_amount
+ assert parser_a.spend_amount == parser_b.spend_amount
+ assert parser_a.change_amount == parser_b.change_amount
+ assert parser_a.fee_amount == parser_b.fee_amount
+ assert parser_a.num_inputs == parser_b.num_inputs
+ assert parser_a.destination_addresses == parser_b.destination_addresses
+ assert parser_a.destination_amounts == parser_b.destination_amounts
+ assert parser_a.change_data == parser_b.change_data
+ assert parser_a.op_return_data == parser_b.op_return_data
+ assert parser_a.psbt.serialize() == parser_b.psbt.serialize()
+
+
def test_my_fingerprint_equals_child0_fingerprint(self):
"""
Reading my_fingerprint in place of child(0).fingerprint is byte-identical,
@@ -697,3 +719,50 @@ def parse_everything():
lambda parent_key, derivation_path, cache=None: uncached(parent_key, derivation_path)))
assert parse_everything() == with_cache
+
+
+ def test_maxed_out_cache_does_not_change_parse_output(self):
+ """
+ There should be no effect on the parse output when the cache is maxed out.
+
+ Parse a multisig and a single-sig psbt with the cache free to grow, then parse
+ them again with the cap low enough that both hit the cap partway through. Verify
+ that we get the identical parser state each time.
+ """
+ multisig_case = (PSBTTestData.MULTISIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.MULTISIG_NATIVE_SEGWIT_CHANGE)
+ singlesig_case = (PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_1_INPUT, PSBTTestData.SINGLE_SIG_NATIVE_SEGWIT_CHANGE)
+
+ def build_psbt(case: tuple) -> PSBT:
+ # A fresh psbt for each parse: the case's base psbt plus its change output
+ input_base64, change_hex = case
+ psbt = PSBT.parse(a2b_base64(input_base64))
+ psbt.outputs.append(create_output(change_hex, 10_000))
+ return psbt
+
+ # Wrapping _derive_with_cache leaves it doing its real work while recording every
+ # call it received. The cache it was handed is the third of those arguments, and
+ # what the recording holds is a reference to the actual cache dict. So reading it
+ # back afterward gives that cache's final contents.
+ with patch.object(PSBTParser, "_derive_with_cache", wraps=PSBTParser._derive_with_cache) as mock_unconstrained:
+ multisig_unconstrained = PSBTParser(build_psbt(multisig_case), self.seed, network=SettingsConstants.REGTEST)
+ singlesig_unconstrained = PSBTParser(build_psbt(singlesig_case), self.seed, network=SettingsConstants.REGTEST)
+
+ # Now constrain the cache enough that both psbts fill it partway through their
+ # parse.
+ cap = 3
+ with patch.object(PSBTParser, "MAX_CACHED_DERIVATIONS", cap):
+ with patch.object(PSBTParser, "_derive_with_cache", wraps=PSBTParser._derive_with_cache) as mock_capped:
+ multisig_capped = PSBTParser(build_psbt(multisig_case), self.seed, network=SettingsConstants.REGTEST)
+ singlesig_capped = PSBTParser(build_psbt(singlesig_case), self.seed, network=SettingsConstants.REGTEST)
+
+ self.assert_same_parse_result(multisig_unconstrained, multisig_capped)
+ self.assert_same_parse_result(singlesig_unconstrained, singlesig_capped)
+
+ # How large did the "unconstrained" (max 1000) cache grow vs the capped?
+ unconstrained_max = max(len(call.args[2]) for call in mock_unconstrained.call_args_list)
+ capped_max = max(len(call.args[2]) for call in mock_capped.call_args_list)
+
+ # Sanity check: this test depends on the unconstrained cache actually being larger
+ # than the capped cache's max.
+ assert unconstrained_max > cap
+ assert capped_max == capWhy this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.