Skip to main content
Brian Muchai

Building an Air-Gapped Bitcoin Signer on an ESP32

I have been building a Bitcoin hardware wallet over the last couple of weeks at the time of writing. It generates a seed, shows its public addresses, reads a transaction from a QR code, works out independently what that transaction does, and signs it only if a user approves. Signed transactions from it have now been accepted on Signet. I have not yet tested it on mainnet at the time of writing.

This is the second of a series on what I built, what went wrong, and what I learned. This post gives the overview of the actual wallet. Later posts will go deep on the camera, on keeping secrets out of memory, and on not trusting the computer. If hardware wallets are new to you, start with my beginner's guide.

What it does

The whole design rests on one assumption, that the computer might be compromised. So the computer never gets a copy of the private key, and the hardware wallet believes nothing the computer tells it. Instead, it builds transactions from source so as to get transaction details. After approval of a transaction, it signs it and sends a QR code with the signature back to the computer.

Let us first define some terms that we will come to use later in this article:

Sparrow - is the desktop wallet software I use as the watch-only wallet. It holds only public keys. Therefore, it can see balances and build transactions but cannot spend funds. That is why it is referred to as a watch-only wallet.

PSBT (Partially Signed Bitcoin Transaction, BIP174) - is the standard format for passing an unsigned transaction between a wallet and a signer. It carries the transaction details such as how much each coin is worth and which output is change.

Signet -is a test network. It behaves like Bitcoin, but its coins are free and worthless. It is used for prototyping before using real assets as there is no loss if you lose funds.

Air-gapped - this means no data connection. Data moves only as QR codes on a screen which are read by a camera.

The hardware and firmware

  1. ESP32 DevKit - this functioned as the main controller. It runs the wallet's firmware and controls the other hardware
  2. ESP32-CAM (OV2640 series) - reads QR codes and sends the data to the ESP32 DevKit
  3. 2.8" TFT shield (ILI9341 with resistive touch) - acts as the trusted display and as an input device. The wallet does not have push buttons

The firmware uses the Arduino framework. I chose this for an optimum developer experience. I also made use of the following libraries:

  • uBitcoin for keys and signing
  • quirc to decode QR codes
  • ricmoo's QRCode library to draw the QR codes
  • My own C++ port of the BC-UR format used for animated QRs

The decisions that shaped the hjardware wallet

1. Stateless

Nothing secret is written to flash. The seed lives in RAM and gets typed or scanned in each session. If the device is stolen while powered off, there should be no seed on it to find. This model comes from SeedSigner. In my application, the seed is cleared from flash after a five minute timeout of inactivity or when the device is powered down.

2. QR only

Transactions go in by QR codes scanned by the camera. This idea is standard among DIY signers. It makes the data channel slow and narrow but gives you an opportunity to inspect everything.

3.The device's screen is the only one that counts

Anything shown there is calculated on the device and never copied from a label in the PSBT.

4. Signet only

The classic ESP32 has a radio that can only be left unused by firmware, and it has published fault-injection attacks against its secure boot and flash encryption. So I treat this as a place to get the logic right. A real product would need different hardware. If you have an idea on how mainnet could be supported safely on the ESP32, feel free to reach out with the idea!

This project was heavily inspired by SeedSigner, Krux, Specter-DIY and Blockstream Jade.

What works today

Seeds

  • Generates a 24-word seed from the hardware random number generator, with an option to mix in 99 or more dice rolls.
  • Shows the words, then a short quiz to check that I wrote them down.
  • Loads a seed by typing, or by scanning a SeedQR.
  • Takes an optional passphrase on a keyboard with all 95 printable ASCII characters.
  • Wipes everything on request, after 5 minutes without a touch or when the device is powered down.

Addresses

  • Exports a watch-only wallet to Sparrow as a QR code.
  • Shows receive and change addresses on the screen.

Transactions

  • Reads Sparrow's animated QR through the camera. At the lowest density setting, a 586-byte PSBT took about 15 seconds to be scanned by the camera. I am making effort to reduce the time it takes for the camera to scan QR codes.
  • Verifies inputs, change, fee and sighash type, then shows one screen at a time the recipient and a summary of the transaction.
  • Signs, checks each signature against the expected key, and shows the signed PSBT as an animated QR.

Security features on the wallet

1. Refusing multi-input spends without proof of the amounts

To work out a fee, you need the value of each coin being spent. A PSBT can state that value as a simple claim, or it can include the full previous transaction which the device can hash to prove the value. There's a known attack (CVE-2020-14199) which allows attackers to trick a user into making two signatures in certain cases, potentially leading to a huge transaction fee

I made a rule in the firmware whereby a transaction with more than one input requires every input to come with its previous transaction, or else the wallet won't accept the transaction. This allows for independent calculation of fees. With a single input, a false fee would produce an invalid signature.

However, there is a limit. Watch-only wallets rarely include previous transaction data in QR codes. Therefore, instead of rejecting every multi-input transaction, an advisory warning would show on the screen but it will does not block the transaction.

2. Signing only what was verified from source

The scanned PSBT is read by my own strict parser, and the signing library never sees it. After the checks pass, the device builds a new, minimal PSBT holding only verified values. These are the unsigned transaction, each input's amount and script, and its key path. That rebuilt PSBT is all the signing library is given.

The limitations of the wallet

Clearing secrets from flash memory has its limitations

I went through great effort to clear secrets from RAM after a timeout or when the device is powered down. However, this does not protect the secrets from being read from flash memory when they are in use.

Nothing resists physical attack

There's no secure element and I have not enabled flash encryption and secure boot at the moment as the wallet is still on active development.

The radios are only off in software

The firmware includes no radio code, but there is still physical Wi-Fi and Bluetooth antennas on the board

The hardware wallet series

  1. What Exactly Is a Hardware Wallet And What Problem Does It Solve?
  2. Building an air-gapped signer on an ESP32 (this post)
  3. Teaching a camera to read animated QR codes (will be available soon)
  4. Getting secrets out of RAM (will be available soon)

I will open source the source code very soon! Be sure to check back here for the GitHub link. I will really appreciate code audits and any other kinds of contributions!

Also, feel free to donate through Bitcoin to bc1q0huq5e7ypz4l4pk8hvanl0clqhh99h5evhk0q9 or through Lightning to muchai254@cake.cash Your support will go a long way in enabling the support of even more hardware, thereby enabling further financial Sovereignty and help support Bitcoin education. This project will remain as a Free and Open Source through out.

← All writing