Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/gui/screens/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
from .mnemonic import MnemonicScreen, NewMnemonicScreen, RecoverMnemonicScreen
from .transaction import TransactionScreen
from .settings import DevSettings
from .debug_info import DebugInfoScreen
80 changes: 80 additions & 0 deletions src/gui/screens/debug_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Debug info screen shown during multi-keystore detection.

Displays firmware version, card presence, and detected applets
while polling for an available keystore in select_keystore().
"""
import lvgl as lv
from .screen import Screen
from ..common import add_label
from ..core import update


class DebugInfoScreen(Screen):
"""Shows firmware version, card presence, and detected applets."""

def __init__(self):
super().__init__()
self.title = add_label("Specter Debug Info", scr=self, style="title")

self.version_label = add_label("", scr=self, style="hint")
self.version_label.align(self.title, lv.ALIGN.OUT_BOTTOM_MID, 0, 20)

self.card_label = add_label("Card: checking...", scr=self, style="small")
self.card_label.align(self.version_label, lv.ALIGN.OUT_BOTTOM_MID, 0, 30)

self.applets_label = add_label("Applets: --", scr=self, style="small")
self.applets_label.align(self.card_label, lv.ALIGN.OUT_BOTTOM_MID, 0, 20)

self.status_label = add_label("", scr=self, style="hint")
self.status_label.align(self.applets_label, lv.ALIGN.OUT_BOTTOM_MID, 0, 20)

self.hint_label = add_label("Waiting for keystore...", scr=self, style="hint")
self.hint_label.set_y(700)

self._set_firmware_info()

def load(self):
lv.scr_load(self)
update()

def _set_firmware_info(self):
try:
from platform import get_git_info, get_version
repo, branch, commit = get_git_info()
ver = get_version()
lines = "Firmware: %s" % ver
if branch != "unknown":
lines += "\nBranch: %s" % branch
if commit != "unknown":
lines += "\nCommit: %s" % commit
self.version_label.set_text(lines)
except Exception:
self.version_label.set_text("Firmware: unknown")

def update_info(self, info: dict) -> None:
if not info:
info = {}

card_present = info.get("card_present", False)
applets = info.get("applets", [])
status = info.get("status", "")

if not isinstance(applets, (list, tuple)):
applets = []

if card_present:
self.card_label.set_text("Card: present")
else:
self.card_label.set_text("Card: not detected")

if applets:
self.applets_label.set_text("Applets:\n" + "\n".join([" - " + str(a) for a in applets]))
else:
self.applets_label.set_text("Applets: (none detected)")

if status:
self.status_label.set_text(str(status))
else:
self.status_label.set_text("")

update()
127 changes: 127 additions & 0 deletions src/hil.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,31 @@ def _process_line(self, line):
self._wipe_storage()
return

# TEST_SECRETS - list BIP39 secret IDs and labels from SeedKeeper
if line == "TEST_SECRETS":
self._list_secrets()
return

# TEST_ALL_SECRETS - list ALL secrets (including descriptors, passwords, etc.)
if line == "TEST_ALL_SECRETS":
self._list_all_secrets()
return

# TEST_IMPORT_SECRET:<hex_data>[:<label>] - import BIP39 secret to card
if line.startswith("TEST_IMPORT_SECRET:"):
self._import_secret(line[len("TEST_IMPORT_SECRET:"):])
return

# TEST_DELETE_SECRET:<sid> - delete secret by ID
if line.startswith("TEST_DELETE_SECRET:"):
self._delete_secret(line[len("TEST_DELETE_SECRET:"):])
return

# TEST_CARD_RESET - power cycle the smartcard (disconnect + reconnect)
if line == "TEST_CARD_RESET":
self._card_reset()
return

# TEST_FINGERPRINT - get current keystore fingerprint
if line == "TEST_FINGERPRINT":
self._get_fingerprint()
Expand Down Expand Up @@ -284,3 +309,105 @@ def _get_mnemonic(self):
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:MNEMONIC_FAIL")

def _list_secrets(self):
try:
ks = _get_keystore()
if ks is None or not hasattr(ks, 'applet'):
self._respond("ERR:NO_SEEDKEEPER")
return
headers = ks.applet.list_secret_headers()
bip39 = [
h for h in headers
if h['type'] in (0x10, 0x30, 0x31)
and (h['type'] != 0x10 or h.get('subtype') == 1)
]
parts = []
for h in bip39:
label = h.get('label', '')
if not isinstance(label, str) or len(label) == 0:
label = 'Secret #%d' % h['id']
fp = h.get('fingerprint', '????????')
parts.append("%d:%s:%s" % (h['id'], label, fp))
self._respond("OK:SECRETS:%s" % ",".join(parts))
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:SECRETS_FAIL")

def _list_all_secrets(self):
try:
ks = _get_keystore()
if ks is None or not hasattr(ks, 'applet'):
self._respond("ERR:NO_SEEDKEEPER")
return
headers = ks.applet.list_secret_headers()
type_names = {
0x10: "MASTERSEED", 0x30: "BIP39", 0x31: "BIP39v2",
0x40: "ELECTRUM", 0x90: "PASSWORD", 0xC0: "DATA",
0xC1: "DESCRIPTOR",
}
parts = []
for h in headers:
label = h.get('label', '')
if not isinstance(label, str) or len(label) == 0:
label = 'Secret #%d' % h['id']
tname = type_names.get(h['type'], "0x%02x" % h['type'])
fp = h.get('fingerprint', '????????')
parts.append("%d:%s:%s:%s" % (h['id'], tname, label, fp))
self._respond("OK:ALL_SECRETS:%s" % ",".join(parts))
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:ALL_SECRETS_FAIL")

def _import_secret(self, args):
try:
from binascii import unhexlify
ks = _get_keystore()
if ks is None or not hasattr(ks, 'applet'):
self._respond("ERR:NO_SEEDKEEPER")
return
parts = args.split(":", 1)
hex_data = parts[0].strip()
label = parts[1].strip() if len(parts) > 1 else ""
secret_data = unhexlify(hex_data)
sid, fp = ks.applet.import_secret(secret_data, secret_type=0x30, label=label)
self._respond("OK:IMPORT:%d:%s" % (sid, fp))
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:IMPORT_FAIL:%s" % str(e))

def _delete_secret(self, sid_str):
try:
ks = _get_keystore()
if ks is None or not hasattr(ks, 'applet'):
self._respond("ERR:NO_SEEDKEEPER")
return
sid = int(sid_str.strip())
ks.applet.delete_secret(sid)
self._respond("OK:DELETED:%d" % sid)
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:DELETE_FAIL:%s" % str(e))

def _card_reset(self):
try:
import time as _time
from keystore.javacard.util import get_connection
ks = _get_keystore()
conn = get_connection()
try:
conn.disconnect()
except Exception:
pass
_time.sleep_ms(500)
conn.connect(conn.T1_protocol)
if ks is not None and hasattr(ks, 'applet'):
ks.applet.select()
ks.applet.init_secure_channel()
ks.applet.verify_pin(ks._last_pin or "1234")
ks.connected = True
ks._pin_unlocked = True
self._respond("OK:CARD_RESET")
except Exception as e:
log_exception("HIL", e)
self._respond("ERR:CARD_RESET_FAIL:%s" % str(e))
144 changes: 144 additions & 0 deletions src/keystore/javacard/applets/satochip_securechannel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Satochip Secure Channel - Crypto primitives for SeedKeeper and Satochip applets.

Implements ECDH key exchange, AES-CBC encryption, and HMAC-SHA1 authentication.
This is the Satochip/SeedKeeper protocol, distinct from MemoryCard's SecureChannel
(which uses HMAC-SHA256 and a different key derivation scheme).
"""

