What changed, and why it matters
This release fixes a bug in Krux, a Bitcoin signing device firmware. When a user scanned an encrypted QR code meant to provide a wallet passphrase, address, or wallet descriptor, the device could silently treat the raw encrypted bytes as the passphrase/data instead of showing an error. That could lead to the wrong Bitcoin wallet being derived, with no warning to the user. The patch adds proper error handling and now rejects non-ASCII passphrases.
Users running Krux 25.09.0 or 25.10.0 should upgrade to 25.10.1, especially if they use encrypted QR passphrases, addresses, or wallet descriptors. Developers should review other decrypt_kef() call sites for the same pattern and consider adding runtime-agnostic exception handling for byte-to-string decoding.
Security signals we found
Silent derivation of wrong wallet from encrypted passphrase/data due to uncaught decoding exception
Cross-runtime behavior difference: CPython UnicodeDecodeError vs MaixPy TypeError
Missing input validation for passphrase character set
Patch adds explicit decode-try blocks and ASCII validation
Vendor changelog explicitly describes the bug as a security-relevant wallet-derivation issue
Evidence from the diff
The commit fixes a cross-runtime exception-handling gap. decrypt_kef() returns bytes, and the old code immediately called .decode(), relying on UnicodeDecodeError being caught by an outer except ValueError. On CPython this worked because UnicodeDecodeError inherits from ValueError, but on MaixPy the runtime raises TypeError instead, which was not caught and caused the encrypted bytes to be used as the decoded string. The patch separates decryption from decoding, catches both UnicodeDecodeError and TypeError, and flashes ‘Failed to load’. It also enforces ASCII-only passphrases in PassphraseEditor.
Changed components
src/krux/pages/wallet_settings.py - PassphraseEditorsrc/krux/pages/home_pages/addresses.py - Addresses scan_addresssrc/krux/pages/home_pages/wallet_descriptor.py - WalletDescriptor wallet loadingKrux encrypted mnemonic/passphrase handling (KEF envelope)Firmware builds based on MaixPy runtimeInspect captured patch +259 / −6
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b10603..78c0ad7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# Changelog 25.10.1 - October 2025
+
+### Bugfix: Krux encrypted mnemonic as a passphrase is invalid, but no error was raised
+Instead of displaying an error, the base43 encoded KEF Envelope was displayed and used as the passphrase - deriving the wrong wallet; since version 25.09.0
+Solution: better error handling when decrypted data is invalid for the current context; error: "Failed to load". Stricter validation to ensure passphrases are ASCII-only strings.
+
# Changelog 25.10.0 - October 2025
### New Device Support: TZT
diff --git a/mkdocs.yml b/mkdocs.yml
index 37ce281..c0a7673 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -52,7 +52,7 @@ edit_uri: edit/main/docs
docs_dir: docs
site_dir: public
extra:
- latest_krux: krux-v25.10.0
+ latest_krux: krux-v25.10.1
latest_installer: v0.0.20
latest_installer_rpm: krux-installer-0.0.20-1.x86_64.rpm
latest_installer_deb: krux-installer_0.0.20_amd64.deb
diff --git a/pyproject.toml b/pyproject.toml
index 77b6800..8fd159d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@
[tool.poetry]
name = "krux"
-version = "25.10.0"
+version = "25.10.1"
description = "Open-source signing device firmware for Bitcoin"
authors = ["Jeff S <jeffreesun@protonmail.com>"]
readme = "README.md"
diff --git a/src/krux/metadata.py b/src/krux/metadata.py
index f31b559..ad56c16 100644
--- a/src/krux/metadata.py
+++ b/src/krux/metadata.py
@@ -19,5 +19,5 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
-VERSION = "25.10.0"
+VERSION = "25.10.1"
SIGNER_PUBKEY = "03339e883157e45891e61ca9df4cd3bb895ef32d475b8e793559ea10a36766689b"
diff --git a/src/krux/pages/home_pages/addresses.py b/src/krux/pages/home_pages/addresses.py
index e72ab80..4b4a59c 100644
--- a/src/krux/pages/home_pages/addresses.py
+++ b/src/krux/pages/home_pages/addresses.py
@@ -297,7 +297,14 @@ class Addresses(Page):
return MENU_CONTINUE
try:
- data = decrypt_kef(self.ctx, data).decode()
+ data = decrypt_kef(self.ctx, data)
+
+ # Cpython raises UnicodeDecodeError, MaixPy raises TypeError
+ try:
+ data = data.decode()
+ except:
+ self.flash_error(t("Failed to load"))
+ return MENU_CONTINUE
except KeyError:
self.flash_error(t("Failed to decrypt"))
return MENU_CONTINUE
diff --git a/src/krux/pages/home_pages/wallet_descriptor.py b/src/krux/pages/home_pages/wallet_descriptor.py
index 13895fe..93fc90c 100644
--- a/src/krux/pages/home_pages/wallet_descriptor.py
+++ b/src/krux/pages/home_pages/wallet_descriptor.py
@@ -162,7 +162,14 @@ class WalletDescriptor(Page):
from ..encryption_ui import decrypt_kef
try:
- wallet_data = decrypt_kef(self.ctx, wallet_data).decode()
+ wallet_data = decrypt_kef(self.ctx, wallet_data)
+
+ # Cpython raises UnicodeDecodeError, MaixPy raises TypeError
+ try:
+ wallet_data = wallet_data.decode()
+ except:
+ self.flash_error(t("Failed to load"))
+ return MENU_CONTINUE
except KeyError:
self.flash_error(t("Failed to decrypt"))
return MENU_CONTINUE
diff --git a/src/krux/pages/wallet_settings.py b/src/krux/pages/wallet_settings.py
index 6ac172e..bc3e967 100644
--- a/src/krux/pages/wallet_settings.py
+++ b/src/krux/pages/wallet_settings.py
@@ -83,6 +83,18 @@ class PassphraseEditor(Page):
if passphrase in (ESC_KEY, MENU_EXIT):
return None
+ # Decode passphrase in case it's a "bytes" object
+ if isinstance(passphrase, bytes):
+ try:
+ passphrase = passphrase.decode()
+ except:
+ self.flash_error(t("Failed to load"))
+ continue
+ # Check if passphrase string is within ascii range
+ if any(byte > 126 for byte in passphrase.encode()):
+ self.flash_error(t("Failed to load"))
+ continue
+
from ..themes import theme
from ..key import Key
@@ -125,7 +137,14 @@ class PassphraseEditor(Page):
return MENU_CONTINUE
try:
- data = decrypt_kef(self.ctx, data).decode()
+ data = decrypt_kef(self.ctx, data)
+
+ # Cpython raises UnicodeDecodeError, MaixPy raises TypeError
+ try:
+ data = data.decode()
+ except:
+ self.flash_error(t("Failed to load"))
+ return MENU_CONTINUE
except KeyError:
self.flash_error(t("Failed to decrypt"))
return MENU_CONTINUE
diff --git a/tests/pages/home_pages/test_addresses.py b/tests/pages/home_pages/test_addresses.py
index a53c020..ed52758 100644
--- a/tests/pages/home_pages/test_addresses.py
+++ b/tests/pages/home_pages/test_addresses.py
@@ -360,6 +360,40 @@ def test_scan_address_highlight(mocker, m5stickv, tdata):
assert ctx.input.wait_for_button.call_count == len(case[5])
+def test_scan_address_fails_on_encrypted_non_ascii_bytes(mocker, m5stickv, tdata):
+ from krux.pages.home_pages.addresses import Addresses
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.pages import MENU_CONTINUE
+
+ # non-ascii 0x8f byte encrypted w/ key="a" to test decoding failure
+ # in Cpython: UnicodeDecodeError is raised; in MaixPy: TypeError is raised
+ mocker.patch.object(
+ QRCodeCapture,
+ "qr_capture_loop",
+ new=lambda self: (
+ b"\x06binkey\x05\x01\x88WB\xb9\xab\xb6\xe9\x83\x97y\x1ab\xb0F\xe2|\xd3E\x84\x2b\x2c",
+ 0,
+ ),
+ )
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm decrypt
+ BUTTON_ENTER, # type key
+ BUTTON_ENTER, # enter "a"
+ BUTTON_PAGE_PREV, # back to Go
+ BUTTON_ENTER, # go Go
+ BUTTON_ENTER, # confirm key "a"
+ ]
+ ctx = create_ctx(mocker, btn_seq)
+ addresses_ui = Addresses(ctx)
+ assert addresses_ui.scan_address() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+ ctx.display.flash_text.assert_called_with(
+ "Failed to load", 248, 2000, highlight_prefix=""
+ )
+
+
def test_list_disable_change_address(mocker, m5stickv, tdata):
from krux.pages.home_pages.addresses import Addresses
from krux.input import BUTTON_ENTER, BUTTON_PAGE
diff --git a/tests/pages/home_pages/test_wallet_descriptor.py b/tests/pages/home_pages/test_wallet_descriptor.py
index a96aab2..cda4512 100644
--- a/tests/pages/home_pages/test_wallet_descriptor.py
+++ b/tests/pages/home_pages/test_wallet_descriptor.py
@@ -225,6 +225,44 @@ def test_wallet_load_fails_on_decrypt_kef_key_error(mocker, m5stickv, tdata):
)
+def test_wallet_load_fails_on_encrypted_non_ascii_bytes(mocker, m5stickv, tdata):
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV
+ from krux.pages.home_pages.wallet_descriptor import WalletDescriptor
+ from krux.wallet import Wallet
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.pages import MENU_CONTINUE
+
+ # non-ascii 0x8f byte encrypted w/ key="a" to test decoding failure
+ # in Cpython: UnicodeDecodeError is raised; in MaixPy: TypeError is raised
+ mocker.patch.object(
+ QRCodeCapture,
+ "qr_capture_loop",
+ new=lambda self: (
+ b"\x06binkey\x05\x01\x88WB\xb9\xab\xb6\xe9\x83\x97y\x1ab\xb0F\xe2|\xd3E\x84\x2b\x2c",
+ 0,
+ ),
+ )
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm load
+ BUTTON_ENTER, # go load from camera
+ BUTTON_ENTER, # confirm decrypt
+ BUTTON_ENTER, # type key
+ BUTTON_ENTER, # enter "a"
+ BUTTON_PAGE_PREV, # back to Go
+ BUTTON_ENTER, # go Go
+ BUTTON_ENTER, # confirm key "a"
+ ]
+ wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
+ ctx = create_ctx(mocker, btn_seq, wallet)
+ walletdescriptor_ui = WalletDescriptor(ctx)
+ assert walletdescriptor_ui.wallet() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+ ctx.display.flash_text.assert_called_with(
+ "Failed to load", 248, 2000, highlight_prefix=""
+ )
+
+
def test_load_desc_without_change(mocker, m5stickv, tdata):
import krux
diff --git a/tests/pages/test_wallet_settings.py b/tests/pages/test_wallet_settings.py
index a0b7927..659eacb 100644
--- a/tests/pages/test_wallet_settings.py
+++ b/tests/pages/test_wallet_settings.py
@@ -128,6 +128,148 @@ def test_qr_passphrase_fails_on_decrypt_kef_key_error(mocker, m5stickv, tdata):
)
+def test_qr_passphrase_fails_on_encrypted_non_ascii_bytes(mocker, m5stickv, tdata):
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+ from krux.pages.wallet_settings import PassphraseEditor
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.pages import MENU_CONTINUE
+
+ # non-ascii 0x8f byte encrypted w/ key="a" to test decoding failure
+ # in Cpython: UnicodeDecodeError is raised; in MaixPy: TypeError is raised
+ mocker.patch.object(
+ QRCodeCapture,
+ "qr_capture_loop",
+ new=lambda self: (
+ b"\x06binkey\x05\x01\x88WB\xb9\xab\xb6\xe9\x83\x97y\x1ab\xb0F\xe2|\xd3E\x84\x2b\x2c",
+ 0,
+ ),
+ )
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm decrypt
+ BUTTON_ENTER, # type key
+ BUTTON_ENTER, # enter "a"
+ BUTTON_PAGE_PREV, # back to Go
+ BUTTON_ENTER, # go Go
+ BUTTON_ENTER, # confirm key "a"
+ ]
+ ctx = create_ctx(mocker, btn_seq)
+ passphrase_editor = PassphraseEditor(ctx)
+ assert passphrase_editor._load_qr_passphrase() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+ ctx.display.flash_text.assert_called_with(
+ "Failed to load", 248, 2000, highlight_prefix=""
+ )
+
+
+def test_qr_passphrase_fails_on_encrypted_non_ascii_bytes(mocker, m5stickv, tdata):
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
+ from krux.pages.wallet_settings import PassphraseEditor
+ from krux.pages.qr_capture import QRCodeCapture
+ from krux.pages import MENU_CONTINUE
+
+ # non-ascii 0x8f byte encrypted w/ key="a" to test decoding failure
+ # in Cpython: UnicodeDecodeError is raised; in MaixPy: TypeError is raised
+ mocker.patch.object(
+ QRCodeCapture,
+ "qr_capture_loop",
+ new=lambda self: (
+ b"\x06binkey\x05\x01\x88WB\xb9\xab\xb6\xe9\x83\x97y\x1ab\xb0F\xe2|\xd3E\x84\x2b\x2c",
+ 0,
+ ),
+ )
+
+ btn_seq = [
+ BUTTON_ENTER, # confirm decrypt
+ BUTTON_ENTER, # type key
+ BUTTON_ENTER, # enter "a"
+ BUTTON_PAGE_PREV, # back to Go
+ BUTTON_ENTER, # go Go
+ BUTTON_ENTER, # confirm key "a"
+ ]
+ ctx = create_ctx(mocker, btn_seq)
+ passphrase_editor = PassphraseEditor(ctx)
+ assert passphrase_editor._load_qr_passphrase() == MENU_CONTINUE
+ assert ctx.input.wait_for_button.call_count == len(btn_seq)
+ ctx.display.flash_text.assert_called_with(
+ "Failed to load", 248, 2000, highlight_prefix=""
+ )
+
+
+def test_passphrase_non_ascii_validation(m5stickv, mocker, tdata):
+ from krux.pages.wallet_settings import PassphraseEditor
+ from krux.pages import Menu, MENU_EXIT
+
+ # Test non-ASCII passphrase rejection
+ NON_ASCII_PASSPHRASE = "Test™" # Contains non-ASCII character
+ ctx = create_ctx(mocker, None, tdata.SINGLESIG_12_WORD_KEY)
+ passphrase_editor = PassphraseEditor(ctx)
+
+ # Mock the Menu's run_loop to return non-ASCII passphrase first, then exit
+ menu_returns = [
+ (0, NON_ASCII_PASSPHRASE), # First call returns non-ASCII passphrase
+ (0, MENU_EXIT), # Second call exits
+ ]
+ mocker.patch.object(
+ Menu,
+ "run_loop",
+ side_effect=menu_returns,
+ )
+
+ # Mock flash_error to track if it was called
+ flash_error_spy = mocker.spy(passphrase_editor, "flash_error")
+
+ result = passphrase_editor.load_passphrase_menu(
+ tdata.SINGLESIG_12_WORD_KEY.mnemonic
+ )
+
+ # Verify that flash_error was called with the ASCII error message
+ flash_error_spy.assert_called()
+ # Get the actual call arguments
+ call_args = flash_error_spy.call_args[0][0]
+ assert call_args == "Failed to load"
+
+ # Verify that the method returned None (exited without accepting passphrase)
+ assert result is None
+
+
+def test_passphrase_non_decodeable_validation(m5stickv, mocker, tdata):
+ from krux.pages.wallet_settings import PassphraseEditor
+ from krux.pages import Menu, MENU_EXIT
+
+ # Test non-ASCII passphrase rejection
+ BINARY_PASSPHRASE = b"\xde\xad\xbe\xef" # Contains 0xdeadbeef, not decodeable
+ ctx = create_ctx(mocker, None, tdata.SINGLESIG_12_WORD_KEY)
+ passphrase_editor = PassphraseEditor(ctx)
+
+ # Mock the Menu's run_loop to return non-ASCII passphrase first, then exit
+ menu_returns = [
+ (0, BINARY_PASSPHRASE), # First call returns binary passphrase
+ (0, MENU_EXIT), # Second call exits
+ ]
+ mocker.patch.object(
+ Menu,
+ "run_loop",
+ side_effect=menu_returns,
+ )
+
+ # Mock flash_error to track if it was called
+ flash_error_spy = mocker.spy(passphrase_editor, "flash_error")
+
+ result = passphrase_editor.load_passphrase_menu(
+ tdata.SINGLESIG_12_WORD_KEY.mnemonic
+ )
+
+ # Verify that flash_error was called with error message
+ flash_error_spy.assert_called()
+ # Get the actual call arguments
+ call_args = flash_error_spy.call_args[0][0]
+ assert call_args == "Failed to load"
+
+ # Verify that the method returned None (exited without accepting passphrase)
+ assert result is None
+
+
def test_change_policy_types(m5stickv, mocker, tdata):
from krux.pages.wallet_settings import WalletSettings
from krux.wallet import Wallet
Why this scored 64/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.