Skip to content

Bound seq_len when decoding UR2 fountain parts - #970

Open
ruipereira1 wants to merge 1 commit into
SeedSigner:devfrom
ruipereira1:fix/ur2-bound-seq-len
Open

Bound seq_len when decoding UR2 fountain parts#970
ruipereira1 wants to merge 1 commit into
SeedSigner:devfrom
ruipereira1:fix/ur2-bound-seq-len

Conversation

@ruipereira1

@ruipereira1 ruipereira1 commented Aug 1, 2026

Copy link
Copy Markdown

Description

Problem or Issue being addressed

A UR2 part's seq_len comes straight off a scanned QR and is effectively unbounded:
URDecoder.parse_sequence_component() only rejects values below 1, and Part.from_cbor()
only rejects values above 2**64. Two things then scale with it.

1. Set allocation in FountainDecoder.validate_part():

self.expected_part_indexes = set()
for i in range(p.seq_len):
    self.expected_part_indexes.add(i)

Measured on an x86 desktop: seq_len = 1,000,000 costs 67 MB and 0.37s for one frame.

2. Quadratic shuffle in choose_fragments(), reached whenever seq_num > seq_len.
Its Fisher-Yates shuffle uses list.pop(index), which is O(n) per step:

seq_len   time (x86 desktop)
 16,000   0.15s
 50,000   1.29s

Scaling measured at ~3.5x per doubling. Extrapolating, 500,000 is roughly two minutes and
5,000,000 is hours — on x86. A Pi Zero has 512 MB and a single ~1 GHz ARMv6 core, so it is
considerably worse there: the device appears frozen and the only recourse is to pull
power, which discards any seeds held in memory.

Both are reachable from one scanned frame, with seq_num and seq_len fully chosen by
whoever produced the QR. This is denial of service, not key compromise.

Solution

Bound seq_len in FountainDecoder.validate_part(), which runs before both the set
allocation and choose_fragments().

The shuffle itself cannot be made linear: its exact permutation is part of the UR fountain
code, so changing it would break interoperability with other wallets. The bound is the fix.

MAX_SEQ_LEN is set to 10,000, which at the 10-byte minimum fragment length still covers
a 100 KB message — far beyond any real PSBT. That number is a suggestion; please pick
whatever ceiling you're comfortable with, the bound itself is the point.

Additional Information

Reproduction:

from seedsigner.helpers.ur2.bytewords import Bytewords, Bytewords_Style_minimal
from seedsigner.helpers.ur2.fountain_encoder import Part
from seedsigner.models.decode_qr import DecodeQR

seq_len = 1_000_000
part = Part(seq_num=1, seq_len=seq_len, message_len=32, checksum=0, data=b"\x00" * 32)
body = Bytewords.encode(Bytewords_Style_minimal, bytes(part.cbor()))
DecodeQR().add_data(f"ur:crypto-psbt/1-{seq_len}/{body}")

Use seq_num = seq_len + 1 for the quadratic path.

helpers/ur2/ is vendored from Foundation Devices' ur-py. I couldn't locate the current
upstream source to check whether it shares this, so it may be worth reporting there too.

Screenshots

No new or modified screens.


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

189 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.


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

New file: tests/test_ur2_decoder.py


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. Worth confirming on-device that normal animated-QR scanning from a coordinator still completes, since this adds a rejection path to the decoder.


I have reviewed these notes:

  • I understand

A part's seq_len comes straight off a scanned QR. URDecoder.parse_sequence_component
only rejects values below 1 and Part.from_cbor only rejects above 2**64, so a single
frame can declare an arbitrary part count. Two things then scale with it:

* FountainDecoder.validate_part() builds a set holding seq_len ints.
  Measured: seq_len=1,000,000 costs 67 MB and 0.37s on an x86 desktop.

* choose_fragments() runs a Fisher-Yates shuffle over seq_len items using
  list.pop(index), which is quadratic. It is reached whenever seq_num > seq_len.
  Measured on x86: 16,000 -> 0.15s, 50,000 -> 1.29s, scaling ~3.5x per doubling.

A Pi Zero has 512 MB and a single ~1 GHz ARMv6 core, so a frame declaring a seq_len in
the millions hangs the device until the user pulls power.

The shuffle cannot be made linear: its exact permutation is part of the UR fountain
code and changing it would break interoperability with other wallets. Bounding seq_len
is the fix.

MAX_SEQ_LEN is set to 10,000, which at the 10-byte minimum fragment length still covers
a 100 KB message - far beyond any real PSBT. Maintainers may want to pick a different
number; the value is a suggestion, the bound is the point.
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