Skip to content

Sync with Upstream and PSBT hardening - #409

Open
3rdIteration wants to merge 134 commits into
devfrom
psbt-hardening
Open

Sync with Upstream and PSBT hardening#409
3rdIteration wants to merge 134 commits into
devfrom
psbt-hardening

Conversation

@3rdIteration

@3rdIteration 3rdIteration commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Two things in one branch: PSBT validation hardening built against an adversarial test corpus, and a merge of upstream/dev so the fork is current.

PSBT validation

A PSBT is written by an untrusted coordinator, so the metadata it carries is a claim rather than a fact. PSBTParser now validates that claim structurally before anything is shown to the user, and refuses transactions it cannot reconcile — with a reason the UI displays rather than a crash screen.

Broadly this covers amount and fee arithmetic, consistency between an input's declared prevout and the script it commits to, sighash handling, and whether an output presented as the user's own sits somewhere their wallet will actually scan.

That last check takes its expectation from the inputs rather than a fixed derivation depth: every input is a utxo the wallet already found, so the prefix they share is evidence of where this wallet keeps its keys. An unusual but internally consistent layout still works. The evidence is verified by re-derivation rather than taken on trust.

Conditions with a genuine innocent explanation stay advisory (HIGH_FEE, DUST_OUTPUT, FUTURE_LOCKTIME) and are surfaced on a review screen. RBF is recorded but never interrupts — opt-in RBF is the default in every modern coordinator, and warning on every ordinary transaction teaches people to click past the warnings that matter.

Two robustness fixes: a legitimate wrapped-segwit PSBT could abort the parse, and a PSBT that embit cannot read is now refused at the scan rather than partway through the signing flow.

New setting: Change Gap Limit (Settings > Wallet), 100 by default. It is the one refusal that is a threshold rather than an impossibility, so the refusal screen points at it.

Upstream merge

118 commits, recorded as a merge so upstream's history becomes ancestry and future syncs only consider what is new.

Adopted: the derivation cache (our per-input verification goes through it — upstream's efficiency test caught it doing 55 derivations where 5 suffice); a dead duplicate p2sh branch removal; claimed_* naming, renamed ahead of the merge so that hunk resolved as a no-op; VersionView; UR2 bytewords/crc32 fixes; and the camera entropy frame-acquisition hardening — reject flat frames and duplicates, require a full pool before capture, require every button released before a capture or review decision registers.

Kept deliberately: the seed_num view API (upstream's Seed-object migration is the better design and is coming separately; taking their seed_views.py wholesale was not an option — 2270 lines to this fork's 5302, with 43 fork-only view classes); SLIP39 on the official Trezor lib; the entropy mixing, and a 5-frame pool against upstream's 50 (45 KB of downsampled greyscale vs ~11 MB of full-res RGBA — material on the Luckfox Pico Zero's 64 MB); dependency pins; the screenshots submodule pointer.

Also added: the camera entropy flow refuses to run when the hardware RNG monitor reports unhealthy, matching the gate the password generator already applies. The sha256 mixing means a degraded RNG cannot weaken the result — the point is not to mint a seed while a source the design relies on is failing.

Tests

tests/data/psbt_test_suite/ vendors 41 vectors from the psbt_faker adversarial corpus with a per-vector expectation table. Flow tests cover the routing; test_psbt_refusal_screens.py measures the real layout of every refusal message, because TextArea renders past the bottom edge instead of raising, so an overlong reason silently draws over the button (it was, by 51px).

Full suite: 1191 passed, 135 skipped, 1 xfailed.

Worth a look during review

  • Dependency pins kept at this fork's values; upstream moved qrcode to 8.0 and Pillow to an older 10.3.0.
  • Six sites where upstream code auto-merged outside conflict regions and left the tree mixing both seed APIs — no conflict markers ever appeared. test_view_kwargs_contract.py and test_no_undefined_names.py caught every one.
  • Two files can't run locally on Windows and are excluded from the figure above: test_flows_menu_navigation.py and test_smartcard_hardware.py hang on an attached PC/SC reader, and the screenshot generator needs libraqm.

🤖 Generated with Claude Code

fedebuyito and others added 30 commits August 29, 2024 10:24
Growning "del" button size property (from 2 to 3) solves navigation issue ("6th dice" -> "del").

