The previous lesson ended on an uncomfortable calculation: RSA gives 128 bits of security with 3072-bit keys, but reaching the gold standard of 256 bits would need ~15360-bit keys — growth so bad that, in practice, nobody does it. This lesson introduces the family of trapdoors that solved that problem and that dominates asymmetric cryptography today, from your phone to TLS: elliptic curve cryptography (ECC). You'll see why it achieves the same guarantees with keys of only 256 bits, the geometric intuition behind its one-way function (no heavy algebra, promised), which specific curves matter today and which to avoid, and how to generate and serialize EC keys with pyca/cryptography. By the end, MediNube will make an architectural decision that shapes the rest of the module: which curve to use for the upcoming electronic prescription signatures.

Contents

  1. RSA's problem: keys that grow badly
  2. What an elliptic curve is: point addition
  3. The trapdoor: the elliptic discrete logarithm
  4. The curves that matter: NIST and Bernstein
  5. Generating EC keys with pyca/cryptography
  6. PEM serialization: same as RSA, but in miniature
  7. RSA or ECC: a practical decision rule
  8. MediNube's decision

RSA's problem: keys that grow badly

RSA's security rests on factorization, and factorization has a problem for the defender: the known algorithms for attacking it (like the general number field sieve, GNFS) are subexponential — much better than brute force. Consequence: to gain a few extra bits of security, you have to grow the RSA key enormously.

With elliptic curves, the opposite happens: the best known attack against their trapdoor is essentially generic (cost ~2^(n/2), the same order as the birthday paradox you saw in 03-01). That means an EC key of n bits gives ~n/2 bits of security, and the equivalence table looks like this:

