2026-09-19

What does SeedSigner actually trust

#SeedSigner #Bitcoin #supply chain

A flaw in a hardware wallet's random number generation was disclosed recently, and weak seeds had been produced as a result.
On some models and firmware versions, the effective entropy fell well short of the expected 128 bits.

COLDCARD Mk3 Seed Generation WarningA firmware bug produced weak seeds. Seeds made from 50 or more dice rolls are unaffectedblog.coinkite.com

SeedSigner is one of those hardware wallets: a device you build yourself from a Raspberry Pi by flashing an open-source OS onto it, and one I actually use to send funds.
The specification is open, which is exactly why I got curious about what it is trusting, and went to look.

What SeedSigner is

SeedSigners in Mini Pill enclosures (from the SeedSigner repository / MIT License, (c) 2021 SeedSigner)
SeedSigners in Mini Pill enclosures (from the SeedSigner repository / MIT License, (c) 2021 SeedSigner)

An air-gapped device that does nothing but sign Bitcoin transactions, assembled from off-the-shelf parts.
A Raspberry Pi Zero, a camera and a small 240x240 LCD, with the distributed OS image written to a MicroSD card.
There is no dedicated secure element.

The recommended Raspberry Pi Zero v1.3 carries neither Wi-Fi nor Bluetooth, so everything that crosses the boundary is a QR code.
The transaction comes in as a QR shown by an external wallet and read through the camera, and the signed data goes back out as a QR on the screen.

It also holds no seed of its own: you feed it from paper each time you sign, and it is gone when the power goes.
The design assumes that what remains is the paper in your hand.

SeedSigner/seedsignerAn air-gapped Bitcoin signing device. Build it from off-the-shelf parts; it stores no seedgithub.com

What is SeedSigner made of

The OS that carries the app is built for the purpose and lives in its own repository.

SeedSigner/seedsigner-osA minimal Raspberry Pi image, assembled for SeedSigner with buildrootgithub.com

None of that OS is written from scratch: each layer is assembled from things that already exist.
The kernel, busybox and the C libraries are all upstream packages that buildroot pulls in, and what SeedSigner adds is six packages plus the overlay that holds the app itself.

Everything to do with operating the device is written in Python.
Signing and QR decoding end up in C libraries, but the screen transitions and the use cases above them stay readable as Python.

What the owner sees - outside the device, and on the LCDseedsigner-os - the buildroot fork assembles one imageHardware - nothing in software can verify itBuild and distribution - everything above enters hereOwnerButtons and QR shown to the cameraPSBT QRUR / Specter / BBQR / base64Sign-message QRsignmessage {path} ascii:{msg}SeedQRTranscribed by hand. 12 words = 25x25Dice entry screenWhere entropy is enteredScan screenStarts the camera and reads QRReview screenShows destination, amount, changeQR output screenUR PSBT / xpub / signatureCPython 3.12 - unpacked into RAM as an initramfsNative C code - built by buildroot, no per-package hash pinOS - supplied by the buildroot forkCamera boardThe only data inlet / CSILCD boardST7789 240x240 / SPIButtons and joystickGPIO. How the owner drives itRaspberry Pi ZeroBCM2835. MicroSD is written herePyPIbuildroot fetches the tarballGitHub release tarballpyzbar / urtypesGitHub Releases.img + sha256 + GPG signatureUse cases - src/seedsigner/views, pinned by commitApp modules - src/seedsigner, pinned by commitPython dependencies - pinned by sha256 in external-packageslibsecp256k1zkp fork bundled with embitlibzbarDoes the QR decodingqrencodeCLI invoked via subprocessfreetype / glibc / libcryptoBehind Pillow and hashlibLinux OSKernel, busybox, init1. Create a seedDice, coin flips, camera2. Load a seedSeedQR or typed in3. Sign a PSBTPSBT QR to signed QR4. Export xpubExport xpub5. Sign a messagesign_message QRmodels/Parses QR and PSBThelpers/Signing and QR helpersgui/Composes screens with Pillowcontroller.pyHolds the seed and settingsTranslation cataloggettext .mo / submodulehardware/displaysDrives SPI and DC/RSThardware/cameraPulls frames and passes them onhardware/buttonsTurns GPIO input into eventspython-embit 0.8.0BIP32/39, PSBT, signingpython-pillow-ep 10.3.0Built from sourcepython-pyzbar 0.1.9-ssEntry point for QR decodingpython-urtypes 0.1.0Reassembles and builds URpython-picamera 1.13Takes frames from the camerapython-spidev 3.6Writes to the LCD over SPIpython-RPi.GPIOReads and writes GPIOpython-numpyCamera frames as arrayspython-qrcodeSeedQR, and fallback for qrencode
Top to bottom, the pinning gets monotonically weaker. Only the Python dependency layer is pinned down to its contents by sha256; below that, everything is built by buildroot

The CPython runtime encloses both the app layer and the Python dependencies, and the seedsigner-os box outside it reaches down through the native C code to the OS.
What stands out is that the root filesystem is embedded in the kernel as an initramfs and unpacked into RAM at boot.
The MicroSD card holds a single 25MB FAT partition, and there is no Linux root partition on it at all.
Everything from / to /tmp lives in RAM, and disappears when the power is cut.

The bottom four bands of the figure are the entire set of open-source software that reaches the device.
Here is who maintains each of them, and where it is fetched from.

Package Project Fetched from
embit github.com/diybitcoinhardware/embit PyPI
Pillow github.com/python-pillow/Pillow PyPI
pyzbar github.com/SeedSigner/pyzbar GitHub release
urtypes github.com/selfcustody/urtypes GitHub release
picamera github.com/waveform80/picamera PyPI
spidev github.com/doceme/py-spidev PyPI
RPi.GPIO sourceforge.net/projects/raspberry-gpio-python PyPI
numpy github.com/numpy/numpy GitHub release
qrcode github.com/lincolnloop/python-qrcode PyPI
libsecp256k1 github.com/BlockstreamResearch/secp256k1-zkp bundled in the embit tarball
libzbar github.com/mchehab/zbar linuxtv.org
qrencode github.com/fukuchi/libqrencode fukuchi.org
freetype github.com/freetype/freetype savannah.gnu.org
libraqm github.com/HOST-Oman/libraqm GitHub release
kernel, busybox github.com/seedsigner/buildroot buildroot upstream

Neither the maintainers nor the download hosts sit in one place; a variety of organizations are involved.
Six of these are packages SeedSigner defines itself, under opt/external-packages/.
pyzbar is not taken from upstream directly: SeedSigner publishes its own fork and pulls that in.

And libsecp256k1 alone is fetched differently from the rest.
It is not retrieved on its own; it arrives pre-built, inside the embit tarball.

How does the signing toolchain work

Since signing is the core of a hardware wallet, it is worth going one level deeper.
From touching the private key to producing a signature, the code seems to descend through four layers.

src/seedsigner Arranges the signing steps. Does no arithmetic of its own
embit Handles BIP32/39 and PSBT. Passes signing down through ctypes
libsecp256k1 The C library that actually computes ECDSA
bitcoin-core/secp256k1 The upstream of that library
1. Create a seedDice, coin flips, camera2. Load a seedSeedQR or typed in3. Sign a PSBTPSBT QR to signed QR4. Export xpubExport xpub5. Sign a messagesign_message QRmodels/Parses QR and PSBThelpers/Signing and QR helperscontroller.pyHolds the seed and settingspython-embit 0.8.0BIP32/39, PSBT, signinglibsecp256k1zkp fork bundled with embitfreetype / glibc / libcryptoBehind Pillow and hashlib to mnemonicparsed by psbt_parser signed via embit_utilsholds seed and settingsparses PSBT / seedderive and signecdsa_sign_recoverablehmac-sha512 / pbkdf2compressed with sha256
The app descends through embit and into a C library over ctypes. ECDSA is computed at the very bottom, in libsecp256k1

SeedSigner does not only use embit's high-level API: it imports the low layer directly with from embit.util import secp256k1 and hands it the raw private key to sign with.
embit picks its backend in the order "bundled shared library, then system libsecp256k1, then the pure-Python implementation", and on a Pi Zero it lands on the first candidate.

What it ultimately depends on traces back to the same lineage of code that Bitcoin Core uses.

What ships on the device is secp256k1-zkp, a derivative with features added for Liquid.
Its README says so in the opening line: this is a fork of bitcoin-core/secp256k1.

BlockstreamResearch/secp256k1-zkpA fork of bitcoin-core/secp256k1, adding experimental modules such as Confidential Assetsgithub.com

So the signing computation itself rests on code Bitcoin has been using for a long time, and neither SeedSigner nor embit has rewritten that part.

How are dependency versions pinned

Supply-chain attacks these days often work by slipping something into the newest version of a library.
Pinning versions is the usual defence, and SeedSigner does pin.

I assumed requirements.txt was where that happened: every line is hash-pinned with sha256, and at a glance it reads like it holds the device's dependencies down.
But seedsigner-os does not appear to read requirements.txt at all.

