What does SeedSigner actually trust
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.
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

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.
What is SeedSigner made of
The OS that carries the app is built for the purpose and lives in its own repository.
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.
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 |
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.
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.
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.
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.pycThe 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.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.