Splitting up MAX_BIP388_XPUB_DERIVATION_STEPS and MAX_BIP32_PATH_STEPS: tests
What changed, and why it matters
This commit only changes test files for the Ledger Bitcoin app. It adds and updates automated tests to verify that the app correctly accepts derivation paths up to a defined maximum length and rejects paths that are too long. There are no changes to the actual app code that runs on the device, so this commit does not introduce or fix a security vulnerability by itself. It appears to be a follow-up testing patch related to separating two internal constants about path length limits.
No immediate security action required. Review the companion non-test commit that actually changes MAX_BIP388_XPUB_DERIVATION_STEPS and MAX_BIP32_PATH_STEPS in the firmware to ensure the limits and error handling are implemented safely.
Security signals we found
Boundary/limit testing added for derivation path lengths
Tests assert IncorrectDataError / WrongDataLengthError for over-limit paths
No application/firmware code changes in this commit
Evidence from the diff
The diff modifies five test files under tests/. It splits the test expectations for two previously unified constants: MAX_BIP388_XPUB_DERIVATION_STEPS (8 steps for BIP-388 xpub derivation) and MAX_BIP32_PATH_STEPS (10 steps for general BIP32 paths). New test cases exercise boundary conditions: 8-step xpub derivation succeeds, 9-step fails with IncorrectDataError; 10-step BIP32 path succeeds for message signing and PSBT signing, 11-step fails with WrongDataLengthError; get_extended_pubkey now tests an 8-step path and an over-limit 11-step path. The helper pubkey_instruction_approve gains a save_screenshot parameter to reduce screenshot noise. No firmware C code is changed.
Changed components
tests/instructions.pytests/test_get_extended_pubkey.pytests/test_register_wallet.pytests/test_sign_message.pytests/test_sign_psbt.pyInspect captured patch +128 / −19
diff --git a/tests/instructions.py b/tests/instructions.py
index db54403..3d425a8 100644
--- a/tests/instructions.py
+++ b/tests/instructions.py
@@ -60,14 +60,14 @@ def message_instruction_reject(model: Firmware) -> Instructions:
return instructions
-def pubkey_instruction_approve(model: Firmware) -> Instructions:
+def pubkey_instruction_approve(model: Firmware, save_screenshot=True) -> Instructions:
instructions = Instructions(model)
if model.name.startswith("nano"):
- instructions.new_request("Approve")
+ instructions.new_request("Approve", save_screenshot=save_screenshot)
else:
- instructions.choice_confirm()
- instructions.status_dismiss("approved")
+ instructions.choice_confirm(save_screenshot=save_screenshot)
+ instructions.status_dismiss("approved", save_screenshot=save_screenshot)
return instructions
diff --git a/tests/test_get_extended_pubkey.py b/tests/test_get_extended_pubkey.py
index ee4f5d9..9705613 100644
--- a/tests/test_get_extended_pubkey.py
+++ b/tests/test_get_extended_pubkey.py
@@ -6,7 +6,7 @@ from ragger.backend import SpeculosBackend
from ragger.firmware import Firmware
from ragger.error import ExceptionRAPDU
-from ledger_bitcoin.exception.errors import NotSupportedError, DenyError
+from ledger_bitcoin.exception.errors import NotSupportedError, DenyError, IncorrectDataError
from ledger_bitcoin.exception.device_exception import DeviceException
from .instructions import pubkey_instruction_approve, pubkey_instruction_reject_early, pubkey_reject
@@ -14,23 +14,25 @@ from .instructions import pubkey_instruction_approve, pubkey_instruction_reject_
def test_get_extended_pubkey_standard_display(navigator: Navigator, firmware: Firmware, client:
RaggerClient, test_name: str):
testcases = {
- "m/44'/1'/0'": "tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT",
- "m/44'/1'/10'": "tpubDCwYjpDhUdPGp21gSpVay2QPJVh6WNySWMXPhbcu1DsxH31dF7mY18oibbu5RxCLBc1Szerjscuc3D5HyvfYqfRvc9mesewnFqGmPjney4d",
- "m/44'/1'/2'/1/42": "tpubDGF9YgHKv6qh777rcqVhpmDrbNzgophJM9ec7nHiSfrbss7fVBXoqhmZfohmJSvhNakDHAspPHjVVNL657tLbmTXvSeGev2vj5kzjMaeupT",
- "m/48'/1'/4'/1'/0/7": "tpubDK8WPFx4WJo1R9mEL7Wq325wBiXvkAe8ipgb9Q1QBDTDUD2YeCfutWtzY88NPokZqJyRPKHLGwTNLT7jBG59aC6VH8q47LDGQitPB6tX2d7",
- "m/49'/1'/1'/1/3": "tpubDGnetmJDCL18TyaaoyRAYbkSE9wbHktSdTS4mfsR6inC8c2r6TjdBt3wkqEQhHYPtXpa46xpxDaCXU2PRNUGVvDzAHPG6hHRavYbwAGfnFr",
- "m/84'/1'/2'/0/10": "tpubDG9YpSUwScWJBBSrhnAT47NcT4NZGLcY18cpkaiWHnkUCi19EtCh8Heeox268NaFF6o56nVeSXuTyK6jpzTvV1h68Kr3edA8AZp27MiLUNt",
- "m/86'/1'/4'/1/12": "tpubDHTZ815MvTaRmo6Qg1rnU6TEU4ZkWyA56jA1UgpmMcBGomnSsyo34EZLoctzZY9MTJ6j7bhccceUeXZZLxZj5vgkVMYfcZ7DNPsyRdFpS3f",
+ # path: (expected_pubkey, save_screenshot)
+ "m/44'/1'/0'": ("tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT", True),
+ "m/44'/1'/10'": ("tpubDCwYjpDhUdPGp21gSpVay2QPJVh6WNySWMXPhbcu1DsxH31dF7mY18oibbu5RxCLBc1Szerjscuc3D5HyvfYqfRvc9mesewnFqGmPjney4d", True),
+ "m/44'/1'/2'/1/42": ("tpubDGF9YgHKv6qh777rcqVhpmDrbNzgophJM9ec7nHiSfrbss7fVBXoqhmZfohmJSvhNakDHAspPHjVVNL657tLbmTXvSeGev2vj5kzjMaeupT", True),
+ "m/48'/1'/4'/1'/0/7": ("tpubDK8WPFx4WJo1R9mEL7Wq325wBiXvkAe8ipgb9Q1QBDTDUD2YeCfutWtzY88NPokZqJyRPKHLGwTNLT7jBG59aC6VH8q47LDGQitPB6tX2d7", True),
+ "m/49'/1'/1'/1/3": ("tpubDGnetmJDCL18TyaaoyRAYbkSE9wbHktSdTS4mfsR6inC8c2r6TjdBt3wkqEQhHYPtXpa46xpxDaCXU2PRNUGVvDzAHPG6hHRavYbwAGfnFr", True),
+ "m/84'/1'/2'/0/10": ("tpubDG9YpSUwScWJBBSrhnAT47NcT4NZGLcY18cpkaiWHnkUCi19EtCh8Heeox268NaFF6o56nVeSXuTyK6jpzTvV1h68Kr3edA8AZp27MiLUNt", True),
+ "m/86'/1'/4'/1/12": ("tpubDHTZ815MvTaRmo6Qg1rnU6TEU4ZkWyA56jA1UgpmMcBGomnSsyo34EZLoctzZY9MTJ6j7bhccceUeXZZLxZj5vgkVMYfcZ7DNPsyRdFpS3f", True),
# the following path tests compatibility with Unchained Capital's multisig setup
- "m/45'/2'/0'/1'": "tpubDFL11pFAgsKed5bv9Tkxe51xyB4qo1cPDwK6c8WZ4wiVEjtGDg5YuMXNk9yZcB6b47k2oaSWADJF3CRmk97qAwnaiRieT2ocWzh4rq2b3F3",
+ "m/45'/2'/0'/1'": ("tpubDFL11pFAgsKed5bv9Tkxe51xyB4qo1cPDwK6c8WZ4wiVEjtGDg5YuMXNk9yZcB6b47k2oaSWADJF3CRmk97qAwnaiRieT2ocWzh4rq2b3F3", True),
+ "m/48'/1'/0'/2'/0'/1'/2'/3'/0/1": ("tpubDSegeM6ezY6VYgNjYiUxk94ZwLYeuUzHpUYff4LLdEEUVUd8VpgGUxyxZ9oRiEepZzmFZogVWFEWznRj3oJgEuWjcg6BERCxTCYdaxwtShk", False)
}
- for path, pubkey in testcases.items():
+ for path, (pubkey, save_screenshot) in testcases.items():
assert pubkey == client.get_extended_pubkey(
path=path,
display=True,
navigator=navigator,
- instructions=pubkey_instruction_approve(firmware),
+ instructions=pubkey_instruction_approve(firmware, save_screenshot=save_screenshot),
testname=f"{test_name}_{path}"
)
@@ -123,7 +125,7 @@ def test_get_extended_pubkey_non_standard(navigator: Navigator, firmware: Firmwa
# The test will be re-enabled for Speculos once the installation parameters are supported
# Deriving a key at root level without HAVE_APPLICATION_FLAG_DERIVE_MASTER permission
with pytest.raises(ExceptionRAPDU) as e:
- pub_key = client.get_extended_pubkey(
+ client.get_extended_pubkey(
path="m", # root pubkey
display=True,
navigator=navigator,
@@ -135,7 +137,7 @@ def test_get_extended_pubkey_non_standard(navigator: Navigator, firmware: Firmwa
# Deriving a key at unauthorized path
# The below part does not raise exception when built with COIN=bitcoin_recovery as all paths are permitted
with pytest.raises(ExceptionRAPDU) as e:
- pub_key = client.get_extended_pubkey(
+ client.get_extended_pubkey(
path="m/44'/2'/333'",
display=True,
navigator=navigator,
@@ -145,6 +147,19 @@ def test_get_extended_pubkey_non_standard(navigator: Navigator, firmware: Firmwa
assert DeviceException.exc.get(e.value.status) == NotSupportedError
assert len(e.value.data) == 0
+ # Deriving a key with a path exceeding maximum one
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.get_extended_pubkey(
+ path="m/48'/1'/0'/2'/0'/1'/2'/3'/0/1/2",
+ display=True,
+ navigator=navigator,
+ instructions=pubkey_instruction_approve(firmware),
+ testname=test_name
+ )
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
+ assert len(e.value.data) == 0
+
+
def test_get_extended_pubkey_non_standard_reject_early(navigator: Navigator, firmware: Firmware,
client: RaggerClient, test_name: str):
diff --git a/tests/test_register_wallet.py b/tests/test_register_wallet.py
index 40bc91b..1ff9485 100644
--- a/tests/test_register_wallet.py
+++ b/tests/test_register_wallet.py
@@ -7,7 +7,7 @@ from ragger.firmware import Firmware
from ragger.error import ExceptionRAPDU
from ragger_bitcoin import RaggerClient
-from .instructions import register_wallet_instruction_approve, register_wallet_instruction_approve_long, register_wallet_instruction_approve_unusual, register_wallet_instruction_reject, Instructions
+from .instructions import register_wallet_instruction_approve, register_wallet_instruction_approve_no_save, register_wallet_instruction_approve_long, register_wallet_instruction_approve_unusual, register_wallet_instruction_reject, Instructions
import hmac
from hashlib import sha256
@@ -437,3 +437,36 @@ def test_register_wallet_tr_script_sortedmulti(navigator: Navigator, firmware: F
),
instructions=register_wallet_instruction_approve_long(firmware),
test_name=test_name)
+
+def test_register_wallet_max_derivation_steps(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str, speculos_globals):
+ # Test that we can register a wallet with MAX_BIP388_XPUB_DERIVATION_STEPS (8) derivation steps
+ # This key has 8 derivation steps: 48'/1'/0'/2'/0'/1'/2'/3'
+ # Using a valid xpub from existing tests but with 8-step derivation path
+ run_register_test(navigator, client, speculos_globals, WalletPolicy(
+ name="Max derivation",
+ descriptor_template="wpkh(@0/**)",
+ keys_info=[
+ "[f5acc2fd/48'/1'/0'/2'/0'/1'/2'/3']tpubDNTK3WZ6g6eZBbCYCdFMpkCNvEqMxYexWRyqrGDkC5WDsNJdUk95z9wHWm6rBZeeMRxKafnuJzT8TqUs94A11KKkW9wT1pcFQShC9p3Vcxi",
+ ],
+ ),
+ instructions=register_wallet_instruction_approve_no_save(firmware),
+ test_name=test_name)
+
+
+def test_register_wallet_too_many_derivation_steps(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ # Test that we cannot register a wallet with MAX_BIP388_XPUB_DERIVATION_STEPS + 1 (9) derivation steps
+ # This key has 9 derivation steps: 48'/1'/0'/2'/0'/1'/2'/3'/4'
+ wallet = WalletPolicy(
+ name="Too many steps",
+ descriptor_template="wpkh(@0/**)",
+ keys_info=[
+ "[f5acc2fd/48'/1'/0'/2'/0'/1'/2'/3'/4']tpubDR1MRwnrfYuCbZ5dvozygap7EkR1pRXfwKhfCEpMTHtzrtN78MnFeipS2Vq9Nr3WHSurdqmrPfm5yQH5ikxJhYUTK12VxmqfTKR8hUF8mty",
+ ],
+ )
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.register_wallet(wallet)
+
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
diff --git a/tests/test_sign_message.py b/tests/test_sign_message.py
index acb97a0..a2296dd 100644
--- a/tests/test_sign_message.py
+++ b/tests/test_sign_message.py
@@ -120,3 +120,34 @@ def test_sign_message_hash_reject(navigator: Navigator, firmware: Firmware, clie
assert DeviceException.exc.get(e.value.status) == DenyError
assert len(e.value.data) == 0
+
+
+def test_sign_message_with_max_bip32_path(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str):
+ # Test signing with MAX_BIP32_PATH_STEPS (10) derivation steps
+ # Path: m/44'/1'/0'/0'/1'/2'/3'/4'/5/6 = 10 steps
+ message = "Maximum derivation path test"
+ path = "m/44'/1'/0'/0'/1'/2'/3'/4'/5/6"
+
+ result = client.sign_message(message, path, navigator,
+ instructions=message_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+
+ # Just verify it succeeds and returns a signature
+ assert result is not None
+ assert len(result) > 0
+
+
+def test_sign_message_fail_too_long_bip32_path(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str):
+ # Test signing with MAX_BIP32_PATH_STEPS + 1 (11) derivation steps should fail
+ # Path: m/44'/1'/0'/0'/1'/2'/3'/4'/5/6/7 = 11 steps
+ message = "Too long derivation path test"
+ path = "m/44'/1'/0'/0'/1'/2'/3'/4'/5/6/7"
+
+ from ledger_bitcoin.exception.errors import WrongDataLengthError
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ client.sign_message(message, path, navigator,
+ instructions=message_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+
+ assert DeviceException.exc.get(e.value.status) == WrongDataLengthError
diff --git a/tests/test_sign_psbt.py b/tests/test_sign_psbt.py
index eca2c7f..838b50e 100644
--- a/tests/test_sign_psbt.py
+++ b/tests/test_sign_psbt.py
@@ -1,5 +1,7 @@
import base64
import pytest
+import hmac
+from hashlib import sha256
from pathlib import Path
@@ -13,7 +15,7 @@ from ragger.navigator import Navigator
from ragger.error import ExceptionRAPDU
from ragger.firmware import Firmware
-from test_utils import bip0340, txmaker
+from test_utils import bip0340, txmaker, SpeculosGlobals
from ragger_bitcoin import RaggerClient
from ragger_bitcoin.ragger_instructions import MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER
@@ -747,6 +749,34 @@ def test_sign_psbt_fail_wrong_non_witness_utxo(navigator: Navigator, firmware: F
assert error_code == EC_SIGN_PSBT_NONWITNESSUTXO_CHECK_FAILED
+def test_sign_psbt_with_max_derivation_path(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Test that we can sign a PSBT with MAX_BIP32_PATH_STEPS (10) derivation steps
+ # The key origin has 8 steps, plus the /** gives us 2 more for a total of 10
+ wallet = WalletPolicy(
+ "",
+ "wpkh(@0/**)",
+ [
+ "[f5acc2fd/48'/1'/0'/2'/0'/1'/2'/3']tpubDNTK3WZ6g6eZBbCYCdFMpkCNvEqMxYexWRyqrGDkC5WDsNJdUk95z9wHWm6rBZeeMRxKafnuJzT8TqUs94A11KKkW9wT1pcFQShC9p3Vcxi"
+ ],
+ )
+
+ psbt = txmaker.createPsbt(
+ wallet,
+ [100_000_000],
+ [99_000_000],
+ [True]
+ )
+
+ wallet_hmac = hmac.new(
+ speculos_globals.wallet_registration_key, wallet.id, sha256).digest()
+
+ # This should succeed as we're at the limit
+ result = client.sign_psbt(psbt, wallet, wallet_hmac, navigator,
+ instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+
+ assert len(result) == 1
def test_sign_psbt_with_opreturn(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str):
Why this scored 12/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.