Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add MicroSD PSBT File support for SeedSignerOS #281

Draft
wants to merge 6 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/seedsigner/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class Controller(Singleton):
# TODO: Refactor these flow-related attrs that survive across multiple Screens.
# TODO: Should all in-memory flow-related attrs get wiped on MainMenuView?
psbt: 'embit.psbt.PSBT' = None
psbt_file: dict = None
psbt_seed: 'Seed' = None
psbt_parser: 'PSBTParser' = None

Expand Down Expand Up @@ -173,6 +174,7 @@ def configure_instance(cls, disable_hardware=False):

# Store one working psbt in memory
controller.psbt = None
controller.psbt_file = None
controller.psbt_parser = None

# Configure the Renderer
Expand Down
50 changes: 48 additions & 2 deletions src/seedsigner/hardware/microsd.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import time
import base64, os, glob

from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
from seedsigner.models.settings import Settings
from seedsigner.models.settings import Settings, SettingsConstants
#from seedsigner.views.view import MicroSDToastView
from seedsigner.gui.screens.screen import MicroSDToastScreen

class MicroSD(Singleton, BaseThread):

ACTION__INSERTED = "add"
ACTION__REMOVED = "remove"
MOUNT_LOCATION = "/mnt/microsd/"

settings_handler = None
MAGIC = b"psbt\xff"
MAGIC_BASE64 = base64.b64encode(MAGIC)[:-1] # remove last byte base64 padding
MAGIC_MAX_LENGTH = max(len(MAGIC), len(MAGIC_BASE64))

@classmethod
def get_instance(cls):
# This is the only way to access the one and only instance
if cls._instance is None:
# Instantiate the one and only instance
microsd = cls.__new__(cls)
microsd.psbt_files = []
microsd.find_psbt_files()
cls._instance = microsd

# explicitly call BaseThread __init__ since multiple class inheritance
Expand Down Expand Up @@ -50,6 +56,46 @@ def run(self):
print(f"fifo message: {action}")

Settings.microsd_handler(action=action)
MicroSD.psbt_files_handler(action=action)

toastscreen = MicroSDToastScreen(action=action)
toastscreen.display()

def psbt_files_handler(action):
from seedsigner.controller import Controller
controller: Controller = Controller.get_instance()
if action == MicroSD.ACTION__INSERTED:
controller.microsd.find_psbt_files()
elif action == MicroSD.ACTION__REMOVED:
controller.microsd.psbt_files = []

def find_psbt_files(self):
self.psbt_files = []
# only populate psbt files from the microsd in seedsigner-os
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
# only populate psbt files if setting is enabled
if Settings.get_instance().get_value(SettingsConstants.SETTING__MICROSD_PSBT) == SettingsConstants.OPTION__ENABLED:
for filepath in sorted(glob.glob(MicroSD.MOUNT_LOCATION + '*')):
if os.path.isfile(filepath):
with open(filepath, 'rb') as psbt_file:
file_header = psbt_file.read(MicroSD.MAGIC_MAX_LENGTH)

# binary psbt file check
if file_header.startswith(MicroSD.MAGIC):
self.psbt_files.append({
"name": os.path.splitext(os.path.basename(filepath))[0],
"filename": os.path.basename(filepath),
"filepath": filepath,
"type": "binary"
})
# base64 psbt file check
elif file_header.startswith(MicroSD.MAGIC_BASE64):
self.psbt_files.append({
"name": os.path.splitext(os.path.basename(filepath))[0],
"filename": os.path.basename(filepath),
"filepath": filepath,
"type": "base64"
})

# sort the list by name of file without extension
self.psbt_files = sorted(self.psbt_files, key=lambda d: d['name'])
8 changes: 7 additions & 1 deletion src/seedsigner/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,14 @@ def set_value(self, attr_name: str, value: any):
print(f"Removed {self.SETTINGS_FILENAME}")
except:
print(f"{self.SETTINGS_FILENAME} not found to be removed")

self._data[attr_name] = value

# Special handling for MicroSD PSBT setting
if attr_name == SettingsConstants.SETTING__MICROSD_PSBT:
from seedsigner.hardware.microsd import MicroSD
MicroSD.get_instance().find_psbt_files()

self.save()