Bits of security RSA key needed ECC key needed Symmetric equivalent
80 (obsolete) 1024 bits 160 bits — (broken/forbidden)
112 (today's minimum) 2048 bits 224 bits 3DES (being phased out)
128 (course standard) 3072 bits 256 bits AES-128
192 7680 bits 384 bits AES-192
256 ~15360 bits 512 bits AES-256

Read the table row by row: for the 128-bit standard (golden rule 5), RSA needs a key 12 times bigger than ECC. And the gap explodes further down: in the 256-bit row, RSA is flatly impractical while ECC is still just a 64-byte key.

Why does this matter, beyond elegance?

  • Performance. EC operations over 256-bit numbers are much faster than RSA exponentiations over 3072-bit ones. On a server handling thousands of TLS connections per second, the difference shows up on the bill.
  • Bandwidth. Every public key, every signature, every exchange travels over the network. 32–64 bytes versus 384 in every handshake, multiplied by millions of connections.
  • Mobile and IoT. A battery-powered medical sensor with a modest microcontroller can do ECC comfortably; RSA-3072 costs it dearly. For MediNube, which plans to integrate home-monitoring devices, this isn't theoretical.
  • Key generation. Generating an RSA-3072 pair means searching for huge primes (seconds); generating an EC pair means picking a random number (microseconds). This will enable the ephemeral keys of 04-04.

What an elliptic curve is: point addition

An elliptic curve is the set of points (x, y) satisfying an equation of the form:

y² = x³ + ax + b

Drawn over the real numbers, it's a smooth curve, symmetric about the X axis. What's interesting isn't the curve itself, but that you can define an addition operation over its points with a surprisingly simple geometric rule:

To add two points P and Q: draw the line through them. That line intersects the curve at a third point. Reflect that point across the X axis: the result is P + Q.

flowchart TD
    A["Points P and Q on the curve"] --> B["Draw the line through P and Q"]
    B --> C["The line intersects the curve at a third point R'"]
    C --> D["Reflect R' across the X axis"]
    D --> E["Result: P + Q = R"]
    F["Special case: adding P to itself (P + P = 2P)"] --> G["Use the line TANGENT to the curve at P"]
    G --> C

With that addition defined, scalar multiplication is defined as repeated addition:

k · G  =  G + G + G + ... + G   (k times)

where G is a fixed point on the curve called the generator point (defined in each curve's standard, and public). And here's the trick that makes it practical: you don't actually need to do k real additions. Just like pow(m, e, n) doesn't multiply e times, scalar multiplication is computed with double-and-add: 8·G = 2·(2·(2·G)) — about ~256 doublings and additions are enough even if k has 256 bits. The "easy" direction really is easy.

Two honest caveats, so the intuition doesn't mislead you:

  • In cryptography the curve doesn't live over the real numbers but over a finite field (integer coordinates modulo a large prime). The continuous picture disappears and what's left is a cloud of points, but the formulas for geometric addition still work exactly the same. The intuition of the line and the reflection is valid; the picture isn't.
  • The exact equation and its parameters a, b, the field's prime, and the generator G are what define a specific curve (P-256, Curve25519...). Choosing those parameters poorly can destroy security — that's why only standardized, audited curves are used, never homemade ones (golden rule 1, again).

The trapdoor: the elliptic discrete logarithm

You now have the two pieces to see the one-way function:

  • Easy: given a secret scalar k and the generator G, compute the point Q = k·G (double-and-add, microseconds).
  • Infeasible: given the point Q and the generator G, recover the scalar k. This is the elliptic curve discrete logarithm problem (ECDLP), and no shortcut is known on well-chosen curves: the best attack costs on the order of 2^128 operations for a 256-bit curve.

The correspondence with what you already know from 04-01 is direct:

RSA ECC
Private key The primes p, q (and d) The scalar k (a 256-bit number)
Public key The modulus n = p·q (and e) The point Q = k·G
Easy direction Multiplying primes Multiplying a scalar by a point
Infeasible problem Factoring n ECDLP: recovering k from Q
Best known attack Subexponential (GNFS) -> huge keys Generic ~2^(n/2) -> compact keys

Notice that generating an EC key pair is trivial: the private key is literally a random number of 256 bits straight from the CSPRNG (golden rule 3), and the public one is obtained with a single scalar multiplication. No searching for primes. That's why generating EC keys is thousands of times faster than generating RSA keys — a detail that will seem minor until in 04-04 we want to generate a fresh pair for every connection.

A note for the future (so a headline doesn't scare you): both factorization and the discrete logarithm — classical and elliptic — would fall to a large-scale quantum computer running Shor's algorithm. No such machine exists today, and the answer (post-quantum cryptography) is lesson 06-05. For everything we build in this module, ECC is the current, correct standard.

The curves that matter: NIST and Bernstein

In practice you don't choose parameters: you choose a named curve from a very short catalog. The ones you need to know fall into two families:

The NIST curves: P-256 and P-384

Standardized by the US NIST (P-256 is also called secp256r1 or prime256v1 — three names, the same curve; P-384 is secp384r1). They're the interoperability standard: TLS certificates, smart cards, national eID cards, government APIs, HSM hardware... if an external system demands a specific curve, it's almost certainly one of these. They're secure with a correct implementation, but carry two shadows:

  • Implementing them in constant time is hard, and there have been historical implementations with side-channel leaks (recall the timing attacks from 01-04). The one in pyca/cryptography (OpenSSL underneath) is correct; the risk lies in homemade or exotic implementations.
  • Their constants derive from publicly unexplained seeds, which has generated historical distrust (nothing bad has ever been proven, but modern cryptography prefers justifiable constants).

The Bernstein curves: Curve25519 and Ed25519

Designed by Daniel J. Bernstein with the opposite philosophy: make the secure implementation the easy path. Explained, minimalist constants, constant-time operations by the curve's own design, no special cases a programmer could mishandle, no dangerous decisions delegated to whoever uses it. They come in two flavors worth not confusing:

  • X25519 (over Curve25519): the curve for key exchange. It's the star of 04-04.
  • Ed25519: the curve (a transformation of the same one) for digital signatures. It's the star of 04-03.

They're the curves of choice in modern software: SSH, Signal, WireGuard, age, and a growing fraction of TLS use them by default.

P-256 / P-384 (NIST) Curve25519 / Ed25519 (Bernstein)
Security ~128 / ~192 bits ~128 bits
Strength Universal interoperability, regulatory requirements Error-proof design, constant-time by construction, speed
Weakness Historical implementations with side channels; opaque constants Some legacy system or regulation still doesn't accept them
When to choose it A third party imposes it on you (certificates, hardware, compliance) Whenever the decision is yours

And the ones you should know to avoid: any curve under 224 bits (SECP192R1, SECT163K1... if you see them in legacy code, that's a finding), and exotic curves picked without a reason. SECP256K1 is a special case: it's Bitcoin/Ethereum's curve, correct in its niche, but outside it there's no reason to pick it.

Generating EC keys with pyca/cryptography

The three variants you'll use, each with its own module under hazmat.primitives.asymmetric:

from cryptography.hazmat.primitives.asymmetric import ec, ed25519, x25519

# 1) NIST curve P-256: when interoperability rules.
priv_nist = ec.generate_private_key(ec.SECP256R1())
pub_nist = priv_nist.public_key()
print(priv_nist.curve.name)       # secp256r1
print(priv_nist.curve.key_size)   # 256

# 2) Ed25519: the modern SIGNING curve (we develop it in 04-03).
priv_sig = ed25519.Ed25519PrivateKey.generate()
pub_sig = priv_sig.public_key()

# 3) X25519: the KEY EXCHANGE curve (we develop it in 04-04).
priv_exchange = x25519.X25519PrivateKey.generate()
pub_exchange = priv_exchange.public_key()

Details worth internalizing:

  • With NIST curves, the curve is passed as a parameter (ec.generate_private_key(ec.SECP256R1())), and the same object type serves several uses. With Bernstein curves, the class is both the curve and the use: Ed25519PrivateKey only knows how to sign, X25519PrivateKey only knows how to exchange. That rigidity is an advantage: the API stops you from reusing a key for two different purposes, a classic mistake.
  • There's no key_size to choose or public_exponent to remember: the curve already defines everything. Fewer decisions, fewer ways to get it wrong — exactly the Bernstein philosophy.
  • Generation is instantaneous. Generate a thousand pairs in a loop and time it; do the same with rsa.generate_private_key and compare (spoiler: you won't want to wait).
  • True to what you've learned: the private key is a random scalar from the system's CSPRNG; the public one, a point. pyca/cryptography won't let you see the scalar by accident, and that's how it should be.

What we're not going to do yet: neither priv_sig.sign(...) nor priv_exchange.exchange(...). Each operation has its own lesson (04-03 and 04-04) and context; today's goal is to have the keys and understand why they're so small.

PEM serialization: same as RSA, but in miniature

Everything you learned about PEM in 04-01 applies unchanged — same API, same formats, same discipline of encrypting the private key:

from cryptography.hazmat.primitives import serialization

# Ed25519 private key: PKCS8 + encrypted with a passphrase, as always.
pem_priv = priv_sig.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.BestAvailableEncryption(
        b"medinube-passphrase"
    ),
)

# Public key: SubjectPublicKeyInfo, unencrypted.
pem_pub = pub_sig.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

print(pem_pub.decode())
# -----BEGIN PUBLIC KEY-----
# MCowBQYDK2VwAyEA...          <- the ENTIRE PEM fits on two lines!
# -----END PUBLIC KEY-----

# Loading them back: the same functions from 04-01.
priv2 = serialization.load_pem_private_key(pem_priv, password=b"medinube-passphrase")
pub2 = serialization.load_pem_public_key(pem_pub)

Compare that ~4-line public PEM with RSA-3072's (a dozen lines of Base64): it's the equivalence table made visible. Practical note: load_pem_public_key returns the correct type based on the content (Ed25519PublicKey, EllipticCurvePublicKey, RSAPublicKey...) — if your code expects a specific curve, check the type with isinstance when loading; don't just assume it.

RSA or ECC: a practical decision rule

The question you'll ask yourself on every new project, answered in a table:

Criterion RSA (3072+) ECC (Ed25519/X25519, or P-256)
Security at matched configuration Equivalent Equivalent
Key and signature size Hundreds of bytes Tens of bytes
Speed (signing, exchange, generation) Slow, and generating keys is very slow Fast across the board
Compatibility with old systems Universal, decades of deployment Excellent in modern software; some legacy systems don't speak it
Implementation risk Correct padding is critical (OAEP/PSS) Minimal with 25519; moderate with NIST outside serious libraries
Encrypting small messages directly Yes (OAEP, ≤318 bytes) Not directly (used via exchange + symmetric, 04-04/04-05)
Verdict for new code Only if a third party requires it Default choice

The last row on the left deserves a note: ECC has no direct equivalent to RSA-OAEP's "encrypt this little message with the public key." With elliptic curves, confidentiality is built by combining key exchange and symmetric encryption — which is exactly the path in 04-04 and 04-05, and also how the real world works. It's not a shortcoming; RSA-OAEP's pattern was the shortcut, not the norm.

MediNube's decision

Time to apply the criterion. The security-layer architecture meeting has two open fronts:

  1. Electronic prescription signatures (module 1's debt): each doctor will sign the prescriptions they issue, and external pharmacies must be able to verify them. No legacy system imposes RSA here — the system is new. Decision: Ed25519. 32-byte keys that fit anywhere, fast and compact signatures, and an API that doesn't let you get it wrong. Every doctor at Clínica Sol and Luna Medical Center will have their own Ed25519 pair. The details, in the next lesson.
  2. Secure channel between MediNube and the clinics (02-01's debt): X25519 for key exchange, for the same reasons. It materializes in 04-04.

And the RSA-3072 pair we generated in 04-01? It stays: integrating with a public hospital's system requires RSA by regulation, and there RSA-OAEP remains correct. They'll coexist — crypto-agility (golden rule 8) is exactly about being able to keep both families and retire one without rewriting the system.

And 04-01's uncomfortable question stands untouched, now multiplied: if every doctor has an Ed25519 public key, how does a pharmacy know which doctor each key belongs to? Write it down; in 04-04 it comes back with drama.

Common Mistakes and Tips

  • Comparing key sizes across families. "My 3072-bit RSA is more secure than your 256-bit Ed25519" is false: both give ~128 bits of security. Key bits are only comparable within the same family; what's comparable across families is bits of security.
  • Picking the "bigger, just in case" curve. P-521 or RSA-4096 signatures rarely add anything over 25519/P-256 and make everything more expensive. 128 bits of security is the course standard; raise it only for a concrete requirement (regulation, extreme data longevity).
  • Confusing Ed25519 with X25519. Related, but not interchangeable: one signs, the other exchanges, with different key formats. pyca/cryptography's API protects you (separate classes); other ecosystems don't always.
  • Reusing the same key to sign and to exchange. Even if a library allowed it, it's a classic anti-pattern: each purpose gets its own key pair. Keep this rule in mind for when we cover key inventory and rotation in 06-01.
  • Implementing curve arithmetic by hand. The point-addition formulas fit on a screen and the temptation is real. But the special cases (point at infinity, P + (-P), constant time) are a minefield. Golden rule 1: the curve is chosen, not implemented.
  • Small or obscure curves in legacy code. SECP192R1 or similar under 224 bits: a security finding, to be migrated. And if you see SECP256K1 outside a blockchain context, ask why — it was almost certainly a copy-paste.
  • Saving an EC private key unencrypted because "it's so small." Size doesn't change 04-01's discipline: PKCS8 + BestAvailableEncryption, passphrase outside the repository.

Exercises

  1. The table, internalized. Without looking at the table: (a) what ECC key size do you need for 192 bits of security, and what would the equivalent RSA key size be? (b) Why isn't the relationship linear — why does RSA "grow badly" while ECC grows gently? (c) How many bits of security does a 256-bit EC key give, and why not 256?

  2. Generation duel. Write a script that generates 50 Ed25519 pairs and 3 RSA-3072 pairs, timing each batch with time.perf_counter(). Compute the average cost per pair and the ratio between them. Serialize one public key from each family to PEM and compare lengths with len(). What does the result imply for a server that wanted to generate an ephemeral pair per connection (a preview of 04-04)?

  3. Legacy code. You're auditing this startup routine in MediNube:

    from cryptography.hazmat.primitives.asymmetric import ec
    from cryptography.hazmat.primitives import serialization
    
    def generate_doctor_keypair_BAD():
        priv = ec.generate_private_key(ec.SECP192R1())
        pem = priv.private_bytes(
            serialization.Encoding.PEM,
            serialization.PrivateFormat.PKCS8,
            serialization.NoEncryption(),
        )
        return pem
    

    Identify the two problems, write a corrected generate_doctor_keypair (recall MediNube's decision: these keys will sign prescriptions), and justify each change.

Solutions

  1. (a) ECC of 384 bits (P-384); the equivalent RSA would be 7680 bits, which practically no one deploys. (b) Because the best attacks are of a different nature: against RSA there's a subexponential algorithm (GNFS), so every extra bit of security demands inflating the key a lot; against the ECDLP on well-chosen curves there are only generic attacks costing ~2^(n/2), so it's enough to add 2 key bits per bit of security. (c) ~128 bits, precisely because of that 2^(n/2) — the same "half the size" phenomenon the birthday paradox imposed on hashes in 03-01.

import time
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from cryptography.hazmat.primitives import serialization

t0 = time.perf_counter()
for _ in range(50):
    ed25519.Ed25519PrivateKey.generate()
t_ed = (time.perf_counter() - t0) / 50

t0 = time.perf_counter()
for _ in range(3):
    rsa.generate_private_key(public_exponent=65537, key_size=3072)
t_rsa = (time.perf_counter() - t0) / 3

print(f"Ed25519: {t_ed*1000:.3f} ms/pair | RSA-3072: {t_rsa*1000:.1f} ms/pair "
      f"| factor: x{t_rsa/t_ed:,.0f}")

spki = serialization.PublicFormat.SubjectPublicKeyInfo
pem_ed = ed25519.Ed25519PrivateKey.generate().public_key().public_bytes(
    serialization.Encoding.PEM, spki)
pem_rsa = rsa.generate_private_key(public_exponent=65537, key_size=3072) \
    .public_key().public_bytes(serialization.Encoding.PEM, spki)
print(len(pem_ed), len(pem_rsa))   # ~113 vs. ~625 bytes

The typical factor ranges from thousands to tens of thousands: Ed25519 takes microseconds (pick 32 random bytes and do one scalar multiplication); RSA-3072 takes tens to hundreds of milliseconds (search for two huge primes, and with variable duration on top — sometimes the primes turn up fast, sometimes they don't). Implication: generating an EC pair per connection is free; generating an RSA pair per connection would be a performance suicide. That asymmetry is exactly what makes ephemeral keys and forward secrecy viable in 04-04.

  1. The two problems: (1) SECP192R1 is a curve with ~96 bits of security, well below the minimum — insufficient and forbidden for new code; (2) NoEncryption() stores the doctor's private key naked: whoever reads the file can sign prescriptions as that doctor. Fix:
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization

def generate_doctor_keypair(passphrase: bytes):
    """A doctor's signing key pair (MediNube's decision: Ed25519)."""
    priv = ed25519.Ed25519PrivateKey.generate()
    pem_priv = priv.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.PKCS8,
        serialization.BestAvailableEncryption(passphrase),
    )
    pem_pub = priv.public_key().public_bytes(
        serialization.Encoding.PEM,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    return pem_priv, pem_pub

Justification: Ed25519 instead of another NIST curve because the prescription system is new (nothing imposes NIST interoperability) and it's the architectural decision made in this lesson; the passphrase comes in as a parameter because it must come from outside the code (secrets manager — module 6), never written into the source.

Conclusion

You now have the second great trapdoor of asymmetric cryptography. Over an elliptic curve you define a geometric point addition, and with it scalar multiplication Q = k·G: computing Q from k is instant, recovering k from Q — the elliptic discrete logarithm — is infeasible. Because the best attacks against the ECDLP are generic (~2^(n/2)) while RSA's are subexponential, ECC gives the same 128 bits of security with keys of 256 bits instead of 3072: fewer bytes on the network, faster operations, and key generation that's practically free. You know the catalog (P-256/P-384 when interoperability rules; Ed25519 for signing and X25519 for exchange when the choice is yours, which is nearly always), you know how to generate and serialize all three variants with pyca/cryptography, and MediNube has made its decision: Ed25519 for doctors' signatures, X25519 for the channel with the clinics. But look at what you're holding: keys capable of signing... and you still don't know how to sign. It's time to settle the course's oldest debt — the non-repudiation promised in module 1 — and have Clínica Sol's doctor sign her first electronic prescription. That's the next lesson, 04-03: Digital Signatures. See you there.

© Copyright 2026. All rights reserved