What's built and why
How AirWallet is put together, from the wallet code to the QR codes between devices, and the choices behind it.
AirWallet is one wallet program, written once in JavaScript, that runs inside 5 different hosts. Your keys sit on a device that never connects, and the only thing that reaches it is a QR code. Every part described here exists to make one moment safe: the offline screen showing you exactly who gets paid, on a device nobody can reach.
The parts
| Part | What it is |
|---|---|
| The wallet program | One JavaScript back end: keys, all 5 coins, signing and every check the signer makes. It is the same code in every copy. |
| The Monero engine | monero-ts, Monero's own wallet library compiled to WebAssembly |
| The Zcash engine | librustzcash, the Zcash team's wallet code, compiled to WebAssembly |
| The hosts | The desktop apps, the Android app, the Chrome extension and the web page at airwallet.tech/online |
| The link between devices | QR codes, animated when a message is long |
One program, 5 hosts
A host gives the wallet program a few services and nothing more: storage, a second random generator, the camera, files and notifications, and network requests when the copy is online. Everything else, from deriving keys to decoding a transaction before it is signed, happens in the shared program. A check written once is tested once, and it cannot drift between platforms.
Each host does this its own way:
- Desktop apps: a Python back end serves the page on
127.0.0.1, on a port the system picks, with a new random token for each launch. Every call to the back end must carry that token, and the server also checks the Host and Origin headers. The window is the system's own web view: WebKit on macOS, WebView2 on Windows and WebKitGTK on Linux. - Android: a WebView loads the page from inside the app, and a Java bridge provides the services.
- Chrome extension: the program runs in the extension's own pages, and a service worker handles websites and the auto-lock.
- AirWallet Online: one web page, watch-only, with the engines as separate files loaded when first needed.
In every host, the page's security policy lets only AirWallet's own scripts run: listed by their SHA-256 fingerprints in the apps and on AirWallet Online, and as the extension's own files in Chrome. See Updates, downloads and outside code.
Engines inside the app
The Monero and Zcash engines ship inside every copy, packed with Brotli and unpacked when first used. Unpacking the 10.6 MB Zcash engine takes about 60 milliseconds. They make the downloads bigger, and the Android app is still 6.8 MB.
We chose this over downloading engines when needed, for 2 reasons. A server never holds anything that can spend. And nothing the app runs can change after you have checked its download.
Only QR codes between the devices
There is no cable, Bluetooth, Wi-Fi or shared file between your devices. A hacked computer can lie to you, but it cannot reach a device it has no connection to. What crosses is a code you can see, and the offline device decodes it and shows you what it does before it signs.
Between 2 copies of AirWallet, codes use our own format, AF1:
- The message is packed and compressed (LZ77) when that makes it shorter.
- It is written in QR's alphanumeric mode, which holds more per code than plain text.
- Up to 1,300 characters fit in one code. A longer message becomes an animation of fountain-coded parts of up to 420 bytes, shown 380 milliseconds apart.
Any large enough set of parts, in any order, rebuilds the message, so a missed frame costs nothing. Bigger dots, slower uses 160-byte parts for cameras that struggle.
With other wallets, such as Sparrow, MetaMask, Solflare and Keystone devices, AirWallet speaks BC-UR, the QR format those apps already use.
The reader treats every code as text, and stops at 2,000 parts or a 1 MB message, so a hostile code cannot make it grow or run forever.
Watching without keys
The everyday device can watch an offline wallet. It knows the wallet's addresses, and, for Monero and Zcash, a view key after a clear warning. It never holds a key that can spend.
Each payment code it makes also carries a snapshot for the offline device: balances, chain heights, its clock, the status of earlier sends, Solana durable nonces, Ethereum transaction counts and recent incoming payments. So the offline device can show balances without ever asking the network. Snapshots are for display only, and nothing the signer signs depends on them.
Keys at rest
Each platform encrypts the wallet with a key the operating system keeps:
| Platform | How the wallet is encrypted |
|---|---|
| macOS | XSalsa20-Poly1305 (NaCl's SecretBox), with the key in the macOS Keychain |
| Windows | The same, with the key protected by your Windows account (DPAPI) |
| Linux | The same, with the key in a file only your account can read |
| Android | AES-256-GCM, with the key in the Android Keystore |
| Chrome extension | NaCl's SecretBox, with the key made from your password by Argon2id (64 MiB, 3 passes) |
The app lock seals the wallet again, under a key made from your PIN or password by Argon2id. Each device picks the strongest cost it can run in about 1.2 seconds, up to 64 MiB and 3 passes. The lock always keeps 8 sealed slots, one per PIN, and fills unused slots with random bytes of the same shape. Each slot is padded to whole 8 KB blocks. So the saved data looks the same with no duress PIN and with several.
On Linux, the key file stops other accounts but not someone using yours, so turn on the app lock there.
The offline screen decides
The signer does not trust the payment it is given. It decodes the bytes itself, works out where the money goes and what it costs, and shows that. What it cannot show completely, it refuses. On Ethereum, that means it signs plain payments, token transfers and the removal of an approval, but never gives an approval or calls other contracts. See What the signer checks.
Fees work the other way. AirWallet warns you about a high fee and asks once more about a very high one, but never refuses it. A wallet that blocks too much gets swapped for one that checks nothing.
Signatures and keys you can check
Signatures are deterministic: the random-looking part of each one comes from the key and the transaction. A tampered signer therefore has nowhere to hide bits of your key, and you can make each signature again on another machine to compare. Monero's ring signatures are the one exception.
New keys mix the web engine's generator with the device's own and with anything you type. 50 or 99 dice rolls can make the words on their own, in the same way SeedSigner does, so other tools can check them. See How keys are made.
Choices we made
| We chose | Over | Because |
|---|---|---|
| One app that asks how a device is used | Separate online and offline apps | 2 downloads with similar names confused people. The hardened AirWallet Offline stays for those who want the operating system to enforce the air gap. |
| Old phones as signers | Requiring a recent phone | Most people have an old phone in a drawer. We rebuilt the Monero engine so it runs on a factory-reset Android 10. |
| No AirWallet account or server | A service in the middle | The apps talk straight to each coin's public servers, or to yours. Fewer parties to trust means fewer things go wrong behind your back. |
| Dice and 2 generators | Microphone, camera or motion "entropy" | Sensor randomness adds little and alarms people |
AirWallet