SeedWitness calculates seed words and addresses from entropy supplied by you through dice rolls or coin flips.
Enter the same test entropy into SeedWitness and another device or implementation following the same SHA-256-to-BIP39 procedure. With the same settings, both should produce the same result. Independent tools such as Ian Coleman's BIP39 tool can also check SeedWitness.
SeedWitness is intended for test data expected to control no real-world value. It cannot determine whether entered or derived material is live.
It is not a wallet. It cannot spend and never signs a transaction.
Current release:
0.1.0. Use the source, manifest and firmware from the same release when reproducing a test.
USB is a full debug interface, not just power. A connected host can inspect the device's memory and filesystem.
Check that the board matches the published build:
python -m pip install esptool mpremote littlefs-python
python tools/verify_device.py --port COM9 --manifest manifest.json --firmware ESP32_GENERIC-20260406-v1.28.0.binIt prints PASS or it names the file that disagrees.
Tier 1 reads the bootloader region, partition table and the published bytes
occupying the factory firmware region through the ESP32's ROM bootloader,
which runs before any firmware and is not the thing being audited. It compares
those bytes against the official ESP32_GENERIC image from micropython.org.
That image is published by people who have never heard of this project, and that is the entire point. A signed release verifies that the author's binary is the author's binary; you still have to trust the author. Here the reference is a third party's build, so the question "did this project ship you what it claims" is answered by someone with no stake in the answer.
It is also why the device runs stock firmware rather than a custom build. A custom build would be more capable and would collapse this check into "compare against the author's own binary", which proves nothing.
Tier 2 reads the filesystem partition the same way and, when
littlefs-python is installed, parses it on your computer. The device is never
asked what files it holds, so nothing running on it can hide from the listing.
Without that dependency the tool labels and uses a weaker running-firmware
fallback rather than presenting it as equivalent.
A pass means the checked bootloader region, partition table and published
factory-firmware bytes match the supplied official image; every non-runtime
application file parsed from raw LittleFS matches the published manifest; and
no unexpected file is present. NVS, phy_init and the named runtime data files
are outside those comparisons. A pass does not make flash immutable or prove
the silicon or ROM interface honest; nothing reachable over USB can.
The device also has a Verify Build screen showing a fingerprint you can
compare against manifest.json without a computer. That is self-attestation,
and the screen says so: tampered software can report whatever it likes. It
catches a bad deploy, not an attacker.
python -m pip install mpy-cross==1.27.0.post2
./tools/build_mpy.sh
python tools/build_manifest.py --check manifest.jsonThe build refuses uncommitted changes to its device and build-script inputs,
so a published manifest corresponds to committed source. The check reproduces
the committed source and deploy entries and their digests; informational
timestamp and toolchain fields are not part of those digests. CI reproduces
the committed deploy digest on Ubuntu and Windows with pinned
mpy-cross==1.27.0.post2. Different mpy-cross versions are not reproduction
targets because .mpy output is toolchain-dependent.
Build verification checks what is installed. Comparing the result checks the calculation against another implementation.
Enter the same test entropy into SeedWitness and another device or implementation following the same SHA-256-to-BIP39 procedure. Use the same mnemonic length and derivation settings, then compare the words and addresses.
Download Ian Coleman's BIP39 tool and open
it offline. To check roll-to-words conversion, show entropy details, choose the
same mnemonic length, select Hex or Base 10, enter the exact roll string,
and compare all seed words. Do not select its Dice [1-6] mode: that mode
interprets a six differently and does not implement this SHA-256 ceremony.
Then enter the words (and exact BIP39 passphrase, if used), choose the same derivation path, and compare the receive address. The tool shares no code with this project: it is JavaScript from the bitcoinjs-lib lineage. Agreement checks both seed generation and address derivation with an unrelated implementation.
SeedSigner is useful for confirming 99-roll interoperability, but it is not
an independent check of address derivation. Both devices vendor embit, so
an embit bug could produce the same wrong address on each and make agreement
look like confirmation.
Two details matter with Coleman's tool. Its BIP49 and BIP84 tabs use the
SLIP-132 spelling (ypub, zpub). SeedWitness's optional Account Key
screen offers that matching spelling beside the canonical xpub; the key
material is identical and only the version bytes differ. Use the same form in
both tests, but compare addresses, not only account-key spellings. The live
Coleman tool has no BIP86 tab, so taproot cannot be checked there.
Independent vector coverage against Coleman's tool and bip-utils (Python,
independent BIP39/BIP32) comprises three published test mnemonics, all four
address types, indices 0, 1 and 5, with and without a passphrase, the account
xpub, and the xpub-only path. 100+ comparisons, zero mismatches, including
the published BIP39 seed vector for passphrase TREZOR. That is agreement on
those inputs; it does not prove embit is correct on inputs nobody tested.
Roll. Enter dice rolls or coin flips supplied from outside the device. The inputs are concatenated and hashed with SHA-256; a 12-word result uses the first 16 digest bytes and a 24-word result uses all 32. The device adds no entropy. Another implementation following the same procedure should produce the same words.
| source | 12 words | 24 words |
|---|---|---|
| coin | 128 flips | 256 flips |
| D6 | 50 rolls | 99 rolls (SeedSigner-compatible) or 100 rolls (full 256-bit input rule) |
| D8 | 43 rolls | 86 rolls |
| D12 | 36 rolls | 72 rolls |
For a 24-word D6 ceremony, the device pauses after roll 99. Finalising there matches SeedSigner's documented English-wordlist dice algorithm exactly. Adding roll 100 instead meets SeedWitness's stricter rule of at least 256 bits of raw dice input; because that roll changes the SHA-256 input, it produces a different seed. Both choices and the second confirmation use press-and-hold controls.
Before a mnemonic is derived, the device checks for obvious repeated,
sequential, missing-face and non-uniform input. A concern opens Check Your
Rolls; the held Use These Rolls action remains available because an
unusual-looking sequence is not proof of bad dice. See
docs/ENTROPY_CHECKS.md for the checks and their
measured false-positive bounds.
Verify. Enter a test mnemonic and derive its addresses. Compare them with another implementation using the same derivation settings. This is not intended for checking a live wallet backup. SeedWitness cannot determine whether entered words are test data or a live seed.
Enrol. Save the xpub derived from a test mnemonic and examine watch-only address derivation without entering the mnemonic again. This is intended for test accounts expected to control no real-world value.
Export. Display test mnemonic data as SeedSigner-compatible SeedQR and compare it with another implementation. A SeedQR contains the complete mnemonic. SeedWitness cannot determine whether the encoded material is live.
Passphrase. Roll a Diceware passphrase of 6, 8 or 10 words (default 8,
about 103 bits) from the EFF large wordlist, D6 only. Generated rather than
typed because BIP39 requires NFKD normalisation and MicroPython has no
unicodedata: a typed non-ASCII passphrase would derive a different wallet
here than in other software. Generated words are ASCII by construction. This
function is intended for derivation tests, not live wallet passphrases.
All four address types. BIP44, BIP49, BIP84 and BIP86. The first derivation pays the full seed stretch; the other three types reuse the cached seed at about 9 s each (see Measured performance). Derive all four from the same test mnemonic and compare each result with another implementation.
Saved accounts. Up to five accounts can be renamed, deleted and shown as
QR. The account key displays as a plain xpub and, where SLIP-132 defines a
form, as ypub (BIP49) or zpub (BIP84): the same key with different version
bytes. BIP44 has no alternate form and SLIP-132 predates taproot; the screen
says so rather than leaving a gap. Enrolled accounts can step through receive
addresses, and each address can be given a neutral "Mark" tick: the device
records the tick and nothing else. Any address can be shown as a QR, ungated,
because an address is public.
Demonstration mode. A [!] button on the first page of either ceremony
fills in the rolls, behind a confirmation, so you can walk the whole device
without recording 50 rolls. It is offered only before the first manually
entered roll, so it cannot be injected into a manual test; every screen it
touches carries a DEMO stamp; the result can never be enrolled.
Sleep. After 60 minutes untouched the device shows a 5 minute countdown, then sleeps. Entering sleep drops the application's live references to the mnemonic, cached seed and passphrase. This is not secure memory erasure. Any touch cancels the countdown or wakes it.
What it does not do. Signing, PSBT, multisig, altcoins, camera and networking are outside scope by design.
You need the board (an ESP32-2432S028R, sold as a "CYD"), a data-capable USB cable, and a computer with Python 3.10 or later.
The supported pin map, orientation and touch-calibration notes are in
docs/HARDWARE.md.
If you have a coding agent on a machine with the board plugged in, it can do
the whole install from this README: flash the official firmware, build,
deploy, verify. Two things it cannot do for you: confirm the cable carries
data (the most common reason a board never appears as a serial port), and be
the judge of success. Run verify_device.py yourself and check it says
PASS.
python -m pip install esptool mpremote littlefs-python mpy-cross==1.27.0.post2Download ESP32_GENERIC v1.28.0 from
https://micropython.org/download/ESP32_GENERIC/. Not from anywhere else:
verification compares your board against micropython.org's published image,
which only means something if that is also where your copy came from.
Windows shows the board as a COM port in Device Manager; macOS and Linux as
/dev/tty.usbserial-* or /dev/ttyUSB0. If nothing appears, the board's
CH340 USB chip may need a driver. The examples use COM9; substitute yours.
esptool --port COM9 erase-flash
esptool --port COM9 --baud 921600 write-flash -z 0x1000 ESP32_GENERIC-20260406-v1.28.0.bingit clone https://github.com/bayanimills/seedwitness.git
cd seedwitness
git checkout 0.1.0
./tools/build_mpy.sh
python tools/deploy.py --port COM9Use the manifest.json from the same tag. Do not mix source, deploy files, or
a manifest from different revisions. build_mpy.sh requires a Bash shell;
the Python deploy command can run from your usual terminal.
The build cross-compiles to .mpy bytecode. It is not optional: plain .py
files fail with MemoryError, because the board lacks the heap to compile
the BIP39 wordlist itself.
deploy.py re-reads every file it writes and compares hashes, because
mpremote cp returns success on copies it did not make.
python tools/verify_device.py --port COM9 --manifest manifest.json --firmware ESP32_GENERIC-20260406-v1.28.0.binResolve any result other than PASS before relying on the board to reproduce
a test.
The USB port is a debug interface, not just power. Disconnect it when the debug connection is not needed.
See CONTRIBUTING.md before proposing a change. In particular, never put real wallet material in an issue, pull request, test, log, or screenshot.
./tools/build_mpy.sh # cross-compile to _build_mpy/
python tools/build_manifest.py -o manifest.json
python tools/deploy.py --port COM9 # copy, then verify by hash
python tools/deploy.py --port COM9 --dry-run # report drift, change nothingSet SEEDWITNESS_ALLOW_DIRTY=1 to build from a dirty tree during
development, never for a build whose manifest you intend to publish.
python -m venv .venv
python -m pip install -r requirements.txt
python -m pytest tests/ -qVirtual-environment activation is .venv\Scripts\Activate.ps1 in Windows
PowerShell or source .venv/bin/activate on macOS and Linux. The deploy build
script requires a Bash shell.
The desktop simulator drives the real UI code through a PIL-backed canvas:
python sim/capture_screens.py # one PNG per screen, into screenshots/Physical-panel comparison is required because simulator agreement does not establish panel rendering or touch behaviour:
python -m mpremote connect COM9 run device_tests/golden_frame.py > cap.txt
python tools/golden_frame.py --capture cap.txtMeasured on the board (ESP32-D0WD-V3, MicroPython v1.28.0, 240 MHz), 2026-08-05.
| operation | time |
|---|---|
| Address from a mnemonic, first | 575 s (9.6 min) |
| Address from a mnemonic, seed cached | ~9 s |
| Address from an enrolled xpub | ~3.1 s |
The 575 s (9.6 min) is PBKDF2-HMAC-SHA512 in pure Python and is a floor on stock firmware, not a tuning problem: this MicroPython port exposes no native SHA-512. It is why enrolment exists. Pay it once, then never again.
Stepping to the next address on screen takes a little longer than 3.1 s, since each step redraws the whole display.
The entropy is supplied externally. The device adds no randomness of its own.
The calculation is deterministic. The same input and settings should produce the same words and addresses elsewhere.
The result can be compared. Enter the same test entropy into another device or an independent implementation following the same procedure.
The build can be inspected. The installed firmware and files can be compared with published references.
These properties make results reproducible. They do not make the CYD suitable for live wallet secrets.
The radios exist and cannot be disabled. The board has Wi-Fi and Bluetooth; calling the teardown boot-loops it. This firmware never opens a connection, but that is a property of the code, not a guarantee from the hardware. Physical antenna or radio removal is unsupported, may damage the board and may not eliminate all RF emissions.
USB is a full debug port. Anything connected to it can read memory and the filesystem.
Physical access wins. No secure boot, no flash encryption, no secure
element. Anyone holding the board can reflash it. verify_device.py detects
that afterwards; nothing prevents it.
It cannot judge your dice. Obvious patterns trigger an advisory warning, but the device cannot prove that a die was fair, private or actually rolled. A loaded die or copied sequence can look ordinary, and choosing Use These Rolls for predictable input still produces a predictable seed. Nothing on the device sees the physical world.
An enrolled account is a privacy exposure until you delete it. An xpub contains no key, but anyone who reads the device can derive every address in that account. Deleting it removes the record and you can re-enrol at any time. Disclosure cannot be undone: an xpub already read cannot be recalled, and cannot be rotated without moving coins.
SECURITY.md states the current threat model, known
limitations, supported versions, and vulnerability-reporting process.
device/ what runs on the board
seedwitness/ ceremony, derivation, attestation, UI
ui/flow_*.py screens loaded on navigation, released on leaving
embit/ vendored: BIP39, BIP32, secp256k1
mp_shims/ stdlib gaps on stock MicroPython
sim/ PIL-backed canvas: screenshots and UI tests
tools/ build, manifest, deploy, verification
tests/ desktop test suite
device_tests/ run under real MicroPython
docs/ hardware profile, design system, entropy checks
Current supporting documentation:
docs/HARDWARE.md: CYD pin map, display and touch profile, clock behaviour and hardware constraints.docs/DESIGN.md: typography, geometry, colour roles and rendering constraints.docs/ENTROPY_CHECKS.md: advisory roll-input checks and their measured false-positive bounds.
embit is vendored pruned to the modules this project imports; every
retained file is unmodified and diffable against upstream (see
device/embit/NOTICE.md). It is also the library SeedSigner depends on, so
agreement between the two devices is not an independent check: a bug in embit
would agree with itself. See Compare the result.
For installation, verification, or device-use help, read SUPPORT.md. Reproducible bugs and focused proposals are welcome through the GitHub issue forms. Read CONTRIBUTING.md before opening a pull request.
Never post real wallet material. Report vulnerabilities using the private instructions in SECURITY.md, not a public issue.
SeedWitness is available under the MIT License. Incorporated material keeps its own licence; sources, modifications, attribution, and licence texts are recorded in THIRD_PARTY_NOTICES.md.
