The Magic of Hash Functions — The Digital Fingerprint That Supports Blockchain

Lesson 218min3,671 chars

Learning Objectives

  • 대칭키와 비대칭키 암호화의 차이를 비유를 사용해 설명할 수 있다
  • Python으로 개인키→공개키→간이 주소를 생성하는 코드를 작성할 수 있다
  • 디지털 서명의 생성과 검증 과정을 코드로 수행하고 각 단계의 목적을 설명할 수 있다

Public Key Cryptography and Digital Signatures: Proving "I Sent This" with Mathematics

Someone claims "Alice sent Bob 1 BTC." How can you trust that? There's no server to verify a password. No bank to mediate. It must be proven with mathematics alone.

In the last lesson, we learned how to create a 'fingerprint' of data with SHA-256. We confirmed that changing even 1 bit completely flips the hash, and we built hash_utils.py. But there's one thing hashes can never do on their own: prove that "I sent this transaction." A hash guarantees data integrity, but it cannot prove ownership. Today we fill that gap.

Honestly, even I didn't think deeply about how msg.sender is guaranteed when I first wrote Ethereum smart contracts. I brushed it off with "MetaMask will handle it." Then I struggled badly on a DeFi project where I had to implement signature verification logic myself. What we're learning today is what I should have known back then.


🎯 Today's Mission

Concrete deliverable: wallet.py — a Wallet class capable of key pair generation, address derivation, signing, and verification

Once we build this, we'll be able to mathematically prove "this transaction truly came from me" when creating transactions in Lesson 3.


🔐 Why Passwords Don't Work in Blockchain

At a bank, you enter a password to make a transfer. The server confirms "password correct" and the transfer is approved. Simple.

Blockchain has no server. There is no 'center' to verify passwords. So a completely different approach is needed — a proof that only I can create, but anyone can verify.

The answer to this problem is asymmetric key cryptography. The difference becomes clear when compared to symmetric keys.


🔑 Symmetric vs Asymmetric Keys — Padlocks and Safes

Symmetric Keys: Lock and Unlock with the Same Key

# Concept of symmetric key encryption (not actual AES — XOR cipher for illustrative purposes)
def symmetric_encrypt(plaintext: str, key: int) -> list:
    """Encrypt/decrypt with the same key"""
    return [ord(c) ^ key for c in plaintext]

def symmetric_decrypt(ciphertext: list, key: int) -> str:
    """Decrypt with the same key — identical operation to encryption"""
    return ''.join(chr(c ^ key) for c in ciphertext)

secret_key = 42
original = "Hello"
encrypted = symmetric_encrypt(original, secret_key)
decrypted = symmetric_decrypt(encrypted, secret_key)

print(f"Original: {original}")
print(f"Ciphertext: {encrypted}")
print(f"Decrypted: {decrypted}")
# Output:
# Original: Hello
# Ciphertext: [98, 79, 70, 70, 85]
# Decrypted: Hello

This code demonstrates the core principle of symmetric key encryption using XOR. Both encryption and decryption are performed with the same key 42. This works because applying XOR with the same value twice restores the original value.

What's the problem? You have to transmit the key to the other party. The moment you send a key over the internet, someone in the middle can intercept it. To send to 10 people, you need 10 different keys. A blockchain has tens of thousands of nodes. This approach simply doesn't work.

Asymmetric Keys: Lock and Unlock with Different Keys

In asymmetric key cryptography, two keys are born as a pair:

  • Private Key: A secret key that must never be disclosed. Used to create signatures
  • Public Key: A key that can be shared with anyone. Used to verify signatures

The core principle: private key → public key is easy, but public key → private key is mathematically impossible.

By analogy, the private key is a signature stamp and the public key is a stamp authenticator. Only you hold the stamp, but the authenticator can be distributed to every bank branch. Anyone can confirm "this stamp is genuinely Alex's stamp." But replicating your stamp is impossible.

🤔 Think about it: I created a transaction sending 1 BTC on the Bitcoin network. Tens of thousands of nodes around the world need to verify this transaction. What problems would arise with a symmetric key approach?

See answer

