- Hardware wallets use hierarchical deterministic (HD) key derivation from a single seed, not key storage — BIP32 defines the cryptographic tree structure, BIP39 encodes entropy as mnemonic phrases with checksums, and BIP44 standardizes derivation paths.
- A 12-word seed has 2^128 valid combinations — brute-forcing would take 10 trillion years even with a billion GPUs testing a billion seeds per second, and adding a passphrase layers another security factor.
- Derivation paths like m/44'/60'/0'/0/0 use hardened (') indexes to prevent parent key recovery from extended public keys, and restoring the same seed on different hardware produces identical keys only if both follow BIP32/39/44 specs precisely.
- Edge cases include theoretical key collisions via HMAC-SHA512 (astronomically unlikely), handling I_L + k_p >= curve order during child derivation, and chain-specific curve differences like Solana's Ed25519 vs Ethereum's secp256k1.
The Signature That Shouldn’t Have Worked
I was debugging a multi-sig wallet integration when I noticed something odd: the hardware wallet was signing transactions for addresses I never explicitly generated. The device had no network connection, yet it was correctly deriving child keys at arbitrary index paths like m/44'/60'/0'/0/42 without any prior setup.
That’s when I realized I’d been fundamentally wrong about how these devices work. They’re not storing a keyring — they’re storing a single seed and computing everything on-demand using a deterministic tree structure defined by BIP32, BIP39, and BIP44.
This isn’t just trivia. Understanding this architecture is a common interview filter for blockchain engineering roles, especially at exchanges, custody providers, or wallet teams. The question usually starts simple: “How does a hardware wallet generate addresses without storing them?” Then it escalates: “What happens if you lose the device but have your 12-word phrase?” or “Why can’t you just brute-force a seed phrase?”
How Most People Get This Wrong
The naive mental model is that a hardware wallet is like a USB drive full of private keys. Generate an address? Store a new key. Sign a transaction? Look up the key from storage.
This breaks down immediately when you think about backup. If you’re storing keys individually, your backup needs to grow every time you generate a new address. That’s why hardware wallets don’t work this way.
Instead, they use hierarchical deterministic (HD) wallets based on BIP32. The entire keychain derives from a single 256-bit seed using a one-way cryptographic function. You only need to back up the seed once — everything else is reproducible.
BIP39: From Entropy to Mnemonic Phrase
Before you can derive keys, you need the seed. BIP39 standardizes how to generate and encode it.
The device starts with 128–256 bits of entropy (usually from a hardware RNG). That’s the master secret. But humans can’t reliably transcribe hexadecimal, so BIP39 maps the entropy to a 12–24 word mnemonic using a fixed 2048-word dictionary.
Here’s a minimal implementation:
import hashlib
import secrets
from typing import List
# BIP39 uses a fixed wordlist (first 8 words shown for brevity)
WORDLIST = [
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
# ... 2040 more words (full list in the official BIP39 spec)
]
def generate_mnemonic(entropy_bits: int = 128) -> str:
"""Generate a BIP39 mnemonic phrase from entropy."""
if entropy_bits not in [128, 160, 192, 224, 256]:
raise ValueError("Entropy must be 128, 160, 192, 224, or 256 bits")
# Generate random entropy
entropy_bytes = secrets.token_bytes(entropy_bits // 8)
# Checksum: first ENT/32 bits of SHA256(entropy)
checksum_length = entropy_bits // 32
hash_bytes = hashlib.sha256(entropy_bytes).digest()
checksum = int.from_bytes(hash_bytes, 'big') >> (256 - checksum_length)
# Combine entropy + checksum
combined = (int.from_bytes(entropy_bytes, 'big') << checksum_length) | checksum
total_bits = entropy_bits + checksum_length
# Split into 11-bit groups (each maps to one word)
words = []
for i in range(total_bits // 11):
idx = (combined >> (total_bits - (i + 1) * 11)) & 0x7FF # Extract 11 bits
words.append(WORDLIST[idx])
return " ".join(words)
# Example output (12 words for 128-bit entropy)
mnemonic = generate_mnemonic(128)
print(f"Mnemonic: {mnemonic}")
# Output: "army van defense carry jealous true garbage claim echo media make crunch"
The checksum is critical. It catches typos during manual entry — flip one word, and the checksum validation fails. This prevents users from restoring wallets with corrupted phrases (which would generate completely different keys).
To convert the mnemonic back to a seed, BIP39 uses PBKDF2-HMAC-SHA512 with 2048 rounds. You can add an optional passphrase as a “25th word” for extra security:
import hashlib
import hmac
def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
"""Convert BIP39 mnemonic to 512-bit seed using PBKDF2."""
mnemonic_bytes = mnemonic.encode('utf-8')
salt = ("mnemonic" + passphrase).encode('utf-8')
# PBKDF2-HMAC-SHA512 with 2048 iterations
seed = hashlib.pbkdf2_hmac('sha512', mnemonic_bytes, salt, 2048)
return seed # 64 bytes (512 bits)
seed = mnemonic_to_seed(mnemonic)
print(f"Seed (hex): {seed.hex()[:64]}...") # First 32 bytes
This 512-bit seed is what the hardware wallet stores. Everything else — every private key, every address — derives from it deterministically.
BIP32: The Key Derivation Tree
Now comes the clever part. BIP32 defines how to derive an unlimited number of child keys from the master seed using a tree structure. Each node in the tree is an extended key consisting of:
- A 256-bit private key
- A 256-bit chain code (used as entropy for child derivation)
The master extended key is derived from the seed:
The first 32 bytes become the master private key , the last 32 bytes become the master chain code .
To derive a child key at index from a parent key :
Where is the compressed public key (33 bytes) and is the 4-byte index. The child private key is:
Here is the left 32 bytes of , and is the secp256k1 curve order. The child chain code is simply (right 32 bytes).
But there’s a catch. This normal derivation exposes a vulnerability: if someone gets your extended public key and ANY child private key, they can derive the parent private key using the linear relationship above. That’s bad if you’re using public key derivation for watch-only wallets.
So BIP32 also defines hardened derivation for indexes . Instead of using the public key, it uses the private key directly:
Hardened keys can’t be derived from the extended public key. That’s why wallet paths use a mix: m/44'/60'/0'/0/42 means hardened derivation for the first three levels (denoted by '), normal derivation for the rest.
Here’s a simplified implementation (real wallets use libsecp256k1 for elliptic curve ops):
import hmac
import hashlib
class ExtendedKey:
def __init__(self, key: bytes, chain_code: bytes, depth: int = 0):
self.key = key # 32 bytes private key
self.chain_code = chain_code # 32 bytes
self.depth = depth
def derive_child(self, index: int) -> 'ExtendedKey':
"""Derive child key at index (hardened if index >= 2^31)."""
hardened = index >= 0x80000000
if hardened:
# Use private key for hardened derivation
data = b'\x00' + self.key + index.to_bytes(4, 'big')
else:
# Use public key for normal derivation (not implemented here)
raise NotImplementedError("Normal derivation requires EC math")
I = hmac.new(self.chain_code, data, hashlib.sha512).digest()
I_L, I_R = I[:32], I[32:]
# Child key = (I_L + parent_key) mod n (simplified; real impl checks edge cases)
child_key_int = (int.from_bytes(I_L, 'big') + int.from_bytes(self.key, 'big'))
child_key = child_key_int.to_bytes(32, 'big') # Should mod by curve order
return ExtendedKey(child_key, I_R, self.depth + 1)
# Create master key from seed
seed = mnemonic_to_seed(mnemonic)
I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
master_key = ExtendedKey(I[:32], I[32:])
# Derive m/44'/60'/0' (BIP44 path for Ethereum account 0)
account_key = master_key.derive_child(0x8000002C) # 44'
account_key = account_key.derive_child(0x8000003C) # 60' (Ethereum)
account_key = account_key.derive_child(0x80000000) # 0' (first account)
print(f"Account extended key (hex): {account_key.key.hex()}")
This produces the account extended key — the root of all addresses for Ethereum account 0. The device stores nothing beyond the original seed, yet it can derive billions of child keys on demand.
BIP44: The Path Convention
BIP32 gives you a tree. BIP44 tells you which branches to use. The standard path structure is:
m / \text{purpose}' / \text{coin_type}' / \text{account}' / \text{change} / \text{address_index}
Breaking it down:
- purpose: Always
44'for BIP44 wallets - coin_type: SLIP-0044 registered index (0 for Bitcoin, 60 for Ethereum, 714 for BNB Chain)
- account: User account number (hardened for privacy)
- change: 0 for external (receiving), 1 for internal (change addresses)
- address_index: Incremental index for generating multiple addresses
So m/44'/60'/0'/0/0 is the first Ethereum receiving address for account 0. m/44'/60'/0'/0/1 is the second address. Same account, different index.
Why does this matter? Because every wallet following BIP44 will generate the same addresses from the same seed. Lose your Ledger? Buy a Trezor, enter your 24 words, and boom — same addresses, same funds. That’s the whole point of these standards.
But here’s a gotcha: some wallets deviate. MetaMask uses m/44'/60'/0'/0/x by default, but Ledger Live uses m/44'/60'/x'/0/0 (account index in the third position). If you restore a Ledger seed in MetaMask and don’t see your funds, it’s probably because you used a non-zero account number.
Why You Can’t Brute-Force a Seed Phrase
This comes up in every interview. “Why is 12 words secure? I could write a script to try all combinations.”
The math:
- BIP39 wordlist has 2048 words
- 12-word phrase = $2048^{12} = 2^{132}$ possibilities
- But the last word includes a checksum, so only $2^{128}$ valid seeds
To put that in perspective: if you had a billion GPUs each testing a billion seeds per second, it would take seconds, or 10 trillion years. The sun will engulf Earth first.
And that’s without a passphrase. Add a strong passphrase, and you’re layering another factor. Even if someone steals your 12 words (say, from a phishing attack), they can’t access your funds without the passphrase.
But — and this is critical — if you forget the passphrase, you’re equally screwed. There’s no “forgot password” link. I’ve seen developers lose entire testnets because they set a passphrase during setup and didn’t write it down. (Yes, it was play money, but still.)
The Interview Curveball: Path Collisions
Here’s the question that trips people up: “Can two different derivation paths produce the same private key?”
The instinct is to say no — it’s a deterministic tree, different paths should give different keys. But the actual answer is: technically yes, but astronomically unlikely.
BIP32 child derivation uses HMAC-SHA512, which outputs 512 bits. The first 256 bits get added to the parent key modulo the curve order . There’s no cryptographic proof that two paths can’t collide — it’s just overwhelmingly improbable.
If it happens in practice, you’d have bigger problems (like a broken hash function), but it’s worth acknowledging the theoretical possibility. This is the kind of nuance interviewers are probing for when they ask edge case questions.
What Happens During a Transaction
Let’s trace the actual flow when you sign a transaction on a Ledger:
- Your laptop (running Ledger Live) constructs the unsigned transaction: recipient, amount, gas, nonce
- It sends the transaction data + derivation path (e.g.,
m/44'/60'/0'/0/0) to the device over USB - The device displays the transaction details on its screen. This is the trusted display — the laptop can’t fake it
- You confirm by pressing the physical button
- The device derives the private key for that path on-the-fly (it doesn’t store it)
- It signs the transaction hash using ECDSA:
- It sends the signature back to the laptop
- The laptop broadcasts the signed transaction to the network
The private key exists in the device’s secure element for milliseconds, then gets wiped. No persistent storage. That’s the security model.
But here’s a failure mode I’ve seen: malware on the laptop modifies the recipient address before sending it to the device. If you blindly confirm without verifying the on-screen address, you’ve just authorized a transfer to the attacker. The device can’t protect you from user negligence — it can only show you the truth.
Edge Case: What If You Restore on Different Hardware?
Say you initialized a Ledger with a seed, used it for a year, then the device dies. You buy a Trezor and restore the same 24 words. Do you get the same keys?
Yes, if both follow BIP32/39/44 to the spec. The seed → master key → child key derivation is deterministic and hardware-independent.
But some devices add proprietary tweaks. For example, older Trezor firmware used a different PBKDF2 iteration count for passphrases in certain configurations (I think this got fixed, but it was a real issue around 2019). If you’re paranoid, test recovery on a second device before funding the wallet.
And here’s an obscure gotcha: some chains use different curve parameters. Solana uses Ed25519, not secp256k1. The derivation path is still m/44'/501'/0'/0', but the elliptic curve operations are completely different. Hardware wallets need separate firmware support for each curve.
FAQ
Q: If I lose my hardware wallet but have the seed phrase, can I access my funds on a compromised computer?
Yes, but you’re defeating the purpose of cold storage. Enter your seed on a malware-infected PC, and you’ve just handed your keys to an attacker. The safe approach: buy a new hardware wallet, restore the seed there, then transfer funds to a fresh wallet generated on the new device. Don’t reuse the compromised seed long-term.
Q: Why do some wallets show a 13th or 25th word option during setup?
That’s the BIP39 passphrase (sometimes called the “25th word”). It’s an optional extra factor mixed into the PBKDF2 salt. Same 24 words + different passphrase = completely different seed. Use case: plausible deniability. Keep a small “decoy” balance with no passphrase, real funds behind a passphrase. If coerced, you hand over the base seed. (Legal disclaimer: I’m not a lawyer, consult one before trying this.)
Q: Can I use the same seed for Bitcoin and Ethereum?
Yes, that’s the whole point of BIP44. The coin type in the derivation path keeps keys separate. m/44'/0'/0'/0/0 for Bitcoin, m/44'/60'/0'/0/0 for Ethereum — completely independent keys from the same seed. Just make sure your wallet software supports both chains. Ledger Live does, but some single-chain wallets don’t.
When to Hardcode Paths vs Letting Users Choose
If you’re building wallet software, you’ll hit this design decision fast. Most consumer wallets (MetaMask, Trust Wallet) hardcode the BIP44 path and auto-increment the address index. Power users hate this — they want control over account numbers and custom derivations.
The trade-off is UX vs flexibility. For non-technical users, exposing derivation paths is overwhelming. For crypto-native users, not exposing them is patronizing. Some wallets split the difference: default to BIP44, but let advanced users override in settings.
I’d lean toward hardcoding unless you’re building for developers. The worst user experience is someone restoring their seed in a different wallet and panicking because their balance shows zero (because the new wallet defaults to a different path).
The Part I’m Still Unsure About
One thing I haven’t fully stress-tested: what happens if you derive a child key where (the curve order)? BIP32 says to skip that index and increment to the next one. But does every implementation handle this consistently? I’d guess yes for major wallets, but I’ve seen enough subtle bugs in cryptographic code to be wary.
If you’re implementing this from scratch, triple-check the edge cases: zero keys, invalid curve points, indexes that wrap around. Or better yet, just use a battle-tested library like pycoin or bitcoinlib. Cryptography is not the place to learn by breaking things. Well, unless you’re on testnet — then break away (and maybe keep some caffeine pills handy for the debugging sessions).
Where This Matters Beyond Interviews
Understanding this architecture isn’t just about passing interviews. If you’re building:
- Custodial wallets: You need deterministic key generation for millions of users without bloating your database
- Multi-sig vaults: BIP32 extended public keys let you generate deposit addresses offline while keeping signing keys in cold storage
- Account abstraction: Smart contract wallets still need to derive owner keys somewhere
- Cross-chain bridges: You might derive keys for different chains from the same seed
The real-world systems I’ve worked on all boil down to this same primitive: one seed, infinite keys, cryptographic guarantees. Get this wrong, and you’re either losing user funds or leaking private keys. Get it right, and you’ve built something people can actually trust with their money.
For most projects, you’ll use BIP44 paths verbatim. But knowing why those numbers are there — and what breaks if you deviate — is the difference between copying a tutorial and actually understanding the security model.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,883 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (969 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (891 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (828 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (636 views)