Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ports/stm32/boards/Passport/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
76 changes: 76 additions & 0 deletions ports/stm32/boards/Passport/modules/bip322.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
18 changes: 15 additions & 3 deletions ports/stm32/boards/Passport/modules/tasks/sign_text_file_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
4 changes: 4 additions & 0 deletions ports/stm32/boards/Passport/modules/tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ def test_ui(test):

def test_foundation(test):
assert test('foundation.py') == b'OK'


def test_bip322(test):
assert test('bip322.py') == b'OK'
45 changes: 45 additions & 0 deletions ports/stm32/boards/Passport/modules/tests/unit/bip322.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 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


# 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 hash256(to_spend)[::-1] == a2b_hex(
'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b'
)
assert hash256(to_sign)[::-1] == 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'

return_value.write(b'OK')