The dependencies that reach the device are defined as buildroot external packages, as a .mk and .hash pair in each directory under opt/external-packages/.
buildroot does the fetching and the checking directly.
So the authoritative pin for the device lives on the OS side, not in the app repository.

What is pinned is not a version number but the contents of the tarball itself.
python-embit.hash carries the sha256 of embit-0.8.0.tar.gz verbatim, with a comment recording which PyPI page it came from.

The two also sit in different places in the image.

App /opt/src/seedsigner (from rootfs-overlay)
Dependencies /usr/lib/python3/site-packages/ (from buildroot)

Where was that binary built

The lineage of the code can be traced; how the binary was produced cannot.

Digging into it, the bundled library turns out to be built from the secp256k1-zkp seen in the toolchain section.
The evidence is in the function names the .so exports.
Run strings over it and you find secp256k1_rangeproof_*, secp256k1_musig_*, secp256k1_surjectionproof_*, secp256k1_pedersen_* and secp256k1_generator_*.
These were added by secp256k1-zkp for Confidential Assets and do not exist in bitcoin-core/secp256k1.
93 of the 220 secp256k1_* symbols fall into that group, so the binary itself tells you it was built from the derivative rather than upstream.

The sha256 pin guarantees the identity of the embit tarball and no further; which source the .so inside it came from, and how it was produced, cannot be followed.
The build environment baked into the binary is GCC 8.3.0 (Raspbian), a considerably older toolchain generation than the tarball's publication date.

As lineage, "the same crypto library as Bitcoin Core" is not wrong, but what runs on the device is neither something Bitcoin Core distributes nor something built from that source locally.

This is all about embit 0.8.0, the version SeedSigner pins.
Upstream embit has since dropped prebuilt/, and the directory is gone from current master.
There is also an open PR on the seedsigner-os side to build libsecp256k1 from source.

Build libsecp256k1 from source codeA proposal to drop the bundled binary and build libsecp256k1 from sourcegithub.com

This is not a report of a vulnerability; it is a change in the direction of widening what can be verified.

How can the download path and the image be verified

What is distributed is a pre-built image, so you cannot simply read it to check it.
Even so, there is a way to say "this was produced from the published source" without building it yourself.
Dependencies are checked by hash, and the image is traceable through reproducible builds and a GPG signature.

PyPIbuildroot fetches the tarballGitHub release tarballpyzbar / urtypesGitHub Releases.img + sha256 + GPG signaturepython-embit 0.8.0BIP32/39, PSBT, signingpython-pyzbar 0.1.9-ssEntry point for QR decodingRaspberry Pi ZeroBCM2835. MicroSD is written hereLinux OSKernel, busybox, init checked against .hashchecked against .hashwritten after GPG check boot and drivers
Dependencies fetched from PyPI and GitHub are checked by buildroot against .hash. The distributed image is built offline and GPG-signed

buildroot verifies each dependency against its .hash, so a compromised download path gets rejected.

The distributed image is built offline and comes with a sha256 manifest and a GPG signature.
Since v0.7.0 the build is reproducible, so you can rebuild the same image from the published source and configuration and compare.

None of this makes trust disappear, though.
The person who updates the hashes, the source repositories, and the compiler used for the build all remain.
Using the distributed artifact as-is also means trusting whoever cut the release.

Is unused code inside a library a risk

Code that is not used, and code that could degrade silently, both get removed.

At the end of the build, opt/pi0/board/post-build.sh deletes embit's entire Liquid support, five non-ARM binaries, and the pure-Python secp256k1 implementation as both .py and .pyc.
verify-secp256k1-binary.sh then runs and fails the build if anything is left behind.

The reason for removing it is not that the cryptography is weak, but to make sure nothing runs on an implementation other than the intended one.

# Remove embit's pure-Python secp256k1 fallback: we must run only on the pre-compiled
# libsecp256k1 C code or not at all. We explicitly opt for hard failure over silent
# fallback.
# We must delete both the .py and the .pyc to fully remove the fallback.
rm -f ${TARGET_DIR}/usr/lib/python3/site-packages/embit/util/py_secp256k1.py
rm -f ${TARGET_DIR}/usr/lib/python3/site-packages/embit/util/py_secp256k1.pyc
SeedSigner/seedsigner-os opt/pi0/board/post-build.sh L40-L45

The reasoning is written at the top of the verification script.

# Two things can go wrong, and they are not equally visible:
#
#   - libsecp256k1 is missing or unusable. embit raises at import and the app
#     never starts. Loud, but much better caught at build time than on first
#     boot. Checks 2-4.
#
#   - The pure python fallback is back in the image. Now if the library ever
#     fails to load, embit no longer raises -- it quietly signs with python
#     instead, on a device that looks and behaves like a working one. Check 1.
SeedSigner/seedsigner-os opt/external-packages/python-embit/verify-secp256k1-binary.sh L6-L14

A failure that stops the device from starting is loud, but you notice it.
Whereas if the pure-Python path is still there, the device quietly signs with Python, and looks and behaves like a working one.
The two are not failures of equal weight, and the build is arranged around that.

Pillow goes the same way: built from source with jpeg, webp, tiff, jpeg2000, lcms and xcb all disabled.
Only freetype and libraqm remain, so the image decoders that ship inside the PyPI wheel never reach the device.

Closing

It made me think about what the main functions of a hardware wallet are, and how a project should implement them in the open.
The volatility of working data on a RAM-only root, the removal of the pure-Python fallback, the reduced Pillow codecs and the trimming of the image all point the same way: avoid running on, without any notice, in a degraded state.

What became clear is that SeedSigner is built so that as much as can be verified in the open is left for you to verify.
The app source, the buildroot configuration, the dependency hashes, the reproducible build and the GPG signature can all be checked by hand if you care to.

Below that, though — the bundled libsecp256k1, the compiler that produced it, the SoC and its firmware, the camera, LCD and MicroSD boards — there are parts that no amount of reading will confirm.

Personally, what I end up trusting when I use SeedSigner comes down to four things.

  • The hardware I sourced myself
  • The set of upstream packages chosen by seedsigner-os's buildroot fork
  • The pre-built libsecp256k1 that embit bundles into its tarball
  • The GPG signature attached to the distributed image

And the most important part, the signing, is implemented on code that starts from bitcoin-core, so the part being trusted belongs to the Bitcoin community — which feels about right.

In the end, being able to choose how far you verify for yourself is what characterises this device.

Appendix: questions that came up while reading

These sit outside the main thread, but they came up while reading and I looked them up.

Can the quality of randomness matter at signing time

It cannot.
The ECDSA nonce is derived deterministically from the private key and the message by RFC6979, so no random number generator is consulted when signing.

Where does the seed's entropy come from

From three sources: dice, coin flips, and a camera image.
All of them are compressed with hashlib.sha256 before being handed to embit.bip39.
Neither os.urandom nor a PRNG is involved.
The entropy comes from something the owner produced physically, and does not depend on a software RNG.

That said, sha256 does not add entropy.
Feed it 111111... from coin flips and you get exactly the weak seed that implies.
What determines the strength is whether the input itself is hard to predict.

What happens if you make a seed from a photo taken in the dark

The code does not stop you for a dark image with low entropy.
The preview checks only two things — that it is not a single flat colour, and that it is not a duplicate — and there is no check on the final image.
In the dark the camera raises its gain, so sensor noise itself does increase, but how many bits of min-entropy that output carries is not something you can tell from reading this implementation.

The risk is predictability.
Values rounded toward zero, or nothing left but fixed-pattern noise, are what make it weak.
The final image goes through JPEG, which also strips the high-frequency components where the noise lives.
If you want certainty, dice are the quicker route.

How large is the attack surface of data coming in through the camera

It is the main path by which untrusted external data reaches the app layer in normal signing.
Three parsers sit along it.
libzbar, implemented in C, decodes the QR, urtypes reassembles the UR, and finally the PSBT parser runs.
PSBT accepts five formats on the way in — UR, Specter, BBQR, base64, base43 — against a single format on the way out.
The more permissive the input, the more parser lineages there are.

Do Pillow's known vulnerabilities affect the device

The PyPI wheel and the build on the device are different things, so they do not apply directly.
Most CVEs originating in image decoders concern code that is not on the device at all.

If GitHub Actions were compromised, would the distributed image be tainted

Not directly.
The images CI produces are for verification; what gets distributed is built offline and GPG-signed.

Do speculative execution attacks work here

The recommended Pi Zero uses an in-order ARM1176, which does not appear on Arm's published list of affected cores.
Only the pi4, with its Cortex-A72, is affected in principle.

There is also no foothold for running an attacker's code on the same CPU.
Even for the pi02w and pi4 targets, which do carry radio hardware, none of the four board configurations include a driver or a stack for it.
The only thing running is a single Python app, and data arriving from outside is parsed as a QR and never executed.

Is it a problem that displayed QRs stay in /tmp

qrencode writes the PNG it generates to /tmp/qrcode.png.
The root is an initramfs in RAM, though, so it disappears when the power is cut.
Nothing is written to the SD card.

What passes through this path is the signed PSBT, xpubs, signature strings and addresses; the SeedQR used for transcription takes a different route, so no seed is left behind.