bugfix: detect RNG_SR_SEIS and RNG_SR_SECS, retry safely, and fail closed on persistent faults
What changed, and why it matters
This update fixes how the COLDCARD hardware wallet's random-number generator (RNG) handles rare hardware faults. Previously, the device could silently continue using weak or repeated random numbers if the RNG reported a seed error. Now it detects those error flags, tries a safe recovery a few times, and if the problem persists it stops with an error instead of producing bad randomness. Two keyboard drivers were also updated so a temporary RNG failure does not lock the user out before login.
Treat as a security-relevant bugfix and include in the next firmware release. Review whether other RNG consumers (e.g., seed generation, nonce creation) already propagate OSError correctly. Consider adding tests that simulate RNG_SR_SEIS/SECS to verify retry and fail-closed behavior.
Security signals we found
RNG seed-error flag detection added (RNG_SR_SEIS, RNG_SR_SECS)
Bounded retry with recovery sequence instead of silent continuation
Fail-closed escalation on persistent RNG faults
Zero-value and repeated-value RNG samples rejected
Post-read status recheck to close polling race
Keyboard/numpad shuffle wrapped in OSError exception handler to maintain usability
Evidence from the diff
The patch hardens STM32L4 RNG usage in both the Mk4 firmware and bootloader. It adds detection of RNG_SR_SEIS (seed error interrupt status) and RNG_SR_SECS (seed error current status), implements a bounded retry loop with a documented recovery sequence (clear SEIS, toggle RNGEN), rechecks status after reading DR to close the polling race, rejects zero samples, and escalates to OSError/fatal_error on persistent failure. shared/keyboard.py and shared/mempad.py now catch OSError from shuffle() so pre-login key scanning remains usable during transient RNG faults.
Changed components
stm32/COLDCARD_MK4/rng.cstm32/mk4-bootloader/rng.cshared/keyboard.pyshared/mempad.pyInspect captured patch +117 / −29
diff --git a/releases/Next-ChangeLog.md b/releases/Next-ChangeLog.md
index 2bfd7e8..a1f1d1b 100644
--- a/releases/Next-ChangeLog.md
+++ b/releases/Next-ChangeLog.md
@@ -4,7 +4,7 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
-- tbd
+- Bugfix: Detect RNG_SR_SEIS and RNG_SR_SECS, retry safely, and fail closed on persistent faults.
# Mk Specific Changes
diff --git a/shared/keyboard.py b/shared/keyboard.py
index b9296e9..9f254d7 100644
--- a/shared/keyboard.py
+++ b/shared/keyboard.py
@@ -97,7 +97,12 @@ class FullKeyboard(NumpadBase):
def _start_scan(self):
# reset and re-start scanning keys
self.lp_time = utime.ticks_ms()
- shuffle(self.scan_order)
+ try:
+ shuffle(self.scan_order)
+ except OSError:
+ # An RNG fault may reduce scan-order randomization, but must not
+ # prevent the keyboard from accepting input before login.
+ pass
self._scan_count = 0
self.waiting_for_any = False
diff --git a/shared/mempad.py b/shared/mempad.py
index 661a0e5..e47343f 100644
--- a/shared/mempad.py
+++ b/shared/mempad.py
@@ -81,7 +81,12 @@ class MembraneNumpad(NumpadBase):
# reset and re-start scanning keys
self.waiting_for_any = False
self.lp_time = utime.ticks_ms()
- shuffle(self.scan_order)
+ try:
+ shuffle(self.scan_order)
+ except OSError:
+ # An RNG fault may reduce scan-order randomization, but must not
+ # leave the keypad disabled before the user can log in.
+ pass
self._scan_count = 0
self._history = bytearray(NUM_ROWS * NUM_COLS)
diff --git a/stm32/COLDCARD_MK4/rng.c b/stm32/COLDCARD_MK4/rng.c
index 00e3d20..c95eacc 100644
--- a/stm32/COLDCARD_MK4/rng.c
+++ b/stm32/COLDCARD_MK4/rng.c
@@ -31,6 +31,7 @@
*/
#include <string.h>
+#include <stdbool.h>
#include "py/obj.h"
#include "py/runtime.h"
@@ -46,36 +47,81 @@ void random_buffer(uint8_t *p, size_t count);
static void rng_init(void) {
if (!(RNG->CR & RNG_CR_RNGEN)) {
__HAL_RCC_RNG_CLK_ENABLE();
-
RNG->CR |= RNG_CR_RNGEN;
-
- // TODO: throw out some samples?
}
}
-
-#define RNG_TIMEOUT_MS (10)
+#define RNG_TIMEOUT_MS (10)
+#define RNG_MAX_ATTEMPTS (3)
+#define RNG_SEED_ERROR_MASK (RNG_SR_SEIS | RNG_SR_SECS)
static uint32_t last_value;
-static uint32_t rng_get_or_fault(void)
+// Recover from a seed error.
+static void rng_recover(void)
{
- // Enable the RNG peripheral if it's not already enabled
- rng_init();
+ // Ensure the peripheral is clocked before touching its registers.
+ __HAL_RCC_RNG_CLK_ENABLE();
+
+ // Clear sticky SEIS, then cycle RNGEN per the STM32L4 recovery sequence.
+ RNG->SR &= ~RNG_SR_SEIS;
+ RNG->CR &= ~RNG_CR_RNGEN;
+ RNG->CR |= RNG_CR_RNGEN;
+}
- // Wait for a new random number to be ready, takes on the order of 10us
+// Make one bounded attempt to obtain a trustworthy, non-zero word.
+static bool rng_try_once(uint32_t *value)
+{
uint32_t start = HAL_GetTick();
- while (!(RNG->SR & RNG_SR_DRDY)) {
+ // Seed errors can suppress DRDY, so check for them while polling.
+ while (1) {
+ uint32_t sr = RNG->SR;
+
+ if (sr & RNG_SEED_ERROR_MASK) {
+ return false;
+ }
+
+ if (sr & RNG_SR_DRDY) {
+ break;
+ }
+
if (HAL_GetTick() - start >= RNG_TIMEOUT_MS) {
- // hardware failure... do not return anything!
- mp_raise_OSError(MP_EFAULT);
+ return false;
}
}
- // Get and return the new random number
- last_value = RNG->DR;
+ uint32_t sample = RNG->DR;
+
+ // Recheck after reading DR to close the polling race; zero is also suspect.
+ if (!sample || (RNG->SR & RNG_SEED_ERROR_MASK)) {
+ return false;
+ }
+
+ *value = sample;
+ return true;
+}
+
+static uint32_t rng_get_or_fault(void)
+{
+ rng_init();
+
+ // Retry transient failures, recovering only between attempts.
+ for (int attempt = 0; attempt < RNG_MAX_ATTEMPTS; attempt++) {
+ uint32_t value;
+
+ if (rng_try_once(&value)) {
+ last_value = value;
+ return last_value;
+ }
+
+ if (attempt + 1 < RNG_MAX_ATTEMPTS) {
+ rng_recover();
+ }
+ }
+ // Persistent hardware failure: never return suspect randomness.
+ mp_raise_OSError(MP_EFAULT);
return last_value;
}
diff --git a/stm32/mk4-bootloader/rng.c b/stm32/mk4-bootloader/rng.c
index 53d6ecd..bc7e15b 100644
--- a/stm32/mk4-bootloader/rng.c
+++ b/stm32/mk4-bootloader/rng.c
@@ -6,7 +6,21 @@
#include "basics.h"
#include "stm32l4xx_hal.h"
+#define RNG_MAX_ATTEMPTS (3)
+#define RNG_SEED_ERROR_MASK (RNG_SR_SEIS | RNG_SR_SECS)
+// Recover from a seed error.
+static void
+rng_recover(void)
+{
+ // Ensure the peripheral is clocked before touching its registers.
+ __HAL_RCC_RNG_CLK_ENABLE();
+
+ // Clear sticky SEIS, then cycle RNGEN per the STM32L4 recovery sequence.
+ RNG->SR &= ~RNG_SR_SEIS;
+ RNG->CR &= ~RNG_CR_RNGEN;
+ RNG->CR |= RNG_CR_RNGEN;
+}
// rng_setup()
//
@@ -47,25 +61,43 @@ rng_sample(void)
{
static uint32_t last_rng_result;
- while(1) {
- // Check if data register contains valid random data
- while(!(RNG->SR & RNG_FLAG_DRDY)) {
- // busy wait; okay to get stuck here... better than failing.
- }
+ // Attempts bound seed-error recovery; DRDY polling remains intentionally unbounded.
+ for(int attempt = 0; attempt < RNG_MAX_ATTEMPTS; attempt++) {
+ while(1) {
+ uint32_t sr = RNG->SR;
- // Get the new number
- uint32_t rv = RNG->DR;
+ if(sr & RNG_SEED_ERROR_MASK) {
+ break;
+ }
- if(rv != last_rng_result && rv) {
- last_rng_result = rv;
+ if(!(sr & RNG_FLAG_DRDY)) {
+ // Missing clocks are a hard failure. Preserve the existing
+ // fail-closed behaviour and wait rather than use bad data.
+ continue;
+ }
- return rv;
+ uint32_t rv = RNG->DR;
+
+ // Recheck after reading DR to close the documented polling race.
+ if(RNG->SR & RNG_SEED_ERROR_MASK) {
+ break;
+ }
+
+ if(rv != last_rng_result && rv) {
+ last_rng_result = rv;
+
+ return rv;
+ }
+
+ // Zero or repeat: poll for another word without consuming an attempt.
}
- // keep trying if not a new number
+ if(attempt + 1 < RNG_MAX_ATTEMPTS) {
+ rng_recover();
+ }
}
- // NOT-REACHED
+ fatal_error("rng");
}
// rng_buffer()
Why this scored 66/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.