Part I · Bricks: the cryptographic floor By the end of this chapter you will have a function that derives a public key from a private key, and a second function that cracks the private key back out. You get to hold both the sword and the shield, and that is the point.
Chapter 1’s hash is purely one-way: nobody gets back, ever. What you build here is different. It has a back door, and the back door is called a private key.
2.0 Nail down the usage before touching the math¶
Forget the mathematics for a moment. First be clear about what a key pair is for.
A piggy bank only you can open. There is a slot in the top — anyone can drop coins in. There is a lock on the bottom, and you hold the only key. You build the bank, put it out in public, and anyone can pay into it. Only you can open it and take the money out.
| Piggy bank | Cryptography | The point |
|---|---|---|
| The open slot | Public key / address | Out in the open; anyone can pay in |
| The key underneath | Private key | Only you have it; never shown |
| Someone paying in | Receiving a payment | Requires no key at all |
| You opening it | Spending with the private key | Only you can |
This is literally how Bitcoin works. A Bitcoin address is a piggy bank: you make one, publish the address, people pay into it, and when you want to spend you open it with your key. What Bitcoin does all day long is make banks to receive, and open them with keys to spend.
One key, unlimited banks¶
Here is where the piggy bank beats “a lock” as an image, and it happens to be Bitcoin’s actual mechanism:
The same key can make a whole pile of banks that look nothing like each other, and it opens every one of them.
A real Bitcoin wallet derives thousands of addresses from one seed, all controlled by one master key. “One key, many banks” is not a metaphor — it is the literal design. You will grow that tree by hand in chapter 15.
And because you can make unlimited banks, you can use a fresh one for every payment. That is not fussiness. Here is why:
| Property | What it means | What it protects |
|---|---|---|
| ① You can’t derive the key | Anyone who finds one of your banks cannot work backwards to your key | Nobody can take your money |
| ② You can’t link them | A hundred banks made by one key look unrelated to outsiders | Nobody can see how much you have |
These are two different guarantees, and confusing them is a common mistake. Your key can be perfectly safe (①) while you destroy your own privacy by receiving everything into one address, because ② has nothing to do with ①. Hence: use each address once.
A hook for later: banks are mutually anonymous by default — but the moment you pour several of them into one new bank, you have publicly announced “these were all mine.” That is precisely the problem change addresses and CoinJoin have to deal with.
The opening condition is programmable¶
The best thing about the piggy bank picture: “who can open it, and under what conditions” is not fixed. A Bitcoin address is really a locking condition that says what must be satisfied before the coins move:
An ordinary bank = “whoever holds this key” (single signature)
A safe needing two people to turn keys together = multisig
A bank that won’t open until a certain date = timelock
A bank that opens for the right password = hashlock (the foundation of Lightning)
So an address is not a fixed lock. It is a spending-condition contract. In chapter 14 you will write the tiny virtual machine that makes that sentence executable.
2.1 The thing a piggy bank cannot explain¶
Real locks and real keys are manufactured together. Neither one “produces” the other. Actual key pairs have a direction:
private key → public key: easy, one computation
public key → private key: effectively impossible
And be careful about which way is which: your key can produce unlimited banks (private → address, forward is trivial); but nobody who finds a bank can cut the key (address → private, backward is hopeless).
“Forward is trivial, backward is hopeless” is not something a piggy bank can express. We need a different picture.
A world with a boundary¶
Don’t look at a curve yet. First picture a clock with exactly 97 positions: numbers don’t run off to infinity, they wrap. Reach 97 and you’re back at 0.
Addition, subtraction and multiplication are easy — do the arithmetic, take the remainder. Division is the interesting one.
There are no fractions in this world, so “6 ÷ 2” cannot mean “cut in half.” It can only mean: find x such that 2 × x = 6. Follow that thought and you get a new object:
The inverse of
ais the number satisfyinga × inv(a) = 1. With it, division becomes multiplication:a ÷ b = a × inv(b).
Use a smaller clock (mod 7) to see it clearly:
p = 97 # the size of our finite world (a prime)
# But start smaller, with mod 7, to understand modular arithmetic.
print("=== arithmetic in the world of mod 7 ===\n")
print("ADDITION:")
print(f" 5 + 3 = {5 + 3} (ordinary)")
print(f" 5 + 3 = {(5 + 3) % 7} (mod 7) -- 8 wraps past 7, leaving 1")
print(f" 5 + 4 = {(5 + 4) % 7} (mod 7) -- 9 wraps past 7, leaving 2")
print()
print("SUBTRACTION:")
print(f" 2 - 5 = {2 - 5} (ordinary)")
print(f" 2 - 5 = {(2 - 5) % 7} (mod 7) -- negatives wrap around too")
print()
print("MULTIPLICATION:")
print(f" 3 x 4 = {(3 * 4) % 7} (mod 7) -- 12 wraps past 7, leaving 5")
print(f" 5 x 6 = {(5 * 6) % 7} (mod 7) -- 30 wraps past 7, leaving 2")
print()
# Which pairs multiply to 1?
print("=== which numbers multiply together to give 1 ===\n")
for x in range(1, 7):
for y in range(1, 7):
if (x * y) % 7 == 1:
print(f" {x} x {y} = 1 (mod 7) -> inv({x}) = {y}")
=== arithmetic in the world of mod 7 ===
ADDITION:
5 + 3 = 8 (ordinary)
5 + 3 = 1 (mod 7) -- 8 wraps past 7, leaving 1
5 + 4 = 2 (mod 7) -- 9 wraps past 7, leaving 2
SUBTRACTION:
2 - 5 = -3 (ordinary)
2 - 5 = 4 (mod 7) -- negatives wrap around too
MULTIPLICATION:
3 x 4 = 5 (mod 7) -- 12 wraps past 7, leaving 5
5 x 6 = 2 (mod 7) -- 30 wraps past 7, leaving 2
=== which numbers multiply together to give 1 ===
1 x 1 = 1 (mod 7) -> inv(1) = 1
2 x 4 = 1 (mod 7) -> inv(2) = 4
3 x 5 = 1 (mod 7) -> inv(3) = 5
4 x 2 = 1 (mod 7) -> inv(4) = 2
5 x 3 = 1 (mod 7) -> inv(5) = 3
6 x 6 = 1 (mod 7) -> inv(6) = 6
Stare at that last loop’s output:
1 x 1 = 1 (mod 7) -> inv(1) = 1
2 x 4 = 1 (mod 7) -> inv(2) = 4
3 x 5 = 1 (mod 7) -> inv(3) = 5
4 x 2 = 1 (mod 7) -> inv(4) = 2
5 x 3 = 1 (mod 7) -> inv(5) = 3
6 x 6 = 1 (mod 7) -> inv(6) = 6Every number from 1 to 6 has exactly one inverse. Not one is missing.
That is not luck — it happens because 7 is prime. (Try 6 instead and you’ll find 2, 3 and 4 have no inverse at all. That is why the modulus in cryptography is always prime.)
Inverses in code¶
def inv_brute(a, p):
'''Brute force: try b = 1, 2, 3... until a*b = 1 (mod p).'''
for b in range(1, p):
if (a * b) % p == 1:
return b
return None
def inv(a, p):
'''Fast: Fermat's little theorem, a^(p-2) mod p.'''
return pow(a % p, p - 2, p)
print("=== inverses mod 7 ===\n")
for a in [1, 2, 3, 4, 5, 6]:
v = inv(a, 7)
print(f"inv({a}) = {v:>2} -> {a} x {v} = {(a * v) % 7} (mod 7)")
print("\n=== division via inverse ===\n")
print(f"6 / 2 (mod 7) = 6 x inv(2) = 6 x {inv(2, 7)} = {(6 * inv(2, 7)) % 7}")
print("\n=== now the prime we actually use, p = 97 ===\n")
for a in [1, 2, 5, 40, 96]:
v = inv(a, 97)
print(f"a={a:<3} inv(a)={v:<3} a*inv(a) % 97 = {(a * v) % 97}")
=== inverses mod 7 ===
inv(1) = 1 -> 1 x 1 = 1 (mod 7)
inv(2) = 4 -> 2 x 4 = 1 (mod 7)
inv(3) = 5 -> 3 x 5 = 1 (mod 7)
inv(4) = 2 -> 4 x 2 = 1 (mod 7)
inv(5) = 3 -> 5 x 3 = 1 (mod 7)
inv(6) = 6 -> 6 x 6 = 1 (mod 7)
=== division via inverse ===
6 / 2 (mod 7) = 6 x inv(2) = 6 x 4 = 3
=== now the prime we actually use, p = 97 ===
a=1 inv(a)=1 a*inv(a) % 97 = 1
a=2 inv(a)=49 a*inv(a) % 97 = 1
a=5 inv(a)=39 a*inv(a) % 97 = 1
a=40 inv(a)=17 a*inv(a) % 97 = 1
a=96 inv(a)=96 a*inv(a) % 97 = 1
inv_brute and inv compute the same thing — one loops, one is a single expression.
Keep that contrast in mind, because this chapter is about to make the same point at a much
larger scale: the same problem, trivial in one direction, hopeless in the other.
The last block prints 1 every time. Whether a is 2 or 96, a × inv(a) % 97 is 1.
That is the definition, made concrete.
YOUR TURN¶
# YOUR TURN:
# 1) What does inv(0, 97) do? Guess first, then run. Why does 0 have no inverse?
# print((0 * inv(0, 97)) % 97)
# 2) Write finite-field division: a / b = a * inv(b)
def fdiv(a, b, p=97):
return ____ # fill this in
# print(fdiv(10, 5)) # 2 in ordinary arithmetic. What about mod 97?
Question 1 is worth thinking through: inv(0) would need a number that times 0 gives 1,
but anything times 0 is 0, so it does not exist. In code, pow(0, 95, 97) quietly
returns 0 instead of raising — that is a trap, and real cryptographic libraries check
for it explicitly.
2.2 A curve you can hop around¶
Now spread that 97-position clock over a curve:
Don’t worry about where that equation came from. You only need one fact: two points on this curve can be “added” to produce a third.
How the addition is defined¶
This “addition” has nothing to do with adding coordinates. It is a geometric convention:
P ≠ Q: draw the line through P and Q. It usually hits the curve a third time. Reflect that point vertically, and you have
P + Q.P == Q: you can’t draw a line through two points when there’s only one. Use the tangent at P instead; where it meets the curve again, reflect, and you have
2P.Why reflect? Convention. Defined this way, these points behave like ordinary numbers under addition — stable, associative, with an identity.
As slopes, it’s two lines of algebra:
P ≠ Q: chord slopem = (y2 - y1) / (x2 - x1)P == Q: tangent slopem = (3·x1² + a) / (2·y1)
But a finite field has no ordinary division. So every / something in the code becomes
* inv(something) % p. That is what the previous section was for — the inverse is not a
math exercise, it is the part that makes this code run.
One more thing is needed: a special point, the point at infinity, written None.
It is the zero of this addition: P + None = P.
a, b = 2, 3 # curve parameters, y^2 = x^3 + a*x + b
def is_on_curve(P):
'''Is P on the curve?'''
if P is None:
return True # the point at infinity always counts
x, y = P
return (y*y - (x*x*x + a*x + b)) % p == 0
def point_add(P, Q):
'''Add two points, return a third.'''
if P is None: return Q # infinity + Q = Q
if Q is None: return P
x1, y1 = P
x2, y2 = Q
if x1 == x2 and (y1 + y2) % p == 0:
return None # P + (-P) = the point at infinity
if P == Q:
# Same point: tangent at P.
# Ordinary math: m = (3*x1^2 + a) / (2*y1)
# Finite field: dividing by 2*y1 becomes multiplying by inv(2*y1), then % p.
m = (3*x1*x1 + a) * inv(2*y1, p) % p
else:
# Different points: the chord through P and Q.
# Ordinary math: m = (y2 - y1) / (x2 - x1)
# Finite field: same substitution.
m = (y2 - y1) * inv(x2 - x1, p) % p
x3 = (m*m - x1 - x2) % p
y3 = (m*(x1 - x3) - y1) % p
return (x3, y3)
G = (0, 10) # the generator: our starting point
print("Is G on the curve?:", is_on_curve(G))
print("G + G =", point_add(G, G))
print("Is G+G on the curve?:", is_on_curve(point_add(G, G)))
Is G on the curve?: True
G + G = (65, 32)
Is G+G on the curve?: True
Be clear about what point_add actually is: an ordinary function that eats two tuples and
returns a tuple.
point_add((0, 10), (0, 10)) -> (65, 32)No objects, no state, no magic. “Elliptic curve” in code is that function, plus
is_on_curve to check you haven’t wandered off.
The output gives G + G = (65, 32), and it is still on the curve. Pause on that:
take any two points on the curve, apply the rules above, and the result is guaranteed to
be on the curve too. That closure is the precondition for everything else — otherwise you’d
fall off after a few hops.
2.3 A private key is a hop count; a public key is where you land¶
Now the definition at the heart of this chapter, and it is simpler than you expect:
Private key = the number of hops, k. Public key = the point you land on after hopping k times from G.
Two sentences. That is all.
def scalar_mul(k, G):
'''Hop k times: add G to itself k times.'''
R = None # start from the point at infinity (zero)
for _ in range(k):
R = point_add(R, G)
return R
priv = 7 # private key = hop 7 times
pub = scalar_mul(priv, G) # public key = where you land
print("private key =", priv)
print("public key =", pub)
private key = 7
public key = (10, 76)
Your private key is the integer 7. Your public key is the tuple (10, 76).
That concrete.
In real Bitcoin the private key is a 256-bit integer (64 hex characters) and the public key is a pair of 256-bit coordinates. Apart from the size, the structure is identical.
Print the whole table¶
print(" k -> public key (landing point)")
for k in range(1, 50):
print(f"{k:>2} -> {scalar_mul(k, G)}")
k -> public key (landing point)
1 -> (0, 10)
2 -> (65, 32)
3 -> (23, 24)
4 -> (52, 68)
5 -> (88, 56)
6 -> (95, 66)
7 -> (10, 76)
8 -> (84, 37)
9 -> (11, 80)
10 -> (80, 10)
11 -> (17, 87)
12 -> (74, 20)
13 -> (87, 70)
14 -> (46, 72)
15 -> (53, 24)
16 -> (92, 81)
17 -> (1, 43)
18 -> (21, 73)
19 -> (85, 26)
20 -> (3, 91)
21 -> (47, 79)
22 -> (32, 7)
23 -> (49, 34)
24 -> (24, 2)
25 -> (30, 0)
26 -> (24, 95)
27 -> (49, 63)
28 -> (32, 90)
29 -> (47, 18)
30 -> (3, 6)
31 -> (85, 71)
32 -> (21, 24)
33 -> (1, 54)
34 -> (92, 16)
35 -> (53, 73)
36 -> (46, 25)
37 -> (87, 27)
38 -> (74, 77)
39 -> (17, 10)
40 -> (80, 87)
41 -> (11, 17)
42 -> (84, 60)
43 -> (10, 21)
44 -> (95, 31)
45 -> (88, 41)
46 -> (52, 29)
47 -> (23, 73)
48 -> (65, 65)
49 -> (0, 87)
Look down the x-coordinates:
0, 65, 23, 52, 88, 95, 10, 84, 11, 80, ...No pattern whatsoever. Hop 1 lands at 0, hop 2 jumps to 65, hop 3 doubles back to 23, hop 4 leaps to 52. Not increasing, not decreasing, not cycling through anything you can see.
That column of scrambled numbers is the entire source of security in this chapter. Remember what it looks like.
YOUR TURN¶
# YOUR TURN:
# 1) What is scalar_mul(50, G)? (Hint: a full lap returns you to "zero" = None.)
# print(scalar_mul(50, G))
# 2) Verify that hops add: 3 hops then 4 hops should equal 7 hops.
p3 = scalar_mul(3, G)
p4 = scalar_mul(4, G)
p7 = scalar_mul(7, G)
# print(point_add(p3, p4) == p7) # expect True -- why?
Question 1 prints None. Fifty hops returns you to zero: the points reachable from G form a
closed loop of size 50 — 49 visible landing points plus the None. That 50 is called
the order of G.
(A number worth getting right: the whole curve has 100 points, while only 50 of them are reachable from G. Those are different quantities. In real Bitcoin they happen to be equal, but that is a property deliberately selected for, not a general rule.)
Question 2 prints True, and it matters more than it looks: 3 hops + 4 hops = 7 hops.
These points add up the way integers do. All of signature mathematics rests on that.
2.4 Crack your own private key¶
Everything above went forwards. Now go backwards.
Forwards (private → public): one function call. Backwards (public → private): no shortcut. Start at 1 and try every k until you land on the target.
def crack(target_pub, G, max_k=1000):
'''Given a public key, brute-force the private key.'''
R = None
for k in range(1, max_k + 1):
R = point_add(R, G) # one hop at a time
if R == target_pub:
return k
return None # not found
# The puzzle: someone's public key is this point, private key hidden.
secret_priv = 29
their_pub = scalar_mul(secret_priv, G)
print("published public key:", their_pub)
found = crack(their_pub, G)
print("cracked private key :", found)
print("correct? :", found == secret_priv)
published public key: (47, 18)
cracked private key : 29
correct? : True
It works. crack finds 29 instantly.
This is one of the few moments in this book where you personally break a cryptographic primitive. Take a good look, because that same code is completely harmless against real Bitcoin.
Why the identical code never finishes on the real curve¶
This toy curve has 50 reachable positions. crack tries at most 50.
Real Bitcoin’s curve (you’ll swap it in during chapter 13) has about 2²⁵⁶ ≈ 10⁷⁷.
That number means nothing to human intuition, so here are two anchors:
The observable universe contains roughly 10⁸⁰ atoms
Suppose you had a machine doing a trillion tries per second, and a billion of those machines, running since the Big Bang. You’d be somewhere around 10⁴⁰ — not a rounding error away from the target, but a rounding error of a rounding error.
The for loop does not change by a single character. Scale it from 50 to 2²⁵⁶ and it goes
from “instant” to “never.”
That is the real conclusion of this chapter: security does not come from the algorithm being clever. It comes from scale.
crackremains perfectly correct on the real curve. It simply never terminates.
YOUR TURN¶
# YOUR TURN:
# 1) Try different values of secret_priv and watch how many attempts crack needs. Average?
# 2) If a curve has N positions, what is crack's worst case? (That number IS the security.)
# 3) Set secret_priv to 48, near the top of this small curve. Can crack still find it?
secret_priv = 48
their_pub = scalar_mul(secret_priv, G)
print("can it be cracked?:", crack(their_pub, G))
can it be cracked?: 48
Question 3 finds 48, because max_k=1000 dwarfs this curve’s 50. That is exactly the
problem with a toy curve — the attacker’s budget is always sufficient.
2.5 Two kinds of one-way, and they are not the same¶
You now hold two things that are “easy forwards, hard backwards”: chapter 1’s hash, and this chapter’s key pair. They look like the same idea. They are not.
| Key pair (hopping) | Hash (blender) | |
|---|---|---|
| Easy forwards, hard back? | Yes | Yes |
| Is there a back door? | Yes — the private key (the other half of the pair walks it backwards) | No — nobody gets back |
| Name for it | Trapdoor one-way | Pure one-way |
In one line: both are easy-forwards-hard-backwards, but a key pair keeps a back door and a hash has none at all.
This is not taxonomy for its own sake. It determines what each can do:
No back door → chapter 1’s commitment must reveal, and the secret is burned on use
A back door → chapter 3’s signature never reveals, and the private key never appears
The back door is the value. A private key is not “a stronger hash.” It is a categorically different object: it lets you prove you hold a secret, repeatedly, without ever exposing it.
(If the multiplication analogy suits you better: 61 × 53 = 3233 is easy, factoring 3233
back into 61 and 53 is hard. Same class of trapdoor. Bitcoin uses the hopping version,
but multiplication is perfectly good for intuition.)
The same pair works in reverse¶
One more common confusion, cleared up. A key pair has two uses, in opposite directions:
| Who acts | Which key | Purpose | |
|---|---|---|---|
| Encryption | Someone → you | Public locks → private opens | Secrecy (only you can read it) |
| Signing | You → everyone | Private signs → public verifies | Proof (confirming it’s you) |
Bitcoin barely uses the encryption direction at all. On-chain transactions are public — anyone can see how much moved and to whom. Bitcoin protects “only I can spend my money,” not “nobody can read this.” So what it uses every single day is signatures.
(Strictly, a signature is not “encrypting with the private key” — it is separate mathematics, which you’ll write out yourself next chapter. But “lock forwards, stamp backwards” is a good enough picture. This book never takes the encryption road; see appendix A if you’re curious.)
2.6 The ledger¶
| What you wrote | Field |
|---|---|
inv() — modular inverse | Mathematics |
point_add() / scalar_mul() — hopping on a curve | Mathematics |
| “Private key = hop count, public key = landing point” | Cryptography |
crack() — brute force | The attacker’s view (and the definition of security itself) |
Still mostly cryptography, but look at that last row: you wrote an attacker.
From here on the book keeps that habit. Every time you build something, you immediately ask how to break it. Chapter 6 builds a double-spend, chapter 7 a 51% attack, chapter 12 a reorg.
Three ideas to carry:
Modular arithmetic — a world with a boundary. Division doesn’t exist; only multiplication by an inverse.
Translating math into code — the
/in a formula becomes* inv(...) % pin the editor. Mathematics isn’t mystical; it is code that hasn’t been typed yet.Complexity is security — the same loop, and scale decides between “instant” and “never.” This is one of the two or three most important sentences in the book.
Next¶
Signatures.
You have a hash (chapter 1) and a key pair (this chapter). Next chapter bolts them together into real ECDSA — the signature family Bitcoin actually uses, running on a smaller curve.
Then you will do one specific thing: count how many times priv appears inside the body
of verify.
The answer is zero, and everything that follows in this book is built on that zero.