You'd need to share a separate secret key with each of the tens of thousands of nodes. Every time a new node joins, you'd need to transmit a key, and it could be intercepted during transmission. With asymmetric keys, you just include the public key in the transaction. Every node can verify the signature with that public key. The key distribution problem disappears entirely.

Symmetric KeyAsymmetric Key
Number of keys1 (same key)2 (private key + public key)
Key transmissionRequired ⚠️Not needed ✅
SpeedFastRelatively slow
Blockchain suitability❌ Impossible without a server✅ Suited for decentralization
Use casesHTTPS data transferTransaction signing, wallets

🧮 Private Key → Public Key → Address: A One-Way Journey

Both Bitcoin and Ethereum use Elliptic Curve Cryptography (ECC). Specifically, a curve called secp256k1. Ethereum's adoption of the same curve was no accident — it simply carried over the curve Bitcoin had already battle-tested.

Every step is one-way. The preimage resistance of hashes we learned in Lesson 1 applies here too. Knowledge is starting to stack up.

Now let's verify this directly in code. First, install the library.

pip install ecdsa

Step 1: Generate a Private Key

# generate_private_key.py — Creating a 256-bit random number
from ecdsa import SigningKey, SECP256k1

# Generate private key — internally uses a cryptographically secure random number
private_key = SigningKey.generate(curve=SECP256k1)

# Display private key as a hex string
private_key_hex = private_key.to_string().hex()
print(f"Private Key (hex): {private_key_hex}")
print(f"Private Key length: {len(private_key_hex)} chars = {len(private_key_hex) * 4} bits")

# Output (different every run):
# Private Key (hex): a3b1c4d5e6f7...  (64-character hex)
# Private Key length: 64 chars = 256 bits

A private key is simply a very large random number. More precisely, a number between 1 and the order of secp256k1 (approximately 2^256). It's hard to grasp how large this range is — the number of atoms in the observable universe is roughly 2^266. The private key space is comparable in scale. The probability of two people generating the same private key is effectively zero.

Step 2: Derive the Public Key

# derive_public_key.py — Compute the public key from the private key
from ecdsa import SigningKey, SECP256k1

private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

print(f"Private Key: {private_key.to_string().hex()}")
print(f"Public Key: {public_key.to_string().hex()}")
print(f"Public Key length: {len(public_key.to_string().hex())} chars = {len(public_key.to_string()) * 8} bits")

# Output (different every run):
# Private Key: 7f3a... (64 chars)
# Public Key: 04ab3c... (128 chars)
# Public Key length: 128 chars = 512 bits

64 characters of private key yields 128 characters of public key. Why twice as long? Because a public key is a point on the elliptic curve, requiring two coordinates: x and y. This process is called elliptic curve scalar multiplication — forward computation is fast, but the reverse is impossible with any currently known algorithm.

Step 3: Generate an Address

# generate_address.py — Derive a simplified address from the public key
import hashlib
from ecdsa import SigningKey, SECP256k1

private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

# Real Bitcoin: SHA-256 → RIPEMD-160 → Base58Check
# Our PyChain: use the first 40 characters of SHA-256 as a simplified address
public_key_bytes = public_key.to_string()
address = hashlib.sha256(public_key_bytes).hexdigest()[:40]

print(f"Private Key: {private_key.to_string().hex()[:16]}...")
print(f"Public Key: {public_key.to_string().hex()[:16]}...")
print(f"Address:   {address}")

# Output (different every run):
# Private Key: 7f3a8b2c1d4e5f6a...
# Public Key: a1b2c3d4e5f6a7b8...
# Address:   3e2f1a8b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f

Real Bitcoin applies SHA-256 and RIPEMD-160 in sequence, but in our PyChain we use the first 40 characters of SHA-256 as a simplified address. The principle is the same — compressing the public key once more with a hash to create a short, safe identifier.

🤔 Think about it: We use a SHA-256 hash to create an address. If a hash collision occurred and two people ended up with the same address, what would happen?

See answer

Theoretically possible, but practically impossible thanks to SHA-256's collision resistance. That's the property we learned in Lesson 1! If a collision truly occurred, whoever found it could access the coins at that address — but the probability is so low that it couldn't be achieved even with every computer on Earth running for the entire age of the universe. Bitcoin's security depends on this mathematical probability.


