Merge pull request #642 from Foundation-Devices/SFT-7110-message-signing-display-mismatch
What changed, and why it matters
This update fixes a display-mismatch bug when signing messages on the Passport hardware wallet. Before the fix, special characters in a message could change how the message looked on the device screen without changing the bytes that would actually be signed. That could trick a user into approving a different message than the one shown. The patch escapes display markup and blocks control characters so the screen now accurately reflects what will be signed.
Treat this as a security fix and include it in the next firmware release. Verify that escape_text() covers all markup tokens used by the device's text renderer, and extend the same escaping to any other user-controlled text shown before signing. Run the new unit tests in CI.
Security signals we found
UI markup injection / display spoofing in message signing
Control-character truncation/obfuscation before signing
Mismatch between displayed message and signed bytes
Addition of input validation (printable-ASCII enforcement)
Addition of output escaping before UI rendering
New regression tests covering display and validation behavior
Evidence from the diff
The commit addresses a message-signing display mismatch (SFT-7110). The firmware uses markup syntax where ‘#’ characters can recolor or hide text. The patch introduces escape_text() calls in LongTextPage rendering for Electrum message signing, health-check signing, and PSBT warning rendering so that ‘#’, control characters, and other formatting tokens are shown literally rather than interpreted. It also re-enables the printable-ASCII check in validate_electrum_message_task (removing check_ascii=False) so messages containing NUL, other C0 controls, DEL, or non-ASCII bytes are rejected. New unit tests verify that displayed text matches escaped expectations and that signing still uses the original unmodified message bytes.
Changed components
flows/sign_electrum_message_flow.pyflows/health_check_common_flow.pyflows/sign_psbt_common_flow.pytasks/validate_electrum_message_task.pytests/unit/message_display.pytests/unit/electrum_message_validation.pyInspect captured patch +178 / −10
### ports/stm32/boards/Passport/modules/flows/health_check_common_flow.py
@@ -52,7 +52,7 @@ async def validate_lines(self):
async def show_message(self):
import stash
- from utils import stylize_address
+ from utils import escape_text, stylize_address
from pages import LongTextPage, LongQuestionPage
import microns
from public_constants import MARGIN_FOR_ADDRESSES
@@ -64,7 +64,7 @@ async def show_message(self):
display_address = stylize_address(self.address)
result = await LongTextPage(centered=True,
- text=('\n' + self.text),
+ text=('\n' + escape_text(self.text)),
card_header={'title': 'Message'}).show()
if not result:
### ports/stm32/boards/Passport/modules/flows/sign_electrum_message_flow.py
@@ -6,7 +6,7 @@
from flows import Flow, ScanQRFlow
from pages import ErrorPage, LongTextPage, LongQuestionPage, ShowQRPage
from tasks import sign_text_file_task, validate_electrum_message_task
-from utils import spinner_task, stylize_address
+from utils import escape_text, spinner_task, stylize_address
from data_codecs.qr_type import QRType
import microns
from wallets.utils import get_addr_type_from_deriv
@@ -61,7 +61,7 @@ async def show_message(self):
display_address = stylize_address(self.address)
result = await LongTextPage(centered=True,
- text=('\n' + self.message),
+ text=('\n' + escape_text(self.message)),
card_header={'title': 'Message'}).show()
if not result:
### ports/stm32/boards/Passport/modules/flows/sign_psbt_common_flow.py
@@ -277,7 +277,7 @@ def render_warnings(self):
if self.psbt.warnings and len(self.psbt.warnings) > 0:
msg.write('\n\n{}'.format(recolor(HIGHLIGHT_TEXT_HEX, 'Warnings')))
for label, m in self.psbt.warnings:
- msg.write('\n{}\n{}\n'.format(recolor(BLACK_HEX, label), m))
+ msg.write('\n{}\n{}\n'.format(recolor(BLACK_HEX, escape_text(label)), escape_text(m)))
gc.collect()
return msg.getvalue()
### ports/stm32/boards/Passport/modules/tasks/validate_electrum_message_task.py
@@ -24,11 +24,10 @@ async def validate_electrum_message_task(on_done, message):
await on_done(None, 'Unsupported message type')
return
- (subpath, error) = validate_sign_text(message,
- header_elements[1],
- space_limit=False,
- check_whitespace=False,
- check_ascii=False)
+ # Keep the printable-ASCII check: controls can truncate or obscure the
+ # preview even though the original message bytes would still be signed.
+ (subpath, error) = validate_sign_text(
+ message, header_elements[1], space_limit=False, check_whitespace=False)
if error:
await on_done(None, error)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -88,6 +88,14 @@ def test_op_return_rendering(test):
assert test('op_return_rendering.py') == b'OK'
+def test_message_display(test):
+ assert test('message_display.py') == b'OK'
+
+
+def test_electrum_message_validation(test):
+ assert test('electrum_message_validation.py') == b'OK'
+
+
def test_foundation(test):
assert test('foundation.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/electrum_message_validation.py
@@ -0,0 +1,45 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# QR messages must be fully displayable without changing the signed text.
+
+import uasyncio as asyncio
+from tasks.validate_electrum_message_task import validate_electrum_message_task
+
+
+PATH = "m/44'/0'/0'/0/0"
+
+
+async def validate(message):
+ results = []
+
+ async def on_done(value, error):
+ results.append((value, error))
+
+ await validate_electrum_message_task(on_done, 'signmessage {} ascii:{}'.format(PATH, message))
+ assert len(results) == 1
+ return results[0]
+
+
+async def run_tests():
+ # Printable ASCII, including markup and colons, reaches review unchanged.
+ for message in (
+ ''.join(chr(code) for code in range(32, 127)),
+ 'Visible #ffffff hidden text#',
+ ' leading spaces and trailing ',
+ 'message:with:colons',
+ ):
+ value, error = await validate(message)
+ assert error is None
+ assert value == (message, PATH)
+
+ # NUL must not hide a signed suffix. Other controls (including LF and TAB),
+ # DEL, and non-ASCII text are also outside the supported display charset.
+ for character in tuple(chr(code) for code in range(32)) + ('\x7f', '\u00e9', '\u202e'):
+ value, error = await validate('visible' + character + 'hidden')
+ assert value is None
+ assert error is not None
+
+
+asyncio.run(run_tests())
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/tests/unit/message_display.py
@@ -0,0 +1,116 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Exercise message review and signing without keys or interactive pages.
+
+import pages
+import stash
+import utils
+import uasyncio as asyncio
+import flows.sign_electrum_message_flow as electrum
+from flows.health_check_common_flow import HealthCheckCommonFlow
+
+
+class MockReviewPage:
+ texts = []
+
+ def __init__(self, text, **kwargs):
+ self.texts.append(text)
+
+ async def show(self):
+ return True
+
+
+class MockSensitiveValues:
+ def __enter__(self):
+ self.chain = self
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ def derive_path(self, path):
+ return path
+
+ def address(self, node, addr_type):
+ return '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
+
+
+class MockFlow:
+ subpath = "m/44'/0'/0'/0/0"
+ addr_type = 0
+ normal_signing = True
+ do_sign = 'sign-electrum'
+ sign_health_check = 'sign-microsd'
+ show_signed = 'show-signed'
+ format_signature = 'format-signature'
+
+ def goto(self, state, **kwargs):
+ self.next_state = state
+
+ def set_result(self, result):
+ raise AssertionError('Unexpected rejection')
+
+
+signed_messages = []
+
+
+async def mock_sign_spinner(label, task, args):
+ assert task is electrum.sign_text_file_task
+ signed_messages.append(args[0])
+ return (b'signature', args[3], None)
+
+
+async def run_tests():
+ original_text_page = pages.LongTextPage
+ original_question_page = pages.LongQuestionPage
+ original_electrum_text_page = electrum.LongTextPage
+ original_electrum_question_page = electrum.LongQuestionPage
+ original_sensitive_values = stash.SensitiveValues
+ original_spinner = utils.spinner_task
+ original_electrum_spinner = electrum.spinner_task
+ try:
+ pages.LongTextPage = electrum.LongTextPage = MockReviewPage
+ pages.LongQuestionPage = electrum.LongQuestionPage = MockReviewPage
+ stash.SensitiveValues = MockSensitiveValues
+ utils.spinner_task = electrum.spinner_task = mock_sign_spinner
+
+ cases = (
+ ('literal # and ## hashes', 'literal ## and #### hashes'),
+ ('#ff0000 red#', '##ff0000 red##'),
+ ('before #00ff00 green# after #', 'before ##00ff00 green## after ##'),
+ )
+ for message, displayed in cases:
+ for is_electrum in (True, False):
+ flow = MockFlow()
+ flow.message = flow.text = message
+ MockReviewPage.texts = []
+ before = len(signed_messages)
+ if is_electrum:
+ await electrum.SignElectrumMessageFlow.show_message(flow)
+ assert flow.next_state == flow.do_sign
+ else:
+ await HealthCheckCommonFlow.show_message(flow)
+ assert flow.next_state == flow.sign_health_check
+ assert MockReviewPage.texts[0] == '\n' + displayed
+ assert len(MockReviewPage.texts) == 2
+ assert len(signed_messages) == before
+ assert flow.message == flow.text == message
+ if is_electrum:
+ await electrum.SignElectrumMessageFlow.do_sign(flow)
+ else:
+ await HealthCheckCommonFlow.sign_health_check(flow)
+ assert len(signed_messages) == before + 1
+ assert signed_messages[-1] == message
+ return_value.write(b'OK')
+ finally:
+ pages.LongTextPage = original_text_page
+ pages.LongQuestionPage = original_question_page
+ electrum.LongTextPage = original_electrum_text_page
+ electrum.LongQuestionPage = original_electrum_question_page
+ stash.SensitiveValues = original_sensitive_values
+ utils.spinner_task = original_spinner
+ electrum.spinner_task = original_electrum_spinner
+
+
+asyncio.run(run_tests())Why this scored 72/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.