solves issue SeedSigner#555
Additional differents sizes "del" buttons
new custom_additional_keys prop on KeyboardScreen class for can to change additional button (KEY_BACKSPACE) on its childs classes
Changing KEY_BACKSPACE size on BIP39, SeedBIP85, SeedCustomDerivation, symbols_1, symbols_2 screens
Changing KEY_BACKSPACE size on ToolsDiceEntropy and ToolsCoinFlip screens
Missed import sentence for commands on last commit
kdmukai and others added 17 commits August 14, 2026 23:39
The class docstring enumerated two claims while the class held six tests,
and each test already documents its own claim, so state the purpose instead
of maintaining a second copy.

test_get_cosigners_identical_with_and_without_cache only ever wrote to the
cache: its three cosigners sit below distinct parent xpubs, so all six level
lookups missed and nothing was read back. Call _get_cosigners a third time
against the populated cache so the comparison covers the read path, which is
where a wrong key would surface.

Move the two assert messages that restated their own expressions into
comments that give the reason instead.
SeedSigner OS installs these dependencies as buildroot packages with
their own pinned, hash-verified versions -- requirements.txt only
governs dev and desktop environments. Two entries had drifted from
what actually runs on devices, so dev setups were exercising
different code than the hardware. The OS versions are definitive.

qrcode moves from 7.3.1 to 8.0 to match buildroot's python-qrcode.
The major bump is safe for us: everything helpers/qr.py touches
(QRCode, ERROR_CORRECT_L, StyledPilImage, the module drawers) works
unchanged under 8.0, and devices have been running it all along.

urtypes moves from PyPI 1.0.1 to a commit pin of selfcustody/urtypes
v0.1.0, the tag the OS builds from GitHub. This is a downgrade -- dev
environments were ahead of the hardware, not behind it. PyPI only
publishes 1.0.0 and 1.0.1, so matching the OS exactly requires a git
pin, in the same style as the existing pyzbar entry.

embit and Pillow already matched, and the pyzbar commit pin is
already identical to the OS's v0.1.9-ss tag, so those are unchanged.
Like requirements.txt, this file only governs dev installs on a Pi --
shipped images get these packages from buildroot, pinned and
hash-verified. Three of the four entries had drifted from the
versions devices actually run, so dev Pis were exercising different
code than production hardware. The OS versions are definitive.

numpy moves from 1.25.2 down to 1.25.0, matching buildroot's
python-numpy: dev environments were ahead of the hardware here, not
behind it. RPi.GPIO moves from 0.7.0 to 0.7.1 and spidev from 3.5 to
3.6, both catching up to the buildroot pins.

picamera already matched the OS's external package at 1.13 and is
unchanged. None of these versions are new to the codebase -- every
shipped image has been running them via buildroot all along; this
just brings dev installs onto the same set.
This file is not just a dev convenience: SeedSigner OS installs it
during image builds to compile the .mo translation catalogs. A version
pin alone only protects against drift -- it still trusts whatever
artifact PyPI serves for that version. Recording sha256 hashes makes
pip verify every downloaded file against the digests audited here, so
a tampered artifact fails the install instead of entering the build.
Each entry's hashes cover the full release (wheel and sdist), so
installs work on any host.

setuptools moves from >=82.0.0 to ==84.0.0 because hash-checking mode
rejects range specifiers. 84.0.0 is what the floor resolves to today,
and compile_catalog was verified working under it. Neither Babel nor
setuptools pulls transitive dependencies on Python >= 3.10, so these
two entries are the complete set pip needs.

The file header records how to refresh a hash when bumping a pin, and
warns that the file now needs its own pip invocation: any hashed
requirement makes pip demand hashes for everything installed alongside
it. CI's combined install trips over exactly that; the next commit
splits it.
pip enables hash-checking for an entire invocation the moment any
requirement in it carries a hash, and then demands hashes for
everything else in that invocation. The combined install line mixed
requirements-l10n.txt with two unhashed files and an editable install,
so it fails now that the l10n pins are hash-locked -- and
requirements.txt cannot simply join hash mode, because hash-checking
rejects its git-pinned entries outright.

