Merge pull request #773 from scgbckbone/bugfix/bip322-por-foreign-inputs-public
What changed, and why it matters
This update fixes a bug in COLDCARD's handling of BIP-322 Proof of Reserves (POR). A maliciously crafted proof could include a foreign (not owned by the device) Bitcoin input disguised to look like the device's own input using a fake key path and a partial signature. Before the fix, the device might have accepted such a foreign input in a POR, potentially allowing someone to falsely inflate or manipulate a proof of reserves. After the fix, the device rejects any POR that contains inputs it is not actually going to sign, beyond the required first input.
Apply this patch and ensure BIP-322 POR proofs are re-tested against the new regression tests. Users relying on Proof of Reserves should upgrade once the release containing this fix is available.
Security signals we found
BIP-322 Proof of Reserves foreign-input bypass
Forged key-path metadata and partial-signature spoofing
Insufficient ownership validation in PSBT input processing
New FatalPSBTIssue guard for POR foreign inputs
Evidence from the diff
In shared/psbt.py, the consider_inputs method now tracks a foreign_por flag during BIP-322 POR processing. For any input after the to_spend input (i > 0), if required_key is None, the input is considered foreign and the PSBT is rejected with FatalPSBTIssue(‘Foreign inputs not allowed in BIP-322 Proof of Reserves’). The previous check relying on num_our_keys was insufficient because forged BIP32 paths (including a zero-XFP placeholder) combined with a partial signature could make a foreign input appear owned and already signed. Tests were added/updated to verify rejection of both incomplete BIP32 paths and a presigned foreign input with forged key-path metadata.
Changed components
shared/psbt.pyBIP-322 Proof of Reserves signing flowPSBT input ownership validationInspect captured patch +36 / −10
### releases/Next-ChangeLog.md
@@ -4,6 +4,8 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
+- Bugfix: Reject foreign inputs from BIP-322 Proof of Reserves, including inputs
+ disguised with forged key-path metadata or partial signatures.
- Bugfix: Restore the ability to view the device-generated seed before adding user
entropy, which was available in the previous dice-roll workflow but was inadvertently
removed in 5.6.1/1.5.1Q. The new **View TRNG Words** menu item displays the full
### shared/psbt.py
@@ -1849,6 +1849,7 @@ def consider_inputs(self, cosign_xfp=None):
total_in = 0
from_wif_store = []
prevouts = set()
+ foreign_por = False
for i, txi in self.input_iter():
# check for duplicate inputs
@@ -1884,6 +1885,13 @@ def consider_inputs(self, cosign_xfp=None):
# - also finds appropriate multisig wallet to be used
inp.determine_my_signing_key(i, utxo, self.my_xfp, self, cosign_xfp)
+ if self.por322 and i and not inp.required_key:
+ # every POR input past to_spend must be one we will actually sign;
+ # num_our_keys is not enough: forged keypaths (incl. zero-xfp
+ # placeholder) plus a partial sig would make a foreign input
+ # look ours and already-signed (required_key is None)
+ foreign_por = True
+
if inp.required_key and not inp.is_segwit and not inp.utxo:
raise FatalPSBTIssue('Legacy input #%d requires non-witness UTXO' % i)
@@ -1919,6 +1927,9 @@ def consider_inputs(self, cosign_xfp=None):
# XXX scan witness data provided, and consider those ins signed if not multisig?
+ if foreign_por:
+ raise FatalPSBTIssue("Foreign inputs not allowed in BIP-322 Proof of Reserves")
+
if not foreign and not unverified_witness_utxo:
# no foreign inputs, we can calculate the total input value
self.total_value_in = total_in
### testing/test_bip322.py
@@ -3,7 +3,7 @@
# BIP-322 Message Signing and Proof of Reserves
# NOTE: Run this module with and without --psbt2 to cover both PSBT versions.
#
-import pytest, time, os
+import pytest, time, os, struct
from io import BytesIO
from decimal import Decimal
from constants import SIGHASH_MAP, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH
@@ -320,8 +320,7 @@ def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, e
[["p2wpkh", None, None], ["p2pkh", None, 10000000], ["p2pkh", None, 10000000]],
[["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
])
-def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story,
- verify_msg_bip322_por):
+def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story):
def hack(psbt_in):
without_paths = 0 if len(psbt_in.inputs) == 1 else 1
@@ -332,16 +331,30 @@ def hack(psbt_in):
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
start_sign(psbt)
title, story = cap_story()
+ assert title == "Failure"
if len(ins) == 1:
- assert title == "Failure"
assert 'PSBT does not contain any key path information.' in story
else:
- verify_msg_bip322_por("POR")
- time.sleep(.1)
- title, story = cap_story()
- assert "warning" in story
- assert "Limited Signing" in story
- assert "because we do not know the key: 1" in story
+ assert "Foreign inputs not allowed in BIP-322 Proof of Reserves" in story
+
+
+def test_bip322_por_presigned_foreign_input(bip322_txn, start_sign, cap_story):
+ # Foreign UTXO (key from a different seed) carrying a forged zero-xfp keypath
+ # - rewritten to our master fingerprint on the fly - plus a partial signature,
+ # so the input looks "ours" and already signed; must still be rejected.
+ foreign_sec = BIP32Node.from_master_secret(b'\x77' * 32).subkey_for_path("0/0").sec()
+
+ def hack(psbt_in):
+ inp = psbt_in.inputs[1]
+ inp.bip32_paths = {foreign_sec: b"\x00" * 4 + struct.pack("<I", 0)}
+ inp.part_sigs[foreign_sec] = b"\x30" + 70 * b"a"
+
+ psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
+ witness_utxo=[1], psbt_hacker=hack)
+ start_sign(psbt)
+ title, story = cap_story()
+ assert title == "Failure"
+ assert "Foreign inputs not allowed in BIP-322 Proof of Reserves" in story
def test_bip322_por_input0_bip32_paths_required(bip322_txn, start_sign, cap_story):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.