Merge pull request #659 from Foundation-Devices/fix/unicode-settings-save
What changed, and why it matters
This update fixes a bug in how the Passport hardware wallet saves user settings that contain non-English characters (Unicode). Previously, the code measured text size in characters instead of encoded bytes, so a setting that looked small could actually exceed the storage slot and either be written past its boundary or silently truncated. The patch now encodes the data to UTF-8 bytes before checking size, rejects oversized saves cleanly, and reports a proper error instead of silently failing. It also adds tests for these edge cases.
Treat this as a security-reliability fix and include it in the next firmware release. Review other settings-backed features for similar character-vs-byte length assumptions. Run the new unit tests (ext_settings.py and multisig_save_task.py) in CI to prevent regressions.
Security signals we found
Buffer size check used character length instead of encoded byte length, leading to potential slot overflow with Unicode data
Oversized settings could previously be partially written or silently ignored instead of failing atomically
Multisig wallet save task now rolls back in-memory state on any save failure and reports distinct errors
New SettingsOutOfSpace exception and USER_SETTINGS_SAVE_FAILED error code added for clearer failure handling
Unit tests added to verify Unicode round-trip, exact-size payload, oversized rejection, and rollback behavior
Evidence from the diff
The ExtSettings class serializes settings to JSON and encrypts them into fixed-size flash slots. The original code called ujson.dumps(self.current) and compared len(d) (string length in MicroPython characters) against max_json_len (bytes). For ASCII this is equivalent, but for non-ASCII/Unicode strings the encoded UTF-8 length is larger than the character count, so the pad_len calculation could underflow or the encrypted write could exceed the slot. The patch introduces _serialize_current() which encodes the JSON to UTF-8 bytes first, raises SettingsOutOfSpace if len(data) > max_json_len, and passes the pre-serialized bytes to save_impl. save_impl now checks pad_len before any flash write, preventing partial/corrupt writes. The multisig save task now distinguishes SettingsOutOfSpace (USER_SETTINGS_FULL) from other failures (USER_SETTINGS_SAVE_FAILED) and rolls back the in-memory change on any exception, not just out-of-space. New unit tests cover Unicode round-trips, exact-size payloads, oversized payloads, and rollback/error-reporting behavior.
Changed components
ports/stm32/boards/Passport/modules/ext_settings.pyports/stm32/boards/Passport/modules/tasks/save_multisig_wallet_task.pyports/stm32/boards/Passport/modules/errors.pyports/stm32/boards/Passport/modules/tests/unit/ext_settings.pyports/stm32/boards/Passport/modules/tests/unit/multisig_save_task.pyInspect captured patch +230 / −20
### ports/stm32/boards/Passport/modules/errors.py
@@ -27,4 +27,5 @@
'PSBT_OVERSIZED',
'QR_TOO_LARGE',
'FIRMWARE_UPDATE_FAILED',
+ 'USER_SETTINGS_SAVE_FAILED',
)
### ports/stm32/boards/Passport/modules/ext_settings.py
@@ -29,6 +29,10 @@
from public_constants import DEVICE_SETTINGS
+class SettingsOutOfSpace(RuntimeError):
+ pass
+
+
class ExtSettings:
"""Settings stored in external flash, with a secondary backup"""
@@ -405,36 +409,39 @@ def do_save(self, erase_old_pos=True):
# print('do_save({})'.format(erase_old_pos))
# render as JSON, encrypt and write it.
self.current['_revision'] = self.current.get('_revision', 1) + 1
+ data = self._serialize_current()
_, pos = self.find_spot(self.my_pos)
- self.save_impl(pos, erase_old_pos=erase_old_pos)
+ self.save_impl(pos, data=data, erase_old_pos=erase_old_pos)
# print('save(): sf={}, pos={}'.format(sf, pos))
- def save_impl(self, pos, erase_old_pos=True):
+ def _serialize_current(self):
+ d = ujson.dumps(self.current).encode('utf8')
+ if len(d) > self.max_json_len:
+ raise SettingsOutOfSpace('JSON data is larger than {} bytes.'.format(self.max_json_len))
+ return d
+
+ def save_impl(self, pos, data, erase_old_pos=True):
+ pad_len = self.max_json_len - len(data)
+ if pad_len < 0:
+ raise SettingsOutOfSpace('JSON data is larger than {} bytes.'.format(self.max_json_len))
+
aes = self.get_aes(pos)
with SFFile(pos, pre_erased=True, max_size=self.slot_size) as fd:
chk = trezorcrypto.sha256()
# first the json data
- d = ujson.dumps(self.current)
# print('pos: {}'.format(pos))
# print('current: {}'.format(self.current))
- # print('data: {}'.format(bytes_to_hex_str(d)))
+ # print('data: {}'.format(bytes_to_hex_str(data)))
# pad w/ zeros
- data_len = len(d)
- pad_len = self.max_json_len - data_len
- if pad_len < 0:
- # print('ERROR: JSON data is too big!')
- return
-
- fd.write(aes.encrypt(d))
- chk.update(d)
- del d
+ fd.write(aes.encrypt(data))
+ chk.update(data)
- # print('data_len={} pad_len={}'.format(data_len, pad_len))
+ # print('pad_len={}'.format(pad_len))
while pad_len > 0:
here = min(32, pad_len)
### ports/stm32/boards/Passport/modules/tasks/save_multisig_wallet_task.py
@@ -14,6 +14,7 @@ async def save_multisig_wallet_task(on_done, ms):
# Data to save: Important that this fails immediately when Settings memory would overflow
from common import settings
from errors import Error
+ from ext_settings import SettingsOutOfSpace
obj = ms.serialize()
@@ -32,9 +33,8 @@ async def save_multisig_wallet_task(on_done, ms):
# Save now, rather than in background, so we can recover from out-of-space situation
try:
settings.save()
- await on_done(None)
- except BaseException:
- # Back out change -- User settings doesn't have enough space for this update
+ except BaseException as exc:
+ # Back out the in-memory change when the save fails for any reason.
try:
settings.set('multisig', original)
settings.save()
@@ -43,4 +43,10 @@ async def save_multisig_wallet_task(on_done, ms):
# Give up on recovery
pass
- await on_done(Error.USER_SETTINGS_FULL)
+ if isinstance(exc, SettingsOutOfSpace):
+ await on_done(Error.USER_SETTINGS_FULL)
+ else:
+ await on_done(Error.USER_SETTINGS_SAVE_FAILED)
+ return
+
+ await on_done(None)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -36,6 +36,10 @@ def test_seedqr_codec(test):
assert test('seedqr_codec.py') == b'OK'
+def test_multisig_save_task(test):
+ assert test('multisig_save_task.py') == b'OK'
+
+
def test_ui(test):
assert test('ui.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/ext_settings.py
@@ -4,8 +4,91 @@
#
# Test the external settings module.
-from ext_settings import ExtSettings
+import common
+import ujson
-settings = ExtSettings()
+from ext_settings import ExtSettings, SettingsOutOfSpace
+
+
+class MockFlash:
+ def __init__(self, size):
+ self.data = bytearray(b'\xff' * size)
+
+ def read(self, address, buf):
+ buf[:] = self.data[address:address + len(buf)]
+
+ def write(self, address, buf):
+ for i in range(len(buf)):
+ self.data[address + i] &= buf[i]
+
+ def wait_done(self):
+ pass
+
+ def is_busy(self):
+ return False
+
+ def sector_erase(self, address):
+ self.data[address:address + 4096] = b'\xff' * 4096
+
+
+SLOT_SIZE = 512
+SLOT_START = 4096
+FLASH_SIZE = SLOT_START + (SLOT_SIZE * 2)
+SLOTS = range(SLOT_START, FLASH_SIZE, SLOT_SIZE)
+
+common.sf = MockFlash(FLASH_SIZE)
+
+settings = ExtSettings(slots=SLOTS, slot_size=SLOT_SIZE)
+names = ['\ube44\ud2b8\ucf54\uc778 \uae08\uace0', 'Multisig \u2018Vault\u2019']
+settings.set('multisig', [{'name': name} for name in names])
+settings.save()
+
+loaded = ExtSettings(slots=SLOTS, slot_size=SLOT_SIZE)
+loaded.load()
+assert [entry['name'] for entry in loaded.get('multisig')] == names
+
+# A payload that exactly fills the encoded data area must still round-trip.
+common.sf = MockFlash(FLASH_SIZE)
+exact = ExtSettings(slots=SLOTS, slot_size=SLOT_SIZE)
+exact.current['value'] = ''
+json_overhead = len(ujson.dumps(exact.current).encode('utf8'))
+exact_value = 'x' * (exact.max_json_len - json_overhead)
+exact.current['value'] = exact_value
+exact.save()
+
+loaded = ExtSettings(slots=SLOTS, slot_size=SLOT_SIZE)
+loaded.load()
+assert loaded.get('value') == exact_value
+
+# An oversized payload must fail before selecting or writing a slot.
+common.sf = MockFlash(FLASH_SIZE)
+oversized = ExtSettings(slots=SLOTS, slot_size=SLOT_SIZE)
+oversized.current['value'] = exact_value + 'x'
+
+try:
+ oversized.save()
+except SettingsOutOfSpace:
+ pass
+else:
+ raise RuntimeError('Oversized settings should fail before writing')
+
+assert common.sf.data == bytearray(b'\xff' * FLASH_SIZE)
+
+# Direct writes must also reject oversized data without changing flash or save state.
+flash_before = bytes(common.sf.data)
+pos_before = oversized.my_pos
+slots_before = oversized.last_save_slots[:]
+dirty_before = oversized.is_dirty
+try:
+ oversized.save_impl(SLOT_START, b'x' * (oversized.max_json_len + 1))
+except SettingsOutOfSpace:
+ pass
+else:
+ raise RuntimeError('Oversized direct writes should fail before writing')
+
+assert common.sf.data == flash_before
+assert oversized.my_pos == pos_before
+assert oversized.last_save_slots == slots_before
+assert oversized.is_dirty == dirty_before
return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/tests/unit/multisig_save_task.py
@@ -0,0 +1,109 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Rollback and error reporting when saving a multisig wallet fails.
+
+import common
+import uasyncio as asyncio
+
+from errors import Error
+from ext_settings import SettingsOutOfSpace
+from tasks.save_multisig_wallet_task import save_multisig_wallet_task
+
+
+EXISTING = [{'name': 'existing'}]
+
+
+class MockSettings:
+ # save() raises the next queued error, so a test can fail the first save
+ # and still control what the rollback save does.
+ def __init__(self, multisig, save_errors=()):
+ self.values = {'multisig': multisig}
+ self.save_errors = list(save_errors)
+ self.saves = 0
+
+ def get(self, key, default=None):
+ return self.values.get(key, default)
+
+ def set(self, key, value):
+ self.values[key] = value
+
+ def save(self):
+ self.saves += 1
+ if self.save_errors:
+ error = self.save_errors.pop(0)
+ if error is not None:
+ raise error
+
+
+class MockWallet:
+ def __init__(self, storage_idx=-1, name='wallet'):
+ self.storage_idx = storage_idx
+ self.name = name
+
+ def serialize(self):
+ return {'name': self.name}
+
+
+async def save(settings, ms):
+ # Collect every on_done call so a test can assert it happened exactly once.
+ results = []
+
+ async def on_done(error):
+ results.append(error)
+
+ common.settings = settings
+ await save_multisig_wallet_task(on_done, ms)
+ return results
+
+
+async def run_tests():
+ original_settings = common.settings
+ try:
+ # A successful save reports no error and leaves the appended wallet in place.
+ settings = MockSettings([dict(entry) for entry in EXISTING])
+ assert await save(settings, MockWallet()) == [None]
+ assert settings.saves == 1
+ assert settings.get('multisig') == EXISTING + [{'name': 'wallet'}]
+
+ # Out of space rolls back and reports the specific error, once.
+ settings = MockSettings([dict(entry) for entry in EXISTING],
+ [SettingsOutOfSpace('too big'), None])
+ assert await save(settings, MockWallet()) == [Error.USER_SETTINGS_FULL]
+ assert settings.get('multisig') == EXISTING
+ assert settings.saves == 2
+
+ # Any other save failure rolls back and reports the generic error, once.
+ settings = MockSettings([dict(entry) for entry in EXISTING],
+ [ValueError('flash write failed'), None])
+ assert await save(settings, MockWallet()) == [Error.USER_SETTINGS_SAVE_FAILED]
+ assert settings.get('multisig') == EXISTING
+ assert settings.saves == 2
+
+ # A rollback that itself fails is swallowed, and the original error is
+ # still reported exactly once.
+ settings = MockSettings([dict(entry) for entry in EXISTING],
+ [SettingsOutOfSpace('too big'), RuntimeError('rollback failed')])
+ assert await save(settings, MockWallet()) == [Error.USER_SETTINGS_FULL]
+ assert settings.get('multisig') == EXISTING
+ assert settings.saves == 2
+
+ settings = MockSettings([dict(entry) for entry in EXISTING],
+ [ValueError('flash write failed'), RuntimeError('rollback failed')])
+ assert await save(settings, MockWallet()) == [Error.USER_SETTINGS_SAVE_FAILED]
+ assert settings.saves == 2
+
+ # Replacing an existing wallet restores the entry it overwrote.
+ stored = [{'name': 'a'}, {'name': 'b'}]
+ settings = MockSettings([dict(entry) for entry in stored],
+ [SettingsOutOfSpace('too big'), None])
+ wallet = MockWallet(storage_idx=1, name='replacement')
+ assert await save(settings, wallet) == [Error.USER_SETTINGS_FULL]
+ assert settings.get('multisig') == stored
+
+ return_value.write(b'OK')
+ finally:
+ common.settings = original_settings
+
+
+asyncio.run(run_tests())Why this scored 55/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.