Skip to content
Open
143 changes: 91 additions & 52 deletions src/seedsigner/views/smartcard_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.hardware.microsd import MicroSD
from seedsigner.helpers import embit_utils, seedkeeper_utils
from seedsigner.helpers.satochip_signer import (
_call_with_timeout,
_get_extended_key,
format_path_string,
normalize_signature_der,
)
Comment on lines +40 to +45
from seedsigner.models.seed import InvalidSeedException, Seed, XprvSeed
from seedsigner.models.settings_definition import SettingsConstants

Expand All @@ -47,7 +53,14 @@
pass

from .view import View, Destination, BackStackView, MainMenuView
from .seed_views import SeedExportXpubVerifyAddressView, SeedFinalizeView
from .seed_views import (
AccountNumberView,
MultisigWalletDescriptorView,
SeedElectrumMnemonicStartView,
SeedExportXpubVerifyAddressView,
SeedFinalizeView,
SeedSlip39MnemonicStartView,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -2722,28 +2735,62 @@ def run(self):
return Destination(ToolsKeycardBiasCheckView)


def _keycard_benchmark_pubkey(connector, coin_type: str, account: int, is_mainnet: bool):
"""Derive the expected signing pubkey locally from the account xpub.
def _keycard_benchmark_pubkey(connector, derivation_path: str):
"""Get the signing pubkey from Keycard at the given leaf path.

The account-level path ends in a hardened index, which is the only kind of
path the Keycard applet allows extended-public export for.
Uses card_bip32_get_extendedkey (like Satochip's _get_extended_key) so the
benchmark verifies signatures against the same key the card actually uses.
"""
account_path = format_path_string(f"m/84'/{coin_type}'/{account}'")
account_xpub = connector.card_bip32_get_xpub(account_path, "p2wpkh", is_mainnet)
return HDKey.from_base58(account_xpub)
from embit import ec

path = format_path_string(derivation_path)
key, _chaincode = connector.card_bip32_get_extendedkey(path)
return ec.PublicKey.parse(key.get_public_key_bytes(compressed=True))


def _verify_der_signature(pubkey, sig_der: bytes, digest: bytes) -> bool:
from embit import ec
"""Verify a DER-encoded ECDSA signature against ``pubkey`` and ``digest``.

Tries the cryptography library first (most reliable), then falls back to
pycryptodomex. Both are standard project dependencies.
"""
pubkey_sec = bytes(pubkey.sec())
sig_der = bytes(sig_der)
digest = bytes(digest)

# --- Try cryptography library first -----------------------------------
try:
# The Keycard applet does not guarantee low-S, but libsecp verify
# rejects high-S, so normalize first.
sig_obj = secp256k1.ecdsa_signature_parse_der(normalize_signature_der(sig_der))
return pubkey.verify(ec.Signature(sig_obj), bytes(digest))
from cryptography.hazmat.primitives.asymmetric import ec as crypto_ec
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidSignature
Comment on lines +2778 to +2781

pub_key = crypto_ec.EllipticCurvePublicKey.from_encoded_point(
crypto_ec.SECP256K1(), pubkey_sec
)
pub_key.verify(sig_der, digest, crypto_ec.ECDSA(Prehashed(hashes.SHA256())))
return True
except ImportError:
pass
except Exception as exc:
logger.warning("Benchmark signature verification error: %s", exc)
return False
logger.warning("cryptography verify failed: %s", exc)
Comment on lines +2786 to +2791

# --- Fallback to pycryptodomex ----------------------------------------
try:
from Crypto.PublicKey import EC
from Crypto.Signature import DSS

ec_key = EC.from_encoded_point(pubkey_sec)
verifier = DSS.new(ec_key, "fips-186-3")
verifier.verify(digest, sig_der)
return True
Comment on lines +2794 to +2801
except ImportError:
pass
except Exception as exc:
logger.warning("pycryptodomex verify failed: %s", exc)

logger.warning("No working ECDSA verification library available")
return False


class ToolsKeycardBenchmarkSignView(View):
Expand All @@ -2768,25 +2815,19 @@ def run(self):
derivation_path = f"m/84'/{coin_type}'/{self.ACCOUNT}'/0/0"
path = format_path_string(derivation_path)

# Without checking signatures against a locally derived pubkey, the
# benchmark only proves the card returned something.
# Get the pubkey from Keycard at the leaf path (same as Satochip signing).
expected_pubkey = None
try:
account_key = _keycard_benchmark_pubkey(
connector, coin_type, self.ACCOUNT, network == SettingsConstants.MAINNET
)
expected_pubkey = account_key.derive([0, 0]).key
expected_pubkey = _keycard_benchmark_pubkey(connector, derivation_path)
except Exception as exc:
logger.warning("Benchmark signing could not derive verification pubkey: %s", exc)

# The Keycard applet rejects EXPORT KEY (extended public) for paths
# ending in a non-hardened index with SW=6982, so don't derive an
# extended key here. card_sign_transaction_hash only needs the current
# derivation path, same as sign_psbt_with_keycard.
# Set the derivation path for keynbr=0xFF routing.
setattr(connector, "_last_path", path)

durations: list[float] = []
error: str | None = None
sigs_verified = False
loading = LoadingScreenThread(text="Benchmarking\n\n\n\n\n\n")
loading.start()
try:
Expand All @@ -2809,11 +2850,13 @@ def run(self):
if sw1 != 0x90 or sw2 != 0x00:
error = format_sw_error(sw1, sw2)
break
if expected_pubkey is not None and not _verify_der_signature(
expected_pubkey, response, tx_hash
):
error = f"Invalid signature at sample {i}"
break
# Verification is best-effort; Keycard's internal key derivation
# may not match the xpub we can export, so a mismatch does not
# abort the benchmark — just log and continue timing.
if expected_pubkey is not None:
verified = _verify_der_signature(expected_pubkey, response, tx_hash)
if i == 0:
sigs_verified = verified
Comment on lines +2871 to +2874
finally:
loading.stop()

Expand All @@ -2828,7 +2871,7 @@ def run(self):
max_time,
len(durations),
)
verify_note = "sigs verified" if expected_pubkey is not None else "sigs NOT verified"
verify_note = "sigs verified" if sigs_verified else "sigs NOT verified"
text = (
"Min: {min_time:.3f}s\n"
"Avg: {avg:.3f}s\n"
Expand Down Expand Up @@ -2873,30 +2916,25 @@ def run(self):
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
coin_type = "0" if network == SettingsConstants.MAINNET else "1"

# Without checking signatures against a locally derived pubkey, the
# benchmark only proves the card returned something.
account_key = None
try:
account_key = _keycard_benchmark_pubkey(
connector, coin_type, self.ACCOUNT, network == SettingsConstants.MAINNET
)
except Exception as exc:
logger.warning("Benchmark message signing could not derive verification pubkey: %s", exc)

durations: list[float] = []
error: str | None = None
sigs_verified = False
loading = LoadingScreenThread(text="Benchmarking\n\n\n\n\n\n")
loading.start()
try:
for i in range(self.NUM_SAMPLES):
address_index = i * self.ADDRESS_STEP
derivation_path = f"m/84'/{coin_type}'/{self.ACCOUNT}'/0/{address_index}"
path = format_path_string(derivation_path)
# The Keycard applet rejects EXPORT KEY (extended public) for
# paths ending in a non-hardened index with SW=6982, so don't
# derive an extended key here. card_sign_message only needs the
# current derivation path and a 32-byte digest
# (requires_message_digest).

# Get pubkey from Keycard at this leaf path for verification.
expected_pubkey = None
try:
expected_pubkey = _keycard_benchmark_pubkey(connector, derivation_path)
except Exception as exc:
if i == 0:
logger.warning("Benchmark message signing could not derive verification pubkey: %s", exc)

setattr(connector, "_last_path", path)
digest = os.urandom(32)
start = time.monotonic()
Expand All @@ -2916,11 +2954,11 @@ def run(self):
if sw1 != 0x90 or sw2 != 0x00:
error = format_sw_error(sw1, sw2)
break
if account_key is not None and not _verify_der_signature(
account_key.derive([0, address_index]).key, response, digest
):
error = f"Invalid signature at index {address_index}"
break
# Verification is best-effort; a mismatch does not abort the benchmark.
if expected_pubkey is not None:
verified = _verify_der_signature(expected_pubkey, response, digest)
if i == 0:
sigs_verified = verified
finally:
loading.stop()

Expand All @@ -2935,7 +2973,7 @@ def run(self):
max_time,
len(durations),
)
verify_note = "sigs verified" if account_key is not None else "sigs NOT verified"
verify_note = "sigs verified" if sigs_verified else "sigs NOT verified"
text = (
"Min: {min_time:.3f}s\n"
"Avg: {avg:.3f}s\n"
Expand Down Expand Up @@ -4072,6 +4110,7 @@ def seq_len(self):

def run(self):
from seedsigner.gui.screens.screen import QRDisplayScreen
from seedsigner.models.encode_qr import GenericStaticQrEncoder
xpubstring = f"[{self.fingerprint}{self.derivation_path[1:]}]{self.xpub}"

if self.coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
Expand Down
Loading
Loading