Fix: Reject multisig policies with m=0 or m>n and guard against ZeroDivisionError in fee calculation and (#845)
What changed, and why it matters
This update fixes two security issues in Krux, a Bitcoin signing device. First, it prevents the device from crashing when calculating fees for transactions that send no regular bitcoin (only data outputs like OP_RETURN). Second, it now rejects invalid multisig wallet setups where zero signatures are required or where more signatures are required than keys exist, which could otherwise allow unauthorized or impossible spending rules.
Users should upgrade to Krux v26.03.1. Developers should review whether other PSBT parsing paths assume non-zero output amounts and ensure all wallet import formats validate multisig quorums consistently.
Security signals we found
CHANGELOG explicitly lists both changes under 'Security Fixes'
Fixes ZeroDivisionError in fee calculation for zero-value output PSBTs
Adds multisig quorum validation: rejects m=0 and m>n
Test comments reference external audit findings (C7, C8)
Defense-in-depth: validation added at both descriptor and key-value file parsing layers
Evidence from the diff
The commit patches two validation gaps. In psbt.py, _get_resume_fee now checks if out_amount > 0 before computing fee_percent = fee*10000//out_amount, avoiding a ZeroDivisionError when all outputs are zero-value (e.g., OP_RETURN-only PSBTs). In wallet.py, it adds m >= 1 and m <= n checks for multisig quorums both in Wallet.load (descriptor path) and parse_key_value_file (plain-text wallet files), raising ValueError with specific messages. Tests are added for both fixes, referencing audit findings C7 and C8.
Changed components
src/krux/psbt.pysrc/krux/wallet.pyBitcoin transaction fee display/calculationMultisig wallet loading and parsingInspect captured patch +149 / −8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a52da09..6ff4765 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,9 @@
# Changelog 26.03.1 - March 2025
### Security Fixes
-Reject PSBT inputs with non-standard sighash types before signing
+- Reject PSBT inputs with non-standard sighash types before signing
+- Fix ZeroDivisionError in fee calculation for zero-value output PSBTs
+- Validate multisig quorum: reject m=0 and m>n in key-value wallet files
# Changelog 26.03.0 - March 2025
diff --git a/mkdocs.yml b/mkdocs.yml
index 088fe2c..5c9eab2 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-v26.03.0
+ latest_krux: krux-v26.03.1
latest_installer: v0.0.21
latest_installer_rpm: krux_installer-0.0.21-1.x86_64.rpm
latest_installer_deb: krux_installer_0.0.21_amd64.deb
diff --git a/pyproject.toml b/pyproject.toml
index 2612728..ee439f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@
[tool.poetry]
name = "krux"
-version = "26.03.0"
+version = "26.03.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 d4ebc55..7c367e6 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 = "26.03.0"
+VERSION = "26.03.1"
SIGNER_PUBKEY = "03339e883157e45891e61ca9df4cd3bb895ef32d475b8e793559ea10a36766689b"
diff --git a/src/krux/psbt.py b/src/krux/psbt.py
index 7dbf6f1..430614a 100644
--- a/src/krux/psbt.py
+++ b/src/krux/psbt.py
@@ -283,10 +283,13 @@ class PSBTSigner:
fee = inp_amount - out_amount
# fee percent with 1 decimal precision using math.ceil (minimum of 0.1)
- fee_percent = max(
- 0.1,
- (((fee * 10000 // (out_amount)) + 9) // 10) / 10,
- )
+ if out_amount > 0:
+ fee_percent = max(
+ 0.1,
+ (((fee * 10000 // out_amount) + 9) // 10) / 10,
+ )
+ else:
+ fee_percent = 100.0
resume_fee_str = (
t("Fee:")
diff --git a/src/krux/wallet.py b/src/krux/wallet.py
index df516e2..732c323 100644
--- a/src/krux/wallet.py
+++ b/src/krux/wallet.py
@@ -250,6 +250,10 @@ class Wallet:
elif self.descriptor.is_basic_multisig:
m = int(str(self.descriptor.miniscript.args[0]))
n = len(self.descriptor.keys)
+ if m < 1:
+ raise ValueError("multisig quorum m must be at least 1, got %d" % m)
+ if m > n:
+ raise ValueError("multisig quorum m (%d) exceeds n (%d)" % (m, n))
cosigners = [key.key.to_base58() for key in self.descriptor.keys]
if self.descriptor.is_sorted:
cosigners = sorted(cosigners)
@@ -363,6 +367,11 @@ def parse_key_value_file(wallet_data):
m = int(policy[: policy.index("of")].strip())
n = int(policy[policy.index("of") + 2 :].strip())
+ if m < 1:
+ raise ValueError("multisig quorum m must be at least 1, got %d" % m)
+ if m > n:
+ raise ValueError("multisig quorum m (%d) exceeds n (%d)" % (m, n))
+
keys = []
for i in range(len(key_vals)):
kv = key_vals[i]
@@ -462,6 +471,8 @@ def parse_wallet(wallet_data):
descriptor, label = parse_key_value_file(wallet_data)
if descriptor and label:
return descriptor, label
+ except ValueError:
+ raise
except:
raise ValueError("invalid wallet format")
diff --git a/tests/test_psbt.py b/tests/test_psbt.py
index 56738f8..0c52a14 100644
--- a/tests/test_psbt.py
+++ b/tests/test_psbt.py
@@ -2257,3 +2257,21 @@ def test_sign_sats_vB(m5stickv):
)
# TODO: Add a multisig with descriptor so change can be deteted
+
+
+def test_fee_percent_zero_out_amount(mocker, m5stickv, tdata):
+ """Test that fee calculation handles zero out_amount (OP_RETURN only PSBTs)
+ without raising ZeroDivisionError (audit finding C7)"""
+ from embit.networks import NETWORKS
+ from krux.psbt import PSBTSigner
+ from krux.key import Key, TYPE_SINGLESIG
+ from krux.wallet import Wallet
+ from krux.qr import FORMAT_NONE
+
+ wallet = Wallet(Key(tdata.TEST_MNEMONIC, TYPE_SINGLESIG, NETWORKS["test"]))
+ signer = PSBTSigner(wallet, tdata.P2WPKH_PSBT, FORMAT_NONE)
+
+ # Call _get_resume_fee with out_amount=0 (simulates OP_RETURN only PSBT)
+ resume_fee_str, fee_percent = signer._get_resume_fee(1000, 0, {})
+ assert fee_percent == 100.0
+ assert "100.0%" in resume_fee_str
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index f113d0c..19a445d 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -1875,3 +1875,110 @@ def test_parse_wallet_via_ur_account(mocker, m5stickv):
descriptor, label = parse_wallet(wallet_data)
assert str(descriptor) == DESCRIPTORS[i]
print(DESCRIPTORS[i])
+
+
+def test_parse_key_value_file_rejects_m_zero(m5stickv):
+ """Audit C8: m=0 in multisig policy means zero signatures required"""
+ from krux.wallet import parse_key_value_file
+
+ wallet_data = """
+ Name: Bad Wallet
+ Policy: 0 of 3
+ Derivation: m/45'
+ Format: P2SH
+
+ d3a80c8b:
+ xpub6ApMSMmLpzeYTv72drQnbXkzg1nedyDQVyvtZVUWCS3B1pSSp24Y1tDtzcnD1ovCBhb3QuhYtN5rE8ayoZ3c2fNBYYmPN9VQVSNTKKFe8SH
+
+ 55f8fc5d:
+ xpub6Ac49WroT3nhb4uicbE5EUD7WiH2Xooubauvqw5fJYTLbmWFHnGjXRXwkPnFcTgK47KzzKTJNcjua2PisceZfwdoCUXYmX5Ju2v4RU2C7ps
+
+ 3e15470d:
+ xpub6AFDaW88dK7HvhWpHPGwGRym7h2pk8BZYjNUQ5GSkqeoGcMXC9cai7zBCwVeRFrD4wGeWkS3wSqU2jh9nEs2f5SHiM8Fa96ffce7maiKVtm
+ """
+
+ with pytest.raises(ValueError, match="m must be at least 1"):
+ parse_key_value_file(wallet_data)
+
+
+def test_parse_key_value_file_rejects_m_greater_than_n(m5stickv):
+ """Audit C8: m > n means more signatures required than keys available"""
+ from krux.wallet import parse_key_value_file
+
+ wallet_data = """
+ Name: Bad Wallet
+ Policy: 4 of 3
+ Derivation: m/45'
+ Format: P2SH
+
+ d3a80c8b:
+ xpub6ApMSMmLpzeYTv72drQnbXkzg1nedyDQVyvtZVUWCS3B1pSSp24Y1tDtzcnD1ovCBhb3QuhYtN5rE8ayoZ3c2fNBYYmPN9VQVSNTKKFe8SH
+
+ 55f8fc5d:
+ xpub6Ac49WroT3nhb4uicbE5EUD7WiH2Xooubauvqw5fJYTLbmWFHnGjXRXwkPnFcTgK47KzzKTJNcjua2PisceZfwdoCUXYmX5Ju2v4RU2C7ps
+
+ 3e15470d:
+ xpub6AFDaW88dK7HvhWpHPGwGRym7h2pk8BZYjNUQ5GSkqeoGcMXC9cai7zBCwVeRFrD4wGeWkS3wSqU2jh9nEs2f5SHiM8Fa96ffce7maiKVtm
+ """
+
+ with pytest.raises(ValueError, match="m .* exceeds n"):
+ parse_key_value_file(wallet_data)
+
+
+def test_descriptor_rejects_m_zero(m5stickv):
+ """Audit C8: embit already rejects sortedmulti(0,...) at descriptor level"""
+ from embit.descriptor import Descriptor
+
+ with pytest.raises(Exception, match="multi.*0"):
+ Descriptor.from_string(
+ "wsh(sortedmulti(0,"
+ "[55f8fc5d/48h/0h/0h/2h]xpub6EKmKYGYc1WY6t9d3d9SksR8keSaPZbFa6tqsGiH4xVxx8d2YyxSX7WG6yXEX3CmG54dPCxaapDw1XsjwCmfoqP7tbsAeqMVfKvqSAu4ndy/0/*,"
+ "[3e15470d/48h/0h/0h/2h]xpub6F2P6Pz5KLPgCc6pTBd2xxCunaSYWc8CdkL28W5z15pJrN3aCYY7mCUAkCMtqrgT2wdhAGgRnJxAkCCUpGKoXKxQ57yffEGmPwtYA3DEXwu/0/*,"
+ "[d3a80c8b/48h/0h/0h/2h]xpub6FKYY6y3oVi7ihSCszFKRSeZj5SzrfSsUFXhKqjMV4iigrLhxwMX3mrjioNyLTZ5iD3u4wU9S3tyzpJGxhd5geaXoQ68jGz2M6dfh2zJrUv/0/*))"
+ )
+
+
+def test_wallet_load_rejects_descriptor_m_zero(mocker, m5stickv):
+ """Audit C8: defense-in-depth in Wallet.load rejects m=0 from descriptor"""
+ from unittest.mock import MagicMock
+ from krux.wallet import Wallet
+
+ mock_descriptor = MagicMock()
+ mock_descriptor.key = None
+ mock_descriptor.is_basic_multisig = True
+ mock_descriptor.miniscript.args.__getitem__ = lambda self, i: 0 # m=0
+ mock_descriptor.keys = [MagicMock(), MagicMock(), MagicMock()] # n=3
+
+ mocker.patch("krux.wallet.parse_wallet", return_value=(mock_descriptor, "Test"))
+ mocker.patch("krux.wallet.to_unambiguous_descriptor", return_value=mock_descriptor)
+ mocker.patch(
+ "krux.wallet.version_to_network_versiontype",
+ return_value=("main", "xpub"),
+ )
+
+ wallet = Wallet(None)
+ with pytest.raises(ValueError, match="m must be at least 1"):
+ wallet.load("fake_data", 0)
+
+
+def test_wallet_load_rejects_descriptor_m_greater_than_n(mocker, m5stickv):
+ """Audit C8: defense-in-depth in Wallet.load rejects m>n from descriptor"""
+ from unittest.mock import MagicMock
+ from krux.wallet import Wallet
+
+ mock_descriptor = MagicMock()
+ mock_descriptor.key = None
+ mock_descriptor.is_basic_multisig = True
+ mock_descriptor.miniscript.args.__getitem__ = lambda self, i: 4 # m=4
+ mock_descriptor.keys = [MagicMock(), MagicMock(), MagicMock()] # n=3
+
+ mocker.patch("krux.wallet.parse_wallet", return_value=(mock_descriptor, "Test"))
+ mocker.patch("krux.wallet.to_unambiguous_descriptor", return_value=mock_descriptor)
+ mocker.patch(
+ "krux.wallet.version_to_network_versiontype",
+ return_value=("main", "xpub"),
+ )
+
+ wallet = Wallet(None)
+ with pytest.raises(ValueError, match="m .* exceeds n"):
+ wallet.load("fake_data", 0)
Why this scored 60/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.