import hashlib
import secp256k1
from ucryptolib import aes
from rng import get_random_bytes


AES_BLOCK = 16


def hmac_sha1(key: bytes, msg: bytes) -> bytes:
BLOCK_SIZE = 64
if len(key) > BLOCK_SIZE:
key = hashlib.sha1(key).digest()
elif len(key) < BLOCK_SIZE:
key = key + b'\x00' * (BLOCK_SIZE - len(key))
ipad = b'\x36' * BLOCK_SIZE
opad = b'\x5c' * BLOCK_SIZE
key_ipad = bytes(a ^ b for a, b in zip(key, ipad))
key_opad = bytes(a ^ b for a, b in zip(key, opad))
inner_hash = hashlib.sha1(key_ipad + msg).digest()
return hashlib.sha1(key_opad + inner_hash).digest()


def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
pad_len = block_size - (len(data) % block_size)
return data + bytes([pad_len] * pad_len)


def pkcs7_unpad(data: bytes) -> bytes:
padding_len = data[-1]
if padding_len == 0:
raise ValueError("Invalid PKCS#7 padding")
for i in range(1, padding_len + 1):
if data[-i] != padding_len:
raise ValueError("Invalid PKCS#7 padding")
return data[:-padding_len]


class SatochipSecureChannel:
"""Secure channel for Satochip/SeedKeeper JavaCard applets.

ECDH key exchange with AES-CBC encryption and HMAC-SHA1 authentication.
"""