Expand Down
7 changes: 7 additions & 0 deletions src/seedsigner/models/settings_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def map_network_to_embit(cls, network) -> str:
SETTING__COMPACT_SEEDQR = "compact_seedqr"
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
SETTING__MESSAGE_SIGNING = "message_signing"
SETTING__MICROSD_PSBT = "microsd_psbt"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
Expand Down Expand Up @@ -461,6 +462,12 @@ class SettingsDefinition:
display_name="Message signing",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),

SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MICROSD_PSBT,
display_name="MicroSD PSBT",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),

SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
Expand Down
122 changes: 118 additions & 4 deletions src/seedsigner/views/psbt_views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,66 @@
import os
from binascii import a2b_base64
from embit.psbt import PSBT
from embit import script
from embit.networks import NETWORKS
from seedsigner.controller import Controller

from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.hardware.microsd import MicroSD
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.settings import SettingsConstants
from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen, LargeIconStatusScreen)
from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination


class PSBTFileSelectionView(View):
def run(self):

# edge case when file selection menu is selected, but no psbt files are available, re-route to home menu
if len(self.controller.microsd.psbt_files) == 0:
return Destination(MainMenuView)

# add button to button list for each psbt file
button_data = []

for psbt_file in self.controller.microsd.psbt_files:
if len(psbt_file["filename"]) > 21:
button_data.append(psbt_file["filename"][:12] + "..." + psbt_file["filename"][-7:])
else:
button_data.append(psbt_file["filename"])

selected_menu_num = ButtonListScreen(
title="Select PSBT File",
is_button_text_centered=False,
button_data=button_data
).display()

if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)

# Read PSBT file select if it exists
psbt_file = self.controller.microsd.psbt_files[selected_menu_num]
tx = PSBT

if os.path.exists(psbt_file["filepath"]):
with open(psbt_file["filepath"], 'rb') as f:
data = f.read()

if psbt_file["type"] == "binary":
tx = PSBT.parse(data)
elif psbt_file["type"] == "base64":
raw = a2b_base64(data)
tx = PSBT.parse(raw)
self.controller.psbt = tx
self.controller.psbt_parser = None
self.controller.psbt_file = psbt_file
return Destination(PSBTSelectSeedView)
else:
# TODO display warning messages that psbt file was not found
return Destination(MainMenuView)

class PSBTSelectSeedView(View):
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
Expand Down Expand Up @@ -482,7 +530,10 @@ def run(self):
# Sign PSBT
sig_cnt = PSBTParser.sig_count(psbt)
psbt.sign_with(psbt_parser.root)
trimmed_psbt = PSBTParser.trim(psbt)

trimmed_psbt = psbt
if self.controller.psbt_file == None: # skip trim when using psbt files from microsd
trimmed_psbt = PSBTParser.trim(psbt)

if sig_cnt == PSBTParser.sig_count(trimmed_psbt):
# Signing failed / didn't do anything
Expand All @@ -492,7 +543,11 @@ def run(self):

else:
self.controller.psbt = trimmed_psbt
return Destination(PSBTSignedQRDisplayView)
# when psbt file is used from microsd, write signed psbt to microsd
if self.controller.psbt_file == None:
return Destination(PSBTSignedQRDisplayView)
else:
return Destination(PSBTSignedFileDisplayView)



Expand All @@ -510,7 +565,66 @@ def run(self):
# clears all ephemeral data (except in-memory seeds).
return Destination(MainMenuView, clear_history=True)


class PSBTSignedFileDisplayView(View):
def run(self):

# extract psbt from memory to write to file. The file name of a signed PSBT is appended with "signed" to the original file name.
# So "Test 123.psbt" unsigned PSBT will be written to the microsd card as "Test 123 signed.psbt".
raw = self.controller.psbt.serialize()
filename, extension = os.path.splitext(self.controller.psbt_file["filename"])
signed_filename = filename + " signed" + extension
signed_filepath = MicroSD.MOUNT_LOCATION + signed_filename
increment = 0

# verify microsd directory exists and is writable, display message warning on failure
selected_menu_num = 0
while (not os.path.exists(MicroSD.MOUNT_LOCATION) or not os.access(MicroSD.MOUNT_LOCATION, os.W_OK)):
selected_menu_num = WarningScreen(
status_headline="MicroSD Card Missing!",
text="MicroSD Card is required to write out signed PSBT file.",
button_data=["Continue", "Exit"],
).display()

# if users chooses to exit, consider the PSBT workflow complete
if selected_menu_num == 1:
# We're done with this PSBT. Remove all related data
self.controller.psbt = None
self.controller.psbt_parser = None
self.controller.psbt_seed = None
self.controller.psbt_file = None
return Destination(MainMenuView)

# if signed filename already exists on disk, add incremented number to the end of the file name
while os.path.exists(signed_filepath):
increment += 1
signed_filename = filename + " signed" + str(increment) + extension
signed_filepath = MicroSD.MOUNT_LOCATION + signed_filename

# write out psbt file sync and force flush to disk as best as possible
with open(signed_filepath, "wb") as f:
f.write(raw)
f.flush()
os.fsync(f.fileno())

LargeIconStatusScreen(
title="PSBT Saved",
show_back_button=False,
status_headline="Success!",
text=signed_filename,
button_data=["OK"],
allow_text_overflow=True
).display()

# refresh list of files to include the new signed psbt
self.controller.microsd.find_psbt_files()

# We're done with this PSBT. Remove all related data
self.controller.psbt = None
self.controller.psbt_parser = None
self.controller.psbt_seed = None
self.controller.psbt_file = None

return Destination(MainMenuView)

class PSBTSigningErrorView(View):
def run(self):
Expand Down
2 changes: 2 additions & 0 deletions src/seedsigner/views/scan_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ def run(self):
psbt = self.decoder.get_psbt()
self.controller.psbt = psbt
self.controller.psbt_parser = None
self.controller.psbt_file = None

return Destination(PSBTSelectSeedView, skip_current_view=True)

elif self.decoder.is_settings:
Expand Down
9 changes: 9 additions & 0 deletions src/seedsigner/views/seed_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ def run(self):
****************************************************************************"""
class SeedOptionsView(View):
SCAN_PSBT = ("Scan PSBT", SeedSignerIconConstants.QRCODE)
OPEN_PSBT = "Open PSBT on MicroSD"
VERIFY_ADDRESS = "Verify Addr"
EXPORT_XPUB = "Export Xpub"
EXPLORER = "Address Explorer"
Expand Down Expand Up @@ -478,6 +479,9 @@ def run(self):

button_data.append(self.SCAN_PSBT)

if len(self.controller.microsd.psbt_files) > 0:
button_data.append(self.OPEN_PSBT)

if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED:
button_data.append(self.EXPORT_XPUB)

Expand Down Expand Up @@ -507,6 +511,11 @@ def run(self):
from seedsigner.views.scan_views import ScanPSBTView
self.controller.psbt_seed = self.controller.get_seed(self.seed_num)
return Destination(ScanPSBTView)

elif button_data[selected_menu_num] == self.OPEN_PSBT:
from seedsigner.views.psbt_views import PSBTFileSelectionView
self.controller.psbt_seed = self.controller.get_seed(self.seed_num)
return Destination(PSBTFileSelectionView)

elif button_data[selected_menu_num] == self.VERIFY_ADDRESS:
return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num))
Expand Down
8 changes: 8 additions & 0 deletions src/seedsigner/views/tools_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView, SeedExportXpubScriptTypeView
from seedsigner.views.psbt_views import PSBTFileSelectionView

from .view import View, Destination, BackStackView

Expand All @@ -27,11 +28,15 @@ class ToolsMenuView(View):
DICE = ("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
EXPLORER = "Address Explorer"
OPEN_PSBT = "Open PSBT on MicroSD"
ADDRESS = "Verify address"

def run(self):
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER, self.ADDRESS]

if len(self.controller.microsd.psbt_files) > 0:
button_data.append(self.OPEN_PSBT)

selected_menu_num = self.run_screen(
ButtonListScreen,
title="Tools",
Expand All @@ -53,6 +58,9 @@ def run(self):

elif button_data[selected_menu_num] == self.EXPLORER:
return Destination(ToolsAddressExplorerSelectSourceView)

elif button_data[selected_menu_num] == OPEN_PSBT:
return Destination(PSBTFileSelectionView)

elif button_data[selected_menu_num] == self.ADDRESS:
from seedsigner.views.scan_views import ScanAddressView
Expand Down