Skip to content

Validate the BIP-39 checksum when decoding a Standard SeedQR - #967

Open
ruipereira1 wants to merge 1 commit into
SeedSigner:devfrom
ruipereira1:fix/seedqr-checksum-validation
Open

Validate the BIP-39 checksum when decoding a Standard SeedQR#967
ruipereira1 wants to merge 1 commit into
SeedSigner:devfrom
ruipereira1:fix/seedqr-checksum-validation

Conversation

@ruipereira1

@ruipereira1 ruipereira1 commented Aug 1, 2026

Copy link
Copy Markdown

Description

Problem or Issue being addressed

SeedQrDecoder.add() decodes a Standard (numeric) SeedQR by mapping 4-digit indices to
words and returns COMPLETE without ever validating the mnemonic. The plaintext-mnemonic
path directly below it does validate, because it constructs a Seed.

Two consequences:

a) A SeedQR with an invalid checksum is accepted by the decoder. A 48-digit payload
encoding abandon × 12 (valid words, invalid checksum) decodes to COMPLETE.
ScanView.run() then does:

self.controller.storage.set_pending_seed(
    Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code)
)

with no try, so the resulting InvalidSeedException propagates to
Controller.handle_exception and the user lands on System Error / InvalidSeedException.

Hand-transcribing SeedQRs from the project's own printable templates is exactly the
workflow where a wrong checksum is expected to happen, and the checksum is what exists to
catch it. Today it surfaces as a crash rather than an explanation.

b) Extra digits are silently discarded. num_words = int(len(segment) / 4) decodes a
prefix and ignores the remainder, so a 50-digit payload loads a 12-word seed from the
first 48 digits.

c) Detection over-matches. detect_segment_type() used re.search(r'\d{48,96}', s),
a loose search that claims any QR merely containing a long digit run. That check runs
before the settings:: check, so "settings::v1 " + "1"*48 was classified as a SeedQR.

Solution

  • Require exactly 48 or 96 digits.
  • Validate via bip39.mnemonic_to_bytes() — checksum only, no PBKDF2, so no meaningful
    cost in the scan loop.
  • Clear seed_phrase on the failure path.
  • Anchor the detection regex with re.fullmatch(r'\d{48}|\d{96}', s.strip()).

An invalid SeedQR now routes to the existing ScanInvalidQRTypeView ("Unknown QR Type")
instead of crashing.

Additional Information

Open question for maintainers: would you prefer a dedicated error screen ("Invalid
SeedQR — checksum failed") over the generic "Unknown QR Type"? That's a UX decision and
I kept it out of this PR.

Screenshots

No new or modified screens — an invalid SeedQR now reaches the existing
ScanInvalidQRTypeView instead of UnhandledExceptionView. Both screens are unchanged.


This pull request is categorized as a:

  • New feature
  • Bug fix
  • Code refactor
  • Documentation
  • Other

Checklist

I ran pytest locally

  • All tests passed before submitting the PR
  • I couldn't run the tests
  • N/A

187 passed with this change. 4 tests in test_seedqr.py that decode QR images could not run in my environment because libzbar does not load on Windows; they fail identically on an unmodified dev checkout (baseline: 184 passed, same 4 failures). They pass in CI, which installs libzbar0.


I included screenshots of any new or modified screens

  • Yes
  • No
  • N/A

I added or updated tests

  • Yes
  • No, I'm a fool
  • N/A

I tested this PR hands-on on the following platform(s):

  • Raspberry Pi OS Manual Build
  • SeedSigner OS on a Pi0/Pi0W board
  • Emulator

Not tested on hardware or the emulator — I don't have a device. Covered by unit tests against the decoder. This one would benefit from an on-device check with a deliberately mis-drawn SeedQR; happy for a maintainer to try that.


I have reviewed these notes:

  • I understand

The numeric SeedQR path mapped 4-digit indices to words and returned COMPLETE
without ever validating the mnemonic, unlike the plaintext-mnemonic path which
constructs a Seed and therefore checks it. Two consequences:

* A SeedQR whose checksum is wrong (a hand-transcribed QR drawn incorrectly)
  was accepted here and only rejected later in ScanView, where the resulting
  InvalidSeedException is uncaught and routes the user to the generic
  "System Error" screen.
* `num_words = int(len(segment) / 4)` decoded a prefix and silently discarded
  any leftover digits, so a 50-digit payload loaded a 12-word seed.

Also anchor the detection regex. `re.search(r'\d{48,96}')` claimed any QR that
merely contained a long run of digits; note that this check runs before the
SettingsQR check in detect_segment_type, so a SettingsQR containing 48+ digits
was classified as a SeedQR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants