What changed, and why it matters
This commit fixes four security weaknesses in Krux, a Bitcoin hardware-wallet project. The changes prevent: (1) encrypted backups from declaring zero or very low password-stretching effort, which would make them easy to brute-force; (2) multi-signature wallet descriptors from silently accepting multiple cosigners whose identity cannot be verified; (3) a malicious or corrupted SD card from tricking the file manager into leaving its intended folder; and (4) repeated wrong password attempts on encrypted backups by adding growing delays between attempts. All four are hardening fixes rather than a single critical vulnerability.
Treat this as a security update and include it in the next release. Users who create or load encrypted backups, use multi-signature descriptors, or browse files from SD cards should upgrade. No immediate external incident response is indicated, but the PBKDF2 and descriptor issues are the most severe because they weaken cryptographic assurances.
Security signals we found
Minimum PBKDF2 iteration enforcement against trivial work-factor envelopes
Rejection of multiple origin-less xpubs in multi-key descriptors
SD file-list filtering of traversal entries ('.', '..', path separators)
In-session exponential backoff on KEF decryption failures
CHANGELOG explicitly labels all four items as security hardening
Evidence from the diff
The patch addresses four independent issues. In src/krux/kef.py, unwrap() now rejects PBKDF2 iteration counts below 10,000, blocking envelopes that set iterations=0/1 to bypass key stretching. In src/krux/psbt.py, xpubs() now raises ValueError if a multi-key descriptor contains more than one origin-less xpub, preserving the single taproot internal-key exception but preventing silent cosigner overwrites. In src/krux/pages/file_manager.py, os.listdir results are filtered to remove ‘.’, ‘..’, and any entry containing ‘/’ or ‘', preventing directory traversal from crafted FAT entries. In src/krux/pages/encryption_ui.py, a shared RAM-only class counter tracks failed KEF decryption attempts across both unseal_ui and LoadEncryptedMnemonic, applying an exponential backoff capped at 30 s before each retry; the counter resets on success and clears on power cycle. Tests cover each fix.
Changed components
src/krux/kef.pysrc/krux/psbt.pysrc/krux/pages/file_manager.pysrc/krux/pages/encryption_ui.pyInspect captured patch +263 / −3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ceb769a..d22c6af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
- DeflateIO enforces 100KB max decompressed size, preventing zip bomb OOM via BBQR encoding "Z" or KEF decryption
- Enforce part_total limits in pMofN (1–99) and BBQR (≥1) QR parsers, preventing OOM via unbounded part accumulation
- Validate settings.json on load: enforce max file size and reject non-object payloads, preventing OOM and type-confusion via a malicious SD card
+- KEF: enforce minimum PBKDF2 iterations on unwrap, preventing malicious envelopes from declaring a trivial work factor and bypassing key stretching
+- PSBT: reject multi-key descriptors with more than one origin-less xpub, which would otherwise silently lose cosigner identity (taproot internal-key exception preserved)
+- File manager: filter "."/".." and entries containing path separators from SD listings, blocking directory traversal via crafted FAT entries
+- KEF decryption: in-session exponential backoff (1s, 2s, 4s … capped at 30s) on failed attempts, slowing interactive brute forcing without persisting lockout state to flash
# Changelog 26.03.0 - March 2025
diff --git a/src/krux/kef.py b/src/krux/kef.py
index 6010a97..a99f8ac 100644
--- a/src/krux/kef.py
+++ b/src/krux/kef.py
@@ -491,6 +491,11 @@ def unwrap(kef_bytes):
iterations = kef_iterations * 10000
else:
iterations = kef_iterations
+ # Enforce a minimum PBKDF2 iteration count to prevent malicious envelopes
+ # from declaring a trivial work factor (e.g. 0 or 1) and bypassing the
+ # password-stretching that protects the encrypted payload.
+ if iterations < 10000:
+ raise ValueError("Invalid iterations")
payload = kef_bytes[len_id + 5 :]
extra = MODE_IVS.get(VERSIONS[version]["mode"], 0)
diff --git a/src/krux/pages/encryption_ui.py b/src/krux/pages/encryption_ui.py
index 76a1efe..11f9fc3 100644
--- a/src/krux/pages/encryption_ui.py
+++ b/src/krux/pages/encryption_ui.py
@@ -153,6 +153,30 @@ def prompt_for_text_update(
class KEFEnvelope(Page):
"""UI to handle KEF-Encryption-Format Envelopes"""
+ # Shared in session counter of failed KEF decryption attempts across
+ # all UI decrypt paths (unseal_ui and LoadEncryptedMnemonic). Lives in
+ # RAM only, never persisted to flash. A power cycle clears it, an
+ # accepted trade off to slow interactive brute forcing within a single
+ # session without durable lockout state.
+ _failed_attempts = 0
+
+ @classmethod
+ def backoff_delay_ms(cls):
+ """Exponential delay (capped at 30s) before the next decrypt attempt."""
+ if cls._failed_attempts <= 0:
+ return 0
+ return min(1000 * (2 ** (cls._failed_attempts - 1)), 30000)
+
+ @classmethod
+ def note_decrypt_failure(cls):
+ """Bump the shared in session failure counter."""
+ cls._failed_attempts += 1
+
+ @classmethod
+ def note_decrypt_success(cls):
+ """Reset the shared in session failure counter."""
+ cls._failed_attempts = 0
+
def __init__(self, ctx):
super().__init__(ctx, None)
self.ctx = ctx
@@ -362,13 +386,23 @@ class KEFEnvelope(Page):
return None
if not (self.__key or self.input_key_ui(creating=False)):
return None
+ delay_ms = KEFEnvelope.backoff_delay_ms()
+ if delay_ms:
+ # Growing in session delay before each new attempt after a failure.
+ # Capped to keep the UI responsive. Cleared on a successful decrypt
+ # or device reset.
+ self.ctx.display.clear()
+ self.ctx.display.draw_centered_text(t("Processing…"))
+ time.sleep_ms(delay_ms)
self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))
cipher = kef.Cipher(self.__key, self.label, self.iterations)
plaintext = cipher.decrypt(self.ciphertext, self.version)
self.__key = None
if plaintext is None:
+ KEFEnvelope.note_decrypt_failure()
raise KeyError("Failed to decrypt")
+ KEFEnvelope.note_decrypt_success()
if display_plain:
self.ctx.display.clear()
try:
@@ -684,16 +718,24 @@ class LoadEncryptedMnemonic(Page):
return MENU_CONTINUE
self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))
+ # Share the in session failure counter with KEFEnvelope.unseal_ui so
+ # an attacker can not split attempts across the two decrypt paths.
+ delay_ms = KEFEnvelope.backoff_delay_ms()
+ if delay_ms:
+ time.sleep_ms(delay_ms)
mnemonic_storage = MnemonicStorage()
try:
words = mnemonic_storage.decrypt(key, mnemonic_id, sd_card).split()
except:
+ KEFEnvelope.note_decrypt_failure()
self.flash_error(error_txt)
return MENU_CONTINUE
if len(words) not in (12, 24):
+ KEFEnvelope.note_decrypt_failure()
self.flash_error(error_txt)
return MENU_CONTINUE
+ KEFEnvelope.note_decrypt_success()
del mnemonic_storage
return words
diff --git a/src/krux/pages/file_manager.py b/src/krux/pages/file_manager.py
index 74d41e1..0a1adcc 100644
--- a/src/krux/pages/file_manager.py
+++ b/src/krux/pages/file_manager.py
@@ -56,8 +56,19 @@ class FileManager(Page):
items.append("..")
menu_items.append(("../", lambda: MENU_EXIT))
- # sorts by name ignorecase
- dir_files = sorted(os.listdir(path), key=str.lower)
+ # sorts by name ignorecase. Filter out any entry that could
+ # escape the current directory: a malicious or corrupted SD
+ # card could in principle return names containing path
+ # separators or "."/".." which, concatenated below, would
+ # allow traversal outside the intended folder.
+ dir_files = sorted(
+ (
+ f
+ for f in os.listdir(path)
+ if f not in (".", "..") and "/" not in f and "\\" not in f
+ ),
+ key=str.lower,
+ )
# separate directories from files
directories = []
diff --git a/src/krux/psbt.py b/src/krux/psbt.py
index 430614a..f9be6c9 100644
--- a/src/krux/psbt.py
+++ b/src/krux/psbt.py
@@ -589,7 +589,13 @@ class PSBTSigner:
)
elif len(descriptor_keys) > 1:
# Allow one descriptor key without origin data for taproot
- # Pure taptree descriptors won't have origin data for internal key
+ # Pure taptree descriptors won't have origin data for internal key.
+ # Reject more than one origin-less key in a multi-key descriptor:
+ # otherwise unverifiable cosigners would be silently accepted.
+ if origin_less_xpub is not None:
+ raise ValueError(
+ "multiple xpubs without origin in multi-key descriptor"
+ )
origin_less_xpub = descriptor_key.key
return xpubs, origin_less_xpub
diff --git a/tests/pages/test_encryption_ui.py b/tests/pages/test_encryption_ui.py
index bb4edb8..5a65179 100644
--- a/tests/pages/test_encryption_ui.py
+++ b/tests/pages/test_encryption_ui.py
@@ -703,6 +703,109 @@ def test_decrypt_kef(m5stickv, mocker):
assert ctx.input.wait_for_button.call_count == 0
+def test_unseal_ui_failed_attempts_backoff(m5stickv, mocker):
+ # A failed decrypt must bump KEFEnvelope._failed_attempts and the next
+ # attempt must wait via time.sleep_ms with an exponentially growing,
+ # capped delay. A successful decrypt resets the counter.
+ from krux import kef
+ from krux.pages.encryption_ui import decrypt_kef, KEFEnvelope
+ from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV
+
+ plaintext = b"super secret payload"
+ envelope = kef.wrap(
+ b"lbl", 0, 10000, kef.Cipher(b"correct", "lbl", 10000).encrypt(plaintext, 0)
+ )
+
+ KEFEnvelope._failed_attempts = 0
+ sleep_mock = mocker.patch("krux.pages.encryption_ui.time.sleep_ms")
+
+ # First attempt with wrong key, no prior failures so no sleep yet.
+ BTN_SEQUENCE = [
+ BUTTON_ENTER, # Decrypt?
+ BUTTON_ENTER, # enter key
+ BUTTON_ENTER, # type "a"
+ BUTTON_PAGE_PREV, # to "Go"
+ BUTTON_ENTER, # Go
+ BUTTON_ENTER, # confirm key
+ ]
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ with pytest.raises(KeyError, match="Failed to decrypt"):
+ decrypt_kef(ctx, envelope)
+ assert KEFEnvelope._failed_attempts == 1
+ assert sleep_mock.call_count == 0
+
+ # Second attempt with wrong key: a 1000ms sleep must precede the work.
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ with pytest.raises(KeyError, match="Failed to decrypt"):
+ decrypt_kef(ctx, envelope)
+ assert KEFEnvelope._failed_attempts == 2
+ assert sleep_mock.call_args_list[-1].args[0] == 1000
+
+ # Third attempt: delay doubles to 2000ms.
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ with pytest.raises(KeyError, match="Failed to decrypt"):
+ decrypt_kef(ctx, envelope)
+ assert KEFEnvelope._failed_attempts == 3
+ assert sleep_mock.call_args_list[-1].args[0] == 2000
+
+ # The 30s cap applies for very large counters.
+ KEFEnvelope._failed_attempts = 50
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ with pytest.raises(KeyError, match="Failed to decrypt"):
+ decrypt_kef(ctx, envelope)
+ assert sleep_mock.call_args_list[-1].args[0] == 30000
+
+ KEFEnvelope._failed_attempts = 0
+
+
+def test_load_encrypted_mnemonic_shares_failed_attempts_backoff(m5stickv, mocker):
+ # LoadEncryptedMnemonic._load_encrypted_mnemonic must read and update the
+ # same KEFEnvelope._failed_attempts counter, so an attacker can not split
+ # brute force attempts across the stored mnemonic and KEFEnvelope paths.
+ from krux.input import BUTTON_ENTER
+ from krux.pages import MENU_CONTINUE
+ from krux.pages.encryption_ui import KEFEnvelope, LoadEncryptedMnemonic
+
+ mocker.patch(
+ "krux.pages.encryption_ui.EncryptionKey.encryption_key",
+ mocker.MagicMock(return_value="wrong key"),
+ )
+ sleep_mock = mocker.patch("krux.pages.encryption_ui.time.sleep_ms")
+
+ # Start from a non zero counter set by a prior KEFEnvelope failure. The
+ # stored mnemonic flow must honour it and delay before its own attempt.
+ KEFEnvelope._failed_attempts = 1
+
+ ctx = create_ctx(mocker, [BUTTON_ENTER])
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data=SEEDS_JSON)):
+ page = LoadEncryptedMnemonic(ctx)
+ result = page._load_encrypted_mnemonic("ecbID")
+ assert result == MENU_CONTINUE
+ assert sleep_mock.call_args_list[-1].args[0] == 1000
+ assert KEFEnvelope._failed_attempts == 2
+
+ # A second wrong key doubles the delay to 2000ms.
+ ctx = create_ctx(mocker, [BUTTON_ENTER])
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data=SEEDS_JSON)):
+ page = LoadEncryptedMnemonic(ctx)
+ result = page._load_encrypted_mnemonic("ecbID")
+ assert result == MENU_CONTINUE
+ assert sleep_mock.call_args_list[-1].args[0] == 2000
+ assert KEFEnvelope._failed_attempts == 3
+
+ # A successful decrypt must reset the shared counter to 0.
+ mocker.patch(
+ "krux.pages.encryption_ui.EncryptionKey.encryption_key",
+ mocker.MagicMock(return_value=TEST_KEY),
+ )
+ ctx = create_ctx(mocker, [BUTTON_ENTER])
+ with patch("krux.encryption.open", new=mocker.mock_open(read_data=SEEDS_JSON)):
+ page = LoadEncryptedMnemonic(ctx)
+ result = page._load_encrypted_mnemonic("ecbID")
+ assert result == ECB_WORDS.split()
+ assert KEFEnvelope._failed_attempts == 0
+
+
def test_decrypt_kef_offers_decrypt_ui_appropriately(m5stickv, mocker):
"""
Intention here is to verify that KEFEnvelope class is instantiated
diff --git a/tests/pages/test_file_manager.py b/tests/pages/test_file_manager.py
index 4164d2a..26a8286 100644
--- a/tests/pages/test_file_manager.py
+++ b/tests/pages/test_file_manager.py
@@ -339,6 +339,46 @@ def test_files_and_folders_with_long_filenames(m5stickv, mocker, mock_file_opera
assert ctx.input.wait_for_button.call_count == len(BTN_SEQUENCE)
+def test_select_file_filters_traversal_entries(m5stickv, mocker):
+ # A malicious or corrupted SD must not be able to inject "."/".." or
+ # entries containing path separators that would let the user traverse
+ # outside the current directory when concatenated into a path.
+ from krux.pages.file_manager import FileManager
+ from krux.input import BUTTON_PAGE_PREV, BUTTON_ENTER
+
+ mocker.patch(
+ "os.listdir",
+ new=mocker.MagicMock(
+ return_value=[
+ ".",
+ "..",
+ "../etc",
+ "sub/evil",
+ "back\\evil",
+ "good.txt",
+ ]
+ ),
+ )
+ mocker.patch("os.remove", mocker.mock_open(read_data=""))
+ mocker.patch(
+ "krux.sd_card.SDHandler.dir_exists", mocker.MagicMock(side_effect=[True])
+ )
+ mocker.patch(
+ "krux.sd_card.SDHandler.file_exists", mocker.MagicMock(return_value=True)
+ )
+
+ BTN_SEQUENCE = [BUTTON_PAGE_PREV, BUTTON_ENTER] # back -> exit
+ ctx = create_ctx(mocker, BTN_SEQUENCE)
+ file_manager = FileManager(ctx)
+ file_manager.select_file()
+
+ # Only the safe entry must be displayed; all traversal candidates filtered.
+ rendered = [c.args[0] for c in ctx.display.to_lines.call_args_list]
+ assert "good.txt" in rendered
+ for bad in (".", "..", "../etc", "sub/evil", "back\\evil"):
+ assert bad not in rendered
+
+
def test_folders_exploring(m5stickv, mocker, mock_file_operations):
from krux.pages.file_manager import FileManager
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV
diff --git a/tests/test_kef.py b/tests/test_kef.py
index b33f572..1d47c06 100644
--- a/tests/test_kef.py
+++ b/tests/test_kef.py
@@ -1097,6 +1097,25 @@ def test_unwrap_exceptions(m5stickv):
kef.unwrap(encoded)
+def test_unwrap_rejects_low_iterations(m5stickv):
+ # A malicious envelope must not be allowed to declare a trivial PBKDF2
+ # work factor (e.g. iterations=0) and bypass key stretching.
+ from krux import kef
+
+ for original in (
+ ECB_ENCRYPTED_KEF,
+ CBC_ENCRYPTED_KEF,
+ CTR_ENCRYPTED_KEF,
+ GCM_ENCRYPTED_KEF,
+ ):
+ len_id = original[0]
+ # iterations field is the 3 bytes immediately after id and version
+ start = 2 + len_id
+ tampered = original[:start] + b"\x00\x00\x00" + original[start + 3 :]
+ with pytest.raises(ValueError, match="Invalid iterations"):
+ kef.unwrap(tampered)
+
+
def test_faithful_encrypted_wrapper(m5stickv):
from krux import kef
diff --git a/tests/test_psbt.py b/tests/test_psbt.py
index 0c52a14..8ed167f 100644
--- a/tests/test_psbt.py
+++ b/tests/test_psbt.py
@@ -2074,6 +2074,36 @@ def test_xpubs_fails_with_no_xpubs(mocker, m5stickv, tdata):
signer.xpubs()
+def test_xpubs_rejects_multiple_origin_less_keys(mocker, m5stickv):
+ # In a multi-key descriptor, more than one key without origin info would
+ # silently lose cosigner identity. Only the single taproot internal-key
+ # exception is allowed; a second origin-less key must raise.
+ from krux.psbt import PSBTSigner
+
+ signer = PSBTSigner.__new__(PSBTSigner)
+ signer.psbt = mocker.MagicMock(xpubs=None)
+ signer.wallet = mocker.MagicMock()
+
+ key_with_origin = mocker.MagicMock()
+ key_with_origin.origin.fingerprint = b"\x00\x01\x02\x03"
+ key_with_origin.origin.derivation = [0]
+ key_with_origin.key = "xpub_with_origin"
+
+ origin_less_a = mocker.MagicMock(origin=None, key="xpub_a")
+ origin_less_b = mocker.MagicMock(origin=None, key="xpub_b")
+
+ # One origin-less key is allowed (taproot internal key exception).
+ signer.wallet.descriptor.keys = [key_with_origin, origin_less_a]
+ xpubs, origin_less = signer.xpubs()
+ assert origin_less == "xpub_a"
+ assert "xpub_with_origin" in xpubs
+
+ # Two origin-less keys must be rejected.
+ signer.wallet.descriptor.keys = [key_with_origin, origin_less_a, origin_less_b]
+ with pytest.raises(ValueError, match="multiple xpubs without origin"):
+ signer.xpubs()
+
+
def test_sign_single_1_input_1_output_no_change(m5stickv):
from embit.networks import NETWORKS
from krux.psbt import PSBTSigner
Why this scored 74/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.