Knox Wallet: Crossing Airgaps with QR Code Streams

When handling digital assets like Bitcoin, Ethereum, ERC-1404, and ERC20 tokens, the first challenge is ensuring that private keys controlling the assets stay, well, private. The threat of theft is especially severe for institutions handling large quantities of crypto assets such as exchanges, investment funds, and digital asset issuers. Over a billion dollars were lost to crypto hacks in 2018 alone.

So when we built TokenSoft’s Knox Wallet, we had to find a way to keep secret keys safe with an air-gapped, defense-in-depth wallet architecture. Almost all user interactions with Knox Wallet take place on a mobile application, but this mobile app does not have access to secret keys.

When a user prepares to send funds, he or she creates an unsigned transaction on the Knox Wallet mobile application, which will only be accepted by the relevant blockchain after a cryptographic signature based on the secret key has been added. To add the signature, the unsigned transaction is sent to Knox Offline Signer, a software tool running on an airgapped computer that is never connected to the internet. Then the unsigned transaction is sent to a secure hardware device such as a Ledger Nano S and signed. Then the signed transaction is returned to the Knox Offline Signer, sent back to Knox Wallet mobile application, and finally broadcast to the blockchain.

The video below shows half of the transaction signing process. The Ledger Nano S adds a signature using its secret key, the Knox Offline Signer generates a QR code, and the Knox Wallet mobile application scans the QR code and presents the signed transaction to the user. It is now ready to broadcast to the blockchain! Below, we’ll look at all the steps it took to get to this point.

Of course, the end result looks elegant but the concept required solving a significant problem. How can information be sent back and forth between the Knox Wallet mobile application and the Knox Offline Signer? The amount of data involved is significant; here is a sample unsigned bitcoin transaction, which could be very large depending on the number of inputs and outputs.

{
"uiData": {
"qrPayloadVersion": 1,
"activityType": "SubmitTransactionActivity",
"referenceCurrency": "USD",
"chain": {
"id": "5c158438d2f998002f48df5a",
"name": "Bitcoin",
"keyname": "bitcoin"
},
"token": {
"name": "Bitcoin",
"symbol": "BTC",
"decimals": 8,
"price": 3240.26406
},
"value": "133700000",
"note": "TokenSoft is hiring.",
"from": {
"id": "5c158438d2f998002f48df70",
"label": "Bitcoin 2 of 7 MultiSig",
"receiveAddress": "rb1q5akufl76um3uzew7leycptg9nhtnv0lm8eg2t8vjc3u9wns0vp5q9gg79d"
},
"to": {
"label": null,
"receiveAddress": "RQKEexR9ZufYP6AKbwhzdv8iuiMFDh4sNZ"
},
"signingWallet": {
"id": "5c158438d2f998002f48df5f",
"label": "Chris's Bitcoin Ledger Nano S",
"receiveAddress": "rb1qya263vl4xvvh6jesvluzrs07ydktlp5teg42xt"
},
"feeToken": {
"name": "Bitcoin",
"symbol": "BTC",
"decimals": 8,
"price": {
"USD": 3240.26406
}
},
"feeValue": 2310,
"publicAddress": "rpubKBA6VcKMuu9dL7PQ3VRrDNi5osscLmMcJBwfVpRuCEygAGUTm1M6EgT7e47gzTR9t9MfJdU8B499xhr6QCvWDu1LadykpyNwwgDLV14VSdzB"
},
"referenceCurrency": "USD",
"rawTx": "{\"destinationAddress\":\"RQKEexR9ZufYP6AKbwhzdv8iuiMFDh4sNZ\",\"value\":133700000,\"bcoinTransaction\":{\"hash\":\"8a258f18dbf2f7cbd528eea495336b02d1e2e036861392103bdb69c015a2e24b\",\"witnessHash\":\"8a258f18dbf2f7cbd528eea495336b02d1e2e036861392103bdb69c015a2e24b\",\"fee\":2310,\"rate\":18046,\"mtime\":1544914102,\"version\":1,\"inputs\":[{\"prevout\":{\"hash\":\"0e96779b3e3bb639cb1f053513ce2508432d63f29a1897c45b7640633447392c\",\"index\":1},\"script\":\"\",\"witness\":\"00\",\"sequence\":4294967295,\"coin\":{\"version\":1,\"height\":2652,\"value\":149998370,\"script\":\"002092fb98a02acb161052db69e4f32228ba24601a1b3a20a02e98031a13012a337b\",\"address\":\"rb1qjtae3gp2evtpq5kmd8j0xg3ghgjxqxsm8gs2qt5cqvdpxqf2xdasq3rnwt\",\"coinbase\":false}}],\"outputs\":[{\"value\":133700000,\"script\":\"76a914a4ecde9642f8070241451c5851431be9b658a7fe88ac\",\"address\":\"RQKEexR9ZufYP6AKbwhzdv8iuiMFDh4sNZ\"},{\"value\":16296060,\"script\":\"0020621b1978a50f6f7ad4f67f7ec24162b417c3b4e15cdd9b3c994309e3e339adb2\",\"address\":\"rb1qvgd3j799pahh448k0alvystzkstu8d8ptnwek0yegvy78cee4keq2cfws7\"}],\"locktime\":0,\"hex\":\"01000000012c3947346340765bc497189af2632d430825ce1335051fcb39b63b3e9b77960e0100000000ffffffff02a019f807000000001976a914a4ecde9642f8070241451c5851431be9b658a7fe88ac7ca8f80000000000220020621b1978a50f6f7ad4f67f7ec24162b417c3b4e15cdd9b3c994309e3e339adb200000000\"},\"ledgerTxInputsData\":[{\"hash\":\"0e96779b3e3bb639cb1f053513ce2508432d63f29a1897c45b7640633447392c\",\"witness\":true,\"rawTx\":\"01000000000101b13368d6fc208181ef05a878f574180440b876a10170483053faaac0d596cade0000000000ffffffff0280f0fa020000000017a91413b7c936cd6b5b77d3c73c0236ffed71f1295bdc8722cbf0080000000022002092fb98a02acb161052db69e4f32228ba24601a1b3a20a02e98031a13012a337b0400483045022100b7c1c76ac2cdf64eccc9c970ef80852bb0962f42539d15d2b4df1f0b1a9694190220149eba09752fe3bffa1247c049c6eaf2c667ce4caa6891acd125263cb0686c5e01483045022100ef3572ead821e5c8aa8a5ba5c537f3f6337770d0804e94e0ac9f85901bde79bd02205e9d1ef3e7ad3706cb867c6fa957c1a2940622faccc59b471530342a3c3ed8c301f15221022710a4b68425805fc0ca32e0c40cbcbb3be7ea9cabe1a0f565d64ff9244d34e321022ae1e4884e8bfaf85060d1b010fe5eafb6cc5bc961cf0646ffc0df51422bc77821026bb77bc03855fb37e619ed33ece546b6ce7175734f5a6781b09169b7c3e6a2a62102938d5e0702f052c590122361c3536115c1cf1f572d72a3317c02ccf61387b5ec2102c004a84696b6f37694ef8db6aa2dae568435453514c57cc84b02976f61bd0e132102f3677ef2edcf555aa577f3a6a9ac94bc714fda980b5e1d5d8855772ead0004c42103637e95c1221892d647633c04630459695cfaf339cb757011e4c21415aaf2313c57ae00000000\",\"index\":1,\"path\":\"m/44'/0'/0'/1/1\",\"redeemScript\":\"522102591382888d4776b5fa1d2bba039ceae222b4e321bf11ba8cb4cd7ab7af78b5b5210268db735e9ee8022c5d9c221fd27da93b3d107f6ce01fd5697ab95e7cd22c2c4821034a195c0504b72910f8856cc5fe633fe35b604f255c9e34e3353ca1c7a3432e5f2103553dded8a42f0370d639a11dcdee1a97deed0148f14633eac95d8a4a7164b3642103ac7a6aa62cba3908624785e1ded618ec4ab9eb5ea6b7bcdcfcff69e902bdc2d32103be59972906548e91713938e765fbe39521d87d3796807338f5c80faea3138dc02103d7acdcf0868095debb121e00c16491bccaa1da9917459e499ecf5dbf7159ad3957ae\"}],\"publicAddress\":\"rpubKBA6VcKMuu9dL7PQ3VRrDNi5osscLmMcJBwfVpRuCEygAGUTm1M6EgT7e47gzTR9t9MfJdU8B499xhr6QCvWDu1LadykpyNwwgDLV14VSdzB\"}"
}

We did not want to use USB flash drives or Bluetooth to transfer this information because these approaches include enough complexity to hide bugs and attacks. For example, Stuxnet was designed to cross airgaps on USB flash drives. We also considered audio approaches like Chirp.io, but found that data transfer might be too slow for large payloads (remember the sounds of 56k dialup?). Next we evaluated QR codes.

Both the Mobile Wallet and the Offline Signer have cameras and screens, and QR codes are simple and auditable. But this is where the problems start: big QR codes are very hard to read with laptop cameras, and since a bitcoin transaction could be arbitrarily large, there is no way to guarantee that a single QR code will suffice. We need to split the payload into several QR codes and then display those in sequence.

Our goal is transferring the information as quickly and reliably as possible: how much data should be placed in each QR code?

The incredibly detailed Thonky QR code tutorial helps explain. There are 40 QR code versions, each with four more “pixels” along each dimension.

Version 1: Up to 17 bytes
Version 40: Up to 2953 bytes, in this case a bit of Shakespeare

QR codes also include error correction, so that the data content can still be read even when sections are missing. But there is a tradeoff: higher error correction means less data can be stored. I added some noise to the QR codes below to illustrate error correction, but I think there are still some subtle limitations (e.g. the noise cannot obscure the corner boxes called finder patterns). Try scanning the codes below, noting that less and less data can be stored as the error correction improves.

“L” error correction: up to 7%
“M” error correction: up to 15%
“Q” error correction: up to 25%
“H” error correction: up to 30%

QR codes also provide different encodings: numeric, alphanumeric, kanji, and byte mode. In practice, the numeric, alphanumeric, and kanji encodings are too limited, so most applications encode strings using byte mode.

After some experimentation, we found that version 7 QR codes with ‘L’ error recovery had a good tradeoff in data content (156 bytes) and rapid legibility (under 300 milliseconds) on several phone and laptop camera models, even those that did not focus very well on objects up close.

Version 7 QR code: up to 156 bytes

Our first approach was encoding binary messages using msgpack and sending them using byte mode. The popular qrious.js library did not accept binary payloads, so we forked it and then discovered the next problem. Many QR code readers cannot handle arbitrary binary payloads. Below is a binary QR code that cannot be scanned on an iPhone.

import msgpack
import pyqrcode
payload = msgpack.packb(
'0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413',
use_bin_type=True
)
pyqrcode.create(
payload,
mode='binary',
version=6
).png(
"qr.png",
scale=10
)
Binary QR code holding msgpack data

It appears that common QR code software modules assume that binary mode is used to encode a string, and if that is untrue (e.g. a generic msgpack payload), the QR code cannot be scanned correctly. The QR code software we tested was not built to sometimes return a string and sometimes raw bytes. Just scanning the QR code above will crash some applications like Expo.

So msgpack is out: we resorted to encoding our payload using JSON.stringify(). Note that the stringified data includes an inner payload with transaction information and an outer payload with metadata on the index of this QR code and the number of QR codes which is used to show a helpful progress bar to the user.

import utf8 from 'utf8'
const payload = utf8.encode(JSON.stringify(data))
const n = Math.ceil(payload.length / dataSize)
const codes = []
for (let i = 0; i < n; i++) {
codes.push(
JSON.stringify({
v: 1, // qr code payload version
i, // index of this qr code
n, // number of qr codes
p: payload.slice(i * dataSize, (i + 1) * dataSize)
})
)
}

Developing this solution took a bit of time, but it enables Knox Wallet users to quickly send transactions between the Knox mobile app and the Offline Signer with assurance that the Offline Signer really is offline.

Connect with me if you have questions or are interested in learning more about TokenSoft.