SFT-6378: made more parts of mnemonic parsing and fetching constant time
What changed, and why it matters
This commit hardens the code that converts a user's BIP-39 seed phrase into secret data. It removes timing and loop-behavior clues that an attacker with physical access might measure to learn how many words the phrase has, how long each word is, or where word boundaries fall. The change is defensive: it makes the parsing routine run in constant time and on a fixed-size buffer, reducing side-channel leakage. There is no claim in the commit that an actual exploit exists.
Treat as a security hardening patch. Review the surrounding BIP-39 and passphrase handling code for similar input-dependent loops, ensure the new fixed-size buffer cannot introduce an out-of-bounds read or write, and run side-channel tests if available. No urgent incident response is indicated by the commit alone.
Security signals we found
Constant-time / secret-independent control-flow hardening
Removal of input-length-dependent loops in mnemonic parsing
Removal of early returns on invalid word length
Addition of fixed-size padded buffer and explicit memzero cleanup
Static assertion enforcing wordlist size consistency
Evidence from the diff
The patch modifies mnemonic_to_bits() in Trezor-derived BIP-39 code. Previously the parser used while (mnemonic[i]) loops that terminated early based on mnemonic length and per-word length, leaking input-dependent control flow and memory-access timing. The new code copies the input into a zero-padded, fixed-size buffer of BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN + 1 bytes, counts spaces over the full fixed length, then parses each word with a fixed inner loop bounded by BIP39_MAX_WORD_LEN - 1 and a latching past_delim flag. A word_too_long latch replaces an early return. The wordlist array also gets a static-assertion that its row count equals BIP39_WORDS. These are classic constant-time / secret-independent control-flow mitigations against microarchitectural side channels.
Changed components
extmod/trezor-firmware/crypto/bip39.cextmod/trezor-firmware/crypto/bip39.hextmod/trezor-firmware/crypto/bip39_english.hInspect captured patch +52 / −18
diff --git a/extmod/trezor-firmware/crypto/bip39.c b/extmod/trezor-firmware/crypto/bip39.c
index 1f714d1..df12c43 100644
--- a/extmod/trezor-firmware/crypto/bip39.c
+++ b/extmod/trezor-firmware/crypto/bip39.c
@@ -132,16 +132,24 @@ int mnemonic_to_bits(const char* mnemonic, uint8_t* bits) {
return 0;
}
+ // Pad input into a fixed-size buffer so both parsing passes always iterate
+ // exactly BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN positions, removing
+ // the mnemonic-length signal from the original while(mnemonic[i]) bounds.
+ // A 24-word mnemonic is at most 24*8 + 23 = 215 chars; 216 bytes suffices.
+ char padded[BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN + 1];
+ memzero(padded, sizeof(padded));
+ strncpy(padded, mnemonic, BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN);
+
uint32_t i = 0, n = 0;
- // Count spaces to determine word count (word count is public information)
- while (mnemonic[i]) {
- if (mnemonic[i] == ' ') {
- n++;
- }
- i++;
+ // Count spaces in a fixed-length pass. Zeros past the real content
+ // contribute nothing, so the count is correct without early termination.
+ for (i = 0; i < BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN; i++) {
+ n += (uint32_t)(padded[i] == ' ');
+ }
+ if (padded[0] != '\0') {
+ n++; // one more word than spaces (non-empty input)
}
- n++;
// check that number of words is valid for BIP-39:
// (a) between 128 and 256 bits of initial entropy (12 - 24 words)
@@ -155,22 +163,42 @@ int mnemonic_to_bits(const char* mnemonic, uint8_t* bits) {
uint32_t j = 0, k = 0, ki = 0, bi = 0;
uint8_t result[32 + 1] = {0};
uint32_t all_words_found = 0xFFFFFFFF; // Track if all words were found
+ uint32_t word_too_long = 0; // Set if any word exceeds max length
memzero(result, sizeof(result));
i = 0;
- while (mnemonic[i]) {
+ for (uint32_t w = 0; w < n; w++) {
j = 0;
memzero(current_word, sizeof(current_word));
- while (mnemonic[i] != ' ' && mnemonic[i] != 0) {
- if (j >= sizeof(current_word) - 1) {
- return 0;
+
+ // Fixed inner loop: always BIP39_MAX_WORD_LEN - 1 iterations.
+ // Uses a latching past_delim flag to suppress copies after the word
+ // boundary instead of breaking early on input-dependent data.
+ uint32_t past_delim = 0;
+ for (uint32_t ci = 0; ci < BIP39_MAX_WORD_LEN - 1; ci++) {
+ char c = padded[i + ci];
+ uint32_t is_delim = (uint32_t)((c == ' ') | (c == '\0'));
+ past_delim |= is_delim;
+ if (!past_delim) {
+ current_word[j++] = c;
}
- current_word[j] = mnemonic[i];
- i++;
- j++;
}
- current_word[j] = 0;
- if (mnemonic[i] != 0) {
+
+ // Advance i past the characters copied into current_word
+ i += j;
+
+ // If past_delim was never set the word overruns BIP39_MAX_WORD_LEN - 1;
+ // skip remaining characters. Valid mnemonics never take this path.
+ if (!past_delim) {
+ word_too_long = 1;
+ while (i < BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN &&
+ padded[i] != ' ' && padded[i] != '\0') {
+ i++;
+ }
+ }
+
+ // Skip the word delimiter (space) if present
+ if (i < BIP39_MNEMONIC_MAX_WORDS * BIP39_MAX_WORD_LEN && padded[i] == ' ') {
i++;
}
@@ -199,8 +227,8 @@ int mnemonic_to_bits(const char* mnemonic, uint8_t* bits) {
}
}
- // Check all words were found
- if (all_words_found == 0) {
+ // Check all words were found and no word exceeded the maximum length
+ if (all_words_found == 0 || word_too_long) {
return 0;
}
@@ -210,6 +238,7 @@ int mnemonic_to_bits(const char* mnemonic, uint8_t* bits) {
memcpy(bits, result, sizeof(result));
memzero(result, sizeof(result));
memzero(current_word, sizeof(current_word));
+ memzero(padded, sizeof(padded));
// returns amount of entropy + checksum BITS
return n * 11;
diff --git a/extmod/trezor-firmware/crypto/bip39.h b/extmod/trezor-firmware/crypto/bip39.h
index 4248ff4..d3bfe89 100644
--- a/extmod/trezor-firmware/crypto/bip39.h
+++ b/extmod/trezor-firmware/crypto/bip39.h
@@ -33,6 +33,9 @@
// Used for fixed-width wordlist storage so ct_word_eq() can read exactly this
// many bytes from every entry without invoking UB on short words.
#define BIP39_MAX_WORD_LEN 9
+// Maximum number of words in a valid BIP-39 mnemonic (256-bit entropy).
+// Used to size the padded parsing buffer in mnemonic_to_bits().
+#define BIP39_MNEMONIC_MAX_WORDS 24
const char *mnemonic_generate(int strength); // strength in bits
const char *mnemonic_from_data(const uint8_t *data, int len);
diff --git a/extmod/trezor-firmware/crypto/bip39_english.h b/extmod/trezor-firmware/crypto/bip39_english.h
index 99841da..d8d6606 100644
--- a/extmod/trezor-firmware/crypto/bip39_english.h
+++ b/extmod/trezor-firmware/crypto/bip39_english.h
@@ -371,3 +371,5 @@ static const char wordlist[][BIP39_MAX_WORD_LEN] = {
"yellow", "you", "young", "youth", "zebra", "zero",
"zone", "zoo",
};
+_Static_assert(sizeof(wordlist) / sizeof(wordlist[0]) == BIP39_WORDS,
+ "wordlist row count != BIP39_WORDS");
Why this scored 59/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.