✍️ Digital Signatures: "I Created This Transaction"

Now that we understand the structure of key pairs, let's see what we can actually do with them. Creating and verifying signatures — this is the entirety of ownership proof on a blockchain.

Comparing the digital signature process to a medieval wax-sealed letter:

  1. Signature creation: The king (private key holder) presses their signet ring into wax to seal the letter
  2. Signature verification: Anyone who receives the letter can look at the wax pattern and confirm "this is definitely the king's seal"
  3. Seal cannot be replicated: You can see the wax pattern, but working backwards from that to carve the signet ring is impossible

Let's implement this in code.

# sign_and_verify.py — The core flow of ECDSA signing
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

# 1. Generate key pair
private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

# 2. Data to sign (simulating a transaction)
transaction = "Alice sends Bob 1 BTC"
data_bytes = transaction.encode('utf-8')

# 3. Sign the hash of the data (using the hash function from Lesson 1!)
data_hash = hashlib.sha256(data_bytes).digest()
signature = private_key.sign(data_hash)

print(f"Transaction: {transaction}")
print(f"Signature (hex): {signature.hex()[:40]}...")
print(f"Signature length: {len(signature)} bytes")

# 4. Verification — anyone can do this with the public key
try:
    public_key.verify(signature, data_hash)
    print("✅ Signature verification succeeded: confirmed sent by Alice!")
except BadSignatureError:
    print("❌ Signature verification failed: forged transaction!")

# Output:
# Transaction: Alice sends Bob 1 BTC
# Signature (hex): 304402201a3b5c7d9e0f... (varies)
# Signature length: 64 bytes
# ✅ Signature verification succeeded: confirmed sent by Alice!

The flow of this code in one line: data → hash → sign with private key → verify with public key. One thing worth noting here: we sign the hash of the data, not the data itself. Two reasons:

  1. Performance: Signing operations are expensive. Signing a 32-byte hash is much faster than directly signing 1MB of data
  2. Fixed size: ECDSA requires a fixed input size

The hash function from Lesson 1 reappears here. Hash → Signature — these two technologies must combine for a blockchain to work.


🚨 Are You Verifying Signatures Correctly? — 3 Common Mistakes

When writing signature verification code for the first time, it's easy to move on thinking "it works, good enough." But a single flaw in verification logic can wipe out an entire treasury. A significant portion of the billions that vanished from real DeFi projects came down to subtle mistakes in signature verification. Compare these three approaches below.

❌ WRONG WAY: Simple string comparison of signatures

# ❌ Never do this — code that only pretends to "check" the signature
from ecdsa import SigningKey, SECP256k1
import hashlib

private_key = SigningKey.generate(curve=SECP256k1)

transaction = "Alice→Bob: 1 BTC"
data_hash = hashlib.sha256(transaction.encode()).digest()
signature = private_key.sign(data_hash)

def bad_verify(signature_hex: str) -> bool:
    """Fake verification that passes if the signature exists and has the right length"""
    return len(signature_hex) == 128  # 64 bytes = 128 hex chars

# An attacker can pass any 128-character string!
fake_signature = "a" * 128
print(f"Real signature check: {bad_verify(signature.hex())}")   # True
print(f"Fake signature check: {bad_verify(fake_signature)}")     # True ← disaster!

# Output:
# Real signature check: True
# Fake signature check: True    ← anyone can steal coins!

Why is this dangerous? It only checks the format of the signature without performing cryptographic verification. An attacker can craft any 128-character string and get "verification passed." This isn't verification — it's leaving a security hole wide open. It may sound like a joke, but variations of this pattern have actually been seen in smart contract audits.

🤔 BETTER: Cryptographic verification done, but original data isn't re-hashed

# 🤔 Cryptographic verification is done, but has a structural vulnerability
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import hashlib

private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

transaction = "Alice→Bob: 1 BTC"
data_hash = hashlib.sha256(transaction.encode()).digest()
signature = private_key.sign(data_hash)

