Merge pull request #700 from Foundation-Devices/SFT-8162-dev-pubkey-confirm
What changed, and why it matters
This commit hardens the way Passport hardware wallets install a 'developer public key' used to authorize custom firmware. Previously, any 64-byte blob read from a microSD card could be written straight into the secure element. Now the device checks that the blob is a mathematically valid point on the Bitcoin curve (secp256k1) and shows the user a confirmation screen before storing it. The change prevents installing a malformed or all-zero key that could never verify firmware signatures, and it adds a user approval step so a key cannot be silently swapped.
Treat as a security hardening fix and include in release notes. Users running developer firmware should update so that only valid developer pubkeys can be enrolled, and so that enrollment requires explicit on-device confirmation. No immediate emergency response is indicated because the feature requires physical microSD access and developer mode, but the fix closes a meaningful trust-boundary weakness.
Security signals we found
Input validation added for public-key material before secure-element write
User confirmation screen added before trusting a developer pubkey
Malformed / all-zero / out-of-range public keys are now rejected
Unit tests added for validation function
Developer-mode feature (custom firmware authorization) is the affected surface
Evidence from the diff
The patch adds is_valid_firmware_pubkey() in utils.py, which verifies that a 64-byte (x||y) coordinate pair lies on secp256k1 (y^2 == x^3 + 7 mod p), rejects coordinates >= p, rejects the all-zero key, and requires exact length. InstallDevPubkeyFlow now calls this validator before invoking system.set_user_firmware_pubkey(), and only proceeds after the user confirms via QuestionPage. Unit tests cover valid curve points, negated points, scalar multiples of G, off-by-one coordinates, out-of-range coordinates, wrong lengths, and bytearray input.
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 62/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.