Splitting the install keeps the l10n file's hashes enforced while the
other files continue to install unhashed. The comment above the
split line records why it exists, so it doesn't get folded back into
the combined line and break CI later.
…n_cache

[performance] PSBT parsing: remove redundant BIP32 derivations and Transaction rebuilds
[chore] Align dev requirements pins with the versions SeedSigner OS ships
[security] Hash-lock the l10n requirements pins
Nothing in a psbt proves the fingerprints and derivation paths it
carries, but change_data's fingerprint and derivation_path keys read
like facts -- the change details view splits one into a wallet path and
hands it straight to get_xpub. Renaming them to claimed_fingerprint and
claimed_derivation_path, along with the locals that carry them, puts the
trust boundary in the data itself rather than in the logic around it.

The class docstring now states the convention behind the prefix:
claimed_ for coordinator-supplied metadata, verified_ for what this
device proved by re-deriving from the signing seed, and the invariant
that no verified_ value comes from a claimed_ one without a derivation
in between. It also records that change_data covers self-transfers, not
just change-branch outputs. No logic changes; the two dict keys are the
only behavioral difference.
claimed_fingerprints and claimed_derivation_paths each hold a list
(one entry per cosigner), so the singular key names misread at their
call sites. Also drop a commented-out debugging print.
…abulary

[refactor] PSBT parsing: rename `change_data`'s unverified fields to `claimed_*`
A psbt is written by an untrusted coordinator, and several constructions
have no honest explanation: they exist so the review screens say
something the signature will not honour. PSBTParser now refuses those
outright rather than displaying them, and refuses with a reason the UI
can show.

Refusals (InvalidPSBTError, carrying a RejectCode):

  UNREACHABLE_CHANGE_PATH   a change output the seed can derive but whose
                            prefix does not match one the inputs
                            demonstrate, or whose branch is outside {0,1}
  CHANGE_INDEX_TOO_FAR      change index beyond the inputs' highest plus
                            the Change Gap Limit
  UNSUPPORTED_SIGHASH       anything but SIGHASH_ALL (SIGHASH_DEFAULT is
                            accepted on taproot per BIP-341)
  NONZERO_OP_RETURN         value attached to a provably unspendable output
  NEGATIVE_FEE              outputs exceeding inputs
  AMOUNT_OUT_OF_RANGE       any amount outside [0, MAX_MONEY]
  UTXO_MISMATCH             witness_utxo contradicting non_witness_utxo
  INVALID_WITNESS_UTXO      witness_utxo that is not a witness program
  EXTRANEOUS_WITNESS_SCRIPT witness_script on a non-p2wsh input
  SCRIPT_HASH_MISMATCH      redeem/witness script not hashing to its spk
  MIXED_INPUTS              mixed input script types
  MISSING_UTXO              an input with no prevout at all

Change binding takes its expectation from the inputs rather than a fixed
depth: every input is a utxo the wallet already found, so the prefix they
share is evidence of where this wallet keeps its keys. An unusual but
internally consistent layout still works; a spliced path does not.

Conditions with a genuine innocent explanation stay advisory and are
surfaced on a new PSBTRiskWarningView: HIGH_FEE, DUST_OUTPUT,
FUTURE_LOCKTIME. RBF is recorded but never interrupts -- opt-in RBF is
the default in every modern coordinator, and warning on every ordinary
transaction teaches people to click past the warnings that matter.

Two crashes fixed along the way: _get_policy exploded on any scope
carrying a non-multisig redeem_script (which a legitimate wrapped-segwit
psbt does), and an unparseable psbt delivered over UR2 reached
PSBTSelectSeedView as None and died there rather than being refused at
the scan.

New setting: Change Gap Limit (Settings > Wallet), 100 by default,
adjustable to 1000/10000/Off. It is the one refusal that is a threshold
rather than an impossibility, so the refusal screen points at it.

Tests: the psbt_faker adversarial corpus (41 vectors) is vendored under
tests/data/psbt_test_suite with a per-vector expectation table; 17 are
accepted, 19 refused by the parser, 5 refused by embit. Flow tests cover
the routing, and test_psbt_refusal_screens.py measures the real layout of
every refusal message -- TextArea renders past the bottom edge instead of
raising, so an overlong reason silently draws over the button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… half