def incomplete_verify(public_key_hex: str, signature_hex: str, 
                      client_supplied_hash: bytes) -> bool:
    """Verification that blindly trusts the hash sent by the client"""
    try:
        vk = VerifyingKey.from_string(
            bytes.fromhex(public_key_hex), curve=SECP256k1
        )
        # ⚠️ Problem: uses the hash as sent by the client!
        # An attacker can swap in the hash of different data
        vk.verify(bytes.fromhex(signature_hex), client_supplied_hash)
        return True
    except BadSignatureError:
        return False

# Normal case: works
result = incomplete_verify(
    public_key.to_string().hex(), signature.hex(), data_hash
)
print(f"Normal verification: {result}")  # True

# ⚠️ Problem: the verifier doesn't hash the original data ("Alice→Bob: 1 BTC") itself
# A scenario where the client sends the signature for a "1 BTC" transaction
# while sneaking in a "100 BTC" transaction becomes possible

# Output:
# Normal verification: True

Why is this incomplete? The cryptographic verification itself is correct, but the verifier doesn't hash the original data directly — it trusts the hash sent by the client. This opens the door for an attacker to slip a valid signature for "Transaction A" onto "Transaction B." This problem actually occurs in API server architectures where the server receives hash values directly from clients.

✅ BEST: Compute the hash directly from the original data for verification

# ✅ Correct signature verification — verifier hashes the original data directly
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError
import hashlib

private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

transaction = "Alice→Bob: 1 BTC"
data_hash = hashlib.sha256(transaction.encode()).digest()
signature = private_key.sign(data_hash)

def correct_verify(public_key_hex: str, signature_hex: str, 
                   original_data: str) -> bool:
    """Verify by computing the hash directly from the original data"""
    try:
        vk = VerifyingKey.from_string(
            bytes.fromhex(public_key_hex), curve=SECP256k1
        )
        # ✅ Key point: the verifier hashes the original data itself
        # Does not trust the hash sent by the client
        data_hash = hashlib.sha256(original_data.encode('utf-8')).digest()
        vk.verify(bytes.fromhex(signature_hex), data_hash)
        return True
    except BadSignatureError:
        return False

# Normal verification: passes
print(f"Valid data verification: {correct_verify(public_key.to_string().hex(), signature.hex(), transaction)}")

# Tampered data: detected immediately
print(f"Tampered data verification: {correct_verify(public_key.to_string().hex(), signature.hex(), 'Alice→Bob: 100 BTC')}")

# Fake signature: naturally fails
print(f"Fake signature verification:   {correct_verify(public_key.to_string().hex(), 'aa' * 64, transaction)}")

# Output:
# Valid data verification: True
# Tampered data verification: False
# Fake signature verification:   False

Why is this the right answer? It upholds all three security principles:

  1. Cryptographic verification: Mathematical verification via the ecdsa library's verify()
  2. Independent hashing: The verifier hashes the original data directly — does not trust the client
  3. Data binding: Guarantees the signature is bound to specific original data

This pattern is the foundation of the Wallet.verify() static method we'll build next. "Never trust external input" — whether blockchain or web server, this is the first principle of security.


❌ When Signatures Break: What if Data is Tampered?

# detect_tampering.py — Even 1 character change invalidates the signature
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

private_key = SigningKey.generate(curve=SECP256k1)
public_key = private_key.get_verifying_key()

# Sign the original transaction
original = "Alice→Bob: 1 BTC"
original_hash = hashlib.sha256(original.encode()).digest()
signature = private_key.sign(original_hash)

# Attacker tampers with the amount (1 BTC → 100 BTC)
tampered = "Alice→Bob: 100 BTC"
tampered_hash = hashlib.sha256(tampered.encode()).digest()

# Verify the original signature against tampered data?
try:
    public_key.verify(signature, tampered_hash)
    print("✅ Verification succeeded")
except BadSignatureError:
    print("❌ Verification failed: data has been tampered!")

# Verify against the original again
try:
    public_key.verify(signature, original_hash)
    print("✅ Original verification succeeded: not tampered")
except BadSignatureError:
    print("❌ Verification failed")

# Output:
# ❌ Verification failed: data has been tampered!
# ✅ Original verification succeeded: not tampered

