SFT-6378: Fix mnemonic_to_bits() and ct_word_eq() to be constant time.
What changed, and why it matters
This commit fixes a timing side-channel weakness in the code that converts a BIP-39 recovery phrase (a list of words) back into the secret digital bits. Before the fix, the code stopped searching the word list as soon as it found a match and used a normal string comparison, so an attacker measuring tiny time differences might learn which words were entered. The new version always scans the entire word list and compares words in constant time, making the process take the same amount of time regardless of the phrase. A new test checks that timing does not vary with the word positions.
Treat this as a security-hardening fix and include it in the next firmware release. Run the new `test_mnemonic_to_bits_constant_time` test on the target hardware/ emulator to confirm the constant-time properties hold in the actual build. Review other mnemonic-handling paths (e.g., `mnemonic_check`, SLIP-39) for similar variable-time behavior.
Security signals we found
Timing side-channel mitigation in mnemonic decoding
Constant-time string comparison added (`ct_word_eq`)
Unconditional full wordlist scan to avoid index-dependent timing
Branch-free bit extraction and result accumulation
New statistical constant-time regression test added
Memory zeroization of working buffers maintained
Evidence from the diff
The patch rewrites mnemonic_to_bits() and adds ct_word_eq() in extmod/trezor-firmware/crypto/bip39.c to eliminate data-dependent branches and memory-access timing during BIP-39 phrase decoding. The old implementation used strcmp() and broke out of the wordlist loop on a match, leaking information about word index and length through timing. The new implementation iterates over all 2048 words unconditionally, uses bitwise masking to select the matching index without branches, and writes bits with arithmetic shifts rather than conditional bit tests. A constant-time test using 1000 PRNG-generated phrases and Pearson correlation is added to test_check.c. The Makefile change undefines VALGRIND and suppresses a bitwise-instead-of-logical warning for the secp256k1-zkp build, which is ancillary to the constant-time work.
Changed components
extmod/trezor-firmware/crypto/bip39.cextmod/trezor-firmware/crypto/tests/test_check.cextmod/trezor-firmware/crypto/MakefileInspect captured patch +238 / −18
diff --git a/extmod/trezor-firmware/crypto/Makefile b/extmod/trezor-firmware/crypto/Makefile
index f636594..ec067a2 100644
--- a/extmod/trezor-firmware/crypto/Makefile
+++ b/extmod/trezor-firmware/crypto/Makefile
@@ -151,7 +151,7 @@ $(ZKP_PATH)/src/ecmult_static_context.h: $(ZKP_PATH)/src/gen_context.c
cd $(ZKP_PATH) && ./gen_context
secp256k1-zkp.o: $(ZKP_PATH)/src/ecmult_static_context.h
- $(CC) $(CFLAGS) -Wno-unused-function $(ZKP_CFLAGS) -fPIC -I$(ZKP_PATH) -I$(ZKP_PATH)/src -c $(ZKP_PATH)/src/secp256k1.c -o secp256k1-zkp.o
+ $(CC) $(CFLAGS) -UVALGRIND -Wno-unused-function -Wno-error=bitwise-instead-of-logical $(ZKP_CFLAGS) -fPIC -I$(ZKP_PATH) -I$(ZKP_PATH)/src -c $(ZKP_PATH)/src/secp256k1.c -o secp256k1-zkp.o
clean:
rm -f *.o aes/*.o chacha20poly1305/*.o ed25519-donna/*.o monero/*.o
diff --git a/extmod/trezor-firmware/crypto/bip39.c b/extmod/trezor-firmware/crypto/bip39.c
index 1b2d721..e24dbb6 100644
--- a/extmod/trezor-firmware/crypto/bip39.c
+++ b/extmod/trezor-firmware/crypto/bip39.c
@@ -94,13 +94,50 @@ const char *mnemonic_from_data(const uint8_t *data, int len) {
void mnemonic_clear(void) { memzero(mnemo, sizeof(mnemo)); }
-int mnemonic_to_bits(const char *mnemonic, uint8_t *bits) {
+// Maximum length of a BIP-39 word (including null terminator)
+#define BIP39_MAX_WORD_LEN 9
+
+// Constant-time comparison of two null-terminated strings up to max length.
+// Returns 1 if equal, 0 if different.
+// This function executes in constant time regardless of string content,
+// providing resistance against timing side-channel attacks.
+static int ct_word_eq(const char* input, const char* wordlist_word) {
+ uint32_t diff = 0;
+ uint32_t active = 0xFFFFFFFF;
+
+ for (int i = 0; i < BIP39_MAX_WORD_LEN; i++) {
+ uint8_t a = (uint8_t)input[i];
+ uint8_t b = (uint8_t)wordlist_word[i];
+
+ // Accumulate XOR difference only while active
+ diff |= (a ^ b) & active;
+
+ // Deactivate when either string terminates (constant-time mask update)
+ // For a uint8_t value: if a==0, (a-1) sign-extends to 0xFFFFFFFF, >>31 = 1
+ // If a!=0, (a-1) is in [0,254], >>31 = 0. Negating gives the mask.
+ uint32_t a_term = -((uint32_t)(a - 1) >> 31);
+ uint32_t b_term = -((uint32_t)(b - 1) >> 31);
+ active &= ~(a_term | b_term);
+ }
+
+ // Return 1 if equal (diff == 0), 0 otherwise
+ // When diff == 0: (0 - 1) = 0xFFFFFFFF, >> 8, & 1 = 1
+ // When diff != 0: (diff - 1) >> 8, & 1 = 0 for diff in [1, 255]
+ return (int)(1 & ((diff - 1) >> 8));
+}
+
+// Constant-time implementation to prevent side-channel attacks.
+// This function always iterates through the entire wordlist for each word,
+// uses constant-time string comparison, and avoids data-dependent branching
+// when converting word indices to bits.
+int mnemonic_to_bits(const char* mnemonic, uint8_t* bits) {
if (!mnemonic) {
return 0;
}
uint32_t i = 0, n = 0;
+ // Count spaces to determine word count (word count is public information)
while (mnemonic[i]) {
if (mnemonic[i] == ' ') {
n++;
@@ -117,14 +154,16 @@ int mnemonic_to_bits(const char *mnemonic, uint8_t *bits) {
return 0;
}
- char current_word[10] = {0};
+ char current_word[BIP39_MAX_WORD_LEN] = {0};
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
memzero(result, sizeof(result));
i = 0;
while (mnemonic[i]) {
j = 0;
+ memzero(current_word, sizeof(current_word));
while (mnemonic[i] != ' ' && mnemonic[i] != 0) {
if (j >= sizeof(current_word) - 1) {
return 0;
@@ -137,28 +176,43 @@ int mnemonic_to_bits(const char *mnemonic, uint8_t *bits) {
if (mnemonic[i] != 0) {
i++;
}
- k = 0;
- for (;;) {
- if (!wordlist[k]) { // word not found
- return 0;
- }
- if (strcmp(current_word, wordlist[k]) == 0) { // word found on index k
- for (ki = 0; ki < 11; ki++) {
- if (k & (1 << (10 - ki))) {
- result[bi / 8] |= 1 << (7 - (bi % 8));
- }
- bi++;
- }
- break;
- }
- k++;
+
+ // Constant-time wordlist search: always iterate through ALL 2048 words
+ // to prevent timing attacks that could reveal word indices
+ uint32_t found_index = 0;
+ uint32_t found = 0;
+
+ for (k = 0; k < BIP39_WORDS; k++) {
+ int eq = ct_word_eq(current_word, wordlist[k]);
+ // Constant-time selection: mask is 0xFFFFFFFF if match, 0 otherwise
+ uint32_t mask = -(uint32_t)eq;
+ found_index = (found_index & ~mask) | (k & mask);
+ found |= mask;
+ }
+
+ // Track if this word was found (constant-time accumulation)
+ all_words_found &= found;
+
+ // Constant-time bit extraction and setting
+ // Always execute all 11 iterations, no conditional branching on bit values
+ for (ki = 0; ki < 11; ki++) {
+ uint8_t bit = (found_index >> (10 - ki)) & 1;
+ result[bi / 8] |= bit << (7 - (bi % 8));
+ bi++;
}
}
+
+ // Check all words were found
+ if (all_words_found == 0) {
+ return 0;
+ }
+
if (bi != n * 11) {
return 0;
}
memcpy(bits, result, sizeof(result));
memzero(result, sizeof(result));
+ memzero(current_word, sizeof(current_word));
// returns amount of entropy + checksum BITS
return n * 11;
diff --git a/extmod/trezor-firmware/crypto/tests/test_check.c b/extmod/trezor-firmware/crypto/tests/test_check.c
index 8be73e1..5bcf23f 100644
--- a/extmod/trezor-firmware/crypto/tests/test_check.c
+++ b/extmod/trezor-firmware/crypto/tests/test_check.c
@@ -24,6 +24,7 @@
#include <assert.h>
#include <check.h>
#include <inttypes.h>
+#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@@ -5556,6 +5557,170 @@ START_TEST(test_mnemonic_find_word) {
}
END_TEST
+// Test that mnemonic_to_bits executes in constant time regardless of word
+// indices. This helps verify resistance to timing side-channel attacks.
+//
+// Uses 1000 PRNG-generated phrases for statistically meaningful correlation
+// analysis. Phrases are generated with a fixed seed for reproducibility.
+// The test runs all phrases in interleaved fashion to simulate real-world
+// usage and detect cache-based timing leaks.
+START_TEST(test_mnemonic_to_bits_constant_time) {
+// Include wordlist for phrase generation
+#include "bip39_english.h"
+
+ const int NUM_PHRASES = 1000;
+ const int ITERATIONS = 1000;
+ const int WORDS_PER_PHRASE = 12;
+ const uint32_t PRNG_SEED = 0xDEADBEEF; // Fixed seed for reproducibility
+
+ // Simple PRNG (xorshift32) for reproducible random numbers
+ uint32_t prng_state = PRNG_SEED;
+#define PRNG_NEXT() \
+ (prng_state ^= prng_state << 13, prng_state ^= prng_state >> 17, \
+ prng_state ^= prng_state << 5, prng_state)
+
+ // Allocate storage for phrases and their average indices
+ char (*phrases)[256] = malloc(NUM_PHRASES * 256);
+ int *avg_indices = malloc(NUM_PHRASES * sizeof(int));
+ double *times = malloc(NUM_PHRASES * sizeof(double));
+ ck_assert(phrases != NULL && avg_indices != NULL && times != NULL);
+
+ // Generate NUM_PHRASES random phrases
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ phrases[p][0] = '\0';
+ int total_index = 0;
+
+ for (int w = 0; w < WORDS_PER_PHRASE; w++) {
+ int word_idx = PRNG_NEXT() % BIP39_WORDS;
+ total_index += word_idx;
+
+ if (w > 0) strcat(phrases[p], " ");
+ strcat(phrases[p], wordlist[word_idx]);
+ }
+ avg_indices[p] = total_index / WORDS_PER_PHRASE;
+ times[p] = 0.0;
+ }
+
+ uint8_t bits[64];
+ struct timespec start, end;
+
+ // Warm up - run each phrase once to load code/data into cache
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ mnemonic_to_bits(phrases[p], bits);
+ }
+
+ // Interleaved timing: run all phrases in each iteration
+ printf(" Running %d iterations of %d phrases...\n", ITERATIONS, NUM_PHRASES);
+ fflush(stdout);
+ for (int iter = 0; iter < ITERATIONS; iter++) {
+ if (iter % 100 == 0) {
+ printf(" Progress: %d/%d (%d%%)\r", iter, ITERATIONS,
+ iter * 100 / ITERATIONS);
+ fflush(stdout);
+ }
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ clock_gettime(CLOCK_MONOTONIC, &start);
+ mnemonic_to_bits(phrases[p], bits);
+ clock_gettime(CLOCK_MONOTONIC, &end);
+
+ double elapsed =
+ (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9;
+ times[p] += elapsed;
+ }
+ }
+ printf(" Progress: %d/%d (100%%) \n", ITERATIONS, ITERATIONS);
+
+ // Calculate average time per call
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ times[p] /= ITERATIONS;
+ }
+
+ // Calculate mean and standard deviation
+ double sum = 0.0;
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ sum += times[p];
+ }
+ double mean = sum / NUM_PHRASES;
+
+ double variance = 0.0;
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ double diff = times[p] - mean;
+ variance += diff * diff;
+ }
+ variance /= NUM_PHRASES;
+ double stddev = sqrt(variance);
+
+ // Coefficient of variation (CV)
+ double cv = (stddev / mean) * 100.0;
+
+ // Find max deviation
+ double max_deviation_percent = 0.0;
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ double deviation_percent = fabs(times[p] - mean) / mean * 100.0;
+ if (deviation_percent > max_deviation_percent) {
+ max_deviation_percent = deviation_percent;
+ }
+ }
+
+ // Calculate Pearson correlation between average word index and timing
+ // With 1000 data points, this is statistically meaningful
+ double sum_x = 0.0, sum_y = 0.0, sum_xy = 0.0, sum_x2 = 0.0, sum_y2 = 0.0;
+ for (int p = 0; p < NUM_PHRASES; p++) {
+ double x = (double)avg_indices[p];
+ double y = times[p];
+ sum_x += x;
+ sum_y += y;
+ sum_xy += x * y;
+ sum_x2 += x * x;
+ sum_y2 += y * y;
+ }
+ double n = (double)NUM_PHRASES;
+ double correlation =
+ (n * sum_xy - sum_x * sum_y) /
+ (sqrt(n * sum_x2 - sum_x * sum_x) * sqrt(n * sum_y2 - sum_y * sum_y));
+
+ // Print results
+ printf("\n Constant-time test results (%d phrases, %d iterations each):\n",
+ NUM_PHRASES, ITERATIONS);
+ printf(" Mean time: %.9f seconds\n", mean);
+ printf(" Std dev: %.9f seconds\n", stddev);
+ printf(" CV: %.2f%%\n", cv);
+ printf(" Max deviation: %.2f%%\n", max_deviation_percent);
+ printf(" Correlation (avg_index vs time): %.6f\n", correlation);
+
+ // Show a few sample phrases for verification
+ printf(" Sample phrases:\n");
+ for (int i = 0; i < 5; i++) {
+ double deviation_percent = (times[i] - mean) / mean * 100.0;
+ printf(" [avg_idx=%4d]: %.9f sec (%+.2f%%)\n",
+ avg_indices[i], times[i], deviation_percent);
+ }
+
+ // Free allocated memory
+ free(phrases);
+ free(avg_indices);
+ free(times);
+
+ // Assert timing variation is within acceptable bounds
+ ck_assert_msg(cv < 5.0,
+ "Coefficient of variation too high (%.2f%%), suggesting "
+ "non-constant-time behavior",
+ cv);
+
+ ck_assert_msg(max_deviation_percent < 100.0,
+ "Max timing deviation too high (%.2f%%), suggesting "
+ "non-constant-time behavior",
+ max_deviation_percent);
+
+ // With 1000 data points, correlation is statistically meaningful
+ // A value > 0.1 would indicate a timing leak correlated with word index
+ ck_assert_msg(fabs(correlation) < 0.1,
+ "Timing correlates with word index (r=%.6f), suggesting "
+ "non-constant-time behavior",
+ correlation);
+}
+END_TEST
+
START_TEST(test_slip39_get_word) {
static const struct {
const int index;
@@ -9726,6 +9891,7 @@ Suite *test_suite(void) {
tcase_add_test(tc, test_mnemonic_check);
tcase_add_test(tc, test_mnemonic_to_bits);
tcase_add_test(tc, test_mnemonic_find_word);
+ tcase_add_test(tc, test_mnemonic_to_bits_constant_time);
suite_add_tcase(s, tc);
tc = tcase_create("slip39");
Why this scored 60/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.