Part V · Toward real engineering The point of a hardware wallet is not that it signs. It is that the key never crosses the line.
Last chapter turned your private key into a tree. This one asks: which machine holds the root?
If it is a networked computer, that computer can be infected, remotely controlled, and read end to end. And chapter 15 established: knowing the mnemonic is owning the money.
The design of hardware signing is one sentence:
The online computer builds and broadcasts transactions, and has no private key.
The hardware device holds the key, shows the transaction, signs on confirmation, and never
goes online.16.1 Setup¶
We keep the stand-in signature. The subject here is where the boundary is.
import hashlib
def H(s): return hashlib.sha256(s.encode()).hexdigest()
PUB_TO_PRIV = {}
def make_key(name):
priv = "priv:" + name
pub = H("pub|" + priv)[:16]
PUB_TO_PRIV[pub] = priv
return priv, pub
def sign(priv, tx_summary):
return H("sig|" + priv + "|" + tx_summary)
def verify(pub, tx_summary, sig):
priv = PUB_TO_PRIV.get(pub)
return priv is not None and sign(priv, tx_summary) == sig
alice_priv, alice_pub = make_key("Alice hardware wallet")
print("the device holds priv; the online computer should only know pub:", alice_pub)
the device holds priv; the online computer should only know pub: 03e462bb33824fd4
16.2 The online computer: allowed to do almost everything, except hold the key¶
A networked computer can know your UTXOs, the destination, the amount, the fee. It can assemble a transaction. It should not possess the private key.
class OnlineWallet:
def __init__(self, pub):
self.pub = pub
self.private_key = None # the point: no private key here
def make_unsigned_tx(self, utxo, to_address, amount, fee):
return {
"from_pub": self.pub,
"utxo": utxo,
"to": to_address,
"amount": amount,
"fee": fee,
}
def summarize_tx(tx):
return f"spend {tx['utxo']} -> {tx['to']} amount={tx['amount']} fee={tx['fee']}"
online = OnlineWallet(alice_pub)
unsigned_tx = online.make_unsigned_tx("coin1", "BobAddress", 10, 0.1)
print("online computer builds:", summarize_tx(unsigned_tx))
print("does the online computer have a private key?", online.private_key is not None)
online computer builds: spend coin1 -> BobAddress amount=10 fee=0.1
does the online computer have a private key? False
Look at self.private_key = None.
That line is the entire technical content of this chapter. A hardware wallet is not an exotic cryptographic device — it is a separation of duties: move the one step that needs the key onto a different machine.
OnlineWallet can still do plenty: check balances, select UTXOs, estimate fees, build the
transaction, broadcast it. None of that requires a private key. Exactly one thing does:
signing.
16.3 The signer returns a signature, never a key¶
class HardwareSigner:
def __init__(self, priv, pub):
self._priv = priv # the key lives inside; nothing outside should reach it
self.pub = pub
def display(self, tx):
return f"CONFIRM: send {tx['amount']} to {tx['to']}, fee {tx['fee']}"
def sign_if_confirmed(self, tx, user_confirms):
print(self.display(tx))
if not user_confirms:
return None
return sign(self._priv, summarize_tx(tx))
hardware = HardwareSigner(alice_priv, alice_pub)
sig = hardware.sign_if_confirmed(unsigned_tx, user_confirms=True)
signed_tx = {**unsigned_tx, "sig": sig}
print("signature:", sig[:18], "...")
print("did the online computer get the private key?", "priv" in signed_tx)
print("does the signature verify?", verify(alice_pub, summarize_tx(unsigned_tx), sig))
CONFIRM: send 10 to BobAddress, fee 0.1
signature: d09f90d58f6b7a8966 ...
did the online computer get the private key? False
does the signature verify? True
Look at HardwareSigner’s public surface: display and sign_if_confirmed. That is all.
The underscore on _priv is more than a Python convention — it stands for a physical
boundary. In a real device the key lives in a dedicated secure element that
even the device’s own main processor cannot read, reachable only through a
“give me data, I’ll give you a signature” interface.
May cross the line : unsigned transactions, signatures, public keys
May never cross : private keys, mnemonics, seedsThat line is the entire product definition of a hardware wallet.
16.4 Why the device needs its own screen¶
You might think the problem is now solved. It is not.
If the online computer is compromised, it can quietly change the destination:
You think you're paying Bob.
The computer actually pays Mallory.The key was never stolen, and the money is gone anyway.
def malware_changes_destination(tx):
evil = dict(tx)
evil["to"] = "MalloryAddress"
return evil
evil_tx = malware_changes_destination(unsigned_tx)
print("the computer screen may claim: sending 10 to BobAddress")
print("the device screen actually shows:", hardware.display(evil_tx))
sig2 = hardware.sign_if_confirmed(evil_tx, user_confirms=False)
print("after the user declines, is there a signature?", sig2 is not None)
the computer screen may claim: sending 10 to BobAddress
the device screen actually shows: CONFIRM: send 10 to MalloryAddress, fee 0.1
CONFIRM: send 10 to MalloryAddress, fee 0.1
after the user declines, is there a signature? False
This is why a hardware wallet must have its own display.
A device with no screen — just a USB dongle — leaves you entirely dependent on the possibly-infected computer to tell you what you are signing.
The device shows “who, how much, what fee” on its own trusted screen and lets you check it with your eyes. That is the one link in the chain malware cannot reach.
Which means: pressing confirm without reading the device screen is the same as not having a hardware wallet at all.
And to be honest: a hardware wallet does not prevent everything. It prevents the most fatal single failure — the online computer stealing the key outright. It cannot save you from phishing, from social engineering, or from photographing your seed phrase.
16.5 The idea behind PSBT¶
In practice this flow has a standard name: PSBT — Partially Signed Bitcoin Transaction.
Online computer: I've prepared the transaction, unsigned.
Hardware device: I've checked it and added a signature.
Online computer: I have the signature; broadcasting.psbt = {"unsigned_tx": unsigned_tx, "signatures": {}}
print("PSBT, initial:", psbt)
device_sig = hardware.sign_if_confirmed(psbt["unsigned_tx"], user_confirms=True)
psbt["signatures"][alice_pub] = device_sig
print("PSBT signature keys:", list(psbt["signatures"].keys()))
print("ready to broadcast?", verify(alice_pub, summarize_tx(psbt["unsigned_tx"]), psbt["signatures"][alice_pub]))
PSBT, initial: {'unsigned_tx': {'from_pub': '03e462bb33824fd4', 'utxo': 'coin1', 'to': 'BobAddress', 'amount': 10, 'fee': 0.1}, 'signatures': {}}
CONFIRM: send 10 to BobAddress, fee 0.1
PSBT signature keys: ['03e462bb33824fd4']
ready to broadcast? True
Notice signatures is a dictionary keyed by public key.
That is not incidental — it exists for multisig. A 2-of-3 transaction can go to the first device for one signature, then the second device for another, each adding an entry to that dictionary, and only broadcast once enough are collected.
“Partially” means exactly that: a transaction container that several parties can fill in by turns.
Which links straight back to chapter 14’s lock: if the UTXO’s locking script demands two signatures, the PSBT must collect two. However the lock is written is how the PSBT gets filled.
YOUR TURN¶
# YOUR TURN:
# 1) Change amount to 100 and see what the device screen shows.
# 2) What is the risk if the user never reads the device screen?
# 3) Why does a hardware wallet still need a mnemonic backup? What if the device breaks?
Question 3 is practical: a hardware wallet is a safe for your key, not the only copy of it. Devices break, get lost, and go through washing machines. Chapter 15’s mnemonic is the actual backup — and it has to live somewhere else, and not as a photograph on a networked device.
16.6 The ledger¶
| What you wrote | Field |
|---|---|
OnlineWallet — a builder with no key | Security engineering (separation of duties) |
HardwareSigner — returns only a signature | Security engineering (trust boundary) |
| Confirmation on an independent screen | Human-computer interaction + security |
| The PSBT container | Interoperability standard |
Zero cryptography.
Three roles and what each holds:
| Role | Has | Does |
|---|---|---|
| Online computer | public keys, UTXOs, the network | builds and broadcasts |
| Hardware signer | the private key, a trusted screen | displays and signs |
| Attacker | possibly control of the online computer | wants the key, or wants you to sign the wrong thing |
The sentence:
A hardware wallet does not make signing more magical. It keeps the key from crossing the line. The networked machine may be compromised, and the key is still sitting in a room with no cable.
Next¶
A multi-node network.
Sixteen chapters have described mechanisms, and every one of them has run inside a single machine and a single Python process.
The next chapter puts them into several mutually distrustful containers, each keeping its own mempool, mining on its own, broadcasting, and verifying independently.
Then you will watch them converge on the same chain.
It is the first time in this book that chapter 7’s “a shared ledger is not the starting point
but the result of convergence” becomes something you can see with curl.