The moment you change 1 BTC to 100 BTC, the hash changes completely (avalanche effect!), and the original signature is immediately invalidated. A signature is bound to specific data. This is the fundamental reason blockchain transactions cannot be tampered with.

In the actual 2016 Ethereum DAO hack, the attacker didn't forge signatures. They exploited a code flaw (reentrancy attack) in the smart contract. The signature system itself has never been broken. This is also why I'm so obsessive about smart contract security — cryptography is solid. What breaks is always the logic humans write.

🤔 Think about it: If you have Alice's public key, can you create a signature pretending to be Alice?

See answer

Absolutely impossible. Creating a signature requires the private key. Reverse-computing a private key from a public key is the Elliptic Curve Discrete Logarithm Problem (ECDLP), which would take billions of years with current technology. This is the fundamental security guarantee of asymmetric key cryptography. However, if quantum computers advance sufficiently, Shor's algorithm could break it — which is why the Bitcoin community is already discussing a transition to post-quantum cryptography.


💀 Why Losing Your Private Key Means Losing Your Coins Forever

In 2013, a British man named James Howells threw away a hard drive containing 7,500 BTC. At current prices, that's hundreds of millions of dollars. He's still petitioning the local council for permission to excavate the landfill, but approval never comes.

Why can't it be recovered? At a bank, you'd press "forgot password." But Bitcoin has:

  1. No password reset server — because it's decentralized
  2. Private key → public key is one-way — reverse-computing a private key from a public key is mathematically impossible
  3. Private key = the only signing tool — without a private key, you can't create the signature needed to move coins
# lost_private_key_simulation.py
from ecdsa import SigningKey, SECP256k1, BadSignatureError
import hashlib

# Create a wallet — assume it received 10 BTC
original_private_key = SigningKey.generate(curve=SECP256k1)
public_key = original_private_key.get_verifying_key()
address = hashlib.sha256(public_key.to_string()).hexdigest()[:40]

print(f"My address: {address}")
print(f"Balance: 10 BTC (recorded on blockchain)")
print()

# Private key is lost! (hard drive failure, lost seed phrase, etc.)
# Creating a new private key is useless
different_private_key = SigningKey.generate(curve=SECP256k1)

# Attempt to move coins from the original address
transaction = f"Withdraw 5 BTC from {address}"
data_hash = hashlib.sha256(transaction.encode()).digest()

# Sign with a different private key?
fake_signature = different_private_key.sign(data_hash)

try:
    public_key.verify(fake_signature, data_hash)
    print("✅ Withdrawal succeeded")
except BadSignatureError:
    print("❌ Signature mismatch: withdrawal rejected!")
    print("Private key is lost, so 10 BTC is locked forever")

# Output:
# My address: 3e2f1a8b9c7d6e5f...
# Balance: 10 BTC (recorded on blockchain)
#
# ❌ Signature mismatch: withdrawal rejected!
# Private key is lost, so 10 BTC is locked forever

This code simulates the consequences of losing a private key. Signing with a newly created private key fails verification because it doesn't pair with the original public key. There is no mathematical way to recover the original private key.

Estimates suggest that roughly 20% of all Bitcoin (about 3.7 million BTC) is permanently stuck due to lost private keys. This isn't a bug — it's part of the design. To guarantee ownership without a central administrator, the responsibility for keeping private keys must rest entirely with the owner.

Working on DeFi projects, I always say: "Not your keys, not your coins." It's not a slogan. It's a mathematical fact.

🔍 Deep Dive: Hardware Wallets and Seed Phrases

Real Bitcoin wallets don't handle private keys directly — they use a seed phrase (12–24 English words). From these words, an unlimited number of private keys can be derived according to the BIP-32/44 standard. Hardware wallets like Ledger or Trezor lock the private key inside the device, so signing happens internally and the private key never leaves. I manage 100% of my personal assets with a hardware wallet. Software wallets are for development and testing only.


🔨 Project Update

We've dissected the concepts one by one. Now it's time to assemble everything into a single class.

Code from Lesson 1: hash_utils.py

# hash_utils.py — Hash utility written in Lesson 1
import hashlib

