Expand detection of possible multisig descriptors & Add data input parsing CI tests (#301)
What changed, and why it matters
This commit widens the patterns that Specter DIY recognizes as a multisig wallet descriptor, so more valid wallet imports are correctly routed to the 'add wallet' flow instead of being misidentified or rejected. It also adds automated tests for parsing different kinds of incoming data. There is no direct evidence of an exploitable vulnerability being fixed; it reads as a robustness and test-coverage improvement.
Treat as a normal quality/test improvement. Reviewers may want to confirm that the new descriptor markers cannot accidentally classify unrelated user input (e.g., arbitrary text containing 'sh(') as a wallet descriptor, and that parse_wallet still validates the descriptor before use.
Security signals we found
Input classification heuristic broadened in wallet manager
New parsing tests added for descriptors, PSBTs, and address requests
CI workflow introduced to run native unit tests
No changes to cryptographic code, authorization, or secret handling
Evidence from the diff
The core change is in src/apps/wallets/manager.py: the heuristic that decides whether incoming data is a wallet descriptor now looks for ‘tr(‘, ‘wsh(‘, and ‘sh(’ in addition to the existing ‘&’ marker, while still excluding data containing ‘?’. The rest of the commit is test infrastructure: a GitHub Actions CI workflow, Makefile compiler-flag tweaks, README test instructions, and native (CPython) stubs plus new unit tests for descriptor/PSBT/address parsing. The patch does not alter descriptor validation or cryptographic handling; it only changes classification of incoming byte streams.
Changed components
src/apps/wallets/manager.pytest/native_support.pytest/run_native_tests.pytest/tests_native/test_wallet_manager_parsing.py.github/workflows/test.ymlMakefileREADME.mdInspect captured patch +398 / −25
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..fada6cd
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ native-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Install dependencies
+ run: |
+ python3 -m pip install --upgrade pip
+ pip install -r requirements.txt -r test/integration/requirements.txt
+ - name: Run native unit tests
+ working-directory: test
+ run: |
+ python3 run_native_tests.py
+ python3 -m compileall ../src ../test
+
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Install dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential \
+ libffi-dev \
+ libgmp-dev \
+ libreadline-dev \
+ libsdl2-dev \
+ pkg-config \
+ python3
+ - name: Run tests
+ run: make test
diff --git a/Makefile b/Makefile
index f9b123b..876c3ef 100644
--- a/Makefile
+++ b/Makefile
@@ -3,6 +3,7 @@ BOARD ?= STM32F469DISC
FLAVOR ?= SPECTER
USER_C_MODULES ?= ../../../usermods
MPY_DIR ?= f469-disco/micropython
+MPY_CFLAGS ?= -Wno-dangling-pointer -Wno-enum-int-mismatch
FROZEN_MANIFEST_DISCO ?= ../../../../manifests/disco.py
FROZEN_MANIFEST_DEBUG ?= ../../../../manifests/debug.py
FROZEN_MANIFEST_UNIX ?= ../../../../manifests/unix.py
@@ -20,55 +21,59 @@ $(MPY_DIR)/mpy-cross/Makefile:
mpy-cross: $(TARGET_DIR) $(MPY_DIR)/mpy-cross/Makefile
@echo Building cross-compiler
make -C $(MPY_DIR)/mpy-cross \
- DEBUG=$(DEBUG) && \
+ DEBUG=$(DEBUG) \
+ CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
cp $(MPY_DIR)/mpy-cross/mpy-cross $(TARGET_DIR)
# disco board with bitcoin library
disco: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32
@echo Building firmware
make -C $(MPY_DIR)/ports/stm32 \
- BOARD=$(BOARD) \
- FLAVOR=$(FLAVOR) \
- USE_DBOOT=$(USE_DBOOT) \
- USER_C_MODULES=$(USER_C_MODULES) \
- FROZEN_MANIFEST=$(FROZEN_MANIFEST_DISCO) \
- DEBUG=$(DEBUG) && \
+ BOARD=$(BOARD) \
+ FLAVOR=$(FLAVOR) \
+ USE_DBOOT=$(USE_DBOOT) \
+ USER_C_MODULES=$(USER_C_MODULES) \
+ FROZEN_MANIFEST=$(FROZEN_MANIFEST_DISCO) \
+ DEBUG=$(DEBUG) \
+ CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
arm-none-eabi-objcopy -O binary \
- $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.elf \
- $(TARGET_DIR)/specter-diy.bin && \
- cp $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.hex \
- $(TARGET_DIR)/specter-diy.hex
+ $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.elf \
+ $(TARGET_DIR)/specter-diy.bin && \
+ cp $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.hex \
+ $(TARGET_DIR)/specter-diy.hex
# disco board with bitcoin library
debug: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/stm32
@echo Building firmware
make -C $(MPY_DIR)/ports/stm32 \
- BOARD=$(BOARD) \
- FLAVOR=$(FLAVOR) \
- USE_DBOOT=$(USE_DBOOT) \
- USER_C_MODULES=$(USER_C_MODULES) \
- FROZEN_MANIFEST=$(FROZEN_MANIFEST_DEBUG) \
- DEBUG=$(DEBUG) && \
+ BOARD=$(BOARD) \
+ FLAVOR=$(FLAVOR) \
+ USE_DBOOT=$(USE_DBOOT) \
+ USER_C_MODULES=$(USER_C_MODULES) \
+ FROZEN_MANIFEST=$(FROZEN_MANIFEST_DEBUG) \
+ DEBUG=$(DEBUG) \
+ CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
arm-none-eabi-objcopy -O binary \
- $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.elf \
- $(TARGET_DIR)/debug.bin && \
+ $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.elf \
+ $(TARGET_DIR)/debug.bin && \
cp $(MPY_DIR)/ports/stm32/build-STM32F469DISC/firmware.hex \
- $(TARGET_DIR)/debug.hex
+ $(TARGET_DIR)/debug.hex
# unixport (simulator)
unix: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/unix
@echo Building binary with frozen files
make -C $(MPY_DIR)/ports/unix \
- USER_C_MODULES=$(USER_C_MODULES) \
- FROZEN_MANIFEST=$(FROZEN_MANIFEST_UNIX) && \
+ USER_C_MODULES=$(USER_C_MODULES) \
+ FROZEN_MANIFEST=$(FROZEN_MANIFEST_UNIX) \
+ CFLAGS_EXTRA="$(MPY_CFLAGS)" && \
cp $(MPY_DIR)/ports/unix/micropython $(TARGET_DIR)/micropython_unix
simulate: unix
$(TARGET_DIR)/micropython_unix simulate.py
test: unix
- $(TARGET_DIR)/micropython_unix tests/run_tests.py
+ cd test && ../$(TARGET_DIR)/micropython_unix run_tests.py
all: mpy-cross disco unix
diff --git a/README.md b/README.md
index 519bf28..43cde9d 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,20 @@ Specter Shield-Lite documentation is available in the [`shield-lite/`](./shield-
Supported networks: Mainnet, Testnet, Regtest, Signet.
+## Running tests
+
+The unit test suite runs on the Unix simulator build. Install the required
+system packages and then run the `make` target:
+
+```
+sudo apt-get update
+sudo apt-get install libsdl2-dev libffi-dev pkg-config libreadline-dev libgmp-dev build-essential python3
+make test
+```
+
+The build system will fetch the necessary submodules and compile the simulator
+before executing the tests.
+
## USB communication on Linux
You may need to set up udev rules and add yourself to `dialout` group. Read more in [`udev`](./udev/README.md) folder.
diff --git a/src/apps/wallets/manager.py b/src/apps/wallets/manager.py
index cdfaa94..d924be3 100644
--- a/src/apps/wallets/manager.py
+++ b/src/apps/wallets/manager.py
@@ -175,7 +175,10 @@ class WalletManager(BaseApp):
except:
pass
# probably wallet descriptor
- if b"&" in data and b"?" not in data:
+ # Check for an & Symbol, typically used when descriptor supplied with a name
+ # Also check the most common descriptor types for multisig wallets
+ common_descriptor_markers = [b"&", b"tr(", b"wsh(", b"sh("]
+ if any(marker in data for marker in common_descriptor_markers) and b"?" not in data:
# rewind
stream.seek(0)
return ADD_WALLET, stream
diff --git a/test/native_support.py b/test/native_support.py
new file mode 100644
index 0000000..8535506
--- /dev/null
+++ b/test/native_support.py
@@ -0,0 +1,208 @@
+import os
+import sys
+import types
+
+
+def _ensure_module(name):
+ mod = sys.modules.get(name)
+ if mod is None:
+ mod = types.ModuleType(name)
+ sys.modules[name] = mod
+ return mod
+
+
+def _ensure_submodule(package, name, attrs):
+ full_name = f"{package}.{name}"
+ module = _ensure_module(full_name)
+ for attr, value in attrs.items():
+ if not hasattr(module, attr):
+ setattr(module, attr, value)
+ parent = _ensure_module(package)
+ if not hasattr(parent, "__path__"):
+ parent.__path__ = []
+ setattr(parent, name, module)
+ return module
+
+
+def setup_native_stubs():
+ if sys.implementation.name == 'micropython':
+ return
+
+ if not hasattr(os, "ilistdir"):
+ def _ilistdir(path):
+ for entry in os.scandir(path):
+ mode = 0x4000 if entry.is_dir() else 0x8000
+ yield (entry.name, mode, 0, 0)
+ os.ilistdir = _ilistdir
+
+ pyb = _ensure_module("pyb")
+ if not hasattr(pyb, "SDCard"):
+ class _DummySDCard:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def present(self):
+ return True
+
+ def power(self, value):
+ pass
+
+ class _DummyLED:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def on(self):
+ pass
+
+ def off(self):
+ pass
+
+ pyb.SDCard = _DummySDCard
+ pyb.LED = _DummyLED
+ pyb.usb_mode = lambda *args, **kwargs: None
+ pyb.UART = lambda *args, **kwargs: None
+ pyb.USB_VCP = lambda *args, **kwargs: None
+
+ lvgl = _ensure_module("lvgl")
+ if not hasattr(lvgl, "SYMBOL"):
+ class _Symbol:
+ EDIT = "[edit]"
+ TRASH = "[trash]"
+
+ def __getattr__(self, name):
+ return f"[{name.lower()}]"
+
+ lvgl.SYMBOL = _Symbol()
+
+ display = _ensure_module("display")
+ if not hasattr(display, "Screen"):
+ display.Screen = type("Screen", (), {})
+
+ gui = _ensure_module("gui")
+ if not hasattr(gui, "__path__"):
+ gui.__path__ = []
+
+ screens = _ensure_module("gui.screens")
+ if not hasattr(screens, "__path__"):
+ screens.__path__ = []
+ for _name in [
+ "Menu",
+ "InputScreen",
+ "Prompt",
+ "TransactionScreen",
+ "WalletScreen",
+ "ConfirmWalletScreen",
+ "QRAlert",
+ "Alert",
+ "PinScreen",
+ "DerivationScreen",
+ "NumericScreen",
+ "MnemonicScreen",
+ "NewMnemonicScreen",
+ "RecoverMnemonicScreen",
+ "Progress",
+ "DevSettings",
+ ]:
+ if not hasattr(screens, _name):
+ setattr(screens, _name, type(_name, (), {}))
+
+ _ensure_submodule("gui.screens", "mnemonic", {
+ "ExportMnemonicScreen": type("ExportMnemonicScreen", (), {}),
+ })
+ _ensure_submodule("gui.screens", "settings", {
+ "HostSettings": type("HostSettings", (), {}),
+ })
+ _ensure_submodule("gui.screens", "screen", {
+ "Screen": type("Screen", (), {}),
+ })
+ _ensure_submodule("gui.screens", "qralert", {
+ "QRAlert": type("QRAlert", (), {}),
+ })
+
+ common = _ensure_module("gui.common")
+ if not hasattr(common, "HOR_RES"):
+ common.HOR_RES = 480
+ if not hasattr(common, "styles"):
+ common.styles = types.SimpleNamespace()
+ for _name in [
+ "add_label",
+ "add_button",
+ "add_button_pair",
+ "align_button_pair",
+ "format_addr",
+ ]:
+ if not hasattr(common, _name):
+ setattr(common, _name, lambda *args, **kwargs: None)
+
+ decorators = _ensure_module("gui.decorators")
+ if not hasattr(decorators, "on_release"):
+ decorators.on_release = lambda func: func
+
+ ucryptolib = _ensure_module("ucryptolib")
+ if not hasattr(ucryptolib, "aes"):
+ class _DummyAES:
+ def __init__(self, key, mode, iv):
+ self.key = key
+ self.mode = mode
+ self.iv = iv
+
+ def encrypt(self, data):
+ return data
+
+ def decrypt(self, data):
+ return data
+
+ ucryptolib.aes = lambda key, mode, iv: _DummyAES(key, mode, iv)
+
+ bcur = _ensure_module("bcur")
+ if not hasattr(bcur, "bcur_decode_stream"):
+ bcur.bcur_decode_stream = lambda stream: stream
+
+ secp256k1 = _ensure_module("secp256k1")
+ if not hasattr(secp256k1, "EC_UNCOMPRESSED"):
+ secp256k1.EC_UNCOMPRESSED = 0
+ secp256k1.ec_pubkey_parse = lambda data: data
+ secp256k1.ec_pubkey_create = lambda secret: secret
+ secp256k1.ec_pubkey_serialize = lambda pub, flag=0: b"\x04" + bytes(64)
+ secp256k1.ec_pubkey_tweak_mul = lambda pub, secret: None
+ secp256k1.ecdsa_signature_parse_der = lambda raw: raw
+ secp256k1.ecdsa_signature_normalize = lambda sig: sig
+ secp256k1.ecdsa_verify = lambda sig, msg, pub: True
+ secp256k1.ecdsa_sign_recoverable = lambda msghash, secret: bytes(65)
+
+ from app import BaseApp
+
+ if not hasattr(BaseApp, "_native_original_get_prefix"):
+ BaseApp._native_original_get_prefix = BaseApp.get_prefix
+
+ def _native_get_prefix(self, stream):
+ pos = stream.tell()
+ prefix = BaseApp._native_original_get_prefix(self, stream)
+ if prefix is not None:
+ prefixes = getattr(self, 'prefixes', None)
+ if prefixes and prefix not in prefixes:
+ stream.seek(pos)
+ return None
+ return prefix
+
+ BaseApp.get_prefix = _native_get_prefix
+
+ try:
+ from apps.wallets.wallet import Wallet as _Wallet
+ except ModuleNotFoundError as exc:
+ if exc.name == "embit":
+ raise ModuleNotFoundError(
+ "Native test suite requires the 'embit' package. "
+ "Install it with 'pip install -r test/integration/requirements.txt'."
+ ) from exc
+ raise
+
+ if not hasattr(_Wallet, '_native_original_from_descriptor'):
+ _Wallet._native_original_from_descriptor = _Wallet.from_descriptor
+
+ def _native_from_descriptor(cls, desc: str, path):
+ desc = desc.split('#')[0].replace(' ', '')
+ descriptor = cls.DescriptorClass.from_string(desc)
+ return cls(descriptor, path)
+
+ _Wallet.from_descriptor = classmethod(_native_from_descriptor)
diff --git a/test/run_native_tests.py b/test/run_native_tests.py
new file mode 100644
index 0000000..3708cd3
--- /dev/null
+++ b/test/run_native_tests.py
@@ -0,0 +1,19 @@
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(0, str((ROOT / "../src").resolve()))
+sys.path.insert(0, str((ROOT / "../f469-disco/libs/common").resolve()))
+sys.path.insert(0, str((ROOT / "../f469-disco/libs/unix").resolve()))
+sys.path.insert(0, str((ROOT / "../f469-disco/usermods/udisplay_f469/display_unixport").resolve()))
+sys.path.insert(0, str((ROOT / "../f469-disco/tests").resolve()))
+
+from native_support import setup_native_stubs
+
+setup_native_stubs()
+
+import unittest
+from tests import util
+
+util.clear_testdir()
+unittest.main('tests_native', verbosity=2)
diff --git a/test/tests/__init__.py b/test/tests/__init__.py
index 09f38ec..0efb9cb 100644
--- a/test/tests/__init__.py
+++ b/test/tests/__init__.py
@@ -2,4 +2,4 @@ from .test_keystore import *
from .test_wallets import *
from .test_sign import *
from .test_revault import *
-from .test_compatibility import *
\ No newline at end of file
+from .test_compatibility import *
diff --git a/test/tests_native/__init__.py b/test/tests_native/__init__.py
new file mode 100644
index 0000000..7cf516b
--- /dev/null
+++ b/test/tests_native/__init__.py
@@ -0,0 +1 @@
+from .test_wallet_manager_parsing import *
diff --git a/test/tests_native/test_wallet_manager_parsing.py b/test/tests_native/test_wallet_manager_parsing.py
new file mode 100644
index 0000000..2e80190
--- /dev/null
+++ b/test/tests_native/test_wallet_manager_parsing.py
@@ -0,0 +1,79 @@
+import sys
+
+if sys.implementation.name != 'micropython':
+ from native_support import setup_native_stubs
+
+ setup_native_stubs()
+
+from unittest import TestCase
+from io import BytesIO
+import gc
+
+from tests.util import get_keystore, get_wallets_app, clear_testdir
+from apps.wallets.manager import ADD_WALLET, SIGN_PSBT, VERIFY_ADDRESS
+
+DOC_DESCRIPTOR = (
+ "wsh(sortedmulti(2,"
+ "[b317ec86/48h/1h/0h/2h]tpubDEToKMGFhyuP6kfwvjtYaf56khzS1cUcwc47C6aMH6bQ8sNVLMcCK6jr21YDCkU2QhTK5CAnddhfgZ8dD4EL1wGCaAKZaGFeVVdXHaJMTMn,"
+ "[f04828fe/48h/1h/0h/2h]tpubDFekS5zvPSdW6WWjH2p7vPRkxmeeNGnirmj36AUyoAYbJvfKBj6UARWR5gQ6FRrr98dzT1XFTi6rfGo9AAAeutY1S6SoWijQ8BKxDhYQzDR,"
+ "[d3c05b2e/48h/1h/0h/2h]tpubDFnAczXQTHxuBh7FxrpLDHBidkC1Di54pTPSPMu4AQjKziFQQTTEFXEVugqm8ucKQhJfLGesBjRZWtLpqAkAmecoXtvaPwCzf4teqrY7Uu5))"
+)
+DOC_NAMED_DESCRIPTOR = "My multisig&" + DOC_DESCRIPTOR
+DOC_ADDWALLET_COMMAND = "addwallet " + DOC_NAMED_DESCRIPTOR
+DOC_ADDRESS_REQUEST = (
+ "bitcoin:bcrt1qd3mtrhysk3k4w6fmu7ayjvwk6q98c2dpf0p4x87zauu8rcgq5dzq73tyrx?index=2"
+)
+# Minimal base64-encoded PSBT prefix. The parser only checks the magic bytes,
+# so using a short fixture keeps memory usage low on constrained interpreters.
+DOC_BASE64_PSBT = "cHNidP8="
+
+
+class WalletManagerParsingTest(TestCase):
+ def setUp(self):
+ clear_testdir()
+ self.keystore = get_keystore()
+ self.wallets_app = get_wallets_app(self.keystore, "regtest")
+ self.manager = self.wallets_app.manager
+
+ def tearDown(self):
+ clear_testdir()
+ gc.collect()
+
+ def _parse_command(self, data):
+ stream = BytesIO(data)
+ cmd, result_stream = self.manager.parse_stream(stream)
+ self.assertIs(result_stream, stream)
+ return cmd, result_stream
+
+ def test_docs_addwallet_command_is_detected(self):
+ cmd, stream = self._parse_command(DOC_ADDWALLET_COMMAND.encode())
+ self.assertEqual(cmd, ADD_WALLET)
+ self.assertEqual(stream.read().decode(), DOC_NAMED_DESCRIPTOR)
+
+ def test_descriptor_without_addwallet_prefix_is_parsed(self):
+ cmd, stream = self._parse_command(DOC_NAMED_DESCRIPTOR.encode())
+ self.assertEqual(cmd, ADD_WALLET)
+ self.assertEqual(stream.read().decode(), DOC_NAMED_DESCRIPTOR)
+ wallet = self.manager.parse_wallet(DOC_NAMED_DESCRIPTOR)
+ self.assertEqual(wallet.name, "My multisig")
+ self.assertEqual(str(wallet.descriptor), DOC_DESCRIPTOR)
+
+ def test_raw_descriptor_is_parsed(self):
+ cmd, stream = self._parse_command(DOC_DESCRIPTOR.encode())
+ self.assertEqual(cmd, ADD_WALLET)
+ self.assertEqual(stream.read().decode(), DOC_DESCRIPTOR)
+ wallet = self.manager.parse_wallet(DOC_DESCRIPTOR)
+ self.assertEqual(wallet.name, "Untitled")
+ self.assertEqual(str(wallet.descriptor), DOC_DESCRIPTOR)
+
+ def test_docs_base64_psbt_is_detected(self):
+ cmd, stream = self._parse_command(DOC_BASE64_PSBT.encode())
+ self.assertEqual(cmd, SIGN_PSBT)
+ # ensure stream is rewound for later processing
+ self.assertEqual(stream.read(10), DOC_BASE64_PSBT[:10].encode())
+
+ def test_docs_address_request_is_detected(self):
+ cmd, stream = self._parse_command(DOC_ADDRESS_REQUEST.encode())
+ self.assertEqual(cmd, VERIFY_ADDRESS)
+ # parse_stream should keep address data available for processing
+ self.assertEqual(stream.read().decode(), DOC_ADDRESS_REQUEST.replace("bitcoin:", "", 1))
Why this scored 28/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.