Skip to content

Add Seed XOR rebuild flow (issue #738) - #1014

Open
21bitstep wants to merge 11 commits into
SeedSigner:devfrom
21bitstep:seedxor-entry
Open

21bitstep wants to merge 11 commits into
SeedSigner:devfrom
21bitstep:seedxor-entry

Conversation

@21bitstep

@21bitstep 21bitstep commented Aug 21, 2026 •

Copy link
Copy Markdown

Description

Problem or Issue being addressed

Implements the long-requested Seed XOR rebuild flow from issue #738.

Seed XOR (documented by Coldcard in seed-xor.md) lets users split a seed into N parts that are combined by bitwise XOR. SeedSigner does not currently provide a Seed XOR splitting or reconstruction flow. This PR adds on-device reconstruction of a seed from existing Seed XOR parts, so users can restore their wallet without first combining the parts on another system.

This is a port of PR #738 (Advaitgaur004) / PR #885 (berlinxray) onto current dev, implemented natively against the Seed-object paradigm (65e312c) rather than the removed seed_num indexing.

Solution

Rebuild only; creating XOR parts from an existing seed is outside this PR’s scope.

  • Tools/Load A Seed -> Rebuild SeedXOR entry, gated behind a new Advanced setting Seed XOR (default disabled; hidden in the General settings UI).
  • Load parts one at a time via SeedQR scan, 12/24-word manual entry, or by selecting a seed already in memory. Each part's fingerprint is shown for confirmation before it is committed.
  • Validation (helpers/seed_xor_validator.py) rejects: passphrases on parts, mnemonic-length mismatches between parts, duplicate parts, and binary-inversion parts (XORing a part with its complement is a footgun). Finalize requires >= 2 parts.
  • Finalize combines parts via combine_mnemonics_with_xor and shows the resulting fingerprint, then offers keep/discard of the loaded parts. A degenerate all-zero-entropy result (the publicly-known "abandon…about" seed) is detected and rejected with an explicit error screen instead of silently loading a worthless wallet.
  • Design decisions carried over from the issue thread: terminology is "part" (per Coldcard docs), and returning Home wipes in-progress XOR parts (wipe-everything-except-finalized-Seeds guarantee) via clear_rebuild_seedxor_data in the MainMenuView branch of Controller.start().

Also extends tests/screenshot_generator/generator.py with the full set of Rebuild Seed XOR screens so the flow is covered by the standard per-locale screenshot generation.

Additional Information

  • Scope is deliberately limited to reconstruction from existing Seed XOR parts; this PR does not add a splitting workflow.
  • Validation guards verified hands-on on hardware (Pi Zero v1.3, SeedSigner OS build from this branch): mismatched lengths, duplicate parts, and zero-entropy result (a+b+c=0) are all rejected as designed; 6 XOR combinations cross-checked against Coldcard (x+y, x+z, y+z, x+n, y+n, z+n, where n is the all-zeros test vector fp 73c5da0a).
  • Test coverage: tests/test_seedxor.py (unit: XOR combination + validators), tests/test_flow_seedxor.py (FlowTests for the full rebuild flow), tests/test_redteam_seedxor.py (security and state-lifecycle regressions), tests/seedxor_test_vectors.py (shared vectors). Full tracked suite: 382 passed; 57 Seed XOR tests pass.
  • Co-authored by the original PR authors: Advaitgaur004, berlinxray.

Screenshots

All 240x240, generated with tests/screenshot_generator/generator.py --locale en (the generator entries are part of this PR):

Load A Seed with "Rebuild SeedXOR" entry visible (Advanced setting enabled):

LoadSeedView_seedxor_enabled

Manage parts — empty / one / two loaded:

RebuildSeedXORManageView_no_parts
RebuildSeedXORManageView_one_part
RebuildSeedXORManageView_two_parts

Load a part / fingerprint confirmation / select an already-loaded seed:

RebuildSeedXORLoadPartView
RebuildSeedXORShowFingerprintView
RebuildSeedXORSelectExistingSeedView

View / remove loaded parts:

RebuildSeedXORViewPartsView
RebuildSeedXORRemovePartsView
RebuildSeedXORConfirmRemovePartView

Finalize (combined fingerprint) / finalize options / cancel:

RebuildSeedXORFinalizeView
RebuildSeedXORFinalizeOptionsView
RebuildSeedXORCancelView

Degenerate-result guard (zero-entropy XOR is rejected):

RebuildSeedXORFinalizeView_zero_entropy_error


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

pytest tests/ --ignore=tests/screenshot_generator: 382 passed (Python 3.11, deps per requirements.txt; l10n catalogs compiled as in CI). The 57 Seed XOR tests all pass. tests/screenshot_generator/generator.py --locale en: 1 passed (used to produce the screenshots above).


I included screenshots of any new or modified screens

Should be part of the PR description above.

  • Yes
  • No
  • N/A

I added or updated tests

Any new or altered functionality should be covered in a unit test. Any new or updated sequences require FlowTests.

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

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


I have reviewed these notes:

  • Keep your changes limited in scope.
  • If you uncover other issues or improvements along the way, ideally submit those as a separate PR.
  • The more complicated the PR, the harder it is to review, test, and merge.
  • We appreciate your efforts, but we're a small team of volunteers so PR review can be a very slow process.
  • Please only "@" mention a contributor if their input is truly needed to enable further progress.
  • I understand

Thank you! Please join our Devs' Telegram group to get more involved.

@felixfelix-bot

Copy link
Copy Markdown

Good to see the combined-seed degenerate-value guard here (all-0x00/all-0xFF rejection) — that case is missing from #738 and is easy to overlook.

One thing I couldn't confirm from the diff: parts entered under a non-English wordlist pass per-part validation, but combine_mnemonics_with_xor() and Seed(mnemonic=...) appear to parse with the English default — so finalize could fail with a generic "Invalid mnemonic" after 72+ typed words. Is language handling on the finalize path something you've already looked at, or am I missing where it gets normalized?

@21bitstep

Copy link
Copy Markdown
Author

Thanks for the close read — and good instinct to trace the language handling through to finalize.

You're right that there's an asymmetry in the code: per-part entry validation resolves the wordlist via Seed.get_wordlist(wordlist_language_code=settings[SETTING__WORDLIST_LANGUAGE]), while the finalize path (combine_mnemonics_with_xor() and the Seed(...) construction in RebuildSeedXORFinalizeView) fell back to the English default.

In practice it can't be triggered today: upstream wordlist support is English-only. Seed.get_wordlist() raises for any non-English code ("Support other BIP-39 wordlist languages" TODO), and the "Mnemonic language" setting is VISIBILITY__HIDDEN with English as the only selectable option. So a non-English part would be rejected at entry validation — not after 72 typed words at finalize.

That said, the fix is cheap future-proofing: both functions already accept wordlist_language_code, so passing the settings value through at the finalize site is a 2-line change with zero behavior change now. Pushed as c903165, along with regression tests asserting the finalize path honors the settings value and fails closed (ErrorView, no stored seed) on an unrecognized language.

@21bitstep

Copy link
Copy Markdown
Author

Added f3adb12: fix discard-by-value in the SeedXOR finalize options (found in a second red-team pass).

Bug: RebuildSeedXORFinalizeOptionsView DISCARD_PARTS used part in storage.seeds and controller.discard_seed() → list.remove(), both riding Seed.__eq__ (seed_bytes equality). If the user typed/scanned a part whose mnemonic equals a seed already loaded before the flow (a distinct object, equal by value), discard silently deleted that pre-existing seed — unwarned, possibly the user's only in-memory copy. The existing code comment already claimed identity semantics; the implementation was equality semantics.

Fix: discard by identity, matching the comment:

  • seed_views.py: membership check is now any(part is s for s in storage.seeds) — only parts that ARE a pre-existing storage seed (loaded via "Use loaded seed") are ever removed from storage; typed/scanned parts are just dropped from the parts list.
  • controller.py: discard_seed() removes by identity (s is seed) instead of list.remove(); silent no-op if the object is not in storage.

Tests: 3 new regression tests in tests/test_redteam_seedxor.py::TestDiscardPartsByIdentity:

  • a typed duplicate of a pre-existing seed survives DISCARD_PARTS (fails on the old code)
  • a "Use loaded seed" part IS the same object and is legitimately discarded
  • discard_seed does not remove an equal-but-distinct seed (fails on the old code)

Full suite: 259/259 pass.

@21bitstep

Copy link
Copy Markdown
Author

Hardware-validated f3adb12 on Pi Zero v1.3 (image built from seedxor-entry tip, SHA256 442c17bcb0fa...).

Test 1 — typed duplicate of a pre-existing seed survives discard:

  • Pre-loaded seed d9a55c1a
  • Part 1: typed the same mnemonic (equal-by-value, distinct object)
  • Part 2: typed different mnemonic (e28c3434)
  • Combine → Discard parts
  • Result: d9a55c1a (pre-existing) and combined 9b3c2479 remain. On the old code, d9a55c1a would have been silently deleted.

Test 2 — "Use loaded seed" parts are legitimately discarded:

  • Parts loaded via "Use loaded seed" (same objects as in-memory seeds)
  • Combine → Discard parts
  • Result: only the combined seed remains in memory.

Both directions behave per the intended identity semantics: typed/scanned parts never touch pre-existing storage; explicitly selected loaded seeds are discarded as chosen.

@hannpet

hannpet commented Aug 31, 2026

Copy link
Copy Markdown

Can you help me out?

SeedSigner can already create XOR parts for a loaded seed, but had no way to rebuild a seed from its parts

Is this true? I cannot find the function to split a seed into xor-shares, that you claim already exists in seedsigner. Thanks!

@21bitstep

Copy link
Copy Markdown
Author

You’re right—SeedSigner does not currently provide a Seed XOR splitting flow. That claim in my description was incorrect; thanks for catching it. This PR adds reconstruction from existing XOR parts only, not splitting. I’ve corrected the description.

@21bitstep

Copy link
Copy Markdown
Author

Fixed F3: canceling after calculating a combined Seed XOR seed no longer leaves that seed accessible through storage.pending_seed.

  • Added identity-aware combined-seed cleanup: clears the pending alias only when it is the same object as this flow's combined seed.
  • Used the same cleanup when adding/removing parts, so invalidating the combined reference cannot orphan its pending alias before cancellation.
  • Unrelated pending seeds (including equal-by-value but distinct objects), finalized seeds, and declined cancellation remain unchanged.

Verification: 265 tracked-suite tests pass, including 15 new cases. The three bug-catching cases fail on the previous tip and pass with the fix. The local suite including audit PoCs passes 274 tests; the old F3 bug-presence PoC was updated locally and remains untracked. Independent pre-commit review passed with no security or logic findings. Pushed as 7001ad6.

No hardware retest or image rebuild for this change yet. F4 is not changed.

Hermes (SeedSigner dev) and others added 11 commits October 24, 2026 21:14
Port of PR SeedSigner#738 (Advaitgaur004) / PR SeedSigner#885 (berlinxray) onto current dev,
implemented natively against the Seed-object paradigm (65e312c) rather than
the removed seed_num indexing.

Feature (rebuild only; splitting out of scope):
- Tools/Load A Seed -> 'Rebuild SeedXOR' (behind Advanced->Seed XOR setting,
  default disabled).
- Load parts one at a time via SeedQR scan, 12/24-word entry, or a seed
  already in memory; each part's fingerprint is shown for confirmation.
- Validation rejects passphrases, mnemonic-length mismatches, duplicates, and
  binary-inversion parts. Finalize requires >= 2 parts.
- Finalize combines parts via XOR (combine_mnemonics_with_xor) and offers
  keep/discard parts + optional BIP-39 passphrase.

Design decisions carried over from the issue thread:
- Terminology is 'part' (Coldcard seed-xor.md), not 'shard'.
- Returning Home wipes in-progress XOR parts (wipe-everything-except-
  finalized-Seeds guarantee), via clear_rebuild_seedxor_data in the
  MainMenuView branch of Controller.start().

Co-Authored-By: Advaitgaur004 <advaitgaur004@users.noreply.github.com>
Co-Authored-By: berlinxray <berlinxray@users.noreply.github.com>
resume_main_flow was set to FLOW__REBUILD_SEEDXOR when loading a part but
only cleared on return to MainMenuView. After finalizing (which lands on
SeedOptionsView, not Home), a subsequent normal seed load in the same session
would be misrouted from SeedMnemonicEntryView into RebuildSeedXORShowFingerprintView
instead of SeedFinalizeView.

Clear resume_main_flow when a part is consumed (fingerprint confirm/cancel)
and on both finalize branches. Adds a flow regression test that finalizes a
2-part XOR then loads an unrelated seed and asserts it reaches SeedFinalizeView
(proven to fail without the fix).
Passphrases are already rejected on XOR parts by SeedXORValidator;
offering one on the combined seed contradicted that policy and bypassed
the keep/discard-parts choice.
Complement parts are not 'cancelled out' (that's identical parts, already
blocked by the duplicate check). The real hazard: A XOR ~A = all-0xFF, a
known seed with no entropy. Reword the validator message and test docstring
to say that.
…k, test coverage

- validate_part: pass Seed.wordlist to bip39.mnemonic_to_bytes instead of
  relying on English default; wrap in try/except so a wordlist mismatch
  produces a clean error dict instead of an unhandled exception.
- Add SeedXORValidator.validate_combined_seed: rejects degenerate XOR
  results (all-zero / all-ones entropy) that pass BIP39 checksum but are
  known, worthless seeds. Wired into RebuildSeedXORFinalizeView.
- Add 16 new tests (211 -> 227):
  - Flow tests: DISCARD_PARTS finalize branch, remove-part flow, keep-part
    backflow, back-button routing (LoadPart/Manage/Finalize/FinalizeOptions),
    cancel-seed-xor flow, Finalize-hidden-with-<2-parts.
  - Unit tests: validate_part wordlist forwarding, unparseable mnemonic
    handling, validate_combined_seed (valid pass, zero-entropy reject,
    all-ones reject).
- Sabotage checks confirm all new tests bite.
Add ScreenshotConfig entries for the Rebuild Seed XOR views (issue SeedSigner#738):
load/manage/view/remove parts, fingerprint confirmation, finalize +
finalize options, cancel, and the zero-entropy XOR Error view.

The feature is gated behind the Advanced setting SETTING__SEED_XOR
(default DISABLED), so entries enable it via mock context managers.
Reuses the EXAMPLE_12_A/B test mnemonics from tests/seedxor_test_vectors.py.
Three leak sites let a stale FLOW__REBUILD_SEEDXOR flag misroute later
unrelated seed loads into RebuildSeedXORShowFingerprintView:

1. RebuildSeedXORShowFingerprintView error branch: clear the flag when a
   part is rejected (duplicate/inverse/invalid). Previously only the
   BACK/CANCEL and success branches cleared it.
2. SeedMnemonicEntryView word-0 BACK: disarm the flag when abandoning an
   XOR part entry so a later normal 'Enter seed' flow is not captured.
3. LoadSeedView REBUILD_SEED_XOR entry: clear any stale flag when starting
   a fresh rebuild flow (defense in depth).

Adds tests/test_redteam_seedxor.py with flow regressions for the typed and
scanned leak paths (assert routing to SeedFinalizeView, not the XOR view).
combine_mnemonics_with_xor() and Seed() were silently defaulting to
English at finalize while every other mnemonic parsing site honors
SETTING__WORDLIST_LANGUAGE. No behavior change today (the app is
English-only: the setting is VISIBILITY__HIDDEN and Seed.get_wordlist()
raises for non-English codes), but this closes the mixed-language
failure mode flagged in review: if upstream ever enables other BIP-39
wordlists, finalize now uses the same language the parts were entered
in, instead of failing with a generic "Invalid mnemonic" after 72+
typed words.

Adds TestSeedXORWordlistLanguage: a spy test asserting both calls
receive the settings value, and a fails-closed test asserting an
unrecognized language routes to ErrorView without storing a combined
seed.
DISCARD_PARTS used 'part in storage.seeds' and discard_seed -> list.remove,
both riding Seed.__eq__ (seed_bytes equality). A part TYPED or SCANNED fresh
whose mnemonic equals a seed loaded before the flow is a distinct object equal
by value; discard silently deleted that pre-existing user seed - unwarned,
possibly the user's only in-memory copy.

- seed_views.py: FinalizeOptionsView DISCARD_PARTS now checks identity
  (any(part is s ...)) before discarding, matching the existing code comment
  that already claimed identity semantics.
- controller.py: discard_seed() removes by identity (s is seed) instead of
  list.remove(seed); silent no-op when the seed object is not in storage.

Regression tests (tests/test_redteam_seedxor.py::TestDiscardPartsByIdentity):
- typed duplicate of a pre-existing seed survives DISCARD_PARTS (flow test)
- a 'Use loaded seed' part IS the same object and is legitimately discarded
- discard_seed does not remove an equal-but-distinct seed (unit)
Both flow tests fail on the unfixed code and pass with the fix. Full suite:
259/259 pass.
@21bitstep

Copy link
Copy Markdown
Author

Rebased onto current dev and pushed as 2f24e298.

Fixed F4: abandoned Seed XOR parts no longer silently carry into a new rebuild.

  • Explicitly selecting Rebuild SeedXOR from LoadSeedView now clears previous rebuild state, using the existing identity-aware combined/pending-seed cleanup.
  • Removed the dead inverted guard from RebuildSeedXORLoadPartView; loading subsequent parts still preserves the active flow.
  • Added a regression test that fails on the previous tip and passes with this fix.

Verification: 382 tracked tests pass; 391 including the nine local audit PoCs. Independent pre-commit review found no security or logic issues.

@kdmukai could you review when you have time? The latest F3/F4 changes still need hardware retesting; I will report that separately.

@21bitstep

Copy link
Copy Markdown
Author

Built a SeedSigner OS pi0 image from 2f24e298.

SHA256: 5880e2faff59cbf15408f5258a9afab9ef11d1a40d54f79744ed48c24340fd85

The image build completed successfully; hands-on hardware retesting of F3/F4 is still pending.

@newtonick newtonick added this to the 0.9.0 milestone Oct 25, 2026
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.

5 participants