def sha256_hash(data: str) -> str:
    """Returns the SHA-256 hash of string data as hexadecimal"""
    return hashlib.sha256(data.encode('utf-8')).hexdigest()

Added this lesson: wallet.py ✨ NEW

# wallet.py — Wallet class responsible for key pair generation, address derivation, signing, and verification
import hashlib
from ecdsa import SigningKey, VerifyingKey, SECP256k1, BadSignatureError


class Wallet:
    """Bitcoin-style wallet — manages private/public keys, signing, and verification"""

    def __init__(self):
        # Generate key pair
        self._private_key = SigningKey.generate(curve=SECP256k1)
        self.public_key = self._private_key.get_verifying_key()
        # Use the first 40 chars of the public key hash as a simplified address
        self.address = hashlib.sha256(
            self.public_key.to_string()
        ).hexdigest()[:40]

    def sign(self, data: str) -> str:
        """Sign data and return as a hex string"""
        data_hash = hashlib.sha256(data.encode('utf-8')).digest()
        signature_bytes = self._private_key.sign(data_hash)
        return signature_bytes.hex()

    @staticmethod
    def verify(public_key_hex: str, signature_hex: str, data: str) -> bool:
        """Verify a signature with a public key — callable by anyone (static method)"""
        try:
            public_key = VerifyingKey.from_string(
                bytes.fromhex(public_key_hex), curve=SECP256k1
            )
            data_hash = hashlib.sha256(data.encode('utf-8')).digest()
            public_key.verify(bytes.fromhex(signature_hex), data_hash)
            return True
        except BadSignatureError:
            return False

    def get_public_key_hex(self) -> str:
        """Return the public key as a hex string"""
        return self.public_key.to_string().hex()


# ===== Test code =====
if __name__ == "__main__":
    # 1. Create wallets
    alice = Wallet()
    bob = Wallet()
    print("=== Wallet Creation ===")
    print(f"Alice's address: {alice.address}")
    print(f"Bob's address:   {bob.address}")
    print()

    # 2. Alice signs a transaction
    transaction = f"{alice.address} -> {bob.address}: 5 PyCoins"
    signature = alice.sign(transaction)
    print("=== Signature Creation ===")
    print(f"Transaction: {transaction}")
    print(f"Signature: {signature[:40]}...")
    print()

    # 3. Anyone can verify
    print("=== Signature Verification ===")
    valid = Wallet.verify(alice.get_public_key_hex(), signature, transaction)
    print(f"Verify with Alice's public key: {'✅ Valid' if valid else '❌ Invalid'}")

    # 4. Verification fails with Bob's key
    forged = Wallet.verify(bob.get_public_key_hex(), signature, transaction)
    print(f"Verify with Bob's public key:   {'✅ Valid' if forged else '❌ Invalid'}")

    # 5. Verification fails when data is tampered
    tampered_transaction = f"{alice.address} -> {bob.address}: 500 PyCoins"
    tampered_verify = Wallet.verify(alice.get_public_key_hex(), signature, tampered_transaction)
    print(f"Verify tampered data:           {'✅ Valid' if tampered_verify else '❌ Invalid'}")

Expected Output

=== Wallet Creation ===
Alice's address: 3e2f1a8b9c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f
Bob's address:   7a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

=== Signature Creation ===
Transaction: 3e2f1a...→7a1b2c...: 5 PyCoins
Signature: 304402201a3b5c7d9e0f1a2b3c4d5e6f7a...

=== Signature Verification ===
Verify with Alice's public key: ✅ Valid
Verify with Bob's public key:   ❌ Invalid
Verify tampered data:           ❌ Invalid

(Addresses and signatures will differ each run. The important thing is that the ✅/❌ pattern remains the same!)

Current Project Structure

pychain/
├── hash_utils.py    ← Lesson 1
├── wallet.py        ← Lesson 2 ✨ NEW
└── (next lesson: transaction.py)

Run the project you've built so far:

python wallet.py

✅ Self-Check Checklist