def __init__(self):
self.aes_key = None
self.mac_key = None
self.iv_counter = 1
self.is_initialized = False
self.card_pubkey = None

def initiate(self, connection, cla=0xB0):
"""Perform ECDH key exchange via INS 0x81."""
try:
from platform import hil_test_mode
except Exception:
hil_test_mode = False
secret = get_random_bytes(32)
if hil_test_mode:
from debug_trace import log
log("SC", "ECDH: generating pubkey...")
pubkey = secp256k1.ec_pubkey_create(secret)
pub_bytes = secp256k1.ec_pubkey_serialize(pubkey, secp256k1.EC_UNCOMPRESSED)

apdu = bytes([cla, 0x81, 0x00, 0x00, 0x41]) + pub_bytes
if hil_test_mode:
log("SC", "ECDH: transmitting %d bytes..." % len(apdu))
data = connection.transmit(apdu)
if hil_test_mode:
log("SC", "ECDH: got response, len=%d" % len(data))
resp_data = data[0]
sw1, sw2 = data[1], data[2]
if hil_test_mode:
log("SC", "ECDH: SW=%02X%02X" % (sw1, sw2))
if sw1 != 0x90 or sw2 != 0x00:
raise ValueError('INIT_SC failed: SW={:02X}{:02X}'.format(sw1, sw2))

if hil_test_mode:
log("SC", "ECDH: parsing card pubkey...")
coordx_size = (resp_data[0] << 8) | resp_data[1]
coordx = bytes(resp_data[2:2 + coordx_size])

card_pubkey_compressed = bytes([0x02]) + coordx

card_pubkey = secp256k1.ec_pubkey_parse(card_pubkey_compressed)
self.card_pubkey = card_pubkey

shared_point = secp256k1.ec_pubkey_parse(
secp256k1.ec_pubkey_serialize(card_pubkey)
)
secp256k1.ec_pubkey_tweak_mul(shared_point, secret)
shared_bytes = secp256k1.ec_pubkey_serialize(shared_point, secp256k1.EC_UNCOMPRESSED)
shared_secret = shared_bytes[1:33]

self.aes_key = hmac_sha1(shared_secret, b'sc_key')[:16]
self.mac_key = hmac_sha1(shared_secret, b'sc_mac')
self.is_initialized = True
if hil_test_mode:
log("SC", "ECDH: secure channel initialized")

def reset(self):
self.aes_key = None
self.mac_key = None
self.iv_counter = 1
self.is_initialized = False
self.card_pubkey = None

def encrypt_apdu(self, inner_apdu: bytes, cla=0xB0) -> bytes:
if not self.is_initialized:
raise ValueError("Secure channel not initialized")

iv = get_random_bytes(12) + self.iv_counter.to_bytes(4, 'big')
if iv[15] % 2 == 0:
iv = iv[:15] + bytes([iv[15] | 0x01])

padded = pkcs7_pad(inner_apdu, AES_BLOCK)
cipher = aes(self.aes_key, 2, iv)
ciphertext = cipher.encrypt(padded)

mac_data = iv + len(ciphertext).to_bytes(2, 'big') + ciphertext
mac = hmac_sha1(self.mac_key, mac_data)

payload = iv + len(ciphertext).to_bytes(2, 'big') + ciphertext + (20).to_bytes(2, 'big') + mac
wrapped = bytes([cla, 0x82, 0x00, 0x00, len(payload)]) + payload

self.iv_counter += 2
return wrapped

def decrypt_response(self, encrypted_response: bytes) -> bytes:
card_iv = encrypted_response[:16]
data_size = int.from_bytes(encrypted_response[16:18], 'big')
ciphertext = encrypted_response[18:18 + data_size]

cipher = aes(self.aes_key, 2, card_iv)
Comment on lines +138 to +142
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Verify MAC before decrypting secure-channel responses

Response handling decrypts ciphertext immediately and never validates any message authentication tag, even though request wrapping includes a MAC. With no integrity check, a faulty or malicious card/link can alter encrypted response bytes and, if padding remains syntactically valid, the device may accept corrupted secret material (including mnemonic data) without detection.

Useful? React with 👍 / 👎.

plaintext = cipher.decrypt(ciphertext)
return pkcs7_unpad(plaintext)
Loading
Loading