Merge pull request #705 from Foundation-Devices/SFT-7322-pairing-secret-erased-word
What changed, and why it matters
This commit fixes a minor sanity check in the bootloader that generates a device pairing secret. The original code compared only the lowest 8 bits of a 32-bit word against 0xff, when it intended to check the entire 32-bit word against 0xffffffff. Because the value comes from a random number generator, the practical effect is tiny: a valid-but-unintended value could slip through the 'not erased flash' check, but it does not create a security hole an attacker can exploit. The change also adds a regression test to ensure the check works correctly in the future.
Treat as a low-risk code-quality and correctness fix. Merge the patch and run the new regression test in CI. No urgent security response is required because the bug is in a sanity check, not in cryptographic entropy generation, and the value space where it mattered is a single 32-bit value out of 2^32 possibilities.
Security signals we found
Boundary/policy check corrected to compare full 32-bit erased-flash pattern
New regression test compiles production provisioning function with deterministic RNG
Comment clarifies bootloader provisioning entropy source constraints
Evidence from the diff
In ports/stm32/boards/Passport/bootloader/flash.c, pick_pairing_secret() enforces a policy that the first 32-bit word of the generated pairing secret must not equal the erased-flash pattern. The original comparison secret[0] == 0xff only tested the least-significant byte, not the full 32-bit erased word 0xffffffffU. The patch corrects the comparison and adds a Python test that compiles the C function with a deterministic RNG to verify that an erased first word triggers resampling and that non-erased words are accepted. A code comment is also added explaining why provisioning retains the MCU RNG source rather than calling the firmware entropy mixer.
Changed components
Passport bootloader flash.c `pick_pairing_secret()`Pairing secret generation during provisioning/first bootNew regression test `test_pairing_secret_policy.py`Inspect captured patch +80 / −1
### ports/stm32/boards/Passport/bootloader/flash.c
@@ -330,6 +330,10 @@ __attribute__((section(".ramfunc"))) void flash_lockdown_hard(void) {
}
static void pick_pairing_secret(rom_secrets_t* local) {
+ // Provisioning runs before se_setup_config(). The production bootloader
+ // does not link the ADC/noise mixer (only FACTORY_TEST does), so retain the
+ // MCU source here. Adding independent entropy requires bootloader hardware
+ // bring-up and provisioning validation, not a call to the firmware mixer.
uint32_t secret[8];
int i;
uint32_t* pos;
@@ -341,7 +345,7 @@ static void pick_pairing_secret(rom_secrets_t* local) {
// enforce policy that first word is not all ones (so it never
// looks like unprogrammed flash).
- while (secret[0] == 0xff) {
+ while (secret[0] == 0xffffffffU) {
secret[0] = rng_sample();
}
### ports/stm32/boards/Passport/modules/tests/test_pairing_secret_policy.py
@@ -0,0 +1,75 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Compile the actual provisioning function with deterministic public RNG samples."""
+
+import os
+import shlex
+import subprocess
+from pathlib import Path
+
+
+BOARD = Path(__file__).resolve().parents[2]
+
+
+def test_erased_first_word_is_resampled(tmp_path):
+ flash = (BOARD / 'bootloader' / 'flash.c').read_text()
+ function = flash.split('static void pick_pairing_secret(', 1)[1].split('\nsecresult flash_first_boot', 1)[0]
+ source = r'''
+#include <assert.h>
+#include <stdint.h>
+#include <string.h>
+#include "secrets.h"
+
+static uint32_t samples[64];
+static unsigned cursor;
+static uint32_t rng_sample(void) {
+ assert(cursor < 64);
+ return samples[cursor++];
+}
+''' + 'static void pick_pairing_secret(' + function + r'''
+static uint32_t read_word(const uint8_t* p) {
+ uint32_t v;
+ memcpy(&v, p, sizeof(v));
+ return v;
+}
+
+static void check(uint32_t first, unsigned retries) {
+ rom_secrets_t secrets;
+ memset(&secrets, 0xaa, sizeof(secrets));
+ for (unsigned i = 0; i < 64; i++) samples[i] = 0x12340000 + i;
+ samples[0] = first;
+ // The first retry also returns an erased word, forcing another retry.
+ if (retries) samples[8] = 0xffffffff;
+ cursor = 0;
+ pick_pairing_secret(&secrets);
+ assert(read_word(secrets.pairing_secret) == (retries ? samples[9] : first));
+ for (unsigned i = 1; i < 8; i++) {
+ assert(read_word(secrets.pairing_secret + 4*i) == samples[i]);
+ }
+ for (unsigned i = 0; i < sizeof(secrets.otp_key)/4; i++) {
+ assert(read_word(secrets.otp_key + 4*i) == samples[8 + retries + i]);
+ }
+ for (unsigned i = 0; i < sizeof(secrets.hash_cache_secret)/4; i++) {
+ assert(read_word(secrets.hash_cache_secret + 4*i) == samples[26 + retries + i]);
+ }
+ assert(cursor == 34 + retries);
+ for (unsigned i = 0; i < sizeof(secrets.se_serial_number); i++) {
+ assert(secrets.se_serial_number[i] == 0xaa);
+ }
+}
+
+int main(void) {
+ check(0xffffffff, 2);
+ check(0x000000ff, 0);
+ check(0x12345678, 0);
+ return 0;
+}
+'''
+ test_source = tmp_path / 'pairing.c'
+ test_source.write_text(source)
+ executable = tmp_path / 'pairing'
+ subprocess.run(shlex.split(os.environ.get('CC', 'cc')) +
+ ['-std=c99', '-Wall', '-Wextra', '-Wno-address-of-packed-member',
+ '-I', str(BOARD / 'include'), str(test_source), '-o', str(executable)], check=True)
+ subprocess.run([str(executable)], check=True)Why this scored 24/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.