Run the code and verify each item below.

  • Does Wallet() generate a different address each time?
  • Does signing the same data produce a ✅ on verification?
  • Does verifying with someone else's public key produce ❌?
  • Does changing even 1 character of data produce ❌?
  • Is _private_key structured to be difficult to access directly from outside? (Python's _ convention)

🤔 Think about it: Why was verify made a static method (@staticmethod)? What problems would arise making it an instance method?

See answer

Verification must be possible for anyone. Every node in the blockchain network needs to verify transactions, and these nodes don't have Alice's wallet instance. Making it a static method means verification only requires a public key (hex string). Real Bitcoin nodes also verify signatures using only the public key included in the transaction.


🚀 How Seniors Do It

Things I ran into directly on production DeFi projects.

1. Deterministic Signatures (RFC 6979)

Standard ECDSA uses a random value k when signing. If this k is predictable or reused, the private key is exposed. The 2010 PlayStation 3 hack was exactly this vulnerability — Sony used a fixed value for ECDSA's k. The ecdsa library generates deterministic k following RFC 6979 by default, so it's safe. But without understanding this principle, you can repeat the same mistake in custom signing logic.

2. Key Serialization Format

This lesson used an uncompressed public key (65 bytes, 04 prefix). Real Bitcoin primarily uses compressed public keys (33 bytes, 02/03 prefix). Smaller transactions mean lower fees. In Ethereum, transaction size directly affects gas costs.

3. Multisig

I'll be direct: for any serious project, never use a single private key wallet. 2-of-3 multisig is the minimum. Transfers are only approved when 2 of 3 keys sign. Losing one, or having one compromised, keeps assets safe. Gnosis Safe (now just Safe) is the industry standard.

🔍 Deep Dive: Ethereum's ecrecover and Signature Verification

In Ethereum Solidity, the ecrecover(hash, v, r, s) function recovers the public key (→ address) from a signature. This uses the mathematical properties of ECDSA — isn't it fascinating that you can reverse-compute a public key from just a signature and message hash? Standards like EIP-712 define structured data signing so users can clearly see "what they're signing" in MetaMask. The most common bug I've seen in smart contract signature verification is replay attacks — reusing the same signature in a different context. You must always include a nonce.


📊 Summary Diagram

Preview of Next Lesson

The wallet is ready. In the next lesson we'll use this wallet to create actual transaction objects. We'll structure sender, recipient, and amount, attach a signature using the wallet's sign() method, and implement a complete, universally-verifiable transaction in transaction.py.


Next Steps by Difficulty

🟢 If it was easy

Well done! The key takeaways:

  • Private key: Random number, absolutely secret
  • Public key: Derived from private key, shareable
  • Address: Hash of the public key
  • Signature: Created with private key, verified with public key, bound to data

The next lesson applies this signing to actual transactions. wallet.py's sign() and verify() will be central, so keep them in mind.

🟡 If it was difficult

If the elliptic curve math felt daunting, just remember this for now:

Let me re-summarize with a bicycle lock analogy:

  • You can lock the lock with the key (private key) → signature creation
  • Anyone can confirm "this lock is locked" → signature verification
  • But you can't open the lock without the key → can't sign without the private key
  • You can't figure out the key's shape by looking at a locked lock → can't reverse-compute a private key from a public key

You can understand the mathematical principles later. Right now, what matters more is directly confirming that wallet.py's sign() and verify() work.

Extra practice: Create 3 wallets in wallet.py and sign/verify in sequence: A→B, B→C, C→A.

🔴 Challenge

Interview question: "Why does reusing the same nonce (k) twice in ECDSA expose the private key?"

Hint: If two signatures (r₁, s₁) and (r₂, s₂) use the same k, then r₁ = r₂, and solving the two equations simultaneously lets you derive the private key d. This is the principle behind the 2010 Sony PS3 ECDSA hack.

Production challenge: Add to_json() / from_json() methods to wallet.py to export and restore the private key as an encrypted JSON file. The structure of actual Ethereum keystore files (UTC--.json) is good reference. You'll need to use password-based key derivation (PBKDF2 or scrypt).

Code Playground

Python
Python
Python
Python
Python코드로 구현해보자.
Python
Python
Python
Python
Python3. **개인키 = 유일한 서명 도구** — 개인키 없이는 코인을 이동시키는 서명을 만들 수 없다

Q&A