Merge pull request #652 from Foundation-Devices/taproot-bip322-message-signing
What changed, and why it matters
This commit adds support for signing text messages with Bitcoin Taproot (P2TR) addresses using the BIP-322 standard. It introduces a new helper module, wires it into existing message-signing flows, and adds unit tests. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a feature addition. However, because message signing touches private keys and signature formats, any implementation error could have security consequences, so the change warrants careful review.
Review the BIP-322 implementation for conformance to the specification, especially the sighash serialization, tagged-hash usage, and key tweaking. Verify that the new private-key path in `sign_text_file_task.py` securely clears sensitive material and that the base64/witness formatting matches wallet expectations. Run the new unit tests on hardware and perform cross-implementation signature verification with external BIP-322 tools.
Security signals we found
New cryptographic signing path using BIP-322/BIP-341
Private key handling in `sign_text_file_task.py` via `stash.SensitiveValues` and `node.private_key()`
Signature format change from raw 65-byte recoverable ECDSA to base64 BIP-322 witness
Custom sighash construction in `taproot_signature_hash` without using a vetted library
Unit tests include tampering checks and independent BIP-340 verification
Evidence from the diff
The commit merges PR #652 to implement BIP-322 simple message signing for Taproot/P2TR outputs on the Foundation Passport firmware. A new bip322.py module constructs the BIP-322 virtual to_spend/to_sign transactions, computes the BIP-341 key-path sighash, and produces a base64-encoded witness prefixed with ‘smp’. Existing signing flows (sign_text_file_task.py, sign_electrum_message_flow.py, health_check_common_flow.py) are updated to route P2TR requests through the new code and to pass signatures as pre-formatted strings rather than raw bytes. Unit tests verify the virtual transaction hashes against known BIP-322 vectors and independently verify the Schnorr signature with a pure-Python BIP-340 verifier.
Changed components
ports/stm32/boards/Passport/modules/bip322.pyports/stm32/boards/Passport/modules/tasks/sign_text_file_task.pyports/stm32/boards/Passport/modules/flows/sign_electrum_message_flow.pyports/stm32/boards/Passport/modules/flows/health_check_common_flow.pyports/stm32/boards/Passport/modules/tests/unit/bip322.pyports/stm32/boards/Passport/modules/tests/test_unit.pyports/stm32/boards/Passport/manifest.pyInspect captured patch +255 / −12
### ports/stm32/boards/Passport/manifest.py
@@ -5,7 +5,8 @@
# Keep lists below sorted for easier reference
freeze('$(MPY_DIR)/ports/stm32/boards/Passport/modules',
- ('callgate.py',
+ ('bip322.py',
+ 'callgate.py',
'chains.py',
'common.py',
'compat7z.py',
### ports/stm32/boards/Passport/modules/bip322.py
@@ -0,0 +1,76 @@
+# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# bip322.py - BIP-322 message-signing helpers
+
+from ubinascii import b2a_base64
+from ustruct import pack
+
+from serializations import COutPoint, CTxIn, CTxOut
+from serializations import SIGHASH_DEFAULT, hash256, ser_compact_size, ser_string, ser_string_vector, sha256
+from taproot import output_script, tagged_hash, taproot_sign_key
+
+
+BIP322_SIMPLE_PREFIX = 'smp'
+
+
+def _serialize_virtual_transaction(txin, txout):
+ return pack('<i', 0) + \
+ ser_compact_size(1) + txin.serialize() + \
+ ser_compact_size(1) + txout.serialize() + \
+ pack('<I', 0)
+
+
+def create_virtual_transactions(message, message_challenge):
+ """Create the BIP-322 to_spend and unsigned to_sign transactions."""
+ message_hash = tagged_hash('BIP0322-signed-message', message)
+
+ to_spend_input = CTxIn(
+ COutPoint(0, 0xFFFFFFFF),
+ b'\x00\x20' + message_hash,
+ 0,
+ )
+ to_spend_output = CTxOut(0, message_challenge)
+ to_spend = _serialize_virtual_transaction(to_spend_input, to_spend_output)
+
+ to_spend_hash = int.from_bytes(hash256(to_spend), 'little')
+ to_sign_input = CTxIn(COutPoint(to_spend_hash, 0), b'', 0)
+ to_sign_output = CTxOut(0, b'\x6a')
+ to_sign = _serialize_virtual_transaction(to_sign_input, to_sign_output)
+
+ return to_spend, to_sign
+
+
+def taproot_signature_hash(message, message_challenge):
+ """Calculate the BIP-341 key-path sighash for a BIP-322 virtual spend."""
+ to_spend, _ = create_virtual_transactions(message, message_challenge)
+
+ outpoint = hash256(to_spend) + pack('<I', 0)
+ to_sign_output = CTxOut(0, b'\x6a')
+
+ # BIP-341 SigMsg: hash type, transaction fields, and aggregate input/output hashes.
+ sigmsg = bytes([SIGHASH_DEFAULT])
+ sigmsg += pack('<i', 0)
+ sigmsg += pack('<I', 0)
+ sigmsg += sha256(outpoint)
+ sigmsg += sha256(pack('<q', 0))
+ sigmsg += sha256(ser_string(message_challenge))
+ sigmsg += sha256(pack('<I', 0))
+ sigmsg += sha256(to_sign_output.serialize())
+
+ # Key-path spend without an annex, followed by the input index.
+ sigmsg += b'\x00'
+ sigmsg += pack('<I', 0)
+
+ return tagged_hash('TapSighash', b'\x00' + sigmsg)
+
+
+def sign_taproot_simple(message, internal_pubkey, internal_seckey):
+ """Create a textual BIP-322 simple signature for a P2TR key-path spend."""
+ message_challenge = output_script(internal_pubkey, None)
+ sighash = taproot_signature_hash(message, message_challenge)
+ signature = taproot_sign_key(None, internal_seckey, SIGHASH_DEFAULT, sighash)
+ witness = ser_string_vector([signature])
+
+ encoded_witness = b2a_base64(witness).decode('ascii').strip()
+ return BIP322_SIMPLE_PREFIX + encoded_witness
### ports/stm32/boards/Passport/modules/flows/health_check_common_flow.py
@@ -105,10 +105,12 @@ async def sign_health_check(self):
return
async def format_signature(self):
- from ubinascii import b2a_base64
from public_constants import RFC_SIGNATURE_TEMPLATE
- sig = b2a_base64(self.signature).decode('ascii').strip()
-
- signed_message = RFC_SIGNATURE_TEMPLATE.format(addr=self.address, msg=self.text, blockchain='BITCOIN', sig=sig)
+ signed_message = RFC_SIGNATURE_TEMPLATE.format(
+ addr=self.address,
+ msg=self.text,
+ blockchain='BITCOIN',
+ sig=self.signature,
+ )
self.set_result(signed_message)
### ports/stm32/boards/Passport/modules/flows/sign_electrum_message_flow.py
@@ -12,7 +12,6 @@
from wallets.utils import get_addr_type_from_deriv
from public_constants import AF_CLASSIC, MARGIN_FOR_ADDRESSES
import stash
-from ubinascii import b2a_base64
class SignElectrumMessageFlow(Flow):
@@ -92,9 +91,7 @@ async def do_sign(self):
return
async def show_signed(self):
- qr_data = b2a_base64(self.signature).strip().decode()
-
- result = await ShowQRPage(qr_data=qr_data,
+ result = await ShowQRPage(qr_data=self.signature,
right_micron=microns.Checkmark).show()
self.set_result(result)
### ports/stm32/boards/Passport/modules/tasks/sign_text_file_task.py
@@ -11,6 +11,8 @@
import stash
import chains
+from ubinascii import b2a_base64
+from public_constants import AF_P2TR
from utils import sign_message_digest_recoverable
@@ -24,8 +26,18 @@ async def sign_text_file_task(on_done, text, subpath, addr_fmt, expected_address
await on_done(None, None, 'Address mismatch: expected {}, got {}'.format(expected_address, address))
return
- digest = chains.current_chain().hash_message(text.encode())
- # signature will be 65 bytes
- signature = sign_message_digest_recoverable(digest, subpath)
+ message = text.encode()
+ if addr_fmt == AF_P2TR:
+ from bip322 import sign_taproot_simple
+
+ with stash.SensitiveValues() as sv:
+ node = sv.derive_path(subpath)
+ private_key = node.private_key()
+ sv.register(private_key)
+ signature = sign_taproot_simple(message, node.public_key()[1:], private_key)
+ else:
+ digest = chains.current_chain().hash_message(message)
+ raw_signature = sign_message_digest_recoverable(digest, subpath)
+ signature = b2a_base64(raw_signature).decode('ascii').strip()
await on_done(signature, address, None)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -66,3 +66,7 @@ def test_unchained(test):
def test_restore_backup(test):
assert test('restore_backup.py') == b'OK'
+
+
+def test_bip322(test):
+ assert test('bip322.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/bip322.py
@@ -0,0 +1,151 @@
+# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test BIP-322 Taproot message signing.
+
+from foundation import secp256k1
+from ubinascii import a2b_base64
+from ubinascii import unhexlify as a2b_hex
+
+from bip322 import create_virtual_transactions, sign_taproot_simple, taproot_signature_hash
+from serializations import hash256
+from taproot import output_script, tagged_hash
+
+
+def reverse_bytes(data):
+ # Transaction ids are displayed least-significant byte first. MicroPython
+ # has no slices with a step, so data[::-1] is not available here.
+ out = bytearray(len(data))
+ for i in range(len(data)):
+ out[len(data) - 1 - i] = data[i]
+ return bytes(out)
+
+
+# A BIP-340 verifier, implemented here so the generated signature is checked
+# against an implementation independent of the one that produced it. Passport
+# builds with FOUNDATION_ADDITIONS, so trezorcrypto.bip340 is compiled out, and
+# foundation.secp256k1 exposes signing but no verification.
+P_FIELD = (1 << 256) - (1 << 32) - 977
+N_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
+G_POINT = (0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,
+ 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8)
+
+
+def lift_x(x):
+ if x >= P_FIELD:
+ return None
+
+ y_sq = (pow(x, 3, P_FIELD) + 7) % P_FIELD
+ y = pow(y_sq, (P_FIELD + 1) // 4, P_FIELD)
+ if pow(y, 2, P_FIELD) != y_sq:
+ return None
+
+ return (x, y if y % 2 == 0 else P_FIELD - y)
+
+
+def point_add(p1, p2):
+ if p1 is None:
+ return p2
+ if p2 is None:
+ return p1
+ if p1[0] == p2[0] and p1[1] != p2[1]:
+ return None
+
+ if p1 == p2:
+ lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P_FIELD - 2, P_FIELD)) % P_FIELD
+ else:
+ lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P_FIELD - 2, P_FIELD)) % P_FIELD
+
+ x = (lam * lam - p1[0] - p2[0]) % P_FIELD
+ return (x, (lam * (p1[0] - x) - p1[1]) % P_FIELD)
+
+
+def point_mul(point, scalar):
+ result = None
+ for i in range(256):
+ if (scalar >> i) & 1:
+ result = point_add(result, point)
+ point = point_add(point, point)
+ return result
+
+
+def bip340_verify(pubkey, signature, digest):
+ point = lift_x(int.from_bytes(pubkey, 'big'))
+ if point is None:
+ return False
+
+ r = int.from_bytes(signature[:32], 'big')
+ s = int.from_bytes(signature[32:], 'big')
+ if r >= P_FIELD or s >= N_ORDER:
+ return False
+
+ challenge = tagged_hash('BIP0340/challenge', signature[:32] + pubkey + digest)
+ e = int.from_bytes(challenge, 'big') % N_ORDER
+
+ computed = point_add(point_mul(G_POINT, s), point_mul(point, N_ORDER - e))
+ if computed is None or computed[1] % 2 != 0 or computed[0] != r:
+ return False
+
+ return True
+
+
+# Check the verifier itself against a plain sign/verify round trip first, so a
+# broken verifier cannot silently pass the BIP-322 assertions below.
+probe_key = a2b_hex('01' * 32)
+probe_pubkey = secp256k1.public_key_schnorr(probe_key)
+probe_digest = a2b_hex('02' * 32)
+probe_signature = secp256k1.sign_schnorr(probe_digest, probe_key)
+
+assert bip340_verify(probe_pubkey, probe_signature, probe_digest)
+assert not bip340_verify(probe_pubkey, probe_signature, a2b_hex('03' * 32))
+
+
+# BIP-322 basic test vector for the virtual transaction construction.
+message = b'Hello World'
+message_challenge = a2b_hex('00142b05d564e6a7a33c087f16e0f730d1440123799d')
+to_spend, to_sign = create_virtual_transactions(message, message_challenge)
+
+assert reverse_bytes(hash256(to_spend)) == a2b_hex(
+ 'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b'
+)
+assert reverse_bytes(hash256(to_sign)) == a2b_hex(
+ '88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf'
+)
+
+# BIP-322 generated P2TR test vector.
+message = b'PURVOQ544B6HUATVBJZN5EZJUU'
+message_challenge = a2b_hex('5120c038cb8c0c783475d76fba41a5866f7e80385898f10609855c20d2aced117127')
+private_key = a2b_hex('f805d22c9379f60b87770c8358c8fc2310b3e65d1c4555a51f58c912862b385b')
+internal_pubkey = secp256k1.public_key_schnorr(private_key)
+
+assert output_script(internal_pubkey, None) == message_challenge
+assert taproot_signature_hash(message, message_challenge) == a2b_hex(
+ '7f9ffcd78cf3111b2ff6ede58671348f25bfc9ac64a4ca44570944cb9f7df734'
+)
+
+signature = sign_taproot_simple(message, internal_pubkey, private_key)
+assert signature.startswith('smp')
+
+witness = a2b_base64(signature[3:])
+assert len(witness) == 66
+assert witness[:2] == b'\x01\x40'
+
+# The encoding checks above pass for any 64-byte signature, so verify the
+# signature cryptographically as well.
+schnorr_signature = witness[2:]
+output_pubkey = message_challenge[2:]
+sighash = taproot_signature_hash(message, message_challenge)
+
+assert bip340_verify(output_pubkey, schnorr_signature, sighash)
+
+# It must bind to the message.
+tampered_sighash = taproot_signature_hash(b'Tampered message', message_challenge)
+assert tampered_sighash != sighash
+assert not bip340_verify(output_pubkey, schnorr_signature, tampered_sighash)
+
+# And it must be made with the tweaked output key, not the internal key. Both
+# are valid x-only keys, so only verification distinguishes them.
+assert internal_pubkey != output_pubkey
+assert not bip340_verify(internal_pubkey, schnorr_signature, sighash)
+
+return_value.write(b'OK')Why this scored 30/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.