SFT-6378: first pass at anti-sidechannel round 2, needs testing and evaluation
What changed, and why it matters
This commit is a defensive hardening change for the BIP-39 seed-word handling code in the Passport hardware wallet firmware. It restructures how the English word list is stored so that a constant-time word comparison function can safely read every entry without accidentally touching memory past the end of a short word. It also removes noisy timing-test thresholds that could fail on shared CI machines and keeps only a stricter statistical correlation check. The change is explicitly described by the developer as an early 'first pass' that still needs testing and evaluation, so it is not a finished security fix.
Treat this as an in-progress hardening commit, not a completed fix. Review the companion constant-time comparison function ct_word_eq() to confirm it actually reads exactly BIP39_MAX_WORD_LEN bytes for every comparison, verify that no other wordlist consumers rely on the old NULL sentinel, and re-run the revised statistical timing tests on real hardware before release.
Security signals we found
Constant-time/side-channel hardening
Fixed-width wordlist storage to avoid out-of-bounds reads in constant-time comparison
Removal of unreliable wall-clock timing assertions from CI tests
Developer note that the change 'needs testing and evaluation'
Evidence from the diff
The patch moves BIP39_MAX_WORD_LEN from bip39.c to bip39.h and changes the wordlist storage from an array of char pointers (const char* const wordlist[]) to a fixed 2-D char array (const char wordlist[][BIP39_MAX_WORD_LEN]). Shorter words are now padded by the compiler to exactly 9 bytes, letting ct_word_eq() read BIP39_MAX_WORD_LEN bytes from any slot without undefined behavior. The sentinel NULL at the end of the wordlist is removed and loops now use the known BIP39_WORDS count. In the test file, coefficient-of-variation and max-deviation assertions are dropped because they are noisy on shared CI runners; only the Pearson correlation assertion between word index and timing is retained as a meaningful constant-time signal.
Changed components
extmod/trezor-firmware/crypto/bip39.cextmod/trezor-firmware/crypto/bip39.hextmod/trezor-firmware/crypto/bip39_english.hextmod/trezor-firmware/crypto/tests/test_check.cInspect captured patch +22 / −22
diff --git a/extmod/trezor-firmware/crypto/bip39.c b/extmod/trezor-firmware/crypto/bip39.c
index e24dbb6..1f714d1 100644
--- a/extmod/trezor-firmware/crypto/bip39.c
+++ b/extmod/trezor-firmware/crypto/bip39.c
@@ -94,9 +94,6 @@ const char *mnemonic_from_data(const uint8_t *data, int len) {
void mnemonic_clear(void) { memzero(mnemo, sizeof(mnemo)); }
-// 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,
@@ -310,9 +307,9 @@ int mnemonic_find_word(const char *word) {
const char *mnemonic_complete_word(const char *prefix, int len) {
// we need to perform linear search,
// because we want to return the first match
- for (const char *const *w = wordlist; *w != 0; w++) {
- if (strncmp(*w, prefix, len) == 0) {
- return *w;
+ for (int k = 0; k < BIP39_WORDS; k++) {
+ if (strncmp(wordlist[k], prefix, len) == 0) {
+ return wordlist[k];
}
}
return NULL;
@@ -331,8 +328,8 @@ uint32_t mnemonic_word_completion_mask(const char *prefix, int len) {
return 0x3ffffff; // all letters (bits 1-26 set)
}
uint32_t res = 0;
- for (const char *const *w = wordlist; *w != 0; w++) {
- const char *word = *w;
+ for (int k = 0; k < BIP39_WORDS; k++) {
+ const char *word = wordlist[k];
if (strncmp(word, prefix, len) == 0 && word[len] >= 'a' &&
word[len] <= 'z') {
res |= 1 << (word[len] - 'a');
diff --git a/extmod/trezor-firmware/crypto/bip39.h b/extmod/trezor-firmware/crypto/bip39.h
index 46e4b1d..4248ff4 100644
--- a/extmod/trezor-firmware/crypto/bip39.h
+++ b/extmod/trezor-firmware/crypto/bip39.h
@@ -29,6 +29,10 @@
#define BIP39_WORDS 2048
#define BIP39_PBKDF2_ROUNDS 2048
+// Maximum byte width of a BIP-39 word entry (8-char word + null terminator).
+// 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
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 c57fca3..99841da 100644
--- a/extmod/trezor-firmware/crypto/bip39_english.h
+++ b/extmod/trezor-firmware/crypto/bip39_english.h
@@ -21,7 +21,13 @@
* OTHER DEALINGS IN THE SOFTWARE.
*/
-static const char* const wordlist[] = {
+#include "bip39.h"
+
+// Fixed-width storage ensures every entry is exactly BIP39_MAX_WORD_LEN bytes.
+// String-literal initialisers pad shorter entries with null bytes, so
+// ct_word_eq() can read the full BIP39_MAX_WORD_LEN bytes from any slot
+// without invoking undefined behaviour.
+static const char wordlist[][BIP39_MAX_WORD_LEN] = {
"abandon", "ability", "able", "about", "above", "absent",
"absorb", "abstract", "absurd", "abuse", "access", "accident",
"account", "accuse", "achieve", "acid", "acoustic", "acquire",
@@ -363,5 +369,5 @@ static const char* const wordlist[] = {
"work", "world", "worry", "worth", "wrap", "wreck",
"wrestle", "wrist", "write", "wrong", "yard", "year",
"yellow", "you", "young", "youth", "zebra", "zero",
- "zone", "zoo", 0,
+ "zone", "zoo",
};
diff --git a/extmod/trezor-firmware/crypto/tests/test_check.c b/extmod/trezor-firmware/crypto/tests/test_check.c
index 5bcf23f..9b4c336 100644
--- a/extmod/trezor-firmware/crypto/tests/test_check.c
+++ b/extmod/trezor-firmware/crypto/tests/test_check.c
@@ -5701,19 +5701,12 @@ START_TEST(test_mnemonic_to_bits_constant_time) {
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);
+ // CV and max-deviation are wall-clock metrics that are inherently noisy on
+ // shared CI hosts and are not meaningful indicators of constant-time behavior.
+ // We print them for informational purposes only.
- // With 1000 data points, correlation is statistically meaningful
- // A value > 0.1 would indicate a timing leak correlated with word index
+ // With 1000 data points the Pearson correlation between word index and timing
+ // is statistically meaningful: |r| >= 0.1 would indicate a real timing leak.
ck_assert_msg(fabs(correlation) < 0.1,
"Timing correlates with word index (r=%.6f), suggesting "
"non-constant-time behavior",
Why this scored 49/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.