What changed, and why it matters
This commit adds a new way for the Ledger Bitcoin client to ask the device for the wallet's master key fingerprint (a short public identifier). It also keeps an older fallback method for compatibility with devices that do not yet support the new command. The change is a feature addition in the client library, not a fix for a known vulnerability, and the commit message does not describe any security problem.
No immediate action required. Treat as a routine feature commit. If reviewing for defense-in-depth, verify that the new 0xD0 APDU on the device side correctly returns exactly 4 bytes and that exposing the master fingerprint without derivation permission is an intentional, documented design choice.
Security signals we found
New APDU exposes master key fingerprint without requiring DERIVE_MASTER permission (per inline comment)
Fallback path still derives master public key via getWalletPublicKey('') and hashes it client-side
No bounds/length checks are visible in the new getMasterFingerprint() wrapper
Commit is a feature addition; no security bug or CVE is mentioned
Evidence from the diff
The patch introduces BTCHIP_INS_GET_MASTER_FINGERPRINT (0xD0) in btchip.py and a _get_master_fingerprint() helper in client_legacy.py. The helper first tries the new APDU and falls back to getWalletPublicKey(‘’) plus client-side HASH160 if the device rejects it. get_extended_pubkey(), sign_psbt(), and get_master_fingerprint() are updated to use the helper when the path is the master node. The rest of the diff is whitespace cleanup.
Changed components
bitcoin_client/ledger_bitcoin/btchip/btchip.pybitcoin_client/ledger_bitcoin/client_legacy.pyInspect captured patch +49 / −17
diff --git a/bitcoin_client/ledger_bitcoin/btchip/btchip.py b/bitcoin_client/ledger_bitcoin/btchip/btchip.py
index 65a54c6..c84668f 100644
--- a/bitcoin_client/ledger_bitcoin/btchip/btchip.py
+++ b/bitcoin_client/ledger_bitcoin/btchip/btchip.py
@@ -56,6 +56,9 @@ class btchip:
BTCHIP_INS_GET_FIRMWARE_VERSION = 0xc4
BTCHIP_INS_COMPOSE_MOFN_ADDRESS = 0xc6
BTCHIP_INS_GET_POS_SEED = 0xca
+ # Introduced in app-bitcoin (legacy) v2.4.11 to provide a way to fetch
+ # the master key fingerprint after path derivation hardening applied
+ BTCHIP_INS_GET_MASTER_FINGERPRINT = 0xd0
BTCHIP_INS_EXT_GET_HALF_PUBLIC_KEY = 0x20
BTCHIP_INS_EXT_CACHE_PUT_PUBLIC_KEY = 0x22
@@ -63,7 +66,7 @@ class btchip:
BTCHIP_INS_EXT_CACHE_GET_FEATURES = 0x26
OPERATION_MODE_WALLET = 0x01
- OPERATION_MODE_RELAXED_WALLET = 0x02
+ OPERATION_MODE_RELAXED_WALLET = 0x02
OPERATION_MODE_SERVER = 0x04
OPERATION_MODE_DEVELOPER = 0x08
@@ -87,13 +90,13 @@ class btchip:
else:
self.scriptBlockLength = 255
except Exception:
- pass
+ pass
def getWalletPublicKey(self, path, showOnScreen=False, segwit=False, segwitNative=False, cashAddr=False):
result = {}
donglePath = parse_bip32_path(path)
if self.needKeyCache:
- self.resolvePublicKeysInPath(path)
+ self.resolvePublicKeysInPath(path)
apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_GET_WALLET_PUBLIC_KEY, 0x01 if showOnScreen else 0x00, 0x03 if cashAddr else 0x02 if segwitNative else 0x01 if segwit else 0x00, len(donglePath) ]
apdu.extend(donglePath)
response = self.dongle.exchange(bytearray(apdu))
@@ -237,14 +240,14 @@ class btchip:
if len(script) == 0:
apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_INPUT_START, 0x80, 0x00, len(sequence) ]
apdu.extend(sequence)
- self.dongle.exchange(bytearray(apdu))
+ self.dongle.exchange(bytearray(apdu))
currentIndex += 1
def finalizeInput(self, outputAddress, amount, fees, changePath, rawTx=None):
alternateEncoding = False
donglePath = parse_bip32_path(changePath)
if self.needKeyCache:
- self.resolvePublicKeysInPath(changePath)
+ self.resolvePublicKeysInPath(changePath)
result = {}
outputs = None
if rawTx is not None:
@@ -291,7 +294,7 @@ class btchip:
if result['confirmationType'] == 0x02:
result['keycardData'] = response[1 + response[0] + 1:]
if result['confirmationType'] == 0x03:
- offset = 1 + response[0] + 1
+ offset = 1 + response[0] + 1
keycardDataLength = response[offset]
offset = offset + 1
result['keycardData'] = response[offset : offset + keycardDataLength]
@@ -300,7 +303,7 @@ class btchip:
if result['confirmationType'] == 0x04:
offset = 1 + response[0] + 1
keycardDataLength = response[offset]
- result['keycardData'] = response[offset + 1 : offset + 1 + keycardDataLength]
+ result['keycardData'] = response[offset + 1 : offset + 1 + keycardDataLength]
if outputs == None:
result['outputData'] = response[1 : 1 + response[0]]
else:
@@ -312,7 +315,7 @@ class btchip:
pin = pin.encode('utf-8')
donglePath = parse_bip32_path(path)
if self.needKeyCache:
- self.resolvePublicKeysInPath(path)
+ self.resolvePublicKeysInPath(path)
apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_SIGN, 0x00, 0x00 ]
params = []
params.extend(donglePath)
@@ -329,7 +332,7 @@ class btchip:
def signMessagePrepareV1(self, path, message):
donglePath = parse_bip32_path(path)
if self.needKeyCache:
- self.resolvePublicKeysInPath(path)
+ self.resolvePublicKeysInPath(path)
result = {}
apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_SIGN_MESSAGE, 0x00, 0x00 ]
params = []
@@ -350,7 +353,7 @@ class btchip:
def signMessagePrepareV2(self, path, message):
donglePath = parse_bip32_path(path)
if self.needKeyCache:
- self.resolvePublicKeysInPath(path)
+ self.resolvePublicKeysInPath(path)
result = {}
offset = 0
encryptedOutputData = b""
@@ -379,8 +382,8 @@ class btchip:
result['confirmationType'] = response[1 + response[0]]
if result['confirmationType'] == 0x03:
offset = 1 + response[0] + 1
- result['secureScreenData'] = response[offset:]
- result['encryptedOutputData'] = encryptedOutputData
+ result['secureScreenData'] = response[offset:]
+ result['encryptedOutputData'] = encryptedOutputData
return result
@@ -444,3 +447,15 @@ class btchip:
result['patch_version'] = response[4]
result['specialVersion'] = response[1]
return result
+
+ def getMasterFingerprint(self):
+ """Returns the 4-byte master key fingerprint.
+
+ Uses the INS_GET_MASTER_FINGERPRINT APDU which internally
+ calls os_perso_get_master_key_identifier() to compute
+ RIPEMD160(SHA256(compressed_master_pubkey)) and returns the first
+ 4 bytes, without requiring DERIVE_MASTER permission.
+ """
+ apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_GET_MASTER_FINGERPRINT, 0x00, 0x00, 0x00 ]
+ response = self.dongle.exchange(bytearray(apdu))
+ return bytes(response[0:4])
diff --git a/bitcoin_client/ledger_bitcoin/client_legacy.py b/bitcoin_client/ledger_bitcoin/client_legacy.py
index 1f21a92..d3cd33b 100644
--- a/bitcoin_client/ledger_bitcoin/client_legacy.py
+++ b/bitcoin_client/ledger_bitcoin/client_legacy.py
@@ -80,6 +80,20 @@ class LegacyClient(Client):
if self.app.getAppName() not in ["Bitcoin", "Bitcoin Legacy", "Bitcoin Test", "Bitcoin Test Legacy", "app"]:
raise ValueError("Ledger is not in either the Bitcoin or Bitcoin Testnet app")
+
+ def _get_master_fingerprint(self) -> bytes:
+ """Returns the 4-byte master key fingerprint.
+
+ Tries the dedicated GET_MASTER_FINGERPRINT APDU first (INS 0xD0).
+ If the device does not support it (old firmware or Nano S), falls
+ back to getWalletPublicKey("") + client-side HASH160.
+ """
+ try:
+ return self.app.getMasterFingerprint()
+ except Exception:
+ master_pubkey = self.app.getWalletPublicKey("")
+ return hash160(compress_public_key(master_pubkey["publicKey"]))[:4]
+
def get_extended_pubkey(self, path: str, display: bool = False) -> str:
# mostly taken from HWI
@@ -97,8 +111,12 @@ class LegacyClient(Client):
parent_path = parent_path[:-1]
# Get parent key fingerprint
- parent = self.app.getWalletPublicKey(parent_path)
- fpr = hash160(compress_public_key(parent["publicKey"]))[:4]
+ if len(parent_path) == 0:
+ # Root level: use dedicated APDU instead of deriving master key
+ fpr = self._get_master_fingerprint()
+ else:
+ parent = self.app.getWalletPublicKey(parent_path)
+ fpr = hash160(compress_public_key(parent["publicKey"]))[:4]
child = int_path[-1]
# Special case for m
@@ -177,7 +195,7 @@ class LegacyClient(Client):
tx_bytes = c_tx.serialize_with_witness()
# Master key fingerprint
- master_fpr = hash160(compress_public_key(self.app.getWalletPublicKey('')["publicKey"]))[:4]
+ master_fpr = self._get_master_fingerprint()
# An entry per input, each with 0 to many keys to sign with
all_signature_attempts: List[List[Tuple[str, bytes]]] = [[]] * len(c_tx.vin)
@@ -328,8 +346,7 @@ class LegacyClient(Client):
return result
def get_master_fingerprint(self) -> bytes:
- master_pubkey = self.app.getWalletPublicKey("")
- return hash160(compress_public_key(master_pubkey["publicKey"]))[:4]
+ return self._get_master_fingerprint()
def sign_message(self, message: Union[str, bytes], keypath: str) -> str:
# copied verbatim from HWI
Why this scored 18/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.