Skip to content

fix(signer): match BIP32 origin by path prefix, not string prefix#81

Open
evanlinjin wants to merge 1 commit into
masterfrom
fix/origin_matching
Open

fix(signer): match BIP32 origin by path prefix, not string prefix#81
evanlinjin wants to merge 1 commit into
masterfrom
fix/origin_matching

Conversation

@evanlinjin

Copy link
Copy Markdown
Member

Description

Signer::get_key decided whether a key origin (fingerprint, path) covered a KeyRequest::Bip32 derivation by stringifying both paths and using starts_with:

derivation.to_string().starts_with(&path.to_string())

Because DerivationPath renders its ChildNumber components as /-joined decimals, this matched on character boundaries rather than path components. That produces false positives whenever the origin's stringified form is a substring of the request's:

  • origin m/1 vs request m/10 ("10".starts_with("1"))
  • origin m/84'/1'/0'/1 vs request m/84'/1'/0'/10
  • unhardened origin .../0 vs hardened request .../0'

When a spurious match fires and the two paths share a component count, skip(path.len()) leaves an empty to_derive, so the signer returns the private key at the origin path while claiming to satisfy an unrelated request — leaking a key the descriptor was never meant to expose.

This PR compares the paths component-by-component with a length check instead of stringifying them.

Notes to the reviewers

Added a regression test get_key_bip32_string_prefix_not_path_prefix that builds a descriptor with origin m/84'/1'/0'/1 and requests m/84'/1'/0'/10: on the old code the signer returns Some(key at m/84'/1'/0'/1); with the fix it correctly returns None.

Reported via llm-code-review (CWE-697). Discovered by Project Loupe.

Changelog notice

Fixed: Signer matched a BIP32 key origin against a derivation request by string prefix instead of path-component prefix, which could cause it to return a private key for an unrelated derivation path.

Before submitting

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 33.33333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.16%. Comparing base (d14f144) to head (58fa5e5).

Files with missing lines Patch % Lines
src/signer.rs 33.33% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #81      +/-   ##
==========================================
+ Coverage   54.01%   54.16%   +0.15%     
==========================================
  Files          12       12              
  Lines        1620     1621       +1     
  Branches       62       62              
==========================================
+ Hits          875      878       +3     
+ Misses        716      714       -2     
  Partials       29       29              
Flag Coverage Δ
rust 54.16% <33.33%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`get_key` decided whether a key origin covered a `KeyRequest::Bip32`
derivation by stringifying both paths and using `starts_with`. Since
`DerivationPath` renders components as `/`-joined decimals, this matched
on character boundaries rather than components: `m/1` spuriously matched
`m/10`, and an unhardened origin matched its hardened sibling. The signer
could then return a private key for a derivation the descriptor never
meant to expose.

Compare the paths component-by-component with a length check instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@evanlinjin evanlinjin force-pushed the fix/origin_matching branch from 3e7e399 to 58fa5e5 Compare June 15, 2026 19:20
@evanlinjin evanlinjin self-assigned this Jun 15, 2026
@evanlinjin evanlinjin added the bug Something isn't working label Jun 15, 2026
@evanlinjin evanlinjin marked this pull request as ready for review June 15, 2026 19:21
Comment thread src/signer.rs
Comment on lines +48 to +50
let is_path_prefix = derivation.len() >= path.len()
&& derivation.into_iter().zip(path).all(|(a, b)| a == b);
if fingerprint == fp && is_path_prefix {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's probably better if you make use of the descriptor/xkey matches() method here, it already handles checking for the fingerprint and matching prefix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That won't work as matches checks for equality whereas we want a "prefix matches" check.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants