What changed, and why it matters
This commit updates the embedded embit cryptography library to version 0.8.2 and adds a small compatibility shim so Specter DIY can accept a new style of Bitcoin wallet descriptor that mixes fixed and multi-path keys. Most of the changes are build/test plumbing (submodules, paths, smoke tests) rather than a security fix. There is no direct evidence in the commit that this resolves an exploitable vulnerability.
Treat as a routine dependency/maintenance commit. Review the embit v0.8.2 changelog for any security advisories, and audit embit_compat.py to ensure the monkey-patch does not weaken descriptor validation (e.g., confirm it still rejects unequal branch lengths and invalid key combinations).
Security signals we found
Dependency update to embit v0.8.2
Monkey-patch of cryptographic descriptor parsing to bypass a library validation error
New test coverage for mixed multipath/fixed-path descriptor parsing
Evidence from the diff
The diff upgrades the embit submodule to v0.8.2 and introduces src/apps/wallets/embit_compat.py, which monkey-patches embit.descriptor.descriptor.Descriptor.init to allow BIP-389 descriptors where one key uses multipath notation (<0;1>/) and another uses a fixed path (/0/). The workaround catches embit’s ‘All branches should have the same length’ DescriptorError, validates that all multipath keys share the same branch count, then manually assigns descriptor fields. Tests are added for mixed-path sortedmulti, miniscript, and Liquid descriptors, plus manifest inventory checks. Build files now check out submodules recursively and add a frozen-import smoke test.
Changed components
src/apps/wallets/embit_compat.pysrc/apps/wallets/wallet.pyf469-disco submodule (embit)Makefile build/test targetstest/tests/test_wallets.pyInspect captured patch +224 / −11
### .github/workflows/test.yml
@@ -10,6 +10,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
+ with:
+ submodules: recursive
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -29,6 +31,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
+ with:
+ submodules: recursive
- name: Install dependencies
run: |
sudo apt-get update
### Makefile
@@ -3,6 +3,7 @@ BOARD ?= STM32F469DISC
FLAVOR ?= SPECTER
USER_C_MODULES ?= ../../../usermods
MPY_DIR ?= f469-disco/micropython
+EMBIT_INIT ?= f469-disco/libs/common/embit/src/embit/__init__.py
ifeq ($(shell uname),Linux)
MPY_CFLAGS ?= -Wno-dangling-pointer -Wno-enum-int-mismatch
else
@@ -22,8 +23,11 @@ $(TARGET_DIR):
$(MPY_DIR)/mpy-cross/Makefile:
git submodule update --init --recursive
+$(EMBIT_INIT): | $(MPY_DIR)/mpy-cross/Makefile
+ git submodule update --init --recursive
+
# cross-compiler
-mpy-cross: $(TARGET_DIR) $(MPY_DIR)/mpy-cross/Makefile
+mpy-cross: $(TARGET_DIR) $(MPY_DIR)/mpy-cross/Makefile $(EMBIT_INIT)
@echo Building cross-compiler
make -C $(MPY_DIR)/mpy-cross \
DEBUG=$(DEBUG) \
@@ -82,7 +86,10 @@ unix: $(TARGET_DIR) mpy-cross $(MPY_DIR)/ports/unix git-info
simulate: unix
$(TARGET_DIR)/micropython_unix simulate.py
-test: unix
+frozen-import-smoke: unix
+ cd /tmp && $(abspath $(TARGET_DIR)/micropython_unix) -c 'import asyncio; import asyncio.core; import microur.encoder; import microur.decoder; import microur.util.bytewords; import embit.bip39; import embit.bip85; import embit.compact; import embit.ec; import embit.hashes; import embit.networks; import embit.psbt; import embit.psbtview; import embit.script; import embit.transaction; import embit.descriptor; import embit.descriptor.arguments; import embit.descriptor.checksum; import embit.liquid; import embit.liquid.addresses; import embit.liquid.descriptor; import embit.liquid.networks; import embit.liquid.pset; import embit.liquid.psetview; import embit.liquid.slip77; import embit.liquid.transaction'
+
+test: unix frozen-import-smoke
cd test && ../$(TARGET_DIR)/micropython_unix run_tests.py
all: mpy-cross disco unix
@@ -98,4 +105,4 @@ clean:
USER_C_MODULES=$(USER_C_MODULES) \
FROZEN_MANIFEST=$(FROZEN_MANIFEST_DISCO) clean
-.PHONY: all clean git-info
+.PHONY: all clean git-info frozen-import-smoke
### f469-disco
@@ -1 +1 @@
-Subproject commit db3ce3e918cf0fd36f076ecd86ef05240d7c3cef
+Subproject commit 9dd8515aaa0de80cf5d2ae1499de14beb33f863a
### simulate.py
@@ -1,9 +1,10 @@
import sys
sys.path.append('./src')
+sys.path.insert(1, './f469-disco/libs/common/embit/src')
sys.path.append('./f469-disco/libs/common')
sys.path.append('./f469-disco/libs/unix')
sys.path.append('./f469-disco/usermods/udisplay_f469/display_unixport')
import main
-main.main()
\ No newline at end of file
+main.main()
### src/apps/wallets/embit_compat.py
@@ -0,0 +1,52 @@
+from embit.descriptor.descriptor import Descriptor
+from embit.descriptor.errors import DescriptorError
+from embit.descriptor.taptree import TapTree
+
+
+_BRANCH_ERROR = "All branches should have the same length"
+
+
+def apply_descriptor_branch_compatibility():
+ """Allow BIP-389 descriptors that mix multipath and fixed-path keys."""
+ if hasattr(Descriptor, "_specter_branch_compat_original_init"):
+ return
+
+ original_init = Descriptor.__init__
+ Descriptor._specter_branch_compat_original_init = original_init
+
+ def compatible_init(
+ self,
+ miniscript=None,
+ sh=False,
+ wsh=True,
+ key=None,
+ wpkh=True,
+ taproot=False,
+ taptree=None,
+ ):
+ try:
+ original_init(self, miniscript, sh, wsh, key, wpkh, taproot, taptree)
+ return
+ except DescriptorError as error:
+ if str(error) != _BRANCH_ERROR or miniscript is None:
+ raise
+
+ # embit v0.8.2 counts fixed paths as one-element branch sets. Remove
+ # this workaround once an upgraded embit accepts mixed BIP-389 keys.
+ branch_lengths = {
+ len(k.branches) for k in miniscript.keys if k.branches is not None
+ }
+ if len(branch_lengths) != 1:
+ raise
+
+ self.sh = sh
+ self.wsh = wsh
+ self.key = key
+ self.miniscript = miniscript
+ self.wpkh = wpkh
+ self.taproot = taproot
+ self.taptree = taptree or TapTree()
+ for descriptor_key in self.keys:
+ descriptor_key.taproot = taproot
+
+ Descriptor.__init__ = compatible_init
### src/apps/wallets/wallet.py
@@ -9,11 +9,14 @@
from embit.descriptor.checksum import add_checksum
from embit.descriptor.arguments import AllowedDerivation
from embit.transaction import SIGHASH
+from .embit_compat import apply_descriptor_branch_compatibility
from .screens import WalletScreen, WalletInfoScreen
from .commands import DELETE, EDIT, MENU, INFO, EXPORT
from gui.screens import Menu, QRAlert, Alert, Prompt
import lvgl as lv
+apply_descriptor_branch_compatibility()
+
class WalletError(AppError):
NAME = "Wallet error"
### test/integration/requirements.txt
@@ -1,2 +1 @@
requests
-embit
### test/integration/run_tests.py
@@ -1,5 +1,10 @@
# this should run with python3
import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+sys.path.insert(1, str((ROOT / "../../f469-disco/libs/common/embit/src").resolve()))
+
if sys.implementation.name == 'micropython':
print("This file should run with python3, not micropython!")
sys.exit(1)
### test/integration/simulator.py
@@ -4,6 +4,7 @@
print("This file should run from micropython!")
sys.exit(1)
sys.path.append('../../src')
+sys.path.insert(1, '../../f469-disco/libs/common/embit/src')
sys.path.append('../../f469-disco/libs/common')
sys.path.append('../../f469-disco/libs/unix')
sys.path.append('../../f469-disco/usermods/udisplay_f469/display_unixport')
@@ -14,4 +15,4 @@
import main
# run on the regtest
-main.main(network="regtest")
\ No newline at end of file
+main.main(network="regtest")
### test/integration/util/controller.py
@@ -77,7 +77,11 @@ def shutdown(self):
time.sleep(0.3)
except:
pass
- os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM) # Send the signal to all the process groups
+ try:
+ # The simulator may exit after handling the quit command above.
+ os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
+ except ProcessLookupError:
+ pass
time.sleep(1)
def query(self, data, commands=[]):
@@ -124,4 +128,4 @@ def shutdown(self):
pass
sim = SimController()
-core = BitcoinCore()
\ No newline at end of file
+core = BitcoinCore()
### test/native_support.py
@@ -209,8 +209,8 @@ def _native_get_prefix(self, stream):
except ModuleNotFoundError as exc:
if exc.name.startswith("embit"):
raise ModuleNotFoundError(
- "Native test suite requires the 'embit' package. "
- "Install it with 'pip install -r test/integration/requirements.txt'."
+ "Native test suite requires the embit git submodule. "
+ "Run 'git submodule update --init --recursive'."
) from exc
raise
### test/run_native_tests.py
@@ -4,6 +4,7 @@
ROOT = Path(__file__).resolve().parent
# Insert src directly after the local dir (highest prio)
sys.path.insert(1, str((ROOT / "../src").resolve()))
+sys.path.insert(2, str((ROOT / "../f469-disco/libs/common/embit/src").resolve()))
# make the other stuff available with lowest prio
sys.path.append(str((ROOT / "../f469-disco/libs/common").resolve()))
### test/run_tests.py
@@ -1,6 +1,7 @@
import sys
sys.path.append('../src')
sys.path.append('../f469-disco/libs/common')
+sys.path.insert(1, '../f469-disco/libs/common/embit/src')
sys.path.append('../f469-disco/libs/unix')
sys.path.append('../f469-disco/usermods/udisplay_f469/display_unixport')
sys.path.append('../f469-disco/tests')
### test/tests/test_wallets.py
@@ -1,11 +1,84 @@
from unittest import TestCase
from apps.wallets.wallet import Wallet
from embit.descriptor import Key
+from embit.descriptor.errors import DescriptorError
+from embit.liquid.descriptor import LDescriptor
TEST_DIR = "testdir"
class WalletsTest(TestCase):
+ XPUB = "[8cce63f8/84h/1h/0h]tpubDCZWxJ6kKqRHep5a2XycxrXRaTES1vs3ysfV7sdv5uhkaEgxBEdVbyQT46m3NcaLJqVNd41TYqDyQfvweLLXGmkxdHRnhxuJPf7BAWMXni2"
+
+ def test_mixed_multipath_sortedmulti(self):
+ multipath = self.XPUB + "/<0;1>/*"
+ fixed = self.XPUB + "/0/*"
+ descriptor = "wsh(sortedmulti(1,%s,%s))" % (multipath, fixed)
+
+ parsed = Wallet.parse(descriptor).descriptor
+ self.assertEqual(parsed.num_branches, 2)
+ self.assertEqual(str(parsed), descriptor)
+ self.assertIn("/0/*", str(parsed.branch(0).keys[0]))
+ self.assertIn("/1/*", str(parsed.branch(1).keys[0]))
+ self.assertEqual(str(parsed.branch(0).keys[1]), str(parsed.branch(1).keys[1]))
+ derived_receive = parsed.derive(7, branch_index=0)
+ derived_change = parsed.derive(7, branch_index=1)
+ self.assertTrue(str(derived_receive.keys[0].origin).endswith("/0/7"))
+ self.assertTrue(str(derived_change.keys[0].origin).endswith("/1/7"))
+ self.assertEqual(
+ str(derived_receive.keys[1]),
+ str(derived_change.keys[1]),
+ )
+
+ def test_stored_mixed_multipath_descriptor_loads(self):
+ multipath = self.XPUB + "/<0;1>/*"
+ fixed = self.XPUB + "/0/*"
+ descriptor = "wsh(sortedmulti(1,%s,%s))" % (multipath, fixed)
+
+ class StoredWalletKeyStore:
+ def load_aead(self, path):
+ if path.endswith("/descriptor"):
+ return None, descriptor.encode()
+ return None, b'{"gaps":[20,20],"name":"Stored","unused_recv":0}'
+
+ wallet = Wallet.from_path(TEST_DIR + "/wallet", StoredWalletKeyStore())
+ self.assertEqual(str(wallet.descriptor), descriptor)
+ self.assertEqual(wallet.descriptor.num_branches, 2)
+ self.assertEqual(wallet.name, "Stored")
+
+ def test_mixed_multipath_recovery_miniscript(self):
+ multipath = self.XPUB + "/<0;1>/*"
+ fixed = self.XPUB + "/0/*"
+ descriptor = "wsh(or_d(pk(%s),and_v(v:pk(%s),older(10))))" % (
+ multipath,
+ fixed,
+ )
+
+ parsed = Wallet.parse(descriptor).descriptor
+ self.assertEqual(parsed.num_branches, 2)
+ self.assertIsNotNone(parsed.derive(7, branch_index=0).script_pubkey())
+ self.assertIsNotNone(parsed.derive(7, branch_index=1).script_pubkey())
+
+ def test_unequal_multipath_lengths_still_fail(self):
+ two_branches = self.XPUB + "/<0;1>/*"
+ three_branches = self.XPUB + "/<0;1;2>/*"
+ descriptor = "wsh(sortedmulti(1,%s,%s))" % (
+ two_branches,
+ three_branches,
+ )
+
+ with self.assertRaises(DescriptorError):
+ Wallet.parse(descriptor)
+
+ def test_mixed_multipath_liquid_descriptor(self):
+ multipath = self.XPUB + "/<0;1>/*"
+ fixed = self.XPUB + "/0/*"
+ descriptor = "wsh(sortedmulti(1,%s,%s))" % (multipath, fixed)
+
+ parsed = LDescriptor.from_string(descriptor)
+ self.assertEqual(parsed.num_branches, 2)
+ self.assertEqual(str(parsed), descriptor)
+
def test_descriptors(self):
"""Test initial config creation"""
k = "[8cce63f8/84h/1h/0h]tpubDCZWxJ6kKqRHep5a2XycxrXRaTES1vs3ysfV7sdv5uhkaEgxBEdVbyQT46m3NcaLJqVNd41TYqDyQfvweLLXGmkxdHRnhxuJPf7BAWMXni2/<0;1>/*"
### test/tests_native/__init__.py
@@ -1,2 +1,3 @@
+from .test_manifest_inventory import *
from .test_wallet_manager_parsing import *
from .test_wallet_manager_warnings import *
### test/tests_native/test_manifest_inventory.py
@@ -0,0 +1,61 @@
+import os
+from pathlib import Path
+from unittest import TestCase
+
+
+ROOT = Path(__file__).resolve().parents[2]
+MANIFEST_DIR = ROOT / "f469-disco" / "manifests"
+
+
+def load_manifest_inventory(filename):
+ freeze_calls = []
+
+ def freeze(root, modules):
+ freeze_calls.append((root, tuple(modules)))
+
+ previous_cwd = os.getcwd()
+ os.chdir(MANIFEST_DIR)
+ try:
+ namespace = {"freeze": freeze, "include": lambda unused: None}
+ exec((MANIFEST_DIR / filename).read_text(), namespace)
+ finally:
+ os.chdir(previous_cwd)
+
+ if len(freeze_calls) != 1:
+ raise AssertionError("expected one freeze declaration")
+ return freeze_calls[0]
+
+
+class ManifestInventoryTest(TestCase):
+ def test_common_inventory_matches_source_tree(self):
+ common_root = ROOT / "f469-disco" / "libs" / "common"
+ expected = {
+ path.relative_to(common_root).as_posix()
+ for path in common_root.rglob("*.py")
+ if path.relative_to(common_root).parts[0] != "embit"
+ }
+
+ freeze_root, modules = load_manifest_inventory("common.py")
+ self.assertEqual(freeze_root, "../libs/common")
+ self.assertEqual(set(modules), expected)
+ self.assertEqual(len(modules), len(expected))
+ self.assertIn("asyncio/core.py", modules)
+ self.assertIn("microur/util/bytewords.py", modules)
+ self.assertEqual(modules, load_manifest_inventory("common.py")[1])
+
+ def test_embit_inventory_matches_source_tree_except_util(self):
+ embit_root = ROOT / "f469-disco" / "libs" / "common" / "embit" / "src"
+ expected = {
+ path.relative_to(embit_root).as_posix()
+ for path in embit_root.rglob("*.py")
+ if path.relative_to(embit_root).parts[:2] != ("embit", "util")
+ }
+
+ freeze_root, modules = load_manifest_inventory("embit.py")
+ self.assertEqual(freeze_root, "../libs/common/embit/src")
+ self.assertEqual(set(modules), expected)
+ self.assertEqual(len(modules), len(expected))
+ self.assertIn("embit/descriptor/arguments.py", modules)
+ self.assertIn("embit/liquid/slip77.py", modules)
+ self.assertFalse(any(path.startswith("embit/util/") for path in modules))
+ self.assertEqual(modules, load_manifest_inventory("embit.py")[1])Why this scored 19/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.