Upstream PR SeedSigner#1005 renames change_data's keys to claimed_fingerprints /
claimed_derivation_paths and documents a convention: claimed_ for what the
coordinator asserts, verified_ for what this device proved by re-deriving,
with the invariant that no verified_ value comes from a claimed_ one
without a derivation in between. It is a naming change only -- verified_
appears nowhere in upstream src/, and nothing upstream enforces the
boundary.

Adopting the key names now makes the pending upstream merge a textual
no-op instead of a semantic conflict.

Instantiating verified_ turned out to require real work rather than a
rename. The change-binding rule measures a claimed change path against
the prefixes the inputs demonstrate, so those prefixes have to be facts.
Two things were needed:

  - Re-derive each input's claimed path and confirm it produces the
    claimed pubkey, AND confirm the prevout's script actually commits to
    that key. Re-deriving alone is not enough: an attacker holding the
    account xpub can supply a matched path/pubkey pair from elsewhere in
    our own tree, which verifies fine but says nothing about the utxo
    being spent. Only the script says which key unlocks these coins.

  - Distinguish "no evidence" from "no means of gathering it". An empty
    prefix set previously meant "skip the check", so withholding every
    input derivation switched change binding off. It now fails closed;
    None is reserved for the cases with no derivable root at all
    (WIF/BIP38 signing, seedless multisig pre-parse), where no evidence
    is expected.

Three attacks now covered by TestEvidenceCannotBeForged: the
self-consistent input-path lie, withholding input derivations entirely,
and mistaking a cosigner's derivation for our own.

Corpus disposition unchanged: 17 accepted, 19 refused by the parser,
5 refused by embit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the fork up to date with SeedSigner/seedsigner so future syncs only
have to consider what is new after this point. Recording the merge (rather
than cherry-picking) is the part that makes that true: upstream's commits
are now ancestors of this branch.

Adopted from upstream:

  - PSBTParser's derivation cache (MAX_CACHED_DERIVATIONS + _derive_with_cache),
    threaded through this fork's additions. Our input verification derives per
    input, so it goes through the cache too; upstream's cache-efficiency test
    caught it doing 55 derivations where 5 suffice.
  - The duplicate p2sh branch removed from _parse_outputs -- it was dead code,
    always overwritten by an identical elif.
  - claimed_fingerprints / claimed_derivation_paths naming (renamed ahead of the
    merge, so that hunk resolved as a textual no-op).
  - VersionView and the Version helper.
  - The UR2 bytewords/crc32 fixes and their tests.
  - Camera entropy frame-acquisition hardening: reject flat frames (covered
    lens) and duplicates (frozen camera), require a full pool before capture is
    possible, and require every button released before a capture or a review
    decision can register. All of it guards frame acquisition and leaves this
    fork's sha256 RNG mixing untouched.

Kept from this fork, deliberately:

  - seed_num-based view API. Upstream migrated to passing Seed objects, which is
    the better design and will be adopted separately; taking their seed_views.py
    wholesale was not an option, as it is 2270 lines to this fork's 5302 with 43
    fork-only view classes (SLIP39, encrypted QR, Passport/BitBox02/Tapsigner
    backups, SeedKeeper, xpub verification).
  - SLIP39 built on the official Trezor shamir-mnemonic lib.
  - The image entropy mixing, and a 5-frame pool against upstream's 50: 45 KB of
    downsampled greyscale versus ~11 MB of full-res RGBA, which matters on the
    Luckfox Pico Zero's 64 MB.
  - Dependency pins, the fork's VERSION string, and the screenshots submodule
    pointer (upstream's screenshots are of upstream's UI).

Also added: the camera entropy flow now refuses to run when the hardware RNG
monitor reports unhealthy, matching the gate the password generator already
applies. The sha256 mixing means a degraded RNG cannot weaken the result; the
point is not to mint a seed while a source the design relies on is failing.

Watch out for: several upstream changes auto-merged outside conflict regions
and left the tree mixing both seed APIs (finalize_pending_seed returning a Seed,
discard_seed, and view signatures). test_view_kwargs_contract.py and
test_no_undefined_names.py are what caught them.

Full suite: 1191 passed, 135 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Views took a seed_num index into controller.storage.seeds, which almost
every one immediately dereferenced. Upstream (PR SeedSigner#810) moved to passing
the Seed itself. The index is the brittle part: it is a position in a
mutable list, so a discard or reorder while a view holds one silently
retargets it at a different seed. On a signing device that is worth
closing, and matching upstream's API is what keeps future merges
tractable.

  SeedStorage.finalize_pending_seed()  int index  ->  the Seed
  Controller.get_seed(seed_num)                   ->  removed
  Controller.discard_seed(seed_num)               ->  discard_seed(seed)
  Views: seed_num: int + lookup                   ->  seed: Seed

Upstream's seed_views.py could not be taken wholesale -- it is 2270 lines
to this fork's 5302, with 43 fork-only view classes -- so the fork's file
was migrated in place. `seed_num is None`, which meant "operate on the
pending seed", becomes `seed is None`.

Two CI failures on the merge commit are fixed here:

  - The desktop job installed l10n/requirements-l10n.txt in the same pip
    invocation as everything else. That file is hash-locked, and one
    hashed requirement puts pip in --require-hashes mode for the whole
    invocation, which the unhashed files cannot satisfy. Upstream already
    split it out for the `test` job; the fork-only `desktop` job needed
    the same treatment.

  - The screenshot generator passed seed= to views that still took
    seed_num -- more of the same auto-merge API mixing. The migration
    resolves it.

test_view_kwargs_contract.py now covers the screenshot generator too. It
instantiates Views directly, breaks the same way, and cannot run on a dev
machine without libraqm (Pillow's Windows wheels omit it), so a mismatch
there reaches CI unnoticed -- which is exactly what happened. Verified by
injecting a known-bad kwarg and confirming the test fails; the first
version silently passed because ScreenshotConfig uses dict(...) calls
rather than {...} literals.

Full suite: 1191 passed, 135 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@3rdIteration 3rdIteration changed the title Refuse deceptive PSBTs; merge upstream/dev (118 commits) Sync with Upstream and PSBT hardening Aug 25, 2026
3rdIteration and others added 12 commits August 25, 2026 12:29
Both slipped through because this machine cannot run the affected tests.

test_bip85_gpg.py monkeypatched Controller.get_seed, which the Seed-object
migration removed. It now captures the selection at the seeds list, since
the view indexes storage.seeds directly. The whole file is skipped on
win32 unconditionally -- not merely when gpg is absent -- so no amount of
local running would have caught it. The same grep that found the other
call sites did surface this one; I read `def fake_get_seed` as a local
helper rather than a patch target.

Also dropped four now-dead get_seed fakes from
test_password_generator_views.py. Those tests passed either way (the fake
Storage.seeds list satisfies the new access path), but a stub standing in
for a deleted method is misleading.

test_psbt_refusal_screens.py failed on Python 3.10 only:

    AttributeError: module 'seedsigner.gui' has no attribute 'renderer'

mock resolves a dotted patch target by importing the root and walking
attributes, so seedsigner.gui.renderer has to already be imported. On 3.13
something else imports it first; on 3.10 nothing does. The fix is the
explicit module-level import, which is now load-bearing and commented as
such.

Note for anyone tempted to switch this to patch.object: that was tried and
is worse. The flow tests install their own Renderer stand-in, and
patch.object binds the class object at decoration time, which does not
survive it -- every test in the file fails when run alongside
test_flows_psbt.py, and the run then hangs. The dotted target plus the
import is the combination that satisfies both constraints.

Full suite: 1192 passed, 135 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orts

tests/base.py replaces sys.modules['seedsigner.gui.renderer'] with a
MagicMock at module import time, so the moment any FlowTest file is
*collected* the real renderer module is gone. That single fact explains
every failure this fixture has produced:

  - A module-level `from seedsigner.gui.renderer import Renderer` binds
    the real class only if this file is imported before base.py.
    components.py imports lazily at call time and therefore gets the
    MagicMock, so the fixture was patching a different object than the
    code under test resolved -- the TextArea measurements came back as
    MagicMocks and the comparison raised TypeError.

  - A dotted patch target ("seedsigner.gui.renderer.Renderer.get_instance")
    cannot resolve at all once that sys.modules entry is swapped, which is
    the AttributeError CI hit on 3.10:
    `module 'seedsigner.gui' has no attribute 'renderer'`.

  - Assigning Renderer._instance directly has the same flaw as the
    module-level import: it sets the attribute on whichever class object
    this module happens to hold.

Importing Renderer inside the fixture resolves whatever sys.modules
currently holds -- the same object screen.py and components.py will get,
real or mocked -- and patching get_instance on that works in either state.
tests/test_tools_screens.py already does this, for this reason.

Verified in all three orderings rather than the convenient one: the file
alone, with a FlowTest module collected alongside, and with a FlowTest
module running first.

Full suite: 1192 passed, 135 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  TypeError: SeedAddressVerificationSuccessView.__init__() missing 1
  required positional argument: 'seed'

Pre-existing, not a regression: at 6654d9c that View took `seed_num: int`,
equally required, and generator.py:484 supplied no view_args there either.
It only surfaced now because the SeedOptionsView mismatch used to abort the
screenshot run before reaching it.

The reason it survived a check that exists to catch exactly this: the
contract test verified that supplied kwargs are *accepted*, never that
*required* ones are *supplied*. Both raise TypeError the moment the View is
instantiated, but only the first is visible from the call site. Added
_required_params() and wired it into the generator check, then reverted the
fix to confirm the test reports the same message CI did -- a check nobody
has watched fail is not evidence of anything.

Also widened the Destination check, which looked for view_args only as a
keyword and so skipped every site passing it positionally as
Destination(SomeView, dict(...)) -- the common form in this codebase. Same
class of hole: coverage that reads as coverage without being it.

Full suite: 1192 passed, 135 skipped, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n read

  TypeError: string indices must be integers, not 'str'   keyboard.py:234

Upstream's Keyboard rewrite sums each additional key's "size" to check the
layout fits, so additional_keys must now be a list of key dicts. But
KeyboardScreen still defaults custom_additional_keys to
Keyboard.ADDITIONAL_KEYS, which is a dict keyed by code -- iterating it
yields strings, and "backspace"["size"] raises.

Every upstream KeyboardScreen overrides that default, so upstream never
executes it and the trap is invisible there. Four screens in this fork
relied on it, three of which lost the override when the merge conflicts in
tools_screens.py resolved to our side:

  ToolsDiceEntropyEntryScreen        3x5, 6 chars   KEY_BACKSPACE
  ToolsCoinFlipEntryScreen           1x4, 2 chars   KEY_BACKSPACE_2
  SeedExportXpubAccountNumberScreen  3x5, 10 chars  KEY_BACKSPACE_5
  SettingPBFDK2IterationsScreen      3x5, 10 chars  KEY_BACKSPACE_5

The first two match upstream's own choices; the last two follow this fork's
existing SeedBIP85SelectChildIndexScreen, which has the identical layout.
SettingPBFDK2IterationsScreen is fork-only, so upstream will never fix it
for us. Found by enumerating every KeyboardScreen subclass rather than
fixing whichever failed first.

Verified locally rather than through CI: WSL Ubuntu has Pillow built
against raqm, so a venv created with --system-site-packages can run the
screenshot generator without sudo or apt. All 22 locales pass. The same
environment runs the GPG tests that skip unconditionally on win32, which
is how the earlier test_bip85_gpg fix is now actually confirmed rather
than assumed.

  Windows / 3.13:  1192 passed, 135 skipped, 1 xfailed
  WSL     / 3.12:  1320 passed, 8 skipped
  Screenshot generator: 22 locales passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SeedSlip39RegenerateSharesView.__init__ ended up with `self.seed = seed`
three times: the pre-migration body bound seed_num twice around a
get_seed() call, and the mechanical rewrite mapped all three lines onto
the same assignment. Idempotent, so behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Walks the UI for the flows that handle secret material and had no flow
coverage: SLIP-39 share regeneration and share selection, the mnemonic
backup verification test (correct, wrong-then-retry, wrong-then-review,
skip), and BIP-85 child derivation including the invalid-index path.
Plus the final-word calculator across all three entropy sources.

Beyond routing, these assert on outcomes: regenerated SLIP-39 shares
still reconstruct the same secret, the picked share index reaches the
view that displays it, and the calculated mnemonic passes BIP-39
checksum validation.

The final-word test found a live crash. Selecting "Word selection
entropy" clears the last slot via update_pending_mnemonic(None, ...),
and the defensive copy added in #335 does "".join(word) unconditionally,
so it raised TypeError and dropped the user on the crash screen.
Upstream assigns the word directly and is unaffected. Pass None through
untouched -- it has no shared wordlist string to defend against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_smartcard_hardware.py drives the card connector directly, which
proves the applet and helper layer work but says nothing about the views
on top of them -- whether they route correctly and hand the card the
seed the user actually selected. That is precisely where the seed_num ->
Seed migration could have failed silently.

These walk the real UI through FlowTest against a real card, following
the same lifecycle as the existing hardware tests: blank JavaCard,
install applet, provision, exercise, uninstall.

  SeedKeeper: save a BIP39 seed and a chosen SLIP-39 share through
  Seeds > Backup > To SeedKeeper, asserting the card's secret count
  and labels, and round-tripping one mnemonic back off the card.

  Satochip: seed a blank card by picking a stored seed in the import
  view, then confirm a second attempt is refused up front.

Skips cleanly with no reader or card. The readiness probe checks pyscard
first and ignores Windows Hello virtual readers, which answer
SCardConnect with an ATR but are not JavaCards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three SeedKeeper code paths did `from mnemonic import Mnemonic` to convert
between BIP-39 entropy and words, but `mnemonic` was never listed in
requirements.txt. seedsigner-os carries python-mnemonic 0.20 as a buildroot
package on every smartcard board config, so devices were fine; anything
installed from requirements.txt -- the desktop simulator, dev machines, CI --
raised ModuleNotFoundError. Saving a BIP-39 seed to a SeedKeeper v2 card
surfaced it as an error screen reading "No module named 'mnemonic'".

embit is already a hard dependency and does both conversions, so this needs
no new package rather than a new requirements line. embit is English-only,
which matches the project-wide English-only mnemonic policy; the wordlist
argument is now validated instead of honoured, so a card secret declaring a
different wordlist is refused rather than silently decoded into the wrong
English words. The save path only ever wrote English.

Also fixes the round-trip assertion in the new hardware flow test. A v2
Masterseed secret stores the master seed plus BIP-39 entropy, not the
plaintext words, so the test now decodes that structure and rebuilds the
mnemonic -- which exercises both halves of the conversion instead of
grepping the blob for words that were never there.

Verified on hardware: 5/5 new flow tests and 51 passed / 2 xfailed in the
existing test_smartcard_hardware.py suite, with `mnemonic` uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The readiness gate called smartcard.System.readers() outside its own try
block. On a machine with no PC/SC subsystem at all -- every Linux and
Windows CI runner -- enumerating readers raises EstablishContextException
("Access denied") rather than returning an empty list, so all five tests
errored at setup instead of skipping. macOS runners passed because the
PC/SC framework is always present there and simply reports no readers,
which is why the failure looked platform-specific.

Move the whole probe inside the guard so an absent subsystem skips for the
same reason an absent card does.

Also stop interpolating the exception into the skip message directly.
pyscard's PC/SC exceptions format themselves by calling back into the
native SCardGetErrorMessage, which can itself raise -- turning a clean
skip back into a spurious error. _describe() falls back to the class name.

Verified three ways: PC/SC missing (simulated via a pytest plugin that
makes readers() raise) skips 5/5; no readers present skips 5/5; a real
card still passes 5/5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up on the corpus vectors that still signed. Five of the seven turn
out not to encode the trap their README describes; two were real gaps,
and looking for others turned up two more.

Refusals:

* PSBT_GLOBAL_VERSION 2 (TX-12). A v2 psbt has no unsigned tx -- inputs
  and outputs carry their own fields and TX_MODIFIABLE says what a
  coordinator may still change after we sign. TX-12 declares v2 while
  also carrying a v0 unsigned tx, so reading either half means ignoring
  the other. We implement v0 only, so refuse rather than guess.

* Outputs with no address representation. Witness versions 2-16 are
  reserved for future soft forks and are currently anyone-can-spend, so
  value sent there is takeable by anyone who notices. This previously
  escaped _parse_outputs as a bare ValueError -- a crash screen rather
  than a decision. The user authorises what the screen shows, so a
  destination that cannot be shown cannot be authorised.

Surfaced:

* BIP-68 relative timelocks. Every relative-timelock sequence is also
  below the RBF ceiling, so it already tripped the RBF check and a user
  would be told only "replaceable" about a transaction that cannot
  confirm for up to a year. Detected separately and it interrupts, for
  the same reason a future nLockTime does.

* RBF itself was recorded and then never displayed anywhere: it is
  INFORMATIONAL, and the risk view filters those out. It stays out of the
  interstitial -- 0xfffffffd is the modern default and warning on every
  transaction teaches people to click past warnings -- but it is now
  stated on the approval screen. Verified by rendering: 171px of content
  against a 200px button top.

Also: nLockTime is only enforced when some input is non-final, so a
future locktime with every sequence at 0xffffffff no longer warns.

XTRAS.NEGATIVE_AMOUNT also happens to declare v2, so it is now refused
for that first; test_amount_bound_still_fires_on_a_v0_psbt keeps the
MAX_MONEY path covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "can any of the catalogue items burn, lock or hide funds": one
could. A block-height nLockTime was not checked at all, while the
semantically identical timestamp form was flagged -- so an attacker
wanting a long lock simply used the height encoding and the review
screens said nothing. Verified: height 1,000,000 signed with zero
warnings.

The device has no RTC and no chain tip, so a height is an
uninterpretable number on its own. Two independent sources fix that,
neither of which requires a clock on the device:

* resources/latest-block.json pins one (height, time) pair, refreshed by
  a scheduled workflow that opens a PR, and hand-editable by copying any
  recent block from an explorer. It turns a height into an approximate
  date by assuming 10-minute blocks -- comparing height to height. The
  approval screen now states "Locked until ~Feb 2031" for both locktime
  encodings, rendered to month granularity so a stale anchor is harmless.

* For psbts loaded from microSD, the file's mtime is a stand-in for
  "roughly now", written by a machine that did have a clock. A locktime
  2+ years past it raises an interrupting warning.

The mtime is attacker-influenceable, so it may only ever raise a
warning, never suppress one. Forging it buys back the previous
behaviour -- the locktime is still displayed -- and nothing more. Every
missing input (QR delivery, no anchor, implausible mtime) degrades to
display-only rather than guessing; there is a test asserting a forged
mtime is indistinguishable from no mtime at all.

Also fixed: the json anchor loader built its path outside its own try
block, so an unreadable path would have raised instead of falling back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the last item with real money attached. The existing check is fee
as a share of inputs, which misses the opposite failure: a 9%-of-inputs
fee on a large consolidation stays under the relative threshold while
burning a fortune, and a modest absolute fee on a tiny transaction can
still be a wild rate. Verified: 0.09 BTC to fees passed unwarned.

A fee is only interpretable as a rate, which needs the vsize of a
transaction that has not been signed yet. estimate_vsize() derives it
from each input's script type; the signature length is the only unknown
and it varies by a byte or two. Anchored against published size tables:
1-in 2-out P2WPKH 140.5 vs 141, P2PKH 226.0 vs 226, P2SH-P2WPKH 165.0 vs
167 -- 0.4%, 0.0%, 1.2%.

Fee rates move by orders of magnitude between quiet periods and
congestion, so a fixed default would either cry wolf or never fire. The
Max Fee Rate setting defaults to Auto, which tracks
resources/latest-block.json; the workflow now also records the mean of
the top fee rate in each of the last 10 blocks. That is deliberately an
outlier-of-outliers -- a "nobody sane pays more than this" line that
ordinary traffic sits well under while still moving with the mempool,
floored at 20 sat/vB so a quiet reading cannot warn on normal traffic.
Fixed thresholds and Off remain available.

Warning, never a refusal: paying a high rate is sometimes exactly what
the user intends.

Corpus rates confirm the headroom: NORMAL vectors run 36-71 sat/vB
against the 120 default, while TX-15.mismatch and XTRAS.HUGE_FEE are
7117 and 6406. Suite expectations pin the threshold rather than reading
the shipped value, so a scheduled refresh cannot change a corpus verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

9 participants