[security] Store multiple verified derivation path entries per input/output; reject all decoy keys - #1044
Conversation
Names the input shape: a list of child indices below parent_key, which is what the cache is keyed on. A companion that takes a psbt entry's DerivationPath object follows; the two names then say which one a caller holds.
A multisig output lists one derivation path entry per cosigner. The output check verified only the first entry claiming this seed against the committed script, so a second claim of ours whose key the script has no use for went unreported when listed behind our real entry or in the place of another cosigner's entry (which keeps the entry count at n and passes the surplus count). Every entry claiming this seed is now held to the script. To feed that, the ownership scan keeps every entry it proved per input and output, as the psbt's own DerivationPath objects, instead of the first one's path. _derive_with_cache_via_derivation_path is added beside the index-taking primitive for callers holding such an entry; it reads only the entry's path, since the single-sig rebuild and the multisig fallback derive at entries carrying a foreign fingerprint on purpose. change_data keeps the index-list path the views read.
Chaitanya-Keyal
left a comment
There was a problem hiding this comment.
ACK at e1cd05d. Only a few minor nits.
| for verified_derivation_paths in self.verified_input_derivation_paths: | ||
| if verified_derivation_paths != []: | ||
| return |
There was a problem hiding this comment.
| for verified_derivation_paths in self.verified_input_derivation_paths: | |
| if verified_derivation_paths != []: | |
| return | |
| if any(self.verified_input_derivation_paths): | |
| return |
I know explicit code is usually the preference here, but in this case any reads much more cleanly than a loop with a negative comparison. The code this replaces already used any, in a more complicated form.
There was a problem hiding this comment.
I'm still personally allergic to any() and all() even though they really are perfectly readable.
I did make one tiny change to make the if check read more affirmatively in 34fb174.
| for entries in [decoy_first, decoy_last, decoy_substituted]: | ||
| psbt.outputs[0].bip32_derivations = entries | ||
|
|
||
| # Prep the our modified psbt in embit |
There was a problem hiding this comment.
| # Prep the our modified psbt in embit | |
| # Prep the modified psbt in embit |
| # def test__parse__accepts_a_multisig_output_holding_this_seed_in_two_slots(self): | ||
| # """ | ||
| # An edge case 2-of-3 that uses the same seed for two of its keys, each at its own | ||
| # derivation path. A legitimate change output for such a multisig should be | ||
| # recognized as change. | ||
|
|
||
| # Test not built; the setup complexity for this test is more effort than it's | ||
| # worth for a wallet nobody would / should set up. | ||
| # """ | ||
| # pass |
There was a problem hiding this comment.
I agree this isn't worth the effort, but maybe we could leave a TODO comment instead of the commented-out block so someone might pick it up later.
There was a problem hiding this comment.
The original Claude-generated test was ~75 lines, most of which was more complex / difficult to review than the rest of the tests.
I wanted to document the intentional test coverage gap, but I'm not sure I'd ever actually want the test to be added.
|
ACK e1cd05d |
|
ACK as of e1cd05d |
|
ACK 34fb174 LGTM |
High-level tldr:
On a multisig output we check for any "decoy" keys: a key that DOES derive from our seed but is NOT referenced/committed to in the output's actual script.
When populating
PSBTParser.verified_[input|output]_derivation_paths, the parser was only storing the derivation path for the FIRST key that we verified to be ours on a given input or output.If that first key turned out to be a decoy, the parser would raise a dire warning:
PSBTOutputOwnershipContradictionError.However, if the first key was legit but a subsequent key for that output was a decoy, it would either go unnoticed (if the total number of keys provided matched the number of keys in the multisig script) or it would raise a lesser warning (
PSBTSurplusDerivationPathsError).The presence of ANY decoy, regardless of its position in the psbt, should raise the same dire warning.
The fix
So this PR is pretty trivial. It's just modifying
PSBTParser.verified_[input|output]_derivation_paths:So now the parser can check EVERY key provided for each output that is derived from our seed and raise the alarm if it finds a decoy.
Minor implementation detail
The verified derivation path was originally stored as a
List[int](the individual indices of the derivation path).But allowing for multiple derivation paths per input or output would have meant:
List[List[int]]. And theverified_*vars are themselves aListencompassing all inputs or all outputs. Altogether, we would have had:List[List[List[int]]].So instead I refactored to just reuse the
embit.DerivationPathentries straight from the psbt:Testing
I have updated testqrs.com to include this PR's test scenario txs.
Description written by Claude:
Problem or Issue being addressed
A multisig output lists one derivation path entry per cosigner. #1032 checks that the key at the
first entry claiming this seed is in the output's script. A second claim of ours whose key the
script has no use for currently goes unreported when it is listed behind our real entry, or when
it is substituted for another cosigner's entry, which keeps the entry count at n and so passes the
surplus count too.
This is conservative (an unraised alarm, never a wrong number) and small.
Solution
Every claim of ours on a multisig output is held to its script
Every entry claiming this seed is held to the output's committed script, so a second claim of
ours whose key the script has no use for is refused as
PSBTOutputOwnershipContradictionErrorwherever the psbt lists it: ahead of our real entry, behind it, or in the place of another
cosigner's entry. The substituted placement keeps the entry count at n, which is why the surplus
count alone could not catch it.
The ownership scan already re-derives every claim of ours and refuses a false one; it now keeps
every entry it proved instead of the first.
verified_output_derivation_paths[i](and the inputlist) holds a list of the psbt's own
DerivationPathentries per output, empty for an outputclaiming none of our keys, and the multisig check iterates that list. The single-sig rebuild
requires that list to be exactly the one entry it rebuilt from. The contradiction messages and
the change data the views read take the first entry's path, which is the same path they used
before.
_derive_with_cacheis renamed_derive_with_cache_via_indices, and_derive_with_cache_via_derivation_pathis added beside it for callers holding a psbt entry.The two names say which shape a caller has; the one caller deriving below a cosigner xpub keeps
the index form, since the two levels below an xpub are not a full path from a master.
Design Consideration
Why an appended decoy now reports as a contradiction
#1032 refuses a decoy entry of ours listed after our real entry on a multisig output by the
surplus count, on a plain Warning, and the same decoy listed first as a contradiction, on a Dire
Warning. Identical content, two screens. Holding every claim to the script puts every placement
on the contradiction, which is what the psbt actually did: it claimed our key on an output whose
script has no use for it. The surplus count still stands behind it, for an output padded with a
stranger's entry, which redirects nothing of ours.
Why the scan keeps every verified path
The multisig check needs every path of ours on the output. Reading them back off the output's
bip32_derivationswould have worked, since the scan had proved each one, but only by relyingon call order: a reader would have a claim-named source standing in for a verified one, and a
future caller ahead of the scan would get unproven paths with nothing to say so. Recording every
verified path where the scan proves it keeps the split visible at each use: the surplus counts
read the psbt's entries (a stranger's entry counts the same as ours there), and ownership reads
the verified list.
Screenshots
No screen changes. The refusal reaches
PSBTOutputOwnershipContradictionView, which #1032 added.Testing
pytestpasses at 257. Two new parser tests (the padded-output case, and a unit test that theentry-taking derive helper ignores the entry's fingerprint), plus the multisig decoy test
reshaped to cover three placements (first, last, substituted) and one verdict.
I mutation-checked the change: checking only the first verified path fails the decoy test,
removing the surplus count fails the padded-output test, and a fingerprint check inside the
derive helper fails its unit test.
I have not tested this on hardware.
Other Notes
since a decoy of ours is refused as a contradiction before the count runs.
test__parse__rejects_a_multisig_output_padded_with_a_strangers_entrycovers it.verified_input_derivation_pathsandverified_output_derivation_pathschange shape fromone index list or
Noneper input/output to a list ofDerivationPathentries perinput/output. Nothing outside the parser reads them; the views read
change_data["verified_derivation_path"], which stays an index list. The existing assertionson the old shape are updated, and two tests are renamed from
..._none_for_not_owned_...to..._empty_for_not_owned_...._derive_with_cacherename is its own commit, so the check's commit reads without it._derive_with_cache_via_derivation_pathreads only the entry's path. The fingerprint-blindchecks in
_parse_outputs(the single-sig rebuild and the multisig fallback) rely on that, soa fingerprint check does not belong in it.
keys, is written up as a commented-out test beside the decoy test rather than built: the
fixture work is out of proportion to a wallet nobody would set up.
any()/all()over agenerator are rewritten as plain loops, since this PR rewrites those lines anyway.
TODOon the ownership tests'_parsehelper: its name reads as a twin ofPSBT.parse()in tests that call both. This PR's new tests construct
PSBTParserdirectly.test_cache_does_not_change_parse_output) isrewritten in passing, per the file-wide style rule.
This pull request is categorized as a:
Checklist
I ran
pytestlocallyI included screenshots of any new or modified screens
Should be part of the PR description above.
I added or updated tests
Any new or altered functionality should be covered in a unit test. Any new or updated sequences require FlowTests.
I tested this PR hands-on on the following platform(s):
I have reviewed these notes:
Thank you! Please join our Devs' Telegram group to get more involved.