SFT-8162: validate and confirm a developer pubkey before installing it
What changed, and why it matters
This commit fixes a security gap in the Passport hardware wallet's developer-public-key installation flow. Previously, the device only checked that the key file was 88 bytes long and installed whatever 64-byte blob it found without showing it to the user. Now it verifies that the 64 bytes actually form a valid point on the secp256k1 cryptographic curve, and it shows the key on screen and asks for user confirmation before writing it to the secure element. The change prevents installing malformed or all-zero keys that could never verify a firmware signature, and it closes a social-engineering path where a user might blindly trust a malicious key file.
Treat this as a security-hardening fix and include it in the next firmware release. Review whether the C binding and bootloader verifier should also enforce the same validation, since the commit notes they are currently out of scope. Ensure the new unit tests run in CI.
Security signals we found
Input validation added for cryptographic public key before secure-element write
User confirmation prompt added before trusting a developer firmware signing key
All-zero key explicitly rejected in the install flow
Card slot lifecycle tightened: released before user prompt rather than held open
Unit tests added for validation function covering valid/invalid curve points and lengths
Evidence from the diff
The patch adds is_valid_firmware_pubkey() in utils.py, which validates that a 64-byte value is a reduced secp256k1 point (y² ≡ x³ + 7 mod p) and rejects the all-zero key. InstallDevPubkeyFlow now calls this validator after reading the key from microSD, displays the hex key in a QuestionPage for user confirmation, and only then writes it via system.set_user_firmware_pubkey(). The card slot is released before prompting. Unit tests cover valid curve points, the zero key, off-by-one coordinates, out-of-range coordinates, and length checks. The C binding and bootloader verifier are explicitly left unchanged.
Changed components
ports/stm32/boards/Passport/modules/flows/install_dev_pubkey_flow.pyports/stm32/boards/Passport/modules/utils.pyports/stm32/boards/Passport/modules/tests/unit/firmware_pubkey.pyports/stm32/boards/Passport/modules/tests/test_unit.pyInspect captured patch +129 / −16
### ports/stm32/boards/Passport/modules/flows/install_dev_pubkey_flow.py
@@ -5,10 +5,11 @@
from flows import Flow, FilePickerFlow
from files import CardMissingError, CardSlot
-from pages import ErrorPage, SuccessPage
+from pages import ErrorPage, QuestionPage, SuccessPage
from pages.insert_microsd_page import InsertMicroSDPage
-from utils import clear_cached_pubkey
-from ubinascii import hexlify
+from utils import (bytes_to_hex_str, clear_cached_pubkey, is_valid_firmware_pubkey,
+ split_to_lines)
+import microns
class InstallDevPubkeyFlow(Flow):
@@ -29,8 +30,6 @@ async def choose_file(self):
self.goto(self.load_dev_pubkey)
async def load_dev_pubkey(self):
- from common import system
-
try:
with CardSlot() as card:
with open(self.pubkey_file_path, 'rb') as fd:
@@ -47,18 +46,45 @@ async def load_dev_pubkey(self):
fd.seek(24) # Skip the header
pubkey = fd.read(64) # Read the pubkey
- # print('pubkey = {}'.format(hexlify(pubkey)))
-
- clear_cached_pubkey()
-
- result = system.set_user_firmware_pubkey(pubkey)
- if result:
- await SuccessPage(text='Successfully Installed!').show()
- self.set_result(True)
- else:
- await ErrorPage(text='Unable to Install.').show()
- self.set_result(False)
+ # print('pubkey = {}'.format(bytes_to_hex_str(pubkey)))
except CardMissingError:
result = await InsertMicroSDPage().show()
if not result:
self.back()
+ return
+
+ # A key that is not a point on the curve can never verify a signature, so
+ # there is no reason to write one into the secure element.
+ if not is_valid_firmware_pubkey(pubkey):
+ await ErrorPage(text='This file does not contain a valid Developer PubKey.').show()
+ self.set_result(False)
+ return
+
+ self.pubkey = pubkey
+ self.goto(self.confirm_dev_pubkey)
+
+ async def confirm_dev_pubkey(self):
+ from common import system
+
+ # Show what is about to be trusted, in the same form as View Developer PubKey
+ confirmed = await QuestionPage(
+ text=split_to_lines(bytes_to_hex_str(self.pubkey), 16),
+ card_header={'title': 'Install PubKey?'},
+ statusbar={'title': 'DEVELOPER'},
+ left_micron=microns.Cancel,
+ right_micron=microns.Checkmark
+ ).show()
+
+ if not confirmed:
+ self.set_result(False)
+ return
+
+ clear_cached_pubkey()
+
+ result = system.set_user_firmware_pubkey(self.pubkey)
+ if result:
+ await SuccessPage(text='Successfully Installed!').show()
+ self.set_result(True)
+ else:
+ await ErrorPage(text='Unable to Install.').show()
+ self.set_result(False)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -32,6 +32,10 @@ def test_crypto_api_surface(test):
assert test('crypto_api_surface.py') == b'OK'
+def test_firmware_pubkey(test):
+ assert test('firmware_pubkey.py') == b'OK'
+
+
def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/firmware_pubkey.py
@@ -0,0 +1,62 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# A developer firmware pubkey that is not a point on secp256k1 can never verify a
+# signature, so it must not reach the secure element slot in the first place.
+
+from taproot import bytes_from_int, p as FIELD_PRIME, scalar_multiply, x, y
+from utils import is_valid_firmware_pubkey
+
+# secp256k1 G, the generator.
+GENERATOR_X = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
+GENERATOR_Y = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
+
+
+def serialize(x_coord, y_coord):
+ '''64 bytes of x then y, the form the secure element slot holds.'''
+
+ return bytes_from_int(x_coord) + bytes_from_int(y_coord)
+
+
+assert is_valid_firmware_pubkey(serialize(GENERATOR_X, GENERATOR_Y))
+
+# The negation of a point is also on the curve.
+assert is_valid_firmware_pubkey(serialize(GENERATOR_X, FIELD_PRIME - GENERATOR_Y))
+
+# A few more real points, so this is not just one hardcoded pair.
+for multiplier in (2, 3, 7, 0x1234567890abcdef):
+ point = scalar_multiply(multiplier)
+ assert is_valid_firmware_pubkey(serialize(x(point), y(point))), \
+ 'rejected {} * G'.format(multiplier)
+
+# The all zero key is how an empty slot reads. Removing a key has its own flow, so
+# this one must not accept it as something to install.
+assert not is_valid_firmware_pubkey(bytes(64))
+
+# y is off by one, so the point is not on the curve.
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X, GENERATOR_Y + 1))
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X, GENERATOR_Y - 1))
+
+# x is off by one: some x values have no square root at all, and this one does not.
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X + 1, GENERATOR_Y))
+
+# A zero coordinate beside a real one is not a point either.
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X, 0))
+assert not is_valid_firmware_pubkey(serialize(0, GENERATOR_Y))
+
+# Coordinates have to be reduced, so p itself is out of range on either side.
+assert not is_valid_firmware_pubkey(bytes_from_int(FIELD_PRIME) + bytes_from_int(GENERATOR_Y))
+assert not is_valid_firmware_pubkey(bytes_from_int(GENERATOR_X) + bytes_from_int(FIELD_PRIME))
+
+# All ones is neither reduced nor on the curve.
+assert not is_valid_firmware_pubkey(b'\xff' * 64)
+
+# The length is fixed by the slot.
+assert not is_valid_firmware_pubkey(b'')
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X, GENERATOR_Y)[0:63])
+assert not is_valid_firmware_pubkey(serialize(GENERATOR_X, GENERATOR_Y) + b'\x00')
+
+# A bytearray, which is what read_user_firmware_pubkey() hands back.
+assert is_valid_firmware_pubkey(bytearray(serialize(GENERATOR_X, GENERATOR_Y)))
+
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/utils.py
@@ -43,6 +43,27 @@ def read_user_firmware_pubkey():
return result, pubkey
+
+def is_valid_firmware_pubkey(pubkey):
+ '''Is this a point on secp256k1, given as 64 bytes of x then y?
+
+ The all zero key is how "no developer key installed" is stored, so it is not
+ accepted here: removing a key goes through its own flow.
+ '''
+ from taproot import p as field_prime
+
+ if len(pubkey) != 64:
+ return False
+
+ x = int.from_bytes(pubkey[0:32], 'big')
+ y = int.from_bytes(pubkey[32:64], 'big')
+
+ if x >= field_prime or y >= field_prime:
+ return False
+
+ # y^2 == x^3 + 7, which also rejects the all zero key
+ return (y * y - x * x * x - 7) % field_prime == 0
+
# We cache this here to avoid slowing down the menus, since the menu items in the Developer Pubkey
# menu look up this value to decide when to become visible/hidden.
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.