diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3dd0fec7..a0649dc7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,12 +1,30 @@ name: Build on: + pull_request: + # Build on changes to this workflow files in PRs to test proposed changes + paths: + - '.github/workflows/build.yml' + push: + branches: + - main + - dev workflow_dispatch: + inputs: + os-ref: + description: The seedsigner-os ref (tag/branch/sha1) to use + default: main + required: true + +# Increment this number as part of a PR to trigger an image build for the PR +# trigger = 0 jobs: build: name: build runs-on: ubuntu-latest + # Prevent resource consuming cron triggered runs in forks + if: (!github.event.repository.fork || github.event_name == 'workflow_dispatch') strategy: fail-fast: false matrix: @@ -16,25 +34,41 @@ jobs: uses: actions/checkout@v3 with: repository: "seedsigner/seedsigner-os" + # use the os-ref input parameter in case of workflow_dispatch or default to main in case of cron triggers + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.os-ref || 'main' }} submodules: true path: "seedsigner-os" - - - name: get seedsigner-os latest commit hash - id: get-seedsigner-os-hash - run: | - cd seedsigner-os - echo "builder_hash=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + # get full history + tags for "git describe" + fetch-depth: 0 - name: checkout source uses: actions/checkout@v3 with: + # ref defaults to repo default-branch=dev (cron) or SHA of event (workflow_dispatch) path: "seedsigner-os/opt/rootfs-overlay/opt" + # get full history + tags for "git describe" + fetch-depth: 0 - - name: get seedsigner latest commit hash - id: get-seedsigner-hash + - name: Get and set meta data run: | - git init - echo "source_hash=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV + # The builder_hash (seedsigner-os hash) for the cache action step key + echo "builder_hash=$(git -C seedsigner-os rev-parse --short HEAD)"| tee -a $GITHUB_ENV + + # Derive tag based versions, like 0.7.0-40-g0424967 (=$tag-$number-of-commits-since-tag-$short-sha1), + # or just e.g. 0.7.0, if we are exactly on a 0.7.0 tagged commit. + # --always to fall back to commit sha, if no tag present like in partial forks of the repo + os_version="$(git -C seedsigner-os describe --tags --always)" + source_version="$(git -C seedsigner-os/opt/rootfs-overlay/opt describe --tags --always)" + + # Combine seedsigner and seedsigner-os version into one version string and squash the versions, if + # they are identical: So os_version=0.7.0 + source_version=0.7.0 combine to just only "0.7.0", + # whereas os_version=0.6.0-61-g9fafebe + source_version=0.7.0-40-g0424967 combine to "os0.6.0-61-g9fafebe_sw0.7.0-40-g0424967" + if [ "${os_version}" = "${source_version}" ]; then + # seedsigner + seedsigner_os have the same tag + echo "img_version=${source_version}"| tee -a $GITHUB_ENV + else + echo "img_version=os${os_version}_sw${source_version}"| tee -a $GITHUB_ENV + fi - name: delete unnecessary files run: | @@ -44,11 +78,9 @@ jobs: ls -la src - name: restore build cache - id: build-cache-restore - uses: actions/cache/restore@v3 - # Caching seedsigner-os/buildroot_dl is optional. - # Caching it can save a small amount of build time, - # but it will occupy a larger amount of storage space. + uses: actions/cache@v3 + # Caching reduces the build time to ~50% (currently: ~30 mins instead of ~1 hour, + # while consuming ~850 MB storage space). with: path: | ~/.buildroot-ccache/ @@ -62,16 +94,6 @@ jobs: cd seedsigner-os/opt ./build.sh --${{ matrix.target }} --skip-repo --no-clean - - name: save build cache - id: build-cache-save - if: steps.build-cache-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v3 - with: - path: | - ~/.buildroot-ccache/ - seedsigner-os/buildroot_dl - key: build-cache-${{ matrix.target }}-${{ env.builder_hash }} - - name: list image (before rename) run: | ls -la seedsigner-os/images @@ -79,7 +101,7 @@ jobs: - name: rename image run: | cd seedsigner-os/images - mv seedsigner_os*.img seedsigner_os.${{ env.source_hash }}.${{ matrix.target }}.img + mv seedsigner_os*.img seedsigner_os.${{ env.img_version }}.${{ matrix.target }}.img - name: print sha256sum run: | @@ -96,6 +118,8 @@ jobs: name: seedsigner_os_images path: "seedsigner-os/images/*.img" if-no-files-found: error + # maximum 90 days retention + retention-days: 90 sha256sum: name: calculate sha256sum @@ -128,4 +152,6 @@ jobs: with: name: seedsigner_os_images path: "images/*.sha256" - if-no-files-found: error \ No newline at end of file + if-no-files-found: error + # maximum 90 days retention + retention-days: 90 diff --git a/README.md b/README.md index 0e4cd3d1..59fe0d80 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,17 @@ * [Verifying the Software](#verifying-the-software) * [Enclosure Designs](#enclosure-designs) * [SeedQR Printable Templates](#seedqr-printable-templates) -* [Manual Installation Instructions](#manual-installation-instructions) +* [Build from Source](#build-from-source) +* [Developer Local Build Instructions](#developer-local-build-instructions) --------------- # Project Summary +[![CI](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/tests.yml) +[![Build](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml/badge.svg)](https://github.com/SeedSigner/seedsigner/actions/workflows/build.yml) + The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. Additional information about the project can be found at [SeedSigner.com](https://seedsigner.com). @@ -81,9 +85,11 @@ Notes: # Software Installation ## A Special Note On Minimizing Trust -As is the nature of pre-packaged software downloads, downloading and using the prepared SeedSigner release images means implicitly placing trust in the individual preparing those images; in our project the release images are prepared and signed by the eponymous creator of the project, SeedSigner "the person". That individual is additionally the only person in possession of the PGP keys that are used to sign the release images. +As is the nature of pre-packaged software downloads, downloading and using the prepared SeedSigner release images means implicitly placing trust in the people preparing those images; in our project the released images are prepared and signed by the eponymous creator of the project, SeedSigner "the person". That individual is additionally the only person in possession of the PGP keys that are used to sign the release images. -However, one of the many advantages of the open source software model is that the need for this kind of trust can be negated by our users' ability to (1) review the project's source code and (2) assemble the operating image necessary to use the software themselves. From our project's inception, instructions to build a SeedSigner operating image (using precisely the same process that is used to create the prepared release images) have been made available. We have put a lot of thought and work into making these instructions easy to understand and follow, even for less technical users. These instructions can be found [here](docs/manual_installation.md). +Starting with v0.7.0, the images distributed via GitHub are reproducible. This means you and others can verify the released images are byte-for-byte the same when built from source. You can contribute to this project by building from source and sharing the hash of the final images. + +Instructions to build a SeedSigner OS image (using precisely the same process that is used to create the prepared release images) have been made available. We have put a lot of thought and work into making these instructions easy to understand and follow, even for less technical users. These instructions can be found [here](https://github.com/SeedSigner/seedsigner-os/blob/main/docs/building.md). ## Downloading the Software @@ -118,6 +124,8 @@ Once the files have all finished downloading, follow the steps below to verify t You can quickly verify that the software you just downloaded is both authentic and unaltered, by following these instructions. We assume you are running the commands from a computer where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are already installed, and that you also know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal). +> You must run the following verification before opening or mounting the .img file. +> Some operating systems modify the file on mount causing verification to fail. ### Step 1. Verify that the signature (.sig) file is genuine: @@ -325,5 +333,8 @@ Letter templates(8.5in * 11in): * [29x29 - stores 24-word seeds ONLY as plaintext SeedQR format ONLY](docs/seed_qr/printable_templates/29x29_letter_trading_card_2sided.pdf) --------------- -# Manual Installation Instructions -see the docs: [Manual Installation Instructions](docs/manual_installation.md) +# Build from Source +See the [SeedSigner OS repo](https://github.com/SeedSigner/seedsigner-os/) for instructions. + +# Developer Local Build Instructions +Raspberry Pi OS is commonly used for development. See the [Raspberry Pi OS Build Instructions](docs/raspberry_pi_os_build_instructions.md) diff --git a/docs/dice_verification.md b/docs/dice_verification.md index 361bf14d..7e3833b4 100644 --- a/docs/dice_verification.md +++ b/docs/dice_verification.md @@ -205,3 +205,70 @@ We double-checked in two different web tools implementing different methods for So congratulations if the fingerprints, zpubs and addresses all match up in your example so you can be much more confident that nothing is wrong with your generated seed. +--- + +# Command Line Tool +_(for more advanced/python-savvy users)_ + +Run the exact same SeedSigner mnemonic generation code from the command line to quickly test and externally verify the results. + +Create a python virtualenv (out of the scope of this doc) and install dependencies: +```bash +pip3 install embit + +# Install the main project code to make it importable +pip3 install -e . +``` + + +Then run the utility script with `-h` to view the usage instructions: +```bash +cd tools +python3 mnemonic.py -h +``` + +``` + Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. + + Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed + + Usage: + # 50 dice rolls / 12-word mnemonic + python3 mnemonic.py dice 5624433434... + + # 99 dice rolls / 24-word mnemonic + python3 mnemonic.py dice 6151463561... + + # 50 dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice 5135535514... + + # 128 coin flips / 12-word mnemonic + python3 mnemonic.py coins 1111100111... + + # 256 coin flips / 24-word mnemonic + python mnemonic.py coins 0010111010... + + # GENERATE 50 random dice rolls / 12-word mnemonic + python3 mnemonic.py dice rand12 + + # GENERATE 99 random dice rolls / 24-word mnemonic + python3 mnemonic.py dice rand24 + + # GENERATE 99 random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic.py coins rand24 +``` + +### How to get the same results in iancoleman.io +Always specify your expected length in the "Mnemonic Length" droplist (defaults to "Use Raw Entropy (3 words per 32 bits)"). + +Dice Rolls: Do NOT use the "Dice [1-6]" option; select "Base 10 [0-9]" or "Hex [0-9A-F]" + +Zero-indexed dice rolls: Select "Base 6 [0-5]", "Base 10 [0-9]", or "Hex [0-9A-F]" + +Coin Flips: Select "Binary [0-1]", "Base 6 [0-5]", "Base 10 [0-9]", or "Hex [0-9A-F]" diff --git a/docs/manual_installation.md b/docs/raspberry_pi_os_build_instructions.md similarity index 97% rename from docs/manual_installation.md rename to docs/raspberry_pi_os_build_instructions.md index 17dbc975..fdfb894e 100644 --- a/docs/manual_installation.md +++ b/docs/raspberry_pi_os_build_instructions.md @@ -1,4 +1,6 @@ -# Manual Installation Instructions +# Raspberry Pi OS Local Dev Build Instructions + +Since v0.6.0, official releases use our custom [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os/) However, project contributors looking to do rapid development cycles typically use the older Raspberry Pi OS that we had previously built on prior to v0.6.0. If you're here to set up your SeedSigner for local development, continue reading. Begin by acquiring the latest 32-bit, Buster-based Raspberry Pi Lite operating system. This guide was tested using the version dated 2023-05-03; which can be found here: @@ -109,7 +111,7 @@ sudo apt install python3-apt -y Copy this entire box and run it as one command (~15 minutes on a Pi Zero 1.3): ```bash sudo apt update && sudo apt install -y wiringpi python3-pip \ - python3-numpy python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ + python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ git python3-opencv python3-picamera libatlas-base-dev qrencode ``` diff --git a/requirements-raspi.txt b/requirements-raspi.txt index 8053eeab..d86e93d2 100644 --- a/requirements-raspi.txt +++ b/requirements-raspi.txt @@ -1,3 +1,5 @@ picamera==1.13 +# numpy = transitive picamera dependency +numpy==1.25.2 RPi.GPIO==0.7.0 spidev==3.5 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 65c471ef..f86231b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ embit==0.7.0 -numpy==1.25.2 Pillow==9.4.0 pyzbar @ git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03 qrcode==7.3.1 -six==1.16.0 urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 206ad2c0..f0a95287 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -398,6 +398,15 @@ class Controller(Singleton): # Start the screensaver, but it will block until it can acquire the Renderer.lock. self.screensaver.start() print("Controller: Screensaver started") + + + def reset_screensaver_timeout(self): + """ + Reset the screensaver's timeout starting point to right now (i.e. make it think + that zero time has elapsed since the last user interaction). + """ + from seedsigner.hardware.buttons import HardwareButtons + HardwareButtons.get_instance().update_last_input_time() def activate_toast(self, toast_manager_thread: BaseToastOverlayManagerThread): diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index e505635a..2046f4e1 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -29,6 +29,8 @@ class GUIConstants: BITCOIN_ORANGE = "#FF9416" TESTNET_COLOR = "#00F100" REGTEST_COLOR = "#00CAF1" + GREEN_INDICATOR_COLOR = "#00FF00" + INACTIVE_COLOR = "#414141" ICON_FONT_NAME__FONT_AWESOME = "Font_Awesome_6_Free-Solid-900" ICON_FONT_NAME__SEEDSIGNER = "seedsigner-icons" diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 13d3dcf7..e1af8e15 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -7,10 +7,10 @@ from seedsigner.gui import renderer from seedsigner.hardware.buttons import HardwareButtonsConstants from seedsigner.hardware.camera import Camera from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus -from seedsigner.models.threads import BaseThread +from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from .screen import BaseScreen, ButtonListScreen -from ..components import GUIConstants, Fonts, TextArea +from .screen import BaseScreen +from ..components import GUIConstants, Fonts, SeedSignerIconConstants @@ -47,6 +47,9 @@ class ScanScreen(BaseScreen): framerate: int = 6 # TODO: alternate optimization for Pi Zero 2W? render_rect: tuple[int,int,int,int] = None + FRAME__ADDED_PART = 1 + FRAME__REPEATED_PART = 2 + FRAME__MISS = 3 def __post_init__(self): from seedsigner.hardware.camera import Camera @@ -58,17 +61,22 @@ class ScanScreen(BaseScreen): self.camera = Camera.get_instance() self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb") + self.frames_decode_status = ThreadsafeCounter() + self.frames_decoded_counter = ThreadsafeCounter() + self.threads.append(ScanScreen.LivePreviewThread( camera=self.camera, decoder=self.decoder, renderer=self.renderer, instructions_text=self.instructions_text, render_rect=self.render_rect, + frame_decode_status=self.frames_decode_status, + frames_decoded_counter=self.frames_decoded_counter, )) class LivePreviewThread(BaseThread): - def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int]): + def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter): self.camera = camera self.decoder = decoder self.renderer = renderer @@ -77,6 +85,9 @@ class ScanScreen(BaseScreen): self.render_rect = render_rect else: self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height) + self.frame_decode_status = frame_decode_status + self.frames_decoded_counter = frames_decoded_counter + self.last_frame_decoded_count = self.frames_decoded_counter.cur_count self.render_width = self.render_rect[2] - self.render_rect[0] self.render_height = self.render_rect[3] - self.render_rect[1] self.decoder_fps = "0.0" @@ -85,12 +96,15 @@ class ScanScreen(BaseScreen): def run(self): - from timeit import default_timer as timer - instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE) + # pre-calculate how big the animated QR percent display can be + left, _, right, _ = instructions_font.getbbox("100%") + progress_text_width = right - left + start_time = time.time() num_frames = 0 + debug = False show_framerate = False # enable for debugging / testing while self.keep_running: frame = self.camera.read_video_stream(as_image=True) @@ -98,16 +112,22 @@ class ScanScreen(BaseScreen): num_frames += 1 cur_time = time.time() cur_fps = num_frames / (cur_time - start_time) - if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt: - scan_text = str(self.decoder.get_percent_complete()) + "% Complete" - if show_framerate: - scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}" - else: + + scan_text = None + progress_percentage = self.decoder.get_percent_complete() + if progress_percentage == 0: + # We've just started scanning, no results yet if show_framerate: scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}" else: scan_text = self.instructions_text + elif debug: + # Special debugging output for animated QRs + scan_text = f"{self.decoder.get_percent_complete()}% | {self.decoder.get_percent_complete(weight_mixed_frames=True)}% (new)" + if show_framerate: + scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}" + with self.renderer.lock: if frame.width > self.render_width or frame.height > self.render_height: frame = frame.resize( @@ -115,14 +135,13 @@ class ScanScreen(BaseScreen): resample=Image.NEAREST # Use nearest neighbor for max speed ) - draw = ImageDraw.Draw(frame) - if scan_text: # Note: shadowed text (adding a 'stroke' outline) can # significantly slow down the rendering. # Temp solution: render a slight 1px shadow behind the text # TODO: Replace the instructions_text with a disappearing # toast/popup (see: QR Brightness UI)? + draw = ImageDraw.Draw(frame) draw.text(xy=( int(self.renderer.canvas_width/2 + 2), self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2 @@ -142,8 +161,82 @@ class ScanScreen(BaseScreen): font=instructions_font, anchor="ms") + else: + # Render the progress bar + rectangle = Image.new('RGBA', (self.renderer.canvas_width - 2*GUIConstants.EDGE_PADDING, GUIConstants.BUTTON_HEIGHT), (0, 0, 0, 0)) + draw = ImageDraw.Draw(rectangle) + + # Start with a background rounded rectangle, same dims as the buttons + overlay_color = (0, 0, 0, 191) # opacity ranges from 0-255 + draw.rounded_rectangle( + ( + (0, 0), + (rectangle.width, rectangle.height) + ), + fill=overlay_color, + radius=8, + outline=overlay_color, + width=2, + ) + + progress_bar_thickness = 4 + progress_bar_width = rectangle.width - 2*GUIConstants.EDGE_PADDING - progress_text_width - int(GUIConstants.EDGE_PADDING/2) + progress_bar_xy = ( + (GUIConstants.EDGE_PADDING, int((rectangle.height - progress_bar_thickness) / 2)), + (GUIConstants.EDGE_PADDING + progress_bar_width, int(rectangle.height + progress_bar_thickness) / 2) + ) + draw.rounded_rectangle( + progress_bar_xy, + fill=GUIConstants.INACTIVE_COLOR, + radius=8 + ) + + progress_percentage = self.decoder.get_percent_complete(weight_mixed_frames=True) + draw.rounded_rectangle( + ( + progress_bar_xy[0], + (GUIConstants.EDGE_PADDING + int(progress_percentage * progress_bar_width / 100.0), progress_bar_xy[1][1]) + ), + fill=GUIConstants.GREEN_INDICATOR_COLOR, + radius=8 + ) + + + draw.text( + xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)), + text=f"{progress_percentage}%", + # text=f"100%", + fill=GUIConstants.BODY_FONT_COLOR, + font=instructions_font, + anchor="rm", # right-justified, middle + ) + + frame.paste(rectangle, (GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - rectangle.height), rectangle) + + # Render the dot to indicate successful QR frame read + indicator_size = 10 + self.last_frame_decoded_count = self.frames_decoded_counter.cur_count + status_color_map = { + ScanScreen.FRAME__ADDED_PART: GUIConstants.SUCCESS_COLOR, + ScanScreen.FRAME__REPEATED_PART: GUIConstants.INACTIVE_COLOR, + ScanScreen.FRAME__MISS: None, + } + status_color = status_color_map.get(self.frame_decode_status.cur_count) + if status_color: + # Good! Most recent frame successfully decoded. + # Draw the onscreen indicator dot + draw = ImageDraw.Draw(frame) + draw.ellipse( + ( + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING - indicator_size, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING - indicator_size), + (self.renderer.canvas_width - GUIConstants.EDGE_PADDING, self.renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING) + ), + fill=status_color, + outline="black", + width=1, + ) + self.renderer.show_image(frame, show_direct=True) - # print(f" {cur_fps:0.2f} | {self.decoder_fps}") if self.camera._video_stream is None: break @@ -169,6 +262,21 @@ class ScanScreen(BaseScreen): if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID): self.camera.stop_video_stream_mode() break + + self.frames_decoded_counter.increment() + # Notify the live preview thread how our most recent decode went + if status == DecodeQRStatus.FALSE: + # Did not find anything to decode in the current frame + self.frames_decode_status.set_value(self.FRAME__MISS) + + else: + if status == DecodeQRStatus.PART_COMPLETE: + # We received a valid frame that added new data + self.frames_decode_status.set_value(self.FRAME__ADDED_PART) + + elif status == DecodeQRStatus.PART_EXISTING: + # We received a valid frame, but we've already seen in + self.frames_decode_status.set_value(self.FRAME__REPEATED_PART) if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT): self.camera.stop_video_stream_mode() diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index b229857d..a4b3a742 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -10,6 +10,7 @@ from seedsigner.gui.components import (GUIConstants, from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay from seedsigner.gui.renderer import Renderer from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons +from seedsigner.models.encode_qr import BaseQrEncoder from seedsigner.models.settings import SettingsConstants from seedsigner.models.threads import BaseThread, ThreadsafeCounter @@ -657,10 +658,10 @@ class LargeButtonScreen(BaseTopNavScreen): @dataclass class QRDisplayScreen(BaseScreen): - qr_encoder: 'EncodeQR' = None + qr_encoder: BaseQrEncoder = None class QRDisplayThread(BaseThread): - def __init__(self, qr_encoder: 'EncodeQR', qr_brightness: ThreadsafeCounter, renderer: Renderer, + def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, renderer: Renderer, tips_start_time: ThreadsafeCounter): super().__init__() self.qr_encoder = qr_encoder diff --git a/src/seedsigner/hardware/camera.py b/src/seedsigner/hardware/camera.py index 32dc182d..59ff4101 100644 --- a/src/seedsigner/hardware/camera.py +++ b/src/seedsigner/hardware/camera.py @@ -38,7 +38,7 @@ class Camera(Singleton): return frame else: if frame is not None: - return Image.fromarray(frame.astype('uint8'), 'RGB').rotate(90 + self._camera_rotation) + return Image.fromarray(frame.astype('uint8'), 'RGB').convert('RGBA').rotate(90 + self._camera_rotation) return None diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index a021a834..b3e90680 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -2,21 +2,40 @@ import hashlib import unicodedata from embit import bip39 -from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes -from typing import List +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.models.seed import Seed + +""" + This is SeedSigner's internal mnemonic generation utility. + + It can also be run as an independently-executable CLI to facilitate external + verification of SeedSigner's results for a given input entropy. + + see: docs/dice_verification.md (the "Command Line Tool" section). +""" + +DICE__NUM_ROLLS__12WORD = 50 +DICE__NUM_ROLLS__24WORD = 99 -def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]: +def calculate_checksum(mnemonic: list | str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: """ Provide 12- or 24-word mnemonic, returns complete mnemonic w/checksum as a list. + Mnemonic may be a list of words or a string of words separated by spaces or commas. + If 11- or 23-words are provided, append word `0000` to end of list as temp final word. """ - from seedsigner.models.seed import Seed + if type(mnemonic) == str: + import re + # split on commas or spaces + mnemonic = re.findall(r'[^,\s]+', mnemonic) + if len(mnemonic) in [11, 23]: - mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0]) + temp_final_word = Seed.get_wordlist(wordlist_language_code)[0] + mnemonic.append(temp_final_word) if len(mnemonic) not in [12, 24]: raise Exception("Pass in a 12- or 24-word mnemonic") @@ -37,28 +56,68 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str] -def generate_mnemonic_from_bytes(entropy_bytes) -> List[str]: - return bip39.mnemonic_from_bytes(entropy_bytes).split() +def generate_mnemonic_from_bytes(entropy_bytes, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() -def generate_mnemonic_from_dice(roll_data: str) -> List[str]: +def generate_mnemonic_from_dice(roll_data: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 50 or 99 dice rolls and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Base 10" or "Hex" mode approach: + * dice rolls are treated as string data. + * hashed via SHA256. + + Important note: This method is NOT compatible with iancoleman's "Dice" mode. + """ entropy_bytes = hashlib.sha256(roll_data.encode()).digest() - if len(roll_data) == 50: + if len(roll_data) == DICE__NUM_ROLLS__12WORD: # 12-word mnemonic; only use 128bits / 16 bytes entropy_bytes = entropy_bytes[:16] # Return as a list - return bip39.mnemonic_from_bytes(entropy_bytes).split() + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() + + + +def generate_mnemonic_from_coin_flips(coin_flips: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: + """ + Takes a string of 128 or 256 0s and 1s and returns a 12- or 24-word mnemonic. + + Uses the iancoleman.io/bip39 and bitcoiner.guide/seed "Binary" mode approach: + * binary digit stream is treated as string data. + * hashed via SHA256. + """ + entropy_bytes = hashlib.sha256(coin_flips.encode()).digest() + + if len(coin_flips) == 128: + # 12-word mnemonic; only use 128bits / 16 bytes + entropy_bytes = entropy_bytes[:16] + + # Return as a list + return bip39.mnemonic_from_bytes(entropy_bytes, wordlist=Seed.get_wordlist(wordlist_language_code)).split() + + + +def get_partial_final_word(coin_flips: str, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> str: + """ Look up the partial final word for the given coin flips. + 7 coin flips: 0101010 + **** where the final 4 bits will be replaced with the checksum + 3 coin flips: 010 + ******** where the final 8 bits will be replaced with the checksum + """ + binary_string = coin_flips + "0" * (11 - len(coin_flips)) + wordlist_index = int(binary_string, 2) + + return Seed.get_wordlist(wordlist_language_code)[wordlist_index] # Note: This currently isn't being used since we're now chaining hashed bytes for the # image-based entropy and aren't just ingesting a single image. -def generate_mnemonic_from_image(image) -> List[str]: +def generate_mnemonic_from_image(image, wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> list[str]: import hashlib hash = hashlib.sha256(image.tobytes()) # Return as a list - return bip39.mnemonic_from_bytes(hash.digest()).split() + return bip39.mnemonic_from_bytes(hash.digest(), wordlist=Seed.get_wordlist(wordlist_language_code)).split() diff --git a/src/seedsigner/helpers/ur2/fountain_decoder.py b/src/seedsigner/helpers/ur2/fountain_decoder.py index 69eb64e3..9e8dcdf3 100644 --- a/src/seedsigner/helpers/ur2/fountain_decoder.py +++ b/src/seedsigner/helpers/ur2/fountain_decoder.py @@ -4,7 +4,7 @@ # Copyright © 2020 Foundation Devices, Inc. # Licensed under the "BSD-2-Clause Plus Patent License" # - +import time from .fountain_utils import choose_fragments, contains, is_strict_subset, set_difference from .utils import join_lists, join_bytes, crc32_int, xor_with, take_first @@ -71,13 +71,53 @@ class FountainDecoder: def result_error(self): return self.result - def estimated_percent_complete(self): + + def estimated_percent_complete(self, weight_mixed_frames: bool = False): + """ + Weighted mixed frame method: + * counts completed frames + * counts each additional frame that is currently XORed in a mixed frame; its + score is weighted by the number of frames mixed together (1/num frames mixed). + """ if self.is_complete(): return 1 if self.expected_part_indexes == None: return 0 - estimated_input_parts = self.expected_part_count() * 1.75 - return min(0.99, self.processed_parts_count / estimated_input_parts) + + if not weight_mixed_frames: + # Original estimation method + estimated_input_parts = self.expected_part_count() * 1.75 + return min(0.99, self.processed_parts_count / estimated_input_parts) + else: + parts = self.expected_part_count() if self.expected_part_indexes != None else 'None' + mixed = [] + mixed_index_scoring = {} + mixed_set = set() + for indexes, p in self.mixed_parts.items(): + if not indexes: + continue + mixed.append(self.indexes_to_string(indexes)) + mixed_set.update(indexes) + score = 1.0 / float(len(indexes)) + for index in indexes: + if index not in mixed_index_scoring: + mixed_index_scoring[index] = 0.0 + + # sum up partial scores + mixed_index_scoring[index] += score + + mixed_score = 0.0 + for index, score in mixed_index_scoring.items(): + # set a ceiling; don't let an index in a mixed/XOR frame + # achieve equal weight as a fully decoded frame. Also if + # the ceiling is too high, can potentially see your + # reported progress percentage DECREASE during a decode. + mixed_score += min(score, 0.75) + + num_complete = len(self.received_part_indexes) + weighted_estimate = (num_complete + mixed_score) / float(parts) + return weighted_estimate + def receive_part(self, encoder_part): # Don't process the part if we're already done @@ -93,6 +133,9 @@ class FountainDecoder: self.last_part_indexes = p.indexes self.enqueue(p) + num_complete = len(self.received_part_indexes) + num_mixed_frames = len(self.mixed_parts) + # Process the queue until we're done or the queue is empty while not self.is_complete() and len(self.queued_parts) != 0: self.process_queue_item() @@ -101,6 +144,12 @@ class FountainDecoder: self.processed_parts_count += 1 # self.print_part_end() + # self.print_state() + + if num_complete == len(self.received_part_indexes) and num_mixed_frames == len(self.mixed_parts): + # This part didn't add any new info + # print("No new data") + return False return True @@ -114,6 +163,7 @@ class FountainDecoder: self.queued_parts.append(p) def process_queue_item(self): + start = time.time() part = self.queued_parts.pop(0) # self.print_part(part) @@ -121,6 +171,8 @@ class FountainDecoder: self.process_simple_part(part) else: self.process_mixed_part(part) + + # print(f"Queue processing: {int((time.time() - start)*1000.0)}ms") # self.print_state() def reduce_mixed_by(self, p): @@ -141,6 +193,7 @@ class FountainDecoder: new_mixed[reduced_part.indexes] = reduced_part self.mixed_parts = new_mixed + # print(self.mixed_parts.keys()) def reduce_part_by_part(self, a, b): # If the fragments mixed into `b` are a strict (proper) subset of those in `a`... @@ -266,13 +319,23 @@ class FountainDecoder: print("processed: {}, expected: {}, received: {}, percent: {}%".format(self.processed_parts_count, expected, len(self.received_part_indexes), percent)) def print_state(self): - parts = self.expected_part_count() if self.expected_part_indexes != None else 'None' - received = self.indexes_to_string(self.received_part_indexes) + guesstimate = self.estimated_percent_complete(weight_mixed_frames=True) + original_metric = self.estimated_percent_complete() mixed = [] - for indexes, p in self.mixed_parts.items(): - mixed.append(self.indexes_to_string(indexes)) - - mixed_s = "[{}]".format(', '.join(mixed)) - queued = len(self.queued_parts) - res = self.result_description() - print('parts: {}, received: {}, mixed: {}, queued: {}, result: {}'.format(parts, received, mixed_s, queued, res)) + mixed_set = set() + try: + for indexes, p in self.mixed_parts.items(): + if not indexes or len(indexes) == 0: + continue + mixed.append(self.indexes_to_string(indexes)) + mixed_set.update(indexes) + + num_complete = len(self.received_part_indexes) + + mixed_s = "[{}]".format(', '.join(mixed)) + queued = len(self.queued_parts) + print(f"{original_metric*100.0:5.1f}% | {guesstimate*100.0:5.1f}% | done: {num_complete:2d}, mixed: {len(mixed_set):2d}, queued: {queued}, frames: {self.processed_parts_count:2d} | {mixed_s}") + + except Exception as e: + import traceback + traceback.print_exc() diff --git a/src/seedsigner/helpers/ur2/fountain_encoder.py b/src/seedsigner/helpers/ur2/fountain_encoder.py index ac43bb62..af5dd338 100644 --- a/src/seedsigner/helpers/ur2/fountain_encoder.py +++ b/src/seedsigner/helpers/ur2/fountain_encoder.py @@ -90,6 +90,7 @@ class FountainEncoder: self.fragment_len = FountainEncoder.find_nominal_fragment_length(self.message_len, min_fragment_len, max_fragment_len) self.fragments = FountainEncoder.partition_message(message, self.fragment_len) self.seq_num = first_seq_num + self.current_part: Part = None @staticmethod def find_nominal_fragment_length(message_len, min_fragment_len, max_fragment_len): @@ -143,7 +144,17 @@ class FountainEncoder: indexes = choose_fragments(self.seq_num, self.seq_len(), self.checksum) mixed = self.mix(indexes) data = bytes(mixed) - return Part(self.seq_num, self.seq_len(), self.message_len, self.checksum, data) + self.current_part = Part(self.seq_num, self.seq_len(), self.message_len, self.checksum, data) + return self.current_part + + + def restart(self): + """ + Restart from the beginning; each cycle's first n frames are full data frames + (not XOR composites). + """ + self.seq_num = 0 + def mix(self, indexes): result = [0] * self.fragment_len diff --git a/src/seedsigner/helpers/ur2/ur_decoder.py b/src/seedsigner/helpers/ur2/ur_decoder.py index c0baf21f..bb282b09 100644 --- a/src/seedsigner/helpers/ur2/ur_decoder.py +++ b/src/seedsigner/helpers/ur2/ur_decoder.py @@ -153,8 +153,8 @@ class URDecoder: def processed_parts_count(self): return self.fountain_decoder.processed_parts_count - def estimated_percent_complete(self): - return self.fountain_decoder.estimated_percent_complete() + def estimated_percent_complete(self, weight_mixed_frames: bool = False): + return self.fountain_decoder.estimated_percent_complete(weight_mixed_frames=weight_mixed_frames) def is_success(self): result = self.result diff --git a/src/seedsigner/helpers/ur2/ur_encoder.py b/src/seedsigner/helpers/ur2/ur_encoder.py index ac1cbc7d..082ad17b 100644 --- a/src/seedsigner/helpers/ur2/ur_encoder.py +++ b/src/seedsigner/helpers/ur2/ur_encoder.py @@ -34,12 +34,26 @@ class UREncoder: def is_single_part(self): return self.fountain_encoder.is_single_part() - def next_part(self): - part = self.fountain_encoder.next_part() + def next_part(self) -> str: if self.is_single_part(): return UREncoder.encode(self.ur) else: + part = self.fountain_encoder.next_part() return UREncoder.encode_part(self.ur.type, part) + + def current_part(self) -> str: + if self.is_single_part(): + return UREncoder.encode(self.ur) + else: + part = self.fountain_encoder.current_part + if not part: + part = self.fountain_encoder.next_part() + return UREncoder.encode_part(self.ur.type, part) + + + def restart(self): + self.fountain_encoder.restart() + @staticmethod def encode_part(type, part): diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index a020c312..da5f2526 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -120,11 +120,14 @@ class DecodeQR: qr_str = data if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: - self.decoder.receive_part(qr_str) + added_part = self.decoder.receive_part(qr_str) if self.decoder.is_complete(): self.complete = True return DecodeQRStatus.COMPLETE - return DecodeQRStatus.PART_COMPLETE # segment added to ur2 decoder + if added_part: + return DecodeQRStatus.PART_COMPLETE + else: + return DecodeQRStatus.PART_EXISTING else: # All other formats use the same method signature @@ -219,12 +222,12 @@ class DecodeQR: return self.decoder.get_wallet_descriptor() - def get_percent_complete(self) -> int: + def get_percent_complete(self, weight_mixed_frames: bool = False) -> int: if not self.decoder: return 0 if self.qr_type in [QRType.PSBT__UR2, QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]: - return int(self.decoder.estimated_percent_complete() * 100) + return int(self.decoder.estimated_percent_complete(weight_mixed_frames=weight_mixed_frames) * 100) elif self.qr_type in [QRType.PSBT__SPECTER]: if self.decoder.total_segments == None: @@ -261,6 +264,7 @@ class DecodeQR: QRType.PSBT__BASE43, ] + @property def is_seed(self): return self.qr_type in [ @@ -305,7 +309,7 @@ class DecodeQR: @staticmethod - def extract_qr_data(image, is_binary:bool = False) -> str: + def extract_qr_data(image, is_binary:bool = False) -> str | None: if image is None: return None @@ -337,10 +341,10 @@ class DecodeQR: # PSBT if re.search("^UR:CRYPTO-PSBT/", s, re.IGNORECASE): return QRType.PSBT__UR2 - + elif re.search("^UR:CRYPTO-OUTPUT/", s, re.IGNORECASE): return QRType.OUTPUT__UR - + elif re.search("^UR:CRYPTO-ACCOUNT/", s, re.IGNORECASE): return QRType.ACCOUNT__UR @@ -362,10 +366,10 @@ class DecodeQR: elif re.search(r'^\{\"label\".*\"descriptor\"\:.*', desc_str, re.IGNORECASE): # if json starting with label and contains descriptor, assume specter wallet json return QRType.WALLET__SPECTER - + elif "multisig setup file" in s.lower(): return QRType.WALLET__CONFIGFILE - + elif "sortedmulti" in s: return QRType.WALLET__GENERIC @@ -392,7 +396,7 @@ class DecodeQR: _4LETTER_WORDLIST = [word[:4].strip() for word in wordlist] except: _4LETTER_WORDLIST = [] - + if all(x in wordlist for x in s.strip().split(" ")): # checks if all words in list are in bip39 word list return QRType.SEED__MNEMONIC @@ -408,7 +412,7 @@ class DecodeQR: # Probably this isn't meant to be string data; check if it's valid byte data # below. pass - + # Is it byte data? # 32 bytes for 24-word CompactSeedQR; 16 bytes for 12-word CompactSeedQR if len(s) == 32 or len(s) == 16: diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index e929c679..7e038fbc 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -2,7 +2,7 @@ import math from embit import bip32 from embit.networks import NETWORKS -from binascii import b2a_base64, hexlify +from binascii import hexlify from dataclasses import dataclass from typing import List from embit import bip32 @@ -11,8 +11,6 @@ from embit.psbt import PSBT from seedsigner.helpers.ur2.ur_encoder import UREncoder from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.qr import QR -from seedsigner.helpers import embit_utils -from seedsigner.models.qr_type import QRType from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants @@ -21,149 +19,62 @@ from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIP - @dataclass -class EncodeQR: - """ - Encode psbt for displaying as qr image - """ - # TODO: Refactor so that this is a base class with implementation classes for each - # QR type. No reason exterior code can't directly instantiate the encoder it needs. - - # Dataclass input vars on __init__() - psbt: PSBT = None - seed: Seed = None - derivation: str = None - network: str = SettingsConstants.MAINNET - qr_type: str = None +class BaseQrEncoder: qr_density: str = SettingsConstants.DENSITY__MEDIUM - wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH - bitcoin_address: str = None - signed_message: str = None - sig_type : str = SettingsConstants.SINGLE_SIG + def __post_init__(self): self.qr = QR() - if not self.qr_type: - raise Exception('qr_type is required') - - if self.qr_density == None: - self.qr_density = SettingsConstants.DENSITY__MEDIUM - - self.encoder: BaseQrEncoder = None - - # PSBT formats - if self.qr_type == QRType.PSBT__SPECTER: - self.encoder = SpecterPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density) - - elif self.qr_type == QRType.PSBT__UR2: - self.encoder = UrPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density) - - # XPUB formats - elif self.qr_type == QRType.XPUB: - self.encoder = XpubQrEncoder( - seed=self.seed, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code, - sig_type=self.sig_type - ) - - elif self.qr_type == QRType.XPUB__UR: - self.encoder = UrXpubQrEncoder( - qr_density=self.qr_density, - seed=self.seed, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code, - sig_type=self.sig_type - ) - - elif self.qr_type == QRType.XPUB__SPECTER: - self.encoder = SpecterXPubQrEncoder( - qr_density=self.qr_density, - seed=self.seed, - derivation=self.derivation, - network=self.network, - wordlist_language_code=self.wordlist_language_code, - sig_type=self.sig_type - ) - - - # SeedQR formats - elif self.qr_type == QRType.SEED__SEEDQR: - self.encoder = SeedQrEncoder(seed_phrase=self.seed.mnemonic_list, - wordlist_language_code=self.wordlist_language_code) - - elif self.qr_type == QRType.SEED__COMPACTSEEDQR: - self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed.mnemonic_list, - wordlist_language_code=self.wordlist_language_code) - - # Misc formats - elif self.qr_type == QRType.BITCOIN_ADDRESS: - self.encoder = BitcoinAddressEncoder(address=self.bitcoin_address) - - elif self.qr_type == QRType.SIGN_MESSAGE: - self.encoder = SignedMessageEncoder(signed_message=self.signed_message) - - else: - raise Exception('QR Type not supported') - - - def total_parts(self) -> int: - return self.encoder.seq_len() - - - def next_part(self): - return self.encoder.next_part() - - - def part_to_image(self, part, width=240, height=240, border=3): - return self.qr.qrimage_io(part, width, height, border) - - - def next_part_image(self, width=240, height=240, border=3, background_color="bdbdbd"): - part = self.next_part() - if self.qr_type == QRType.SEED__SEEDQR: - return self.qr.qrimage(part, width, height, border) - else: - return self.qr.qrimage_io(part, width, height, border, background_color=background_color) - - - # TODO: Make these properties? - def is_complete(self): - return self.encoder.is_complete - - - def get_qr_density(self): - return self.qr_density - - - def get_qr_type(self): - return self.qr_type - - - -class BaseQrEncoder: - def seq_len(self): - raise Exception("Not implemented in child class") - - def next_part(self) -> str: - raise Exception("Not implemented in child class") @property def is_complete(self): raise Exception("Not implemented in child class") + @property + def qr_max_fragment_size(self): + raise Exception("Not implemented in child class") + + def seq_len(self): + raise Exception("Not implemented in child class") + + def next_part(self) -> str: + raise Exception("Not implemented in child class") + + def cur_part(self) -> str: + raise Exception("Not implemented in child class") + + def restart(self): + # only used by animated QR encoders + pass + def _create_parts(self): raise Exception("Not implemented in child class") + def part_to_image(self, part, width, height, border: int = 3, background_color: str = "ffffff"): + return self.qr.qrimage_io(part, width, height, border, background_color=background_color) + + def next_part_image(self, width=240, height=240, border=3, background_color="bdbdbd"): + part = self.next_part() + return self.part_to_image(part, width, height, border, background_color=background_color) + + + + +"""************************************************************************************** + STATIC QR encoders +**************************************************************************************""" +@dataclass class BaseStaticQrEncoder(BaseQrEncoder): def seq_len(self): return 1 + + def cur_part(self) -> str: + """ static QRs only have a single part, which `next_part` always returns """ + return self.next_part() @property @@ -172,86 +83,125 @@ class BaseStaticQrEncoder(BaseQrEncoder): -class BasePsbtQrEncoder(BaseQrEncoder): - def __init__(self, psbt: PSBT): - self.psbt = psbt +@dataclass +class SeedQrEncoder(BaseStaticQrEncoder): + seed : Seed = None + + def __post_init__(self): + self.wordlist = Seed.get_wordlist(self.seed.wordlist_language_code) + super().__post_init__() + + self.data = "" + # Output as Numeric data format + for word in self.seed.mnemonic_list: + index = self.wordlist.index(word) + self.data += str("%04d" % index) + + + def next_part(self): + return self.data -class UrPsbtQrEncoder(BasePsbtQrEncoder): - def __init__(self, psbt, qr_density): - super().__init__(psbt) - self.qr_max_fragment_size = 20 +@dataclass +class CompactSeedQrEncoder(SeedQrEncoder): + def next_part(self): + # Output as binary data format + binary_str = "" + for word in self.seed.mnemonic_list: + index = self.wordlist.index(word) + + # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits + binary_str += bin(index).split('b')[1].zfill(11) + + # We can exclude the checksum bits at the end + if len(self.seed.mnemonic_list) == 24: + # 8 checksum bits in a 24-word seed + binary_str = binary_str[:-8] + + elif len(self.seed.mnemonic_list) == 12: + # 4 checksum bits in a 12-word seed + binary_str = binary_str[:-4] + + # Now convert to bytes, 8 bits at a time + as_bytes = bytearray() + for i in range(0, math.ceil(len(binary_str) / 8)): + # int conversion reads byte data as a string prefixed with '0b' + as_bytes.append(int('0b' + binary_str[i*8:(i+1)*8], 2)) - qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor()) - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 10 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 30 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 120 - - self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) + # Must return data as `bytes` for `qrcode` to properly recognize it as byte data + return bytes(as_bytes) - def seq_len(self): - return self.ur2_encode.fountain_encoder.seq_len() + +@dataclass +class GenericStaticQrEncoder(BaseStaticQrEncoder): + data: str = None + + def next_part(self): + return self.data - def next_part(self) -> str: - return self.ur2_encode.next_part().upper() + +@dataclass +class BaseXpubQrEncoder(BaseQrEncoder): + """ + Base Xpub QrEncoder for static and animated formats + """ + seed: Seed = None + derivation: str = None + network: str = SettingsConstants.MAINNET + wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + + def prep_xpub(self): + self.wordlist = Seed.get_wordlist() + + if self.wordlist == None: + raise Exception('Wordlist Required') + + version = bip32.detect_version(self.derivation, default="xpub", network=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]) + self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"]) + self.fingerprint = self.root.child(0).fingerprint + self.xprv = self.root.derive(self.derivation) + self.xpub = self.xprv.to_public() + self.xpub_base58 = self.xpub.to_string(version=version) + + self.xpubstring = "[{}{}]{}".format( + hexlify(self.fingerprint).decode('utf-8'), + self.derivation[1:], + self.xpub_base58 + ) + + + +class StaticXpubQrEncoder(BaseXpubQrEncoder, BaseStaticQrEncoder): + def __post_init__(self): + super().__post_init__() + self.prep_xpub() + + + def next_part(self): + self.prep_xpub() + return self.xpubstring + + + +"""************************************************************************************** + Simple animated QR encoders +**************************************************************************************""" +@dataclass +class BaseSimpleAnimatedQREncoder(BaseQrEncoder): + def __post_init__(self): + super().__post_init__() + self.parts = [] + self.part_num_sent = 0 + self.sent_complete = False + self._create_parts() @property def is_complete(self): - return self.ur2_encode.is_complete() - - - -class SpecterPsbtQrEncoder(BasePsbtQrEncoder): - def __init__(self, psbt, qr_density): - super().__init__(psbt) - self.qr_max_fragement_size = 65 - self.parts = [] - self.part_num_sent = 0 - self.sent_complete = False - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragement_size = 40 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragement_size = 65 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragement_size = 90 - - self._create_parts() - - - def _create_parts(self): - base64_psbt = b2a_base64(self.psbt.serialize()) - - if base64_psbt[-1:] == b"\n": - base64_psbt = base64_psbt[:-1] - - base64_psbt = base64_psbt.decode('utf-8') - - start = 0 - stop = self.qr_max_fragement_size - qr_cnt = ((len(base64_psbt)-1) // self.qr_max_fragement_size) + 1 - - if qr_cnt == 1: - self.parts.append(base64_psbt[start:stop]) - - cnt = 0 - while cnt < qr_cnt and qr_cnt != 1: - part = "p" + str(cnt+1) + "of" + str(qr_cnt) + " " + base64_psbt[start:stop] - self.parts.append(part) - - start = start + self.qr_max_fragement_size - stop = stop + self.qr_max_fragement_size - if stop > len(base64_psbt): - stop = len(base64_psbt) - cnt += 1 + return self.sent_complete def seq_len(self): @@ -275,154 +225,34 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder): return part - @property - def is_complete(self): - return self.sent_complete + def cur_part(self) -> str: + if self.part_num_sent == 0: + # Rewind all the way back to the end + self.part_num_sent = len(self.parts) - 1 + else: + self.part_num_sent -= 1 + return self.next_part() - -class SeedQrEncoder(BaseStaticQrEncoder): - def __init__(self, seed_phrase: List[str], wordlist_language_code: str): - super().__init__() - self.seed_phrase = seed_phrase - self.wordlist = Seed.get_wordlist(wordlist_language_code) - - if self.wordlist == None: - raise Exception('Wordlist Required') - - - def next_part(self): - data = "" - # Output as Numeric data format - for word in self.seed_phrase: - index = self.wordlist.index(word) - data += str("%04d" % index) - return data - - - -class CompactSeedQrEncoder(SeedQrEncoder): - def next_part(self): - # Output as binary data format - binary_str = "" - for word in self.seed_phrase: - index = self.wordlist.index(word) - - # Convert index to binary, strip out '0b' prefix; zero-pad to 11 bits - binary_str += bin(index).split('b')[1].zfill(11) - - # We can exclude the checksum bits at the end - if len(self.seed_phrase) == 24: - # 8 checksum bits in a 24-word seed - binary_str = binary_str[:-8] - - elif len(self.seed_phrase) == 12: - # 4 checksum bits in a 12-word seed - binary_str = binary_str[:-4] - - # Now convert to bytes, 8 bits at a time - as_bytes = bytearray() - for i in range(0, math.ceil(len(binary_str) / 8)): - # int conversion reads byte data as a string prefixed with '0b' - as_bytes.append(int('0b' + binary_str[i*8:(i+1)*8], 2)) - - # Must return data as `bytes` for `qrcode` to properly recognize it as byte data - return bytes(as_bytes) - - - -class BitcoinAddressEncoder(BaseStaticQrEncoder): - def __init__(self, address: str): - super().__init__() - self.address = address - - - def next_part(self): - return self.address - - - -class SignedMessageEncoder(BaseStaticQrEncoder): - """ - Assumes that a signed message will fit in a single-frame QR - """ - def __init__(self, signed_message: str): - super().__init__() - self.signed_message = signed_message - - - def next_part(self): - return self.signed_message - - - -class XpubQrEncoder(BaseQrEncoder): - def __init__(self, seed, derivation, network, wordlist_language_code, sig_type : str = SettingsConstants.SINGLE_SIG): - self.seed = seed - self.derivation = derivation - self.network = network - self.wordlist = Seed.get_wordlist(wordlist_language_code) - self.parts = [] + def restart(self) -> str: self.part_num_sent = 0 - self.sent_complete = False - - if self.wordlist == None: - raise Exception('Wordlist Required') - - version = self.seed.detect_version(self.derivation, network, sig_type) - self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"]) - self.fingerprint = self.root.child(0).fingerprint - self.xprv = self.root.derive(self.derivation) - self.xpub = self.xprv.to_public() - self.xpub_base58 = self.xpub.to_string(version=version) - - self.xpubstring = "[{}{}]{}".format( - hexlify(self.fingerprint).decode('utf-8'), - self.derivation[1:], - self.xpub_base58 - ) - - self._create_parts() - def _create_parts(self): - self.parts = [] - self.parts.append(self.xpubstring) - - - def next_part(self) -> str: - if len(self.parts) > 0: - self.sent_complete = True - return self.parts[0] - - - def seq_len(self): - return len(self.parts) - +@dataclass +class SpecterXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder): @property - def is_complete(self): - return self.sent_complete - - - -class SpecterXPubQrEncoder(XpubQrEncoder): - def __init__(self, qr_density, **kwargs): - # Must set up qr_max_fragment_size before calling super().__init__() - self.qr_max_fragment_size = 65 - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 40 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 65 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 90 - - super().__init__(**kwargs) + def qr_max_fragment_size(self): + density_mapping = { + SettingsConstants.DENSITY__LOW: 40, + SettingsConstants.DENSITY__MEDIUM: 65, + SettingsConstants.DENSITY__HIGH: 90, + } + return density_mapping.get(self.qr_density, 65) def _create_parts(self): - self.parts = [] - + self.prep_xpub() start = 0 stop = self.qr_max_fragment_size qr_cnt = ((len(self.xpubstring)-1) // self.qr_max_fragment_size) + 1 @@ -442,34 +272,60 @@ class SpecterXPubQrEncoder(XpubQrEncoder): cnt += 1 + +"""************************************************************************************** + Fountain encoded animated QR encoders +**************************************************************************************""" +@dataclass +class BaseFountainQrEncoder(BaseQrEncoder): + def __post_init__(self): + super().__post_init__() + + self.ur2_encode: UREncoder = None + + + @property + def is_complete(self): + return self.ur2_encode.is_complete() + + + @property + def qr_max_fragment_size(self): + density_mapping = { + SettingsConstants.DENSITY__LOW: 10, + SettingsConstants.DENSITY__MEDIUM: 30, + SettingsConstants.DENSITY__HIGH: 120, + } + return density_mapping.get(self.qr_density, 30) + + + def _create_parts(self): + """ parts are dynamically generated by the fountain encoder """ + pass + + + def seq_len(self): + return self.ur2_encode.fountain_encoder.seq_len() + + def next_part(self) -> str: - # if part num sent is gt number of parts, start at 0 - if self.part_num_sent > (len(self.parts) - 1): - self.part_num_sent = 0 + return self.ur2_encode.next_part().upper() - part = self.parts[self.part_num_sent] - # when parts sent eq num of parts in list - if self.part_num_sent == (len(self.parts) - 1): - self.sent_complete = True + def cur_part(self) -> str: + return self.ur2_encode.current_part().upper() + - # increment to next part - self.part_num_sent += 1 - - return part + def restart(self): + self.ur2_encode.fountain_encoder.restart() -class UrXpubQrEncoder(XpubQrEncoder): - def __init__(self, qr_density, **kwargs): - super().__init__(**kwargs) - - if qr_density == SettingsConstants.DENSITY__LOW: - self.qr_max_fragment_size = 10 - elif qr_density == SettingsConstants.DENSITY__MEDIUM: - self.qr_max_fragment_size = 30 - elif qr_density == SettingsConstants.DENSITY__HIGH: - self.qr_max_fragment_size = 120 +@dataclass +class UrXpubQrEncoder(BaseFountainQrEncoder, BaseXpubQrEncoder): + def __post_init__(self): + super().__post_init__() + self.prep_xpub() def derivation_to_keypath(path: str) -> list: arr = path.split("/") @@ -527,9 +383,12 @@ class UrXpubQrEncoder(XpubQrEncoder): self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) - def seq_len(self): - return self.ur2_encode.fountain_encoder.seq_len() +@dataclass +class UrPsbtQrEncoder(BaseFountainQrEncoder): + psbt: PSBT = None - def next_part(self) -> str: - return self.ur2_encode.next_part().upper() + def __post_init__(self): + super().__post_init__() + qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor()) + self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size) diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 55a95d1e..4c4066a8 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -20,7 +20,7 @@ class Seed: mnemonic: List[str] = None, passphrase: str = "", wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> None: - self.wordlist_language_code = wordlist_language_code + self._wordlist_language_code = wordlist_language_code if not mnemonic: raise Exception("Must initialize a Seed with a mnemonic List[str]") @@ -58,7 +58,10 @@ class Seed: @property def mnemonic_list(self) -> List[str]: return self._mnemonic - + + @property + def wordlist_language_code(self) -> str: + return self._wordlist_language_code @property def mnemonic_display_str(self) -> str: @@ -95,7 +98,7 @@ class Seed: @property def wordlist(self) -> List[str]: - return Seed.get_wordlist(self.wordlist_language_code) + return Seed.get_wordlist(self._wordlist_language_code) def set_wordlist_language_code(self, language_code: str): diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index c576e6d0..acedd4f6 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -430,7 +430,7 @@ class SettingsDefinition: type=SettingsConstants.TYPE__MULTISELECT, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_SCRIPT_TYPES, - default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]), + default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__XPUB_DETAILS, diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 09ac597c..98ad5c20 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -4,9 +4,8 @@ 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.models.encode_qr import UrPsbtQrEncoder 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) @@ -300,6 +299,7 @@ class PSBTAddressDetailsView(View): class PSBTChangeDetailsView(View): NEXT = "Next" + SKIP_VERIFICATION = "Skip Verificiation" VERIFY_MULTISIG = "Verify Multisig Change" @@ -360,7 +360,7 @@ class PSBTChangeDetailsView(View): else: # Have the Screen offer to load in the multisig descriptor. - button_data = [self.VERIFY_MULTISIG, self.NEXT] + button_data = [self.VERIFY_MULTISIG, self.SKIP_VERIFICATION] else: # Single sig @@ -428,7 +428,8 @@ class PSBTChangeDetailsView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == self.NEXT: + elif button_data[selected_menu_num] == self.NEXT or button_data[selected_menu_num] == self.SKIP_VERIFICATION: + if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) else: @@ -513,11 +514,9 @@ class PSBTFinalizeView(View): class PSBTSignedQRDisplayView(View): def run(self): - qr_encoder = EncodeQR( + qr_encoder = UrPsbtQrEncoder( psbt=self.controller.psbt, - qr_type=QRType.PSBT__UR2, # All coordinators (as of 2022-08) use this format qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE), ) self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 17f71333..7bac9a45 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -47,6 +47,10 @@ class ScanView(View): decoder=self.decoder ) + # A long scan might have exceeded the screensaver timeout; ensure screensaver + # doesn't immediately engage when we leave here. + self.controller.reset_screensaver_timeout() + # Handle the results if self.decoder.is_complete: if not self.is_valid_qr_type: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 9a801ea2..b299a50f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -16,7 +16,7 @@ from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen from seedsigner.helpers import embit_utils from seedsigner.models.decode_qr import DecodeQR -from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.seed import InvalidSeedException, Seed @@ -754,20 +754,25 @@ class SeedExportXpubCoordinatorView(View): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0] return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True) + button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS) + selected_menu_num = self.run_screen( ButtonListScreen, title="Export Xpub", is_button_text_centered=False, - button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), + button_data=button_data, ) - if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)): - args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num] - return Destination(SeedExportXpubWarningView, view_args=args) - - elif selected_menu_num == RET_CODE__BACK_BUTTON: + if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + selected_display_name = button_data[selected_menu_num] + args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name) + + return Destination(SeedExportXpubWarningView, view_args=args) + + class SeedExportXpubWarningView(View): @@ -898,35 +903,24 @@ class SeedExportXpubQRDisplayView(View): super().__init__() self.seed = self.controller.get_seed(seed_num) - qr_density = self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) - if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: - qr_type = QRType.XPUB__SPECTER - - elif coordinator == SettingsConstants.COORDINATOR__BLUE_WALLET: - qr_type = QRType.XPUB - - elif coordinator == SettingsConstants.COORDINATOR__KEEPER: - qr_type = QRType.XPUB - - elif coordinator == SettingsConstants.COORDINATOR__NUNCHUK: - qr_type = QRType.XPUB__UR - - # As of 2022-03-02 Nunchuk doesn't seem to support animated QRs for Xpub import - qr_density = SettingsConstants.DENSITY__HIGH - - else: - qr_type = QRType.XPUB__UR - - self.qr_encoder = EncodeQR( + encoder_args = dict( seed=self.seed, derivation=derivation_path, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK), - qr_type=qr_type, - qr_density=qr_density, - wordlist_language_code=self.seed.wordlist_language_code, - sig_type=sig_type + qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) ) + if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP: + self.qr_encoder = SpecterXPubQrEncoder(**encoder_args) + + elif coordinator in [SettingsConstants.COORDINATOR__BLUE_WALLET, + SettingsConstants.COORDINATOR__KEEPER, + SettingsConstants.COORDINATOR__NUNCHUK]: + self.qr_encoder = StaticXpubQrEncoder(**encoder_args) + + else: + self.qr_encoder = UrXpubQrEncoder(**encoder_args) + def run(self): self.run_screen( @@ -1426,11 +1420,12 @@ class SeedTranscribeSeedQRWholeQRView(View): def run(self): - e = EncodeQR( - seed=self.seed, - qr_type=self.seedqr_format, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - ) + encoder_args = dict(seed=self.seed) + if self.seedqr_format == QRType.SEED__SEEDQR: + e = SeedQrEncoder(**encoder_args) + elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(**encoder_args) + data = e.next_part() ret = seed_screens.SeedTranscribeSeedQRWholeQRScreen( @@ -1461,11 +1456,12 @@ class SeedTranscribeSeedQRZoomedInView(View): def run(self): - e = EncodeQR( - seed=self.seed, - qr_type=self.seedqr_format, - wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - ) + encoder_args = dict(seed=self.seed) + if self.seedqr_format == QRType.SEED__SEEDQR: + e = SeedQrEncoder(**encoder_args) + elif self.seedqr_format == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(**encoder_args) + data = e.next_part() if len(self.seed.mnemonic_list) == 24: @@ -2108,7 +2104,7 @@ class SeedSignMessageSignedMessageQRView(View): def run(self): - qr_encoder = EncodeQR(qr_type=QRType.SIGN_MESSAGE, signed_message=self.signed_message) + qr_encoder = GenericStaticQrEncoder(data=self.signed_message) self.run_screen( QRDisplayScreen, diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 40bfa6d3..2d41ac4a 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -14,8 +14,7 @@ from seedsigner.gui.screens.tools_screens import (ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen) from seedsigner.helpers import embit_utils, mnemonic_generation -from seedsigner.models.encode_qr import EncodeQR -from seedsigner.models.qr_type import QRType +from seedsigner.models.encode_qr import GenericStaticQrEncoder from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView @@ -188,8 +187,8 @@ class ToolsImageEntropyMnemonicLengthView(View): ****************************************************************************""" class ToolsDiceEntropyMnemonicLengthView(View): def run(self): - TWELVE = "12 words (50 rolls)" - TWENTY_FOUR = "24 words (99 rolls)" + TWELVE = f"12 words ({mnemonic_generation.DICE__NUM_ROLLS__12WORD} rolls)" + TWENTY_FOUR = f"24 words ({mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls)" button_data = [TWELVE, TWENTY_FOUR] selected_menu_num = ButtonListScreen( @@ -203,10 +202,10 @@ class ToolsDiceEntropyMnemonicLengthView(View): return Destination(BackStackView) elif button_data[selected_menu_num] == TWELVE: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=50)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__12WORD)) elif button_data[selected_menu_num] == TWENTY_FOUR: - return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=99)) + return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=mnemonic_generation.DICE__NUM_ROLLS__24WORD)) @@ -713,7 +712,7 @@ class ToolsAddressExplorerAddressView(View): def run(self): from seedsigner.gui.screens.screen import QRDisplayScreen - qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address) + qr_encoder = GenericStaticQrEncoder(data=self.address) self.run_screen( QRDisplayScreen, qr_encoder=qr_encoder, diff --git a/tests/base.py b/tests/base.py index 496e1b86..e7bb2ddf 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,6 @@ import sys from dataclasses import dataclass -from mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, patch from typing import Callable # Prevent importing modules w/Raspi hardware dependencies. diff --git a/tests/requirements.txt b/tests/requirements.txt index a66aac6b..d42adea7 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,3 @@ -coverage==7.2.1 -mock==4.0.3 -pytest==6.2.4 +coverage==7.3.1 +pytest==7.4.2 pytest-cov==4.1.0 diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 20887567..278df338 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -2,7 +2,7 @@ import embit import os import sys import time -from mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch, MagicMock from seedsigner.helpers import embit_utils from seedsigner.models.settings import Settings diff --git a/tests/test_bip85.py b/tests/test_bip85.py index 5ade3917..737be569 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -1,5 +1,5 @@ import pytest -from mock import MagicMock +from unittest.mock import MagicMock from seedsigner.models.seed import Seed from embit import bip39 diff --git a/tests/test_controller.py b/tests/test_controller.py index 9eacc792..375ae9ff 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -99,7 +99,7 @@ class TestController(BaseTest): assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] - assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] + assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT] assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__180 diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 3bb89ee1..34f28631 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -1,5 +1,4 @@ -from seedsigner.models.encode_qr import EncodeQR -from seedsigner.models.qr_type import QRType +from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder from embit import psbt from binascii import a2b_base64 @@ -8,75 +7,47 @@ from seedsigner.models.seed import Seed -def test_ur_qr_encode(): +def test_ur_psbt_qr_encode(): base64_psbt = "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" tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - e = EncodeQR(psbt=tx, qr_type=QRType.PSBT__UR2) + e = UrPsbtQrEncoder(psbt=tx, qr_density=SettingsConstants.DENSITY__MEDIUM) cnt = 0 while cnt <= 10: fragment = e.next_part() - img = e.part_to_image(fragment) - cnt += 1 - - - -def test_specter_qr_encode(): - base64_psbt = "cHNidP8BAIkCAAAAAaLlQ/VRNpx3IFtoRTOCnq2xfJwg/n7R9XB0TTTnlX/UHQAAAAD9////AtzQAwAAAAAAIgAgCwVSg4Ae1lGNHzy76jLN6GSQaSVnktnmNDByu/wkn7FQwwAAAAAAACIAIJyFZJe7xxQjXpoEBhb8mIkau9OhobDS7xbYxnRIjJUSAAAAAE8BBIiyHgQFgrfagAAAAqP8rWjHFRBmTEWK39AFjd6Wo1sw1UxlgIvROVHUOHbiAzre+t61zOqKFV1xXtDPuUcQRh3M92zh0Zar8rDLPJKQFH7fnFkwAACAAAAAgAAAAIACAACATwEEiLIeBFIg7+eAAAACubwMfJNby3zfn9owhFfgl/Xe/GiHciMMxxB9v6q7BWcCurV9rH+K8ucVU3w52mcEttDldz7kh5cS0xBtWs7wmTYU4IEbazAAAIAAAACAAAAAgAIAAIBPAQSIsh4EX+8GLoAAAALvSlncnGchVCfK7tnzHPVYcBRcck0JGQuspGFpcGP+YQIAXYODa8PIF3hOOnUeYHhlv4PQ+UZCYynQCOoKgVJRhhQYTQfrMAAAgAAAAIAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAACAAAAAgAAAAIACAACATwEEiLIeBLQlJwmAAAAC4IOLeQD9ojcPbh5QGsPVUt/g+dCiQrlZ1DvZK21ajf8CN4aND6VGGhYiFtI9NNyna/M03ovmM4PSg3nR7Df9jsoUhSswjzAAAIAAAACAAAAAgAIAAIBPAQSIsh4EwYVAaYAAAAKvbrl5PeuwgEBUqMQqBYTaTR+PUfKrOXzPQ87VbyLgXwMFEpYG8cv4ljYX+uebG0hJLXsD8K9Lc9K2RqaBmFOtyBQ+RR7+MAAAgAAAAIAAAACAAgAAgAABAP0DDAIAAAADnI5jmO6QLNrEFUwjGd8ZaVBeFqwJGZ3APH1mGpO+GU1CAAAAAP////8tMJlbqdEddNCzmBnmZXSdFFfNTTzD8fd0L2l15pJNWwIAAAAA/////+zKvZECNrGUsrUdWJZnB42n6r1Rhi1XkTyPs/nHuhyoFAAAAAD/////WlvbBAAAAAAAF6kUoY7q7sUcfiktUmzDPQGi//fFvXyHb44BAAAAAAAXqRTHugXLmX3w/lCFhTkfalnedRIHrIeGWAIAAAAAABl2qRQUGQLrLqWokxaHzt65bE2Qle3FQYishdwFAAAAAAAZdqkUX4m0fmwPCUO6pcw8Zbx2YkykKIyIrNACJwAAAAAAFgAUuej6+oAPU186R0ACxtFVG1po+oU7EQUAAAAAABl2qRTGnZD1MfBn92OncmMkRD2Ea+fToYisEksCAAAAAAAXqRS8oz8RN18jt3aeydd/y+/StoEsdYcLajcAAAAAABepFHeYIqSnDz5GuBfk0XbjLOlqNF6Dhy4IFwAAAAAAGXapFDomdwaFLA0OfjLfhXXxGYcjwi94iKyGWAIAAAAAABl2qRSdfDacqPqO9CPL8VtX8vldzlBo6YisSVgCAAAAAAAWABR0DohsF88/3DalzIZyF2ZToibTSxXkLQAAAAAAGXapFPmlzMsqjXuIMCBczer3vGR+GX7KiKwyQAIAAAAAABl2qRTt97UCSLs90250ctaRfDmj6KvZzYis9QgXAAAAAAAWABS526HCIlmm4yis1TxaDNbeCCGyHkakAQAAAAAAF6kUIv85Uai5pFu94QXUYU6YV6ZY/HmHR8gBAAAAAAAZdqkU4cbRwoLlhic5Mx6SdsH8m8bF1nqIrNBvBgAAAAAAGXapFFBUM1drdEjYVzE3ZQOodhobVeNBiKwdSwIAAAAAABl2qRRV48mXLau6y2HwytGPyw/YWXIDR4isD+UGAAAAAAAZdqkUhup0yRlrxdycP9543gF4HEdYVX6IrEiVBAAAAAAAF6kU0xyHPo/mQeDTWX3uHecVQr3QNwmHLjUDAAAAAAAZdqkUR4aV0HjC/bVmOwfjXsbcMzVsbSKIrOqKBAAAAAAAFgAUEx8rhKUzD3fHWdK2v6R9xHvFqHpMhQIAAAAAABl2qRRIg2u4Ow74IDCcGKYnssKpOi2VeYisN6YbAAAAAAAXqRRCSDT9kY3HvkNQI480GmDcu8fffocMQQMAAAAAABl2qRQ83W+Njx6fiXXbXE0fFR9QfA0zJoisdggXAAAAAAAXqRQ9/3PVGaBETVlM+auG6MBXkqNa6YeQt04AAAAAABl2qRRHmF4qf2fYqxhuYPulQCJIhkKyo4isfY4BAAAAAAAXqRQb2OkwwU0kbjeTepBie2hH9Nyhy4d4oAIAAAAAABepFLxzGvLxJoys+l4fvCfHyKNfzx5XhzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeil4QAIAAAAAABepFJhDXqvM/jN8FZw/lHXkusDCJRTgh2q4AgAAAAAAF6kUtcjnRyRtAOawAnBvMygHecHHRCiHIIkLAAAAAAAZdqkU7X7qc767ZmR51ucTsc5G3uu4XDCIrNBnAwAAAAAAF6kU1SVFH4lwDMpvZEe/g4OXPA/R9EeHcIMDAAAAAAAXqRSGSbWdefvglK8rcFv861TKX7H4Lod63AUAAAAAABepFDspcMlIbqM0IOnk4iOp+VVWsIeIh1zsAQAAAAAAGXapFHrgjevUcXAC9y3FSqtB4O6YHBf9iKxiyAQAAAAAABYAFJWB2pija792dDLax+k7ko3rc3MTmdUBAAAAAAAWABTEd9Lq4RQ5DqWrFEJG0yGwTyNGVmzIAQAAAAAAF6kUvQ1oX0X+EqSr9nm5yVgTEqqwbBKH/jEJAAAAAAAWABS5Q9n7WwpD8V+DoUr1PhtaPjEAzpw4AwAAAAAAGXapFJV07D6tUzUodx2WS8O5Co4x365viKxedB8AAAAAABl2qRQwCg19nxZttgRYVNtqm634kcvwI4islXUXAAAAAAAXqRQdpv7S8UHAtUzhN9UjDzbA1r8l3odrcAMAAAAAABepFFY77ZQ2QH8SBYqU4lswOS2SM3NphxXvCQAAAAAAF6kUQIs3Q5sPU0ubPufbIGTl5aforUWHXtACAAAAAAAZdqkUomTncvcva43IhQjLUn/gkddAA4+IrL1sAwAAAAAAF6kUF0IHuQXB2WY+UKhOt2Fe1PP0YKeHziuGAwAAAAAWABQmwHI2oSXayEsODm4irKumczJcu2hZDQAAAAAAF6kUkEVhUQ33XDK3OqdRZDvT9lpyh5CHoNMJAAAAAAAXqRSoGqG/VTHq7TmPlXD2YYU8ih0HQYdqfgsAAAAAABl2qRTJ1SEupAnvPWOSxpcXFnbfVCx2r4issK0BAAAAAAAXqRTb7/iq9K3zhZIGk0VpFBtYiVJ724cDdRcAAAAAABl2qRRHkxnD6L4pYcssWJdqkDrkja7kmYisokoCAAAAAAAXqRT75VCujkWKFY/ifu/0Orj3JUV0Z4ezjAQAAAAAABYAFBpX8ddXQJ95Vy/v3zi+yYZVi/yglrAEAAAAAAAXqRSLFecvMVMAuxjHz6iSn0XpfQ98gIdNTRgAAAAAABepFB3Wn0gQsayX8cOkCtmSF/NRy08zh5QUBwAAAAAAGXapFLbjX9PnCBzJKViLkLVzyMwtVwNUiKyJSQUAAAAAABepFNDbsKF4ZizxSBuY7NHYnOEuPemxh9LxWwAAAAAAF6kUquecjseAlaEpHxPc83v8kGUFdkuHLzQIAAAAAAAXqRRX8vVVucqqFgH6mGJPEK+/reJ2oYd03AUAAAAAABepFBsBZvNXH4r6Ro8ojq4rmGTcNtBih9maAQAAAAAAGXapFF9oCUBEmn9pA1ddXZGZjsfEFsMOiKzEzwUAAAAAABl2qRRxJyHXAGx4sfRS9WH4eyJLHi+Wd4isLfotAAAAAAAXqRSORo5USPBTLgHEvyfKgfjCqMhnm4csWAIAAAAAABl2qRSRQ+PgB7THRZz/rts1ZV1kB3xCU4ishkoCAAAAAAAZdqkU6tpAL4E1Y53hpyNDyup0NNkIWZ2IrHaaAQAAAAAAF6kUdCiIVs9RAe6mhTnBrZ9rXDmBUwqHMRUHAAAAAAAWABRWDPxl5JVG+87QXnn6mxroeokXegbVCgAAAAAAF6kUc1nZFyA2yQQlxjG7wC8EmcwSYAaHkooLAAAAAAAZdqkUbWMloEkzLZaPkqmvj48ayjP24pWIrKXBCQAAAAAAF6kUhor0BRMQSHMrs8huHLt3PzkmwY+HLTQCAAAAAAAXqRRu2+r5RZ97rALhlGzLcTqXL0qWBYdtdQIAAAAAABepFOvvX6e4KHStEF9gAeP5sueSWoj8h8xKAgAAAAAAF6kUtCOpxVPaoJX6I6x4sYcxi0FRkZuHHEsCAAAAAAAXqRR0jXC8f5rOvLMnaCqNbFhgYV1VI4eynwUAAAAAABepFKk9GBH39jPYAijN98mQiXQLwO6th6KVBAAAAAAAF6kUxAXYvAMMGpeUwbSehQ6yl7PfBKaHhAwGAAAAAAAXqRSgEqI18gMoa8oDed3Nmw0e0JjANodsEAIAAAAAABl2qRSZlcV+iJuoM2F5GdNLhAmJB8LZkYisD9QBAAAAAAAXqRTltOpfMjLJA3a0569jL3OdK96kLYeQXgIAAAAAABl2qRRJxL+Ewl1I7R0UVRYyhvyTdhGt+Yisg0oCAAAAAAAZdqkUqzUSwEEJDrGszAlQNOTOyiXHGc6IrH0qCQAAAAAAF6kUA9gkZnXrwD3nSird5PjY/mKrjrKHrZUEAAAAAAAXqRR2GK6PRPCUdeDBifrkXqVW6OjTVocldRcAAAAAABYAFIALieV/hlNyLSnLzygXuapZ5ZWOeFACAAAAAAAXqRQzJK44f4kGcK0Mr67rQIf8V6K004eNCBcAAAAAABepFEPC9GKHNg91b0VHjiGqN9jskJBnh3wYBgAAAAAAF6kUac+U//Z6fP0Sd1hF+7H2spE6W3uHAAAAAAEBKzeVBAAAAAAAIgAgWE9I4MhYpgx9StM1jKekhXHNQ8ohBTlx4N8wbBvDeikBBc9UIQI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JSECXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4hAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/IQMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+yEDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkhA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+Vq4iBgI90obbwglkzCu7YY5szpmsifPSjmmkMWB2zirsF7i5JRw+RR7+MAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYCXtSG8zlgDJHpslDlTL+/MPiyMHW404co4O9XwhrFJD4c4IEbazAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGAsaYDVoTjPJ1xm5KIpmVjO8AerWFj+0ij7ti1GkxvyI/HIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAEAAAAiBgMNJ5G2tHM6GGX9OMrL1a5LLFjx3eyHE9dG8/00BGJ6+xwYTQfrMAAAgAAAAIAAAACAAgAAgAAAAAABAAAAIgYDW0BA9BSig0YYQcMhaCQ5EgJhYPx0HfMNsknOEzNVBfkctDPglTAAAIAAAACAAAAAgAIAAIAAAAAAAQAAACIGA4/77ELJ9rT3+zhaRN/L3lk81Eie5dlCI15SuNT45ZV+HH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAEAAAAAAQHPVCEC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEhAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnIQMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WSEDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08hA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFIQPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4VauIgIC5eStpJd5y6MpbkWgUYRhL6Sta3BAtONOSEC2uIXXIcEcGE0H6zAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICAw5hli91LeHlLHv5WR6/xjfFTjCsXxE9MtO0wV/a7mTnHOCBG2swAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgMT9IzdgTJDxQ0CO5Ka1HcnXfbBnCdLN9NZrDKMf3Z+WRx+35xZMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAIgIDn6BiNDZ7YI//rSuZjrNIY0k0C3h7MBEur/nzJ7gVF08cPkUe/jAAAIAAAACAAAAAgAIAAIABAAAAAAAAACICA7UGbXn9OfXGcHLWujN7D1wpZqwQrOV49XIiJNtqr6dFHLQz4JUwAACAAAAAgAAAAIACAACAAQAAAAAAAAAiAgPwycXFPO4Rf5xaNDQ1zryEERu4z+A3C6iz0+aKHfHq4RyFKzCPMAAAgAAAAIAAAACAAgAAgAEAAAAAAAAAAAEBz1QhAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jIQKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkyECrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4shAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keIQPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitSED0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBhWriICAqLp+NQOoYyma8paUW8hucqCdQu2VAZmFGMbV79csI7jHIUrMI8wAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgKtZYJ+sgBVWQwp/xCIeS/x+/SZXAD4VHf56HFmnK9fkxzggRtrMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgICrvaSdw5m5ZxvwhF7/EbFGJP5MGIDhdbdcILAGsept4scGE0H6zAAAIAAAACAAAAAgAIAAIAAAAAAAgAAACICAwlGvi1FP2ybbd5xYnQhz7Cvh2gWaTn5yvMVWm+Ev5keHH7fnFkwAACAAAAAgAAAAIACAACAAAAAAAIAAAAiAgPCy/yDc1y1RCJYDMEy6UYkduq4Eq1dyLOoInv5xwsitRy0M+CVMAAAgAAAAIAAAACAAgAAgAAAAAACAAAAIgID0sEPo41jUtW51+oiJDQPHFt0scWX6aPHivum+kT7WBgcPkUe/jAAAIAAAACAAAAAgAIAAIAAAAAAAgAAAAA=" - - tx = psbt.PSBT.parse(a2b_base64(base64_psbt)) - - e = EncodeQR(psbt=tx, qr_type=QRType.PSBT__SPECTER) - - cnt = 0 - while cnt <= 10: - fragment = e.next_part() - if (cnt+1) == 1: - assert fragment == "p1of117 cHNidP8BAIkCAAAAAaLlQ/VRNpx3IFtoRTOCnq2xfJwg/n7R9XB0TTTnlX/UHQAAA" - elif (cnt+1) == 2: - assert fragment == "p2of117 AD9////AtzQAwAAAAAAIgAgCwVSg4Ae1lGNHzy76jLN6GSQaSVnktnmNDByu/wkn7" - elif (cnt+1) == 3: - assert fragment == "p3of117 FQwwAAAAAAACIAIJyFZJe7xxQjXpoEBhb8mIkau9OhobDS7xbYxnRIjJUSAAAAAE8" - elif (cnt+1) == 4: - assert fragment == "p4of117 BBIiyHgQFgrfagAAAAqP8rWjHFRBmTEWK39AFjd6Wo1sw1UxlgIvROVHUOHbiAzre" - elif (cnt+1) == 5: - assert fragment == "p5of117 +t61zOqKFV1xXtDPuUcQRh3M92zh0Zar8rDLPJKQFH7fnFkwAACAAAAAgAAAAIACA" - elif (cnt+1) == 6: - assert fragment == "p6of117 ACATwEEiLIeBFIg7+eAAAACubwMfJNby3zfn9owhFfgl/Xe/GiHciMMxxB9v6q7BW" - elif (cnt+1) == 7: - assert fragment == "p7of117 cCurV9rH+K8ucVU3w52mcEttDldz7kh5cS0xBtWs7wmTYU4IEbazAAAIAAAACAAAA" - elif (cnt+1) == 8: - assert fragment == "p8of117 AgAIAAIBPAQSIsh4EX+8GLoAAAALvSlncnGchVCfK7tnzHPVYcBRcck0JGQuspGFp" - elif (cnt+1) == 9: - assert fragment == "p9of117 cGP+YQIAXYODa8PIF3hOOnUeYHhlv4PQ+UZCYynQCOoKgVJRhhQYTQfrMAAAgAAAA" - elif (cnt+1) == 10: - assert fragment == "p10of117 IAAAACAAgAAgE8BBIiyHgRgkAVVgAAAAsgLKl/ahhLHvS/3Cth+9Hde12MHJO5PP8" - elif (cnt+1) == 11: - assert fragment == "p11of117 REKtbWkqONAvETqIlMPWJ/f1uBvSCGFm+zzDYnnEBtuAYjZiQrzj9mFLQz4JUwAAC" - - img = e.part_to_image(fragment) + e.part_to_image(fragment, 240, 240) cnt += 1 def test_seedsigner_qr(): - mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" + # test vector 1 from the SeedQR docs + mnemonic = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split() + e = SeedQrEncoder(seed=Seed(mnemonic)) + assert e.next_part() == "011513251154012711900771041507421289190620080870026613431420201617920614089619290300152408010643" - e = EncodeQR(seed=Seed(mnemonic.split()), qr_type=QRType.SEED__SEEDQR) + e = CompactSeedQrEncoder(seed=Seed(mnemonic)) + assert e.next_part() == b'\x0et\xb6A\x07\xf9L\xc0\xcc\xfa\xe6\xa1=\xcb\xec6b\x15O\xecg\xe0\xe0\t\x99\xc0x\x92Y}\x19\n' - print(e.next_part()) + # test vector 4 from the SeedQR docs + mnemonic="forum undo fragile fade shy sign arrest garment culture tube off merit".split() + seed = Seed(mnemonic) + e = SeedQrEncoder(seed=seed) + assert e.next_part() == "073318950739065415961602009907670428187212261116" - assert e.next_part() == "121802020768124106400009195602431595117715840445" + e = CompactSeedQrEncoder(seed=seed) + assert e.next_part() == b'[\xbd\x9dq\xa8\xecy\x90\x83\x1a\xff5\x9dBeE' def test_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed=Seed(mnemonic.split(), passphrase="pass"), qr_type=QRType.XPUB, network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h") - + e = StaticXpubQrEncoder(seed=Seed(mnemonic.split(), passphrase="pass"), + derivation="m/48h/1h/0h/2h", + network=SettingsConstants.TESTNET) assert e.next_part() == "[c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDNc5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBoMxfrVggXj5NJmaNEToWpax8GMMucozvAdqf1bW1JsZsfdBzsK3VUC5" @@ -84,7 +55,7 @@ def test_xpub_qr(): def test_specter_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR(seed=Seed(mnemonic.split(), passphrase="pass"), qr_type=QRType.XPUB__SPECTER, network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW) + e = SpecterXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW) assert e.next_part() == "p1of4 [c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDN" assert e.next_part() == "p2of4 c5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBo" @@ -96,9 +67,8 @@ def test_specter_xpub_qr(): def test_ur_xpub_qr(): mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash" - e = EncodeQR( + e = UrXpubQrEncoder( seed=Seed(mnemonic.split(), passphrase="pass"), - qr_type=QRType.XPUB__UR, network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__MEDIUM diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index a14abb04..830f0cf5 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,6 +1,5 @@ from base import FlowTest, FlowStep -from seedsigner.controller import Controller from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views from seedsigner.models.settings import SettingsConstants @@ -74,7 +73,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTOverviewView), FlowStep(psbt_views.PSBTMathView), FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0), - FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION), FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED), FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), @@ -86,7 +85,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTOverviewView), FlowStep(psbt_views.PSBTMathView), FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0), - FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.SKIP_VERIFICATION), FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView), diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index a6eb34d9..c449f679 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,7 +1,7 @@ import os from typing import Callable -from mock import PropertyMock, patch +from unittest.mock import PropertyMock, patch # Must import test base before the Controller from base import FlowTest, FlowStep diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 6d032460..695ee093 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -1,4 +1,4 @@ -from mock import patch +from unittest.mock import patch # Must import test base before the Controller from base import FlowTest, FlowStep diff --git a/tests/test_mnemonic_generation.py b/tests/test_mnemonic_generation.py index 76298d34..a1f46e85 100644 --- a/tests/test_mnemonic_generation.py +++ b/tests/test_mnemonic_generation.py @@ -12,16 +12,17 @@ def test_dice_rolls(): dice_rolls = "" for i in range(0, 99): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) + assert len(mnemonic) == 24 assert bip39.mnemonic_is_valid(" ".join(mnemonic)) dice_rolls = "" - for i in range(0, 50): + for i in range(0, mnemonic_generation.DICE__NUM_ROLLS__12WORD): # Do not need truly rigorous random for this test - dice_rolls += str(random.randint(0, 5)) + dice_rolls += str(random.randint(1, 6)) mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls) assert len(mnemonic) == 12 @@ -29,18 +30,40 @@ def test_dice_rolls(): -def test_calculate_checksum(): - """ Given an 11-word or 23-word mnemonic, the calculated checksum should yield a +def test_calculate_checksum_input_type(): + """ + Given an 11-word or 23-word mnemonic, the calculated checksum should yield a valid complete mnemonic. + + calculate_checksum should accept the mnemonic as: + * a list of strings + * string: "A B C", "A, B, C", "A,B,C" """ # Test mnemonics from https://iancoleman.io/bip39/ + def _try_all_input_formats(partial_mnemonic: str): + # List of strings + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ",")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Comma-separated string w/space + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.replace(" ", ", ")) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + + # Space-separated string + mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic) + assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) - assert bip39.mnemonic_is_valid(" ".join(mnemonic)) + _try_all_input_formats(partial_mnemonic) + + def test_calculate_checksum_invalid_mnemonics(): @@ -50,25 +73,25 @@ def test_calculate_checksum_invalid_mnemonics(): with pytest.raises(Exception) as e: # Mnemonic is too short: 10 words instead of 11 partial_mnemonic = "abandon " * 9 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic) assert "12- or 24-word" in str(e) with pytest.raises(Exception) as e: # Valid mnemonic but unsupported length mnemonic = "devote myth base logic dust horse nut collect buddy element eyebrow visit empty dress jungle" - mnemonic_generation.calculate_checksum(mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(mnemonic) assert "12- or 24-word" in str(e) with pytest.raises(Exception) as e: # Mnemonic is too short: 22 words instead of 23 partial_mnemonic = "abandon " * 21 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic) assert "12- or 24-word" in str(e) with pytest.raises(ValueError) as e: # Invalid BIP-39 word partial_mnemonic = "foobar " * 11 + "about" - mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic_generation.calculate_checksum(partial_mnemonic) assert "not in the dictionary" in str(e) @@ -78,17 +101,17 @@ def test_calculate_checksum_with_default_final_word(): the mnemonic. """ partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic) assert mnemonic1 == mnemonic2 partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile" - mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic) partial_mnemonic += " abandon" - mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) + mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic) assert mnemonic1 == mnemonic2 diff --git a/tests/test_seedqr.py b/tests/test_seedqr.py index f8146d06..e855252b 100644 --- a/tests/test_seedqr.py +++ b/tests/test_seedqr.py @@ -1,10 +1,8 @@ import os -import pyzbar from embit import bip39 from seedsigner.helpers.qr import QR -from seedsigner.helpers.ur2.bytewords import decode from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus -from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.encode_qr import SeedQrEncoder, CompactSeedQrEncoder from seedsigner.models.qr_type import QRType from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants @@ -13,12 +11,14 @@ from seedsigner.models.settings import SettingsConstants def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): """ Helper method to re-run multiple variations of the same encode/decode test """ - print(entropy) - seed_phrase = bip39.mnemonic_from_bytes(entropy).split() - print(seed_phrase) - assert len(seed_phrase) == mnemonic_length + mnemonic = bip39.mnemonic_from_bytes(entropy).split() + assert len(mnemonic) == mnemonic_length + + if qr_type == QRType.SEED__SEEDQR: + e = SeedQrEncoder(seed=Seed(mnemonic)) + elif qr_type == QRType.SEED__COMPACTSEEDQR: + e = CompactSeedQrEncoder(seed=Seed(mnemonic)) - e = EncodeQR(seed=Seed(seed_phrase), qr_type=qr_type) data = e.next_part() print(data) @@ -35,8 +35,7 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type): assert status == DecodeQRStatus.COMPLETE decoded_seed_phrase = decoder.get_seed_phrase() - print(decoded_seed_phrase) - assert seed_phrase == decoded_seed_phrase + assert mnemonic == decoded_seed_phrase diff --git a/tools/mnemonic.py b/tools/mnemonic.py new file mode 100644 index 00000000..28490e94 --- /dev/null +++ b/tools/mnemonic.py @@ -0,0 +1,169 @@ +import argparse +import random +from seedsigner.helpers import mnemonic_generation +from embit.wordlists.bip39 import WORDLIST as WORDLIST__ENGLISH + +""" +see: docs/dice_verification.md (the "Command Line Tool" section) for full instructions. + +tldr: + pip3 install embit + pip3 install -e . + cd tools + python3 mnemonic.py -h +""" + + +usage = f""" +Verify SeedSigner's dice rolls and coin flip entropy-to-mnemonic conversion via this tool. + +Compare its results against iancoleman.io/bip39 and bitcoiner.guide/seed + +Usage: + # {mnemonic_generation.DICE__NUM_ROLLS__12WORD} dice rolls / 12-word mnemonic + python3 mnemonic.py dice 5624433434... + + # {mnemonic_generation.DICE__NUM_ROLLS__24WORD} dice rolls / 24-word mnemonic + python3 mnemonic.py dice 6151463561... + + # {mnemonic_generation.DICE__NUM_ROLLS__12WORD} dice rolls, entered as 0-5 / 12-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice 5135535514... + + # 128 coin flips / 12-word mnemonic + python3 mnemonic.py coins 1111100111... + + # 256 coin flips / 24-word mnemonic + python mnemonic.py coins 0010111010... + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__12WORD} random dice rolls / 12-word mnemonic + python3 mnemonic.py dice rand12 + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__24WORD} random dice rolls / 24-word mnemonic + python3 mnemonic.py dice rand24 + + # GENERATE {mnemonic_generation.DICE__NUM_ROLLS__24WORD} random dice rolls, entered as 0-5 / 24-word mnemonic + python3 mnemonic.py --zero-indexed-dice dice rand24 + + # GENERATE 128 random coin flips / 12-word mnemonic + python3 mnemonic.py coins rand12 + + # GENERATE 256 random coin flips / 24-word mnemonic + python3 mnemonic.py coins rand24 +""" +RAND_12 = "rand12" +RAND_24 = "rand24" +parser = argparse.ArgumentParser(description=f'SeedSigner entropy-to-mnemonic tool\n\n{usage}', formatter_class=argparse.RawTextHelpFormatter) + +# Required positional arguments +parser.add_argument('method', type=str, choices=['dice', 'coins', 'final_word'], help="Input entropy method") +parser.add_argument('entropy', type=str, help=f"""Entropy data. Enter "{ RAND_12 }" or "{ RAND_24 }" to create a random (not-secure) example seed.""") + +# Optional arguments +parser.add_argument('-z', '--zero-indexed-dice', + action="store_true", + default=False, + dest="zero_indexed_dice", + help="Enables dice entry as [0-5] instead of default [1-6]") + +args = parser.parse_args() + +method = args.method +entropy = args.entropy +zero_indexed_dice = args.zero_indexed_dice + +is_rand_seed = 'rand' in entropy +if is_rand_seed: + # Generate random data as our entropy + if entropy not in [RAND_12, RAND_24]: + print(f"""Invalid random entropy value: Must be either "{RAND_12}" or "{RAND_24}".""") + exit(1) + mnemonic_length = 12 if entropy == RAND_12 else 24 + + if method == 'dice': + if zero_indexed_dice: + entropy = ''.join([str(random.randint(0, 5)) for i in range(mnemonic_generation.DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else mnemonic_generation.DICE__NUM_ROLLS__24WORD)]) + else: + entropy = ''.join([str(random.randint(1, 6)) for i in range(mnemonic_generation.DICE__NUM_ROLLS__12WORD if mnemonic_length == 12 else mnemonic_generation.DICE__NUM_ROLLS__24WORD)]) + + elif method == 'coins': + entropy = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + + elif method == 'final_word': + random_dice_rolls = ''.join([str(random.randint(0, 1)) for i in range(128 if mnemonic_length == 12 else 256)]) + entropy = " ".join(mnemonic_generation.generate_mnemonic_from_coin_flips(random_dice_rolls)[:-1]) + print(len(entropy.split()), entropy) + +if method == 'dice': + if not zero_indexed_dice and ('0' in entropy or '6' not in entropy): + print("Dice entry must be 1-6 unless --zero-indexed-dice is specified") + exit(1) + if len(entropy) not in [mnemonic_generation.DICE__NUM_ROLLS__12WORD, mnemonic_generation.DICE__NUM_ROLLS__24WORD]: + print(f"Dice entropy must be {mnemonic_generation.DICE__NUM_ROLLS__12WORD} or {mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls") + exit(1) + mnemonic = mnemonic_generation.generate_mnemonic_from_dice(entropy) + +elif method == 'coins': + if len(entropy) not in [128, 256]: + print("Coin flip entropy must be 128 or 256 flips") + exit(1) + mnemonic = mnemonic_generation.generate_mnemonic_from_coin_flips(entropy) + +elif method == 'final_word': + num_input_words = len(entropy.split()) + if num_input_words not in [11, 12, 23, 24]: + print(f"Final word entropy must be 11, 12, 23, or 24 words ({num_input_words} provided)") + exit(1) + + if num_input_words in [11, 23]: + # We need to fill the last bits of entropy + if num_input_words == 11: + # 7 final bits of entropy in the 12th word (7 + 4-bit checksum) + num_final_entropy_bits = 7 + else: + # 3 final bits of entropy in the 24th word (3 + 8-bit checksum) + num_final_entropy_bits = 3 + + final_entropy_method = None + while final_entropy_method not in ['1', '2', '3']: + final_entropy_method = input(f""" +How would you like to fill the final {num_final_entropy_bits} bits of entropy? + +1.) {num_final_entropy_bits} coin flips +2.) Select an additional word from the wordlist +3.) Fill with zeros + +Type 1, 2, or 3: """) + + if final_entropy_method == '1': + coin_flips = input(f""" Enter {num_final_entropy_bits} coin flips as 0 or 1 (e.g. { "".join(str(random.randint(0,1)) for f in range(0, num_final_entropy_bits)) }): """) + if len(coin_flips) != num_final_entropy_bits: + print(f"Invalid number of coin flips: needed {num_final_entropy_bits}, got {len(coin_flips)}") + final_word = mnemonic_generation.get_partial_final_word(coin_flips) + entropy += f" {final_word}" + + elif final_entropy_method == '2': + final_word = input(f""" Enter the final word: """) + if final_word not in WORDLIST__ENGLISH: + print(f"Invalid word: {final_word}") + exit(1) + entropy += f" {final_word}" + + elif final_entropy_method == '3': + # Nothing to do; just pass the 11 or 23 words straight to calculate_checksum + pass + + mnemonic = mnemonic_generation.calculate_checksum(entropy) + +print("\n") +if is_rand_seed: + print("\t***** This is a demo seed. Do not use it to store funds!!! *****") + +print(f"""\t{" ".join(mnemonic)}\n""") + +if is_rand_seed: + print(f"\tEntropy: {entropy}\n") + +if method == "dice": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Base 10" or "Hex" mode.\n""") +elif method == "coins": + print(f"""\tVerify at iancoleman.io/bip39 or bitcoiner.guide/seed using "Binary" mode.\n""")