Keys, Signatures & Addresses
So far our coins move freely. In Transactions & the UTXO Model we built outputs that say “this coin belongs to address X,” but nothing stops anyone from spending a coin that isn’t theirs. That’s the gap this chapter closes.
The recurring question of this whole book is: how do untrusting strangers agree on one ledger? Part of the answer is authorization — when Alice spends a coin, the network must be able to check that Alice, and only Alice, approved it, without Alice ever revealing the secret that makes her Alice. That magic is public-key cryptography, and Bitcoin does it with a specific elliptic curve called secp256k1.
The idea before the code
Section titled “The idea before the code”A keypair is two numbers that are mathematically linked:
- A secret key (or private key) — a random 256-bit number. Whoever knows it controls the coins. You guard it with your life.
- A public key — derived from the secret key by one-way math on the secp256k1 curve. You can hand it out freely. Nobody can run the math backwards to recover the secret key.
From the public key we derive an address — a short fingerprint you publish so people can pay you.
The trick that makes coins spendable is the signature. Given a message (in our chain, the hash of a transaction) and your secret key, you can produce a signature. Anyone holding your public key can check that signature and become convinced of two things: the signer knew the secret key, and the message wasn’t altered. Crucially, the signature reveals nothing about the secret key itself. That’s how signing proves authorization without leaking the secret.
Bringing in an external crate
Section titled “Bringing in an external crate”We are not going to implement elliptic-curve math by hand — that is famously easy to get
catastrophically wrong. Instead we lean on a battle-tested crate (a Rust library). The
secp256k1 crate is a binding to the same C library Bitcoin Core itself uses, and rand gives us
secure randomness.
use rand::RngCore;use secp256k1::ecdsa::Signature;use secp256k1::{Message, PublicKey, Secp256k1, SecretKey};
use crate::hashing::{sha256, Hash};Each use line pulls a name into scope so we can write SecretKey instead of the full path.
RngCore is a trait — importing it makes its methods (like fill_bytes) callable on our random
number generator. The last line reaches back into our own crate for the sha256 function and the
Hash type (a [u8; 32]) we built back in Hashing & Fingerprints.
The Wallet struct
Section titled “The Wallet struct”A wallet, stripped to its essence, is just a keypair:
#[derive(Clone)]pub struct Wallet { secret_key: SecretKey, pub public_key: PublicKey,}Notice the asymmetry in visibility, and how deliberate it is. public_key is marked pub — anyone
with a Wallet can read it, which is fine; public keys are meant to be shared. But secret_key
has no pub, so it’s private to this module. Code outside wallet.rs literally cannot read the
secret key out of a Wallet. The struct owns the secret key, and Rust’s ownership rules plus this
visibility wall mean the secret never accidentally escapes.
Generating a keypair
Section titled “Generating a keypair”impl Wallet { /// Generate a fresh random keypair. pub fn new() -> Self { let secp = Secp256k1::new(); let mut rng = rand::thread_rng(); // A secret key is a random 256-bit number (with a few invalid values we retry past). let secret_key = loop { let mut bytes = [0u8; 32]; rng.fill_bytes(&mut bytes); if let Ok(sk) = SecretKey::from_slice(&bytes) { break sk; } }; let public_key = PublicKey::from_secret_key(&secp, &secret_key); Wallet { secret_key, public_key } }Let’s walk through it slowly.
Secp256k1::new() builds a context — a reusable object the crate uses to do curve math. We hand it
out by reference (&secp) wherever it’s needed.
rand::thread_rng() gives us a cryptographically secure random generator tied to this thread. We mark
it mut because pulling bytes out of it mutates its internal state.
Now the interesting part — that loop. A secret key is almost any random 256-bit number, but the
curve forbids a tiny handful of values (like zero, or anything at or above the curve’s order). So we:
- Fill a 32-byte buffer with randomness via
rng.fill_bytes(&mut bytes). - Try to turn those bytes into a
SecretKey.SecretKey::from_slicereturns aResult—Ok(sk)on success,Err(...)if the bytes were one of the forbidden values. if let Ok(sk) = ...pattern-matches the success case. If it matches,break skexits the loop and yieldsskas the loop’s value. If not, the loop simply spins again with fresh bytes.
The odds of hitting an invalid value are astronomically small, but handling it makes the code
correct, not just usually-correct. This is a recurring Rust theme: the type system (here, Result)
forces you to acknowledge the failure case instead of pretending it can’t happen.
Finally, PublicKey::from_secret_key(&secp, &secret_key) runs the one-way math to derive the public
key, and we pack both into a Wallet.
Default — the idiomatic “make me a fresh one”
Section titled “Default — the idiomatic “make me a fresh one””impl Default for Wallet { fn default() -> Self { Self::new() }}Default is a standard trait meaning “there’s a sensible default value for this type.” For a wallet,
the sensible default is a brand-new random keypair, so Wallet::default() just calls new(). This
lets Wallet slot into any generic code that asks for a default, and it’s also why Clippy nudges you
to add it whenever you write a no-argument new().
Reading the public key and address
Section titled “Reading the public key and address” /// The compressed (33-byte) public key. pub fn pubkey_bytes(&self) -> Vec<u8> { self.public_key.serialize().to_vec() }
/// Our address: `SHA256(pubkey)`. pub fn address(&self) -> Hash { sha256(&self.pubkey_bytes()) }pubkey_bytes serializes the public key into its compressed form: 33 bytes (a 1-byte prefix plus
the 32-byte x-coordinate). .to_vec() copies the fixed-size array into an owned Vec<u8> we can pass
around.
address then hashes those bytes with SHA-256. This is a deliberate simplification. Real Bitcoin
runs the public key through HASH160 (SHA-256 then RIPEMD-160) and encodes the result as Base58Check or
Bech32, which gives you the familiar 1..., 3..., and bc1... formats covered in
Address types. We use a plain SHA256(pubkey) so the mechanic — an
address is a fingerprint of a public key — stays front and center without the encoding ceremony.
Signing
Section titled “Signing” /// Sign a 32-byte message hash; returns a compact 64-byte signature. pub fn sign(&self, message_hash: &Hash) -> Vec<u8> { let secp = Secp256k1::new(); let message = Message::from_digest(*message_hash); secp.sign_ecdsa(&message, &self.secret_key) .serialize_compact() .to_vec() }}ECDSA signs a fixed-size digest, not arbitrary data — so sign takes a &Hash (a reference to a
32-byte hash, typically the hash of a transaction). Message::from_digest(*message_hash) wraps those
bytes in the crate’s Message type; the * dereferences the borrowed Hash to copy the array in.
secp.sign_ecdsa(&message, &self.secret_key) is where the secret key finally does its job. We pass it
by reference — the wallet keeps ownership; signing borrows it briefly and never moves or consumes
it. The result is serialized into the compact 64-byte form and returned as a Vec<u8>.
Verifying — the free function
Section titled “Verifying — the free function”Verification doesn’t need a wallet or a secret key; it just needs the public key and the signature. So it lives as a free function, not a method:
/// Verify a compact signature over `message_hash` made by `pubkey_bytes`.pub fn verify(message_hash: &Hash, signature: &[u8], pubkey_bytes: &[u8]) -> bool { let secp = Secp256k1::new(); let message = Message::from_digest(*message_hash); let Ok(sig) = Signature::from_compact(signature) else { return false; }; let Ok(pubkey) = PublicKey::from_slice(pubkey_bytes) else { return false; }; secp.verify_ecdsa(&message, &sig, &pubkey).is_ok()}Here’s a lovely Rust idiom: let ... else. The line
let Ok(sig) = Signature::from_compact(signature) else { return false;};reads as: “bind sig to the value inside Ok; but if the pattern doesn’t match (we got an Err),
run the else block.” The else block must diverge — it has to return, break, panic, or
otherwise not fall through. The payoff is that after this line, sig is the unwrapped signature, no
indentation pyramid required. Compare that to nesting two match expressions: this stays flat and
reads top-to-bottom.
We use it twice — once for the signature bytes, once for the public-key bytes — because both come from
untrusted input over the network. Garbage in either should mean “signature invalid,” not a crash,
so we return false. Only if both parse do we run verify_ecdsa, whose Result we collapse to a
plain bool with .is_ok(). The whole function answers one yes/no question: did the holder of the
private key behind this public key sign this exact hash?
address_of — mapping a public key to its address
Section titled “address_of — mapping a public key to its address”/// The address a public key maps to (used to check an output's recipient).pub fn address_of(pubkey_bytes: &[u8]) -> Hash { sha256(pubkey_bytes)}When validating a spend, a node needs to confirm that the public key offered in a transaction input
actually matches the address that owned the coin. address_of recomputes the address from raw public-key
bytes — the same SHA256 that Wallet::address uses, but available without a Wallet. Validation
will lean on this in Full Chain Validation.
Try it
Section titled “Try it”Drop this into a scratch file or a test to watch a signature round-trip:
use blockchain_from_scratch::hashing::sha256;use blockchain_from_scratch::wallet::{verify, Wallet};
fn main() { let alice = Wallet::new(); let message = sha256(b"pay bob 10 coins");
// Alice signs with her secret key (never revealed). let signature = alice.sign(&message);
// Anyone can verify using only her public key. let pubkey = alice.pubkey_bytes(); assert!(verify(&message, &signature, &pubkey));
// A different message must NOT verify against the same signature. let tampered = sha256(b"pay bob 1000 coins"); assert!(!verify(&tampered, &signature, &pubkey));
println!("signature verified, and tampering was rejected!");}Or run the project’s own checks:
cargo testCheck your understanding
Section titled “Check your understanding”- Why does
secret_keyhave nopubkeyword whilepublic_keydoes? What would break, conceptually, if both were public? Wallet::new()retries inside aloop. What is it retrying past, and how likely is a retry?- Explain the
let Ok(sig) = ... else { return false; }pattern. Why must theelseblock diverge? - Why is
verifya free function instead of a method onWallet? What does it need that a method would force you to over-provide? - Our
addressisSHA256(pubkey). Name two things real Bitcoin does differently, and why our simplification is still faithful to the core idea.
Show answers
secret_keyis private (nopub) so code outsidewallet.rsliterally cannot read it — the secret never escapes;public_keyispubbecause public keys are meant to be shared. If both were public, anyone with aWalletcould read the secret key and spend its coins, destroying the whole “prove ownership without revealing the secret” guarantee. Visibility is enforced by the compiler, so it’s a real defense, not a convention.- It’s retrying past the handful of invalid secret-key values the curve forbids (like zero, or anything at or above the curve’s order), which make
SecretKey::from_slicereturnErr. A retry is astronomically unlikely — the forbidden set is vanishingly small against all 256-bit values — but handling it makes the code correct, not just usually-correct. let Ok(sig) = ... else { ... }bindssigto the value insideOk, but if the pattern doesn’t match (anErr), it runs theelseblock — letting the happy path stay flat with no nesting. Theelsemust diverge (return, break, panic) because if it fell through,sigwould be unbound and unusable; here itreturn falses on garbage input from the network.- Verification needs only the public key and signature, never a secret key or a
Wallet. Making it a method would force callers to over-provide a wholeWallet(with its private key) just to check a signature — but anyone verifying a spend (e.g. a validating node) only holds the public data, so a free function fits exactly what’s available. - Real Bitcoin runs the pubkey through HASH160 (SHA-256 then RIPEMD-160) and encodes the result as Base58Check or Bech32 (giving
1…,3…,bc1…with a checksum). OurSHA256(pubkey)is faithful because the mechanic is identical — an address is just a fingerprint of a public key — and the encoding ceremony is orthogonal to that core idea.