feat(core): save binary representation of mnemonic in storage for Cardano secret derivation
What changed, and why it matters
This commit changes how Trezor stores wallet backup words (mnemonics) for Cardano support. It now keeps an extra binary copy of the mnemonic in device storage, separate from the encrypted text version. The change is described as a feature, not a security fix. It does not appear to introduce an obvious remote exploit, but it does add a new storage location holding sensitive seed material and includes a debug-only option that lets invalid mnemonics be loaded without raising an error.
Review whether the new `_BINARY_MNEMONIC` field receives the same encryption and access-control protections as `_MNEMONIC_SECRET`. Confirm that the debug-only `allow_derivation_fail` path cannot be reached in production builds. Verify that the Cardano C function `secret_from_entropy_cardano_icarus` correctly bounds-checks the new `binary_mnemonic` length and that the migration cannot be triggered on a locked or partially-initialized device.
Security signals we found
New persistent storage field for raw mnemonic entropy+checksum
Cardano derivation now consumes binary mnemonic bytes instead of text
Storage migration derives and stores binary mnemonic from existing secrets
Debug-only `allow_derivation_fail` flag suppresses ValueError for invalid mnemonics
No changelog entry despite storage format change
Evidence from the diff
The patch adds a new _BINARY_MNEMONIC storage field and a bip39.mnemonic_to_bits() C wrapper. When a BIP-39 mnemonic is stored, the firmware now also stores its raw entropy+checksum bytes. Cardano Icarus derivation is refactored to read from this binary copy instead of re-parsing the text mnemonic. Storage version is bumped from 2 to 3 with a migration that derives the binary form from existing text mnemonics. A new allow_derivation_fail flag is added to store_mnemonic_secret() so debug builds can skip checksum failures for non-Cardano-compatible test mnemonics.
Changed components
core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip39.hcore/embed/upymod/modtrezorcrypto/modtrezorcrypto-cardano.hcore/src/apps/common/mnemonic.pycore/src/apps/debug/load_device.pycore/src/apps/management/recovery_device/homescreen.pycore/src/apps/management/reset_device/__init__.pycore/src/storage/__init__.pycore/src/storage/common.pycore/src/storage/device.pyInspect captured patch +154 / −46
diff --git a/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip39.h b/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip39.h
index 43754f7f..9d21caf8 100644
--- a/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip39.h
+++ b/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-bip39.h
@@ -91,12 +91,36 @@ STATIC mp_obj_t mod_trezorcrypto_bip39_seed(size_t n_args,
STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mod_trezorcrypto_bip39_seed_obj, 2,
3, mod_trezorcrypto_bip39_seed);
+#if !BITCOIN_ONLY
+/// def mnemonic_to_bits(mnemonic: str) -> bytes:
+/// """
+/// Convert the mnemonic to its binary representation (including checksum).
+/// """
+STATIC mp_obj_t mod_trezorcrypto_bip39_mnemonic_to_bits(mp_obj_t mnemonic) {
+ mp_buffer_info_t text = {0};
+ mp_get_buffer_raise(mnemonic, &text, MP_BUFFER_READ);
+
+ uint8_t bits[33] = {0};
+ int binary_mnemonics_len = mnemonic_to_bits((const char *)text.buf, bits);
+ if (binary_mnemonics_len <= 0) {
+ mp_raise_ValueError(MP_ERROR_TEXT("Invalid mnemonic"));
+ }
+ return mp_obj_new_bytes(bits, (binary_mnemonics_len + 7) / 8);
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_trezorcrypto_bip39_mnemonic_to_bits_obj,
+ mod_trezorcrypto_bip39_mnemonic_to_bits);
+#endif // !BITCOIN_ONLY
+
STATIC const mp_rom_map_elem_t mod_trezorcrypto_bip39_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_bip39)},
{MP_ROM_QSTR(MP_QSTR_from_data),
MP_ROM_PTR(&mod_trezorcrypto_bip39_from_data_obj)},
{MP_ROM_QSTR(MP_QSTR_check), MP_ROM_PTR(&mod_trezorcrypto_bip39_check_obj)},
{MP_ROM_QSTR(MP_QSTR_seed), MP_ROM_PTR(&mod_trezorcrypto_bip39_seed_obj)},
+#if !BITCOIN_ONLY
+ {MP_ROM_QSTR(MP_QSTR_mnemonic_to_bits),
+ MP_ROM_PTR(&mod_trezorcrypto_bip39_mnemonic_to_bits_obj)},
+#endif // !BITCOIN_ONLY
};
STATIC MP_DEFINE_CONST_DICT(mod_trezorcrypto_bip39_globals,
mod_trezorcrypto_bip39_globals_table);
diff --git a/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-cardano.h b/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-cardano.h
index 5a7cda2b..a7eadca6 100644
--- a/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-cardano.h
+++ b/core/embed/upymod/modtrezorcrypto/modtrezorcrypto-cardano.h
@@ -33,33 +33,26 @@
/// from trezorcrypto.bip32 import HDNode
/// def derive_icarus(
-/// mnemonic: str,
+/// binary_mnemonic: bytes,
/// passphrase: str,
/// trezor_derivation: bool,
/// callback: Callable[[int, int], None] | None = None,
/// ) -> bytes:
/// """
-/// Derives a Cardano master secret from a mnemonic and passphrase using the
-/// Icarus derivation scheme.
-/// If `trezor_derivation` is True, the Icarus-Trezor variant is used (see
-/// CIP-3).
+/// Derives a Cardano master secret from a mnemonic represented in bits
+/// (including checksum) and a passphrase using the Icarus derivation
+/// scheme. If `trezor_derivation` is True, the Icarus-Trezor variant is
+/// used (see CIP-3).
/// """
STATIC mp_obj_t mod_trezorcrypto_cardano_derive_icarus(size_t n_args,
const mp_obj_t *args) {
- mp_buffer_info_t mnemo = {0}, phrase = {0};
- mp_get_buffer_raise(args[0], &mnemo, MP_BUFFER_READ);
+ mp_buffer_info_t binary_mnemonic = {0}, phrase = {0};
+ mp_get_buffer_raise(args[0], &binary_mnemonic, MP_BUFFER_READ);
mp_get_buffer_raise(args[1], &phrase, MP_BUFFER_READ);
- const char *pmnemonic = mnemo.len > 0 ? mnemo.buf : "";
const char *ppassphrase = phrase.len > 0 ? phrase.buf : "";
bool trezor_derivation = mp_obj_is_true(args[2]);
- uint8_t mnemonic_bits[64] = {0};
- int mnemonic_bits_len = mnemonic_to_bits(pmnemonic, mnemonic_bits);
- if (mnemonic_bits_len == 0 || mnemonic_bits_len % 33 != 0) {
- mp_raise_ValueError(MP_ERROR_TEXT("Invalid mnemonic"));
- }
-
vstr_t vstr = {0};
vstr_init_len(&vstr, CARDANO_SECRET_LENGTH);
@@ -70,19 +63,21 @@ STATIC mp_obj_t mod_trezorcrypto_cardano_derive_icarus(size_t n_args,
callback = wrapped_ui_wait_callback;
}
- int entropy_len = mnemonic_bits_len - mnemonic_bits_len / 33;
+ int checksum_bytes = (binary_mnemonic.len + 32) / 33;
+ int entropy_bytes = binary_mnemonic.len - checksum_bytes;
int mnemonic_bytes_used = 0;
if (!trezor_derivation) {
// Exclude checksum (original Icarus spec)
- mnemonic_bytes_used = entropy_len / 8;
+ mnemonic_bytes_used = entropy_bytes;
} else {
// Include checksum if it is a full byte (Trezor bug)
// see also https://github.com/trezor/trezor-firmware/issues/1387 and CIP-3
- mnemonic_bytes_used = mnemonic_bits_len / 8;
+ mnemonic_bytes_used = entropy_bytes + (binary_mnemonic.len / 33);
}
const int res = secret_from_entropy_cardano_icarus(
- (const uint8_t *)ppassphrase, phrase.len, mnemonic_bits,
- mnemonic_bytes_used, (uint8_t *)vstr.buf, callback);
+ (const uint8_t *)ppassphrase, phrase.len,
+ (const uint8_t *)binary_mnemonic.buf, mnemonic_bytes_used,
+ (uint8_t *)vstr.buf, callback);
ui_wait_callback = mp_const_none;
diff --git a/core/mocks/generated/trezorcrypto/bip39.pyi b/core/mocks/generated/trezorcrypto/bip39.pyi
index a1c915c7..43920bb8 100644
--- a/core/mocks/generated/trezorcrypto/bip39.pyi
+++ b/core/mocks/generated/trezorcrypto/bip39.pyi
@@ -25,3 +25,10 @@ def seed(
"""
Generate seed from mnemonic and passphrase.
"""
+
+
+# upymod/modtrezorcrypto/modtrezorcrypto-bip39.h
+def mnemonic_to_bits(mnemonic: str) -> bytes:
+ """
+ Convert the mnemonic to its binary representation (including checksum).
+ """
diff --git a/core/mocks/generated/trezorcrypto/cardano.pyi b/core/mocks/generated/trezorcrypto/cardano.pyi
index 29158df8..015e2ecd 100644
--- a/core/mocks/generated/trezorcrypto/cardano.pyi
+++ b/core/mocks/generated/trezorcrypto/cardano.pyi
@@ -5,16 +5,16 @@ from trezorcrypto.bip32 import HDNode
# upymod/modtrezorcrypto/modtrezorcrypto-cardano.h
def derive_icarus(
- mnemonic: str,
+ binary_mnemonic: bytes,
passphrase: str,
trezor_derivation: bool,
callback: Callable[[int, int], None] | None = None,
) -> bytes:
"""
- Derives a Cardano master secret from a mnemonic and passphrase using the
- Icarus derivation scheme.
- If `trezor_derivation` is True, the Icarus-Trezor variant is used (see
- CIP-3).
+ Derives a Cardano master secret from a mnemonic represented in bits
+ (including checksum) and a passphrase using the Icarus derivation
+ scheme. If `trezor_derivation` is True, the Icarus-Trezor variant is
+ used (see CIP-3).
"""
diff --git a/core/src/apps/common/mnemonic.py b/core/src/apps/common/mnemonic.py
index a33b0ee5..0fa7b79f 100644
--- a/core/src/apps/common/mnemonic.py
+++ b/core/src/apps/common/mnemonic.py
@@ -82,9 +82,9 @@ if not utils.BITCOIN_ONLY:
if not is_bip39():
raise ValueError # should not be called for SLIP-39
- mnemonic_secret = get_secret()
- if mnemonic_secret is None:
- raise ValueError("Mnemonic not set")
+ binary_mnemonic = storage_device.get_binary_mnemonic()
+ if binary_mnemonic is None:
+ raise RuntimeError("Failed to get binary mnemonic.")
render_func = None
if progress_bar and not utils.DISABLE_ANIMATION:
@@ -94,7 +94,10 @@ if not utils.BITCOIN_ONLY:
from trezor.crypto import cardano
seed = cardano.derive_icarus(
- mnemonic_secret.decode(), passphrase, trezor_derivation, render_func
+ binary_mnemonic,
+ passphrase,
+ trezor_derivation,
+ render_func,
)
_finish_progress()
return seed
diff --git a/core/src/apps/debug/load_device.py b/core/src/apps/debug/load_device.py
index dbb7589a..c1da1b94 100644
--- a/core/src/apps/debug/load_device.py
+++ b/core/src/apps/debug/load_device.py
@@ -60,12 +60,13 @@ async def load_device(msg: LoadDevice) -> Success:
storage_device.set_slip39_identifier(identifier)
storage_device.set_slip39_iteration_exponent(iteration_exponent)
+ storage_device.set_backup_type(backup_type)
storage_device.store_mnemonic_secret(
- secret,
+ secret=secret,
needs_backup=msg.needs_backup is True,
no_backup=msg.no_backup is True,
+ allow_derivation_fail=msg.skip_checksum is True,
)
- storage_device.set_backup_type(backup_type)
storage_device.set_passphrase_enabled(bool(msg.passphrase_protection))
storage_device.set_label(msg.label or "")
if msg.pin:
diff --git a/core/src/apps/management/recovery_device/homescreen.py b/core/src/apps/management/recovery_device/homescreen.py
index eacf06c2..6b1b960f 100644
--- a/core/src/apps/management/recovery_device/homescreen.py
+++ b/core/src/apps/management/recovery_device/homescreen.py
@@ -235,8 +235,12 @@ async def _finish_recovery(secret: bytes, backup_type: BackupType) -> Success:
if backup_type is None:
raise RuntimeError
- storage_device.store_mnemonic_secret(secret, needs_backup=False, no_backup=False)
storage_device.set_backup_type(backup_type)
+ storage_device.store_mnemonic_secret(
+ secret=secret,
+ needs_backup=False,
+ no_backup=False,
+ )
if backup_types.is_slip39_backup_type(backup_type):
if not backup_types.is_extendable_backup_type(backup_type):
identifier = storage_recovery.get_slip39_identifier()
diff --git a/core/src/apps/management/reset_device/__init__.py b/core/src/apps/management/reset_device/__init__.py
index b10b4bf2..ff2fb3df 100644
--- a/core/src/apps/management/reset_device/__init__.py
+++ b/core/src/apps/management/reset_device/__init__.py
@@ -132,7 +132,7 @@ async def reset_device(msg: ResetDevice) -> Success:
storage_device.set_label(msg.label)
storage_device.set_passphrase_enabled(bool(msg.passphrase_protection))
storage_device.store_mnemonic_secret(
- secret, # for SLIP-39, this is the EMS
+ secret=secret, # for SLIP-39, this is the EMS
needs_backup=not perform_backup,
no_backup=bool(msg.no_backup),
)
diff --git a/core/src/storage/__init__.py b/core/src/storage/__init__.py
index 23f52b5b..664cb59d 100644
--- a/core/src/storage/__init__.py
+++ b/core/src/storage/__init__.py
@@ -29,6 +29,8 @@ def init_unlocked() -> None:
version = device.get_version()
if version == common.STORAGE_VERSION_01:
_migrate_from_version_01()
+ elif version == common.STORAGE_VERSION_02:
+ _migrate_from_version_02()
# In FWs <= 2.3.1 'version' denoted whether the device is initialized or not.
# In 2.3.2 we have introduced a new field 'initialized' for that.
@@ -70,5 +72,26 @@ def _migrate_from_version_01() -> None:
device.set_u2f_counter(int.from_bytes(counter, "big"))
# Delete the old, non-public U2F_COUNTER.
common.delete(common.APP_DEVICE, device.U2F_COUNTER)
+ # the device is now at version 2
+ device.set_version(common.STORAGE_VERSION_02)
+
+ # update from version 2 to version 3
+ _migrate_from_version_02()
+
+
+def _migrate_from_version_02() -> None:
+ from trezor import utils
+
+ # This update concerns Cardano derivation. There is no need for update for Bitcoin-only builds
+ if not utils.BITCOIN_ONLY:
+ from storage.device import get_backup_type, store_binary_mnemonic
+ from trezor.enums import BackupType
+
+ if get_backup_type() == BackupType.Bip39:
+ # Ensure binary mnemonic is stored
+ secret = device.get_mnemonic_secret()
+ if secret is not None:
+ store_binary_mnemonic(secret)
+
# set_current_version
device.set_version(common.STORAGE_VERSION_CURRENT)
diff --git a/core/src/storage/common.py b/core/src/storage/common.py
index b3832201..78be8d2e 100644
--- a/core/src/storage/common.py
+++ b/core/src/storage/common.py
@@ -19,7 +19,8 @@ _FALSE_BYTE = b"\x00"
_TRUE_BYTE = b"\x01"
STORAGE_VERSION_01 = b"\x01"
-STORAGE_VERSION_CURRENT = b"\x02"
+STORAGE_VERSION_02 = b"\x02"
+STORAGE_VERSION_CURRENT = b"\x03"
def set(app: int, key: int, data: AnyBytes, public: bool = False) -> None:
diff --git a/core/src/storage/device.py b/core/src/storage/device.py
index 8388c8bd..d580f7a8 100644
--- a/core/src/storage/device.py
+++ b/core/src/storage/device.py
@@ -49,6 +49,8 @@ if utils.USE_THP:
if utils.USE_POWER_MANAGER:
_AUTOLOCK_DELAY_BATT_MS = const(0x23) # int
_DISABLE_BLUETOOTH = const(0x24) # bool (0x01 or empty)
+if not utils.BITCOIN_ONLY:
+ _BINARY_MNEMONIC = const(0x25) # bytes
SAFETY_CHECK_LEVEL_STRICT : Literal[0] = const(0)
@@ -151,6 +153,67 @@ def get_mnemonic_secret() -> bytes | None:
return common.get(_NAMESPACE, _MNEMONIC_SECRET)
+def store_mnemonic_secret(
+ secret: bytes,
+ needs_backup: bool = False,
+ no_backup: bool = False,
+ allow_derivation_fail: bool = False,
+) -> None:
+ set_version(common.STORAGE_VERSION_CURRENT)
+ common.set(_NAMESPACE, _MNEMONIC_SECRET, secret)
+ common.set_true_or_delete(_NAMESPACE, _NO_BACKUP, no_backup)
+ common.set_bool(_NAMESPACE, INITIALIZED, True, public=True)
+ if not no_backup:
+ common.set_true_or_delete(_NAMESPACE, _NEEDS_BACKUP, needs_backup)
+
+ if not utils.BITCOIN_ONLY:
+ store_binary_mnemonic(secret, allow_derivation_fail)
+
+
+if not utils.BITCOIN_ONLY:
+
+ def get_binary_mnemonic() -> bytes | None:
+ """
+ Get the binary representation of mnemonic (including checksum).
+ """
+ return common.get(_NAMESPACE, _BINARY_MNEMONIC)
+
+ def store_binary_mnemonic(
+ secret: bytes,
+ allow_derivation_fail: bool = False,
+ ) -> None:
+ """
+ Store the binary representation of mnemonic (including checksum) for Cardano
+ Icarus derivation. Works only for BIP-39.
+
+ If `allow_derivation_fail` is True, exception during derivation is ignored.
+ """
+ from trezorcrypto import bip39
+
+ from trezor.enums import BackupType
+
+ if get_backup_type() == BackupType.Bip39:
+ try:
+ binary_mnemonic = bip39.mnemonic_to_bits(secret.decode())
+ except ValueError:
+ if __debug__ and allow_derivation_fail:
+ # There is a possibility to load device with mnemonics that cannot
+ # be used for Caradno derivation. These mnemonics are not generated
+ # by Trezor and user must actively choose them. For exmample, see
+ # `tests/device_tests/test_msg_loaddevice.py::test_load_device_utf`.
+ # We do not want to raise an exception for them. But we cannot
+ # derive Cardano secrets either.
+ return
+ else:
+ raise
+
+ common.set(
+ _NAMESPACE,
+ _BINARY_MNEMONIC,
+ binary_mnemonic,
+ )
+
+
def get_backup_type() -> BackupType:
from trezor.enums import BackupType
@@ -191,19 +254,6 @@ def set_homescreen(homescreen: AnyBytes) -> None:
common.set(_NAMESPACE, _HOMESCREEN, homescreen, public=True)
-def store_mnemonic_secret(
- secret: bytes,
- needs_backup: bool = False,
- no_backup: bool = False,
-) -> None:
- set_version(common.STORAGE_VERSION_CURRENT)
- common.set(_NAMESPACE, _MNEMONIC_SECRET, secret)
- common.set_true_or_delete(_NAMESPACE, _NO_BACKUP, no_backup)
- common.set_bool(_NAMESPACE, INITIALIZED, True, public=True)
- if not no_backup:
- common.set_true_or_delete(_NAMESPACE, _NEEDS_BACKUP, needs_backup)
-
-
def needs_backup() -> bool:
return common.get_bool(_NAMESPACE, _NEEDS_BACKUP)
Why this scored 33/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.