Skip to content

feat: Legacy Encryption — dual-key AES-256-GCM seed phrase backup for Bitcoin inheritance#938

Open
ericscalibur wants to merge 1 commit into
SeedSigner:devfrom
ericscalibur:legacy-encryption-pr
Open

feat: Legacy Encryption — dual-key AES-256-GCM seed phrase backup for Bitcoin inheritance#938
ericscalibur wants to merge 1 commit into
SeedSigner:devfrom
ericscalibur:legacy-encryption-pr

Conversation

@ericscalibur

Copy link
Copy Markdown

What this is

Legacy Encryption is a Bitcoin inheritance tool built on top of SeedSigner's existing hardware. A benefactor and a beneficiary each choose a password. The seed phrase is encrypted with both (PBKDF2-SHA256 · 600 000 iterations + AES-256-GCM) and stored as a QR code photographed by the user — no backend, no cloud.

Decryption requires both keys. Neither key alone reveals the seed. The encrypted QR can live in a drawer, a safety deposit box, or a family document — it is safe to store publicly.

Cross-compatibility: the Python implementation produces byte-identical output to a companion browser app (Legacy-offline.html), so the seed can always be recovered with a browser, without any SeedSigner hardware.


User flow

Encrypt

  1. Tools → Legacy Encryption → Encrypt Seed Phrase
  2. Scan a SeedQR or enter words manually (12 or 24)
  3. Enter Benefactor Key on the passphrase keyboard
  4. Enter Beneficiary Key
  5. Confirm → "Encrypting… (15–30 sec)" loading screen
  6. Encrypted QR appears → photograph it → Done

Decrypt

  1. Tools → Legacy Encryption → Decrypt Seed Phrase
  2. Scan the encrypted QR
  3. Enter Benefactor Key, then Beneficiary Key
  4. Confirm → "Decrypting… (15–30 sec)" loading screen
  5. Recovered seed: read words on-screen, or export as SeedQR

Files changed

New files

File Purpose
src/seedsigner/helpers/legacy_encryption.py AES-256-GCM + PBKDF2 crypto. No extra deps — cryptography is already in the Buildroot image for PSBT signing. Produces byte-identical output to the JS reference.
src/seedsigner/views/legacy_views.py Full encrypt + decrypt UI. Follows the existing View/Destination/BackStackView/ButtonOption patterns throughout.

Modified files

src/seedsigner/views/tools_views.py — adds "Legacy Encryption" as the last entry in ToolsMenuView.

src/seedsigner/hardware/camera.py — Pi Zero–specific fixes required to keep the UI responsive during PBKDF2:

  • Stream parking: stop_video_stream_mode() now parks the PiVideoStream background thread (sets _video_stream = None so LivePreviewThread exits, but leaves the underlying PiCamera open at 1 fps). On Pi Zero, calling PiCamera() a second time while MMAL is releasing from the first session blocks the constructor indefinitely in the main thread. Parking avoids a second PiCamera() call entirely.
  • 1 fps throttle while parked: 12 DMA interrupts/sec on a single-core Pi Zero starves the main thread during PBKDF2 and causes UI freezes on subsequent screens (menus, confirmation, seed words). 1 fps drops interrupt load to negligible.
  • 5 s first-frame watchdog: raises CameraConnectionError (existing upstream exception) if capture_continuous silently stalls after PiCamera() opens.
  • _force_close_stream(): gracefully signals then hard-closes a stalled PiVideoStream; used by both stop paths and the watchdog.
  • start_single_frame_mode() drains any parked stream before opening its own PiCamera instance — two open MMAL instances deadlock.

src/seedsigner/hardware/pivideostream.py — adds last_capture_time (updated every captured frame) used by Camera's stale-stream watchdog.


Performance

PBKDF2 at 600 000 iterations takes 15–30 seconds on Pi Zero 1.3. A loading screen is shown. This is intentional — the iteration count is what makes brute-force attacks on a publicly-stored encrypted QR infeasible.


Testing

Tested end-to-end on Pi Zero 1.3 + Waveshare 1.3" 240×240 LCD HAT + OV5647 camera:

  • Encrypt via SeedQR scan ✓
  • Encrypt via manual word entry (12 + 24 word) ✓
  • Decrypt scanned encrypted QR → recover seed words ✓
  • Decrypt → export recovered seed as SeedQR ✓
  • Cross-compat: encrypted on device → decrypted in browser (Legacy-offline.html) ✓
  • Cross-compat: encrypted in browser → decrypted on device ✓
  • Back-button navigation at every step ✓
  • Wrong key → clear error message, no crash ✓

Security model

  • Air-gapped: Pi Zero 1.3 has no WiFi, Bluetooth, or networking
  • No persistent storage: seed phrase lives in RAM only; power cycle erases it
  • Dual-key: benefactor and beneficiary passwords are required — neither alone decrypts
  • Standard primitives: PBKDF2-SHA256 (600 000 iter, 16-byte random salt) + AES-256-GCM (12-byte random IV) from the cryptography library
  • Auditable: the encryption module is ~100 lines of straightforward Python

🤖 Generated with Claude Code

Adds a new "Legacy Encryption" entry to the Tools menu that lets a
benefactor and a beneficiary each set a password. The seed phrase is
encrypted with both keys (PBKDF2-SHA256, 600 000 iterations + AES-256-GCM)
and displayed as a QR code the user photographs and stores publicly.
Decryption requires both keys — neither alone reveals the seed.

The encrypted QR is fully cross-compatible with Legacy-offline.html
(the browser-side companion at https://legacyencryption.com) so the seed
can be recovered with a browser, no hardware required.

New files
---------
src/seedsigner/helpers/legacy_encryption.py
  - Pure-Python AES-256-GCM + PBKDF2 crypto module; no extra deps beyond
    `cryptography` (already in the Buildroot image for PSBT signing).
  - Produces byte-identical output to the JavaScript reference implementation.

src/seedsigner/views/legacy_views.py
  - Full encrypt + decrypt UI flow: scan/enter seed → enter both keys →
    show encrypted QR → scan encrypted QR → enter both keys → show words.
  - _RawQRDecoder: custom QR decoder for the encrypted base64 blob (standard
    DecodeQR rejects it as INVALID); uses a daemon thread so a libzbar hang
    never freezes the UI.
  - _make_key_entry_screen: thin subclass of SeedAddPassphraseScreen with a
    custom TopNav title for "Benefactor Key" / "Beneficiary Key".
  - All navigation via View/Destination/BackStackView; follows existing patterns.

Changed files
-------------
src/seedsigner/views/tools_views.py
  - Adds "Legacy Encryption" entry to ToolsMenuView (last item).

src/seedsigner/hardware/camera.py
  - stream parking: stop_video_stream_mode() now parks rather than destroys
    the PiVideoStream. On Pi Zero, calling PiCamera() a second time while
    MMAL is still releasing from the first session blocks indefinitely.
    Parking keeps the background capture thread alive at 1 fps; the next
    start_video_stream_mode() reattaches and flushes stale frames (500 ms)
    before handing control back to ScanScreen.
  - 1 fps throttle during PBKDF2: 12 DMA interrupts/sec on a single-core
    Pi Zero starves the main thread during PBKDF2 causing UI freezes on
    every subsequent screen. 1 fps drops interrupt load to negligible.
  - 5 s first-frame watchdog: raises CameraConnectionError (upstream class)
    if capture_continuous silently stalls after PiCamera() opens.
  - _force_close_stream(): gracefully signals then hard-closes a stalled
    PiVideoStream; used by both stop paths and the watchdog.
  - start_single_frame_mode() now drains any parked stream before opening
    its own PiCamera instance — two open instances deadlock on MMAL.

src/seedsigner/hardware/pivideostream.py
  - Adds last_capture_time (float) updated on every captured frame; used by
    Camera's watchdog to detect a stalled stream.

Performance note: PBKDF2 at 600 000 iterations takes 15–30 s on Pi Zero 1.3.
A loading screen is shown. This is intentional — the high iteration count is
what makes brute-force attacks on the backup infeasible.

Tested on Pi Zero 1.3 + Waveshare 1.3" 240×240 LCD HAT + OV5647 camera.
Encrypt and decrypt verified end-to-end; cross-compat confirmed with browser
version (Legacy-offline.html).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jdlcdl

jdlcdl commented Jun 11, 2026

Copy link
Copy Markdown

Noting that, at least for me, the anchored"Legacy-offline.html" text in the description above links to a domain that doesn't appear to exist. Can you please correct it or point me to the original project (where this implementation provides byte-for-byte ciphertext)?

I was looking for a site but following your github now. Neat stuff!

@ericscalibur

ericscalibur commented Jun 13, 2026

Copy link
Copy Markdown
Author

@jdlcdl My main page https://ericscalibur.github.io/
Legacy -> https://ericscalibur.github.io/Legacy_Encryption/index.html

If you have other questions hmu! @ericscalibur on all socials

@kdmukai

kdmukai commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Concept NACK.

There's rarely (never?) a reason to add new cryptography to help secure a bitcoin setup, even when complex inheritance planning is involved.

We already have enough tools that are either enforced onchain or have been through rigorous review and testing:

  • Single sig + bip39 passphrase
  • 2-of-2, 2-of-3, 3-of-5, etc key distribution strategies
  • miniscript multi-multisigs and/or degrading multisigs
  • Newer musig2, FROST composite keys

How a key / passphrase / etc is managed in the real world is up to the user or third party service or whomever. Some standardized, documented, vetted processes can have a place in SeedSigner (e.g. Shamir, SeedXOR, bip85). Our one novel approach -- SeedQR -- was taken with great hesitation but has since been adopted more broadly (admittedly, if it were to be introduced today, I'd want to see it go through a BIP process first).

But this PR is too focused on a specific third party service's offering. If there was a BIP that had been discussed and merged and implemented in at least one major project elsewhere, then it would a stronger consideration. But we're already behind on building support for more well-established bitcoin advances (e.g. Silent Payments, BIP-353, miniscript).


Aside from the above, I had Claude analyze the PR. Its critiques on the actual approach and code changes are included here for your review. It may be wrong but I would be very cautious about going forward with any plans to actually build out this service.

You have the freedom to keep all of these proposed changes and put out a custom forked release for your users. But the cryptography questions are concerning.

Also, Krux already has encrypted SeedQR support. If that's your goal, you're better off using an existing implementation.

Claude's AI review:


Review: PR #938 — "Legacy Encryption"

Recommendation: Close / do not merge

This should be declined on scope grounds first, and even setting scope aside, the implementation has cryptographic and architectural problems that would make it a hard no on its own.


1. Scope — this is the deciding issue

The PR adds a bespoke off-chain secret-encryption scheme to SeedSigner. Bitcoin already provides the right primitives for shared custody, inheritance, and recovery. On-chain enforced, auditable, and don't depend on SeedSigner inventing or maintaining a key-management scheme.

This feature is the opposite of that: a custom AES-GCM blob whose security lives entirely off-chain, in how two passwords are stored and shared. That's exactly the secret-management surface that belongs outside the device's domain. Adopting it would commit the project to maintaining and vouching for an encryption product it doesn't own.

A specific red flag reinforcing the scope concern: the feature's value proposition is byte-identical cross-compatibility with a third-party website (legacyencryption.com → now ericscalibur.github.io/Legacy_Encryption). That couples SeedSigner's behavior to an external, single-maintainer spec the project doesn't control, and effectively makes SeedSigner a client of someone's personal product (the PR description even links the author's socials). Merging this is an implicit, indefinite endorsement.


2. The "dual-key" design doesn't deliver what it claims

Even if encryption were in scope, the core security claim is misleading:

combined_key = benefactor_key + beneficiary_key   # legacy_encryption.py
enc = encrypt_data(seed_phrase, combined_key)

"Dual-key" is just string concatenation of two passwords into one PBKDF2 password. This is not threshold cryptography, not secret sharing, nothing like Shamir or a 2-of-2 multisig. Consequences:

  • "Neither key alone reveals the seed" is trivially true of any password and provides none of the guarantees the framing implies.
  • Concatenation is ambiguous: "ab" + "c" derives the same key as "a" + "bc". There's no separator or length binding between the two halves.
  • It's strictly weaker than the multisig/passphrase options Bitcoin already gives you, while looking like it offers more.

3. Other crypto/correctness problems

  • Padding is pointless and fragile. It appends 0–4 random chr(randbelow(256)) characters, but chr(128–255) encodes to two UTF-8 bytes while paddingLength counts characters; decrypt then strips characters from an errors="replace"-decoded string. GCM needs no padding at all — this only adds a corruption/ambiguity vector.
  • validate_seed_phrase does not validate the BIP-39 checksum — it only checks word membership and count (12/24). Yet on failure the UI says "Bad Checksum" / "doesn't match the checksum." The error message is simply false, and invalid-checksum seeds pass validation.
  • Reimplements wordlist loading (_load_wordlist with path-guessing + embedded fallback) and seed validation instead of reusing Seed / embit, which are already in the project.

4. Architectural / code-quality blockers

  • Invasive camera rewrite for all flows. camera.py gains stream "parking," busy-wait watchdogs, _force_close_stream, framerate throttling, and time.sleep flush loops — ~130 lines of fragile MMAL workarounds added to a singleton used by every scan flow, purely to compensate for PBKDF2 starving the Pi Zero's single core. High regression risk to core hardware code, in service of one out-of-scope feature.
  • Concurrency hacks in the view layer: per-frame daemon decode threads that are explicitly allowed to "hang forever," SIGALRM handlers, DecodeQR subclasses that bypass type detection, manual Renderer frame pushes (_sync_loading_frame), and dynamic SeedAddPassphraseScreen subclassing that mutates self.components by index.
  • Secret handling regresses the project's discipline. The seed phrase and both keys are threaded as plain view_args strings across many View instances and remain in the back-stack history; the self.seed_phrase = "" "clear" is cosmetic since copies persist upstream. This sidesteps the careful seed-in-controller.storage handling SeedSigner uses elsewhere.
  • No tests. Zero. SeedSigner maintains a high-coverage pytest suite as a merge requirement; this is a standalone hard blocker.
  • No i18n. Every string is a hardcoded English literal, not _()-wrapped — right after the translations submodule was updated. Breaks the localization convention project-wide.
  • Debug _log.info tracing and scattered import gc; gc.collect() left throughout.

@ericscalibur

ericscalibur commented Jun 22, 2026 via email

Copy link
Copy Markdown
Author

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.

3 participants