Merge pull request #641 from Foundation-Devices/SFT-7112-single-line-signmessage
What changed, and why it matters
This commit adds support for a new single-line message-signing format used by Foundation's Envoy app. It also tightens parsing so that multi-line files split on all line endings (not just '\n') and so that embedded newlines in the single-line format are rejected rather than silently dropped. The change is a feature addition with defensive hardening, not a clear fix for an active vulnerability.
Review the new single-line parser for path-traversal or injection issues, confirm that get_addr_type_from_deriv() cannot be tricked by unusual path components, and ensure the strict validation rules are consistently enforced across all signing entry points. No urgent patch is indicated from the diff alone.
Security signals we found
New input format parsing added to signing/health-check flow
Embedded newline handling changed from silent truncation to validation rejection
Address type now derived from derivation path for single-line format
Strict ASCII/whitespace checks applied to both normal signing and health-check modes
Unit tests added for validation edge cases
Evidence from the diff
The patch extends HealthCheckCommonFlow.validate_lines() to accept ‘signmessage
Changed components
ports/stm32/boards/Passport/modules/flows/health_check_common_flow.pyports/stm32/boards/Passport/modules/flows/health_check_microsd_flow.pyports/stm32/boards/Passport/modules/tests/test_unit.pyports/stm32/boards/Passport/modules/tests/unit/single_line_message.pyInspect captured patch +169 / −20
### ports/stm32/boards/Passport/modules/flows/health_check_common_flow.py
@@ -4,7 +4,7 @@
# health_check_common_flow.py - Scan and process a health check QR code in `crypto-request` format
from flows import Flow
-from wallets.utils import get_addr_type_from_string
+from wallets.utils import get_addr_type_from_deriv, get_addr_type_from_string
from public_constants import AF_CLASSIC
@@ -20,28 +20,70 @@ def __init__(self, lines, normal_signing=False):
async def validate_lines(self):
from pages import ErrorPage
from utils import validate_sign_text
- if len(self.lines) not in [2, 3]:
- await ErrorPage('{} format is invalid.'.format('Message' if self.normal_signing else 'Health check')).show()
- self.set_result(None)
- return
- # Common function to validate the message
- self.text = self.lines[0]
- self.subpath = self.lines[1]
+ err_label = 'Message' if self.normal_signing else 'Health check'
- if len(self.lines) == 3:
- self.addr_type = get_addr_type_from_string(self.lines[2])
- # print('text={}'.format(self.text))
- # print('subpath={}'.format(self.subpath))
+ # single-line `signmessage <path> ascii:<message>` (Envoy export)
+ # Join lines so validation rejects embedded newlines instead of silently
+ # discarding text. Keep the legacy strict whitespace and ASCII checks.
+ # This format is intentionally supported for health checks too.
+ if self.lines and self.lines[0].startswith('signmessage '):
+ raw = '\n'.join(self.lines)
+ parts = raw.split(' ', 2)
- # Validate
- (subpath, error) = validate_sign_text(self.text, self.subpath)
- if error is not None:
- await ErrorPage(text=error).show()
- self.set_result(None)
- return
+ if len(parts) != 3 or not parts[2].startswith('ascii:'):
+ await ErrorPage('{} format is invalid.'.format(err_label)).show()
+ self.set_result(None)
+ return
+
+ self.subpath = parts[1]
+ self.text = parts[2][len('ascii:'):]
+
+ if not self.text:
+ await ErrorPage(text='Message is empty.').show()
+ self.set_result(None)
+ return
+
+ (subpath, error) = validate_sign_text(self.text, self.subpath)
+
+ if error is not None:
+ await ErrorPage(text=error).show()
+ self.set_result(None)
+ return
+
+ self.subpath = subpath
+
+ # A root or missing path has no purpose component for address-type
+ # detection, even though the general path validator accepts it.
+ if not subpath or subpath == 'm':
+ await ErrorPage(text='Message derivation path is invalid.').show()
+ self.set_result(None)
+ return
+
+ derived = get_addr_type_from_deriv(self.subpath)
+
+ if derived is not None:
+ self.addr_type = derived
+ else:
+ if len(self.lines) not in [2, 3]:
+ await ErrorPage('{} format is invalid.'.format(err_label)).show()
+ self.set_result(None)
+ return
+
+ self.text = self.lines[0]
+ self.subpath = self.lines[1]
+
+ if len(self.lines) == 3:
+ self.addr_type = get_addr_type_from_string(self.lines[2])
+
+ (subpath, error) = validate_sign_text(self.text, self.subpath)
+
+ if error is not None:
+ await ErrorPage(text=error).show()
+ self.set_result(None)
+ return
- self.subpath = subpath
+ self.subpath = subpath
# User Interaction for non-health check signing
if self.normal_signing:
### ports/stm32/boards/Passport/modules/flows/health_check_microsd_flow.py
@@ -91,7 +91,7 @@ async def parse_message(self):
self.set_result(False)
return
- self.lines = data.split('\n')
+ self.lines = data.splitlines()
self.goto(self.common_flow)
async def common_flow(self):
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -138,3 +138,7 @@ def test_bip322(test):
def test_psbt_change_validation(test):
assert test('psbt_change_validation.py') == b'OK'
+
+
+def test_single_line_message(test):
+ assert test('single_line_message.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/single_line_message.py
@@ -0,0 +1,103 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Test validation of the microSD signmessage format before review or signing.
+
+import pages
+import uasyncio as asyncio
+from flows.health_check_common_flow import HealthCheckCommonFlow
+from public_constants import AF_CLASSIC, AF_P2WPKH
+
+
+class MockErrorPage:
+ errors = []
+
+ def __init__(self, text):
+ self.errors.append(text)
+
+ async def show(self):
+ return True
+
+
+class MockFlow:
+ show_message = 'review'
+ sign_health_check = 'sign'
+
+ def __init__(self, lines, normal_signing):
+ self.lines = lines
+ self.normal_signing = normal_signing
+ self.addr_type = AF_CLASSIC
+ self.next_state = None
+ self.result = 'unset'
+
+ def goto(self, state):
+ self.next_state = state
+
+ def set_result(self, result):
+ self.result = result
+
+
+async def run_tests():
+ original_error_page = pages.ErrorPage
+ path = "m/84'/0'/0'/0/0"
+ try:
+ pages.ErrorPage = MockErrorPage
+ for normal_signing in (True, False):
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage ' + path + ' ascii:'], normal_signing)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert MockErrorPage.errors == ['Message is empty.']
+ assert flow.result is None
+ assert flow.next_state is None
+
+ for normal_signing in (True, False):
+ for invalid_path in ('x', 'm', 'm/foo', ''):
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage ' + invalid_path + ' ascii:hi'], normal_signing)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert MockErrorPage.errors
+ assert flow.result is None
+ assert flow.next_state is None
+
+ # Strict text checks also apply to health checks, deliberately.
+ for message in (' leading', 'trailing ', 'four spaces', 'line\nbreak', 'a\x00b', '\u00e9'):
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage ' + path + ' ascii:' + message], normal_signing)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert MockErrorPage.errors
+ assert flow.result is None
+ assert flow.next_state is None
+
+ # A normalized path drives address selection; health-check mode
+ # intentionally accepts this format without entering message review.
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage m/84h/0h/0h/0/0 ascii:hello'], normal_signing)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert not MockErrorPage.errors
+ assert flow.subpath == path
+ assert flow.addr_type == AF_P2WPKH
+ assert flow.next_state == (flow.show_message if normal_signing else flow.sign_health_check)
+
+ for message in ('x', 'message with spaces and ascii: inside'):
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage ' + path + ' ascii:' + message], True)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert not MockErrorPage.errors
+ assert flow.text == message
+ assert flow.subpath == path
+ assert flow.addr_type == AF_P2WPKH
+ assert flow.next_state == flow.show_message
+ assert flow.result == 'unset'
+
+ MockErrorPage.errors = []
+ flow = MockFlow(['signmessage ' + path + ' hex:00'], True)
+ await HealthCheckCommonFlow.validate_lines(flow)
+ assert MockErrorPage.errors == ['Message format is invalid.']
+ assert flow.result is None
+ assert flow.next_state is None
+ return_value.write(b'OK')
+ finally:
+ pages.ErrorPage = original_error_page
+
+
+asyncio.run(run_tests())Why this scored 36/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.