We've reached the module's last lesson, and it's the one that ties every thread together. Throughout the course you've built up two families of tools with opposite virtues: symmetric cryptography (AES-GCM, ChaCha20) is blazingly fast and encrypts gigabytes, but demands a shared key we didn't know how to distribute; asymmetric cryptography (RSA, ECC) solves key distribution, but is slow and only handles tiny messages. Each one limps exactly where the other shines. The solution isn't to choose: it's to combine them. The pattern is called hybrid encryption or the digital envelope (envelope encryption), and it is, without exaggeration, how real encryption is actually done in the real world — from TLS to your cloud backups. In this lesson you'll build the whole thing: MediNube will send a full record export to Clínica Sol, and you'll see the two ways to "seal the envelope" (classic RSA-OAEP and the modern pattern with ephemeral X25519). We'll close the module with a decision map for every asymmetric primitive, and with the one piece we're still missing.
Contents
- The dilemma, summed up in a table
- The idea of the digital envelope
- Digital envelope with RSA-OAEP (classic variant)
- Digital envelope with ephemeral X25519 (modern variant, ECIES-like)
- MediNube's versioned envelope format
- Where this pattern lives in the real world
- The module's decision map
The dilemma, summed up in a table
Let's put what we know side by side, because the hybrid approach is born exactly from this tension:
| Symmetric (AES-256-GCM) | Asymmetric (RSA-OAEP / ECC) | |
|---|---|---|
| Speed | Gigabytes per second (AES-NI) | Thousands of operations/s: thousands of times slower |
| Message size | Practically unlimited | RSA: ≤318 bytes; ECC: doesn't encrypt data directly |
| Key distribution | Unsolved: needs a prior shared secret | Solved: the public key is published without fear |
| Strength | Encrypting the data | Getting a key across |
| Weakness | How does the key get there? | How do I encrypt something big, fast? |
Read the last row and you'll see the weaknesses fit together like puzzle pieces: what symmetric crypto can't do (distribute the key), asymmetric does effortlessly; what asymmetric crypto can't do (encrypt large data fast), symmetric does effortlessly. The conclusion we already anticipated in 04-01 — "RSA encrypts keys, not data" — turns here into an architecture.
The idea of the digital envelope
Hybrid encryption uses each primitive for what it's good at:
- Generate a random symmetric session key (a fresh AES-256 key, from the CSPRNG — golden rule 3), single-use.
- Encrypt the data (which is large) with that key using AES-256-GCM: fast, no size limit.
- Encrypt only the session key (32 bytes) with asymmetric cryptography, using the recipient's public key. There, asymmetric crypto's slowness and size limit don't get in the way: 32 bytes fit easily and only get encrypted once.
- Send the envelope: the encrypted session key + the encrypted data, together.
The recipient does the reverse: with their private key they open the envelope and recover the session key; with it they decrypt the data.
flowchart TD
subgraph Emisor["MediNube (sender)"]
K["RANDOM AES-256 session key"] --> C1["AES-256-GCM encrypts the EXPORT (large, fast)"]
PUB["Clínica Sol's public key"] --> C2["Encrypts ONLY the session key (32 bytes)"]
K --> C2
end
C2 --> S["ENVELOPE: encrypted key + encrypted data"]
C1 --> S
S -->|public channel| Dest
subgraph Dest["Clínica Sol (recipient)"]
PRIV["Its PRIVATE key"] --> D1["Opens the envelope -> recovers the session key"]
D1 --> D2["AES-256-GCM decrypts the export"]
end
The beauty of the pattern: you get confidentiality toward a recipient with a public key (no one else can open the envelope) with the speed of symmetric crypto (the data travels with AES). And since the session key is fresh every time, two identical exports produce completely different envelopes. All that's left to decide is how the envelope gets sealed — step 3 — and here there are two schools of thought.
Digital envelope with RSA-OAEP (classic variant)
If the recipient has an RSA key (like the public hospital in MediNube's integration), sealing the envelope is literally what you learned in 04-01: encrypt the session key's 32 bytes with RSA-OAEP.
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
OAEP = padding.OAEP(mgf=padding.MGF1(hashes.SHA256()),
algorithm=hashes.SHA256(), label=None)
def encrypt_hybrid_rsa(data: bytes, aad: bytes, pub_rsa) -> dict:
"""Digital envelope: AES-GCM for the data, RSA-OAEP for the key."""
session_key = AESGCM.generate_key(bit_length=256) # random, single-use
nonce = os.urandom(12) # fresh nonce (module 2)
encrypted_data = AESGCM(session_key).encrypt(nonce, data, aad)
encrypted_key = pub_rsa.encrypt(session_key, OAEP) # only 32 bytes through RSA
return {"scheme": "rsa-oaep", "encrypted_key": encrypted_key,
"nonce": nonce, "encrypted_data": encrypted_data}
def decrypt_hybrid_rsa(envelope: dict, aad: bytes, priv_rsa) -> bytes:
session_key = priv_rsa.decrypt(envelope["encrypted_key"], OAEP) # open the envelope
return AESGCM(session_key).decrypt(envelope["nonce"], envelope["encrypted_data"], aad)
# --- Test with a "large" export ---
priv = rsa.generate_private_key(public_exponent=65537, key_size=3072)
export = b"CLINICA SOL RECORDS\n" + b"ana.perez;A+;penicillin\n" * 5000
aad = b"destino=clinica-sol;formato=v1"
envelope = encrypt_hybrid_rsa(export, aad, priv.public_key())
print(len(export), "bytes of data ->", len(envelope["encrypted_key"]), "bytes of encrypted key")
assert decrypt_hybrid_rsa(envelope, aad, priv) == exportNotice the key point: export can weigh megabytes and RSA doesn't care, because RSA only ever touches the 32-byte session key. The 318-byte limit from 04-01 stops being a problem. This is "RSA encrypts keys, not data" turned into code.
Digital envelope with ephemeral X25519 (modern variant, ECIES-like)
What if the recipient has an elliptic curve key (X25519), which — as you saw in 04-04 — doesn't encrypt directly? You combine the previous lesson's key exchange with the envelope: the sender generates an ephemeral X25519 pair, does ECDH against the recipient's static public key, derives the session key with HKDF, and attaches its ephemeral public key to the envelope. The recipient rebuilds the same secret with their private key and the received ephemeral public key. This pattern has its own name: ECIES (Elliptic Curve Integrated Encryption Scheme), and it inherits forward secrecy from 04-04 for free.
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def _derive(secret: bytes) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
info=b"medinube:sobre-hibrido:v1").derive(secret)
def encrypt_hybrid_x25519(data: bytes, aad: bytes, pub_recipient: x25519.X25519PublicKey) -> dict:
ephemeral = x25519.X25519PrivateKey.generate() # single-use EPHEMERAL pair
session_key = _derive(ephemeral.exchange(pub_recipient)) # ECDH + HKDF (04-04)
nonce = os.urandom(12)
encrypted_data = AESGCM(session_key).encrypt(nonce, data, aad)
return {"scheme": "x25519-ecies",
"ephemeral_pub": ephemeral.public_key().public_bytes_raw(), # travels IN THE CLEAR
"nonce": nonce, "encrypted_data": encrypted_data}
def decrypt_hybrid_x25519(envelope: dict, aad: bytes, priv_recipient: x25519.X25519PrivateKey) -> bytes:
ephemeral_pub = x25519.X25519PublicKey.from_public_bytes(envelope["ephemeral_pub"])
session_key = _derive(priv_recipient.exchange(ephemeral_pub)) # same secret
return AESGCM(session_key).decrypt(envelope["nonce"], envelope["encrypted_data"], aad)
# --- Test ---
priv_clinic = x25519.X25519PrivateKey.generate()
export = b"RECORDS\n" + b"ana.perez;A+\n" * 5000
aad = b"destino=clinica-sol;formato=v1"
envelope = encrypt_hybrid_x25519(export, aad, priv_clinic.public_key())
assert decrypt_hybrid_x25519(envelope, aad, priv_clinic) == exportDifferences from the RSA variant worth having clear:
| RSA-OAEP (classic) | Ephemeral X25519 / ECIES (modern) | |
|---|---|---|
| What travels to "open the envelope" | The session key, encrypted with the public key | The sender's ephemeral public key (in the clear) |
| How the recipient gets the key | Decrypts it with their private key | Re-derives it via ECDH + HKDF |
| Forward secrecy | No (if the RSA private key is stolen, all past envelopes open) | Yes (ephemeral pair per envelope, discarded) |
| Extra envelope size | 384 bytes (encrypted key, RSA-3072) | 32 bytes (ephemeral public key) |
| When to use it | Recipient with an RSA key / legacy requirement | Modern default choice |
In ECIES the session key isn't "encrypted": it's derived from an exchange, and the only thing that travels is the ephemeral public key — harmless, like any public key. That's why it gives forward secrecy almost for free, the same property that made ephemeral keys valuable in 04-04.
MediNube's versioned envelope format
Example dictionaries are fine for learning, but to send the envelope over a channel or store it on disk, you need a concrete, versioned byte format, consistent with the v1 style we've carried since module 2 (version || ...). That way medinube/envelope.py can evolve without breaking old envelopes (crypto-agility, golden rule 8):
# medinube/envelope.py — versioned hybrid envelope format
#
# byte 0 : format version
# 0x01 -> RSA-OAEP : 0x01 || encrypted_key(384) || nonce(12) || encrypted_data
# 0x02 -> X25519 : 0x02 || ephemeral_pub(32) || nonce(12) || encrypted_data
#
# The data travels with AES-256-GCM (tag included). The course's AAD identifies the destination.
def pack_x25519(envelope: dict) -> bytes:
return b"\x02" + envelope["ephemeral_pub"] + envelope["nonce"] + envelope["encrypted_data"]
def unpack_x25519(blob: bytes, aad: bytes, priv_recipient) -> bytes:
if blob[0] != 0x02:
raise ValueError(f"unsupported envelope version: {blob[0]:#x}")
envelope = {"ephemeral_pub": blob[1:33], "nonce": blob[33:45], "encrypted_data": blob[45:]}
return decrypt_hybrid_x25519(envelope, aad, priv_recipient)
# ana.perez's record export, ready to travel as a single blob:
blob = pack_x25519(encrypt_hybrid_x25519(export, aad, priv_clinic.public_key()))
print(blob[0], len(blob)) # 2 <total length>
assert unpack_x25519(blob, aad, priv_clinic) == exportThe first byte rules everything: whoever receives the envelope reads the version and knows exactly how to interpret it. The day MediNube adds a post-quantum scheme (06-05), it'll be 0x03; envelopes 0x01 and 0x02 will keep decrypting without touching anything. It's the same discipline as the AES-GCM v1 format (02-03) and the prescription's v1=<hex> signature (04-03): the version, always up front.
Compliance reminder. A record export is special-category health data. This code shows the cryptographic mechanics with fictional data; a real deployment requires security and data-protection (GDPR) review — just as we warned with the prescriptions.
Where this pattern lives in the real world
Hybrid encryption isn't a trick from this course: it's the dominant pattern. Almost anything you encrypt daily is a digital envelope:
- TLS/HTTPS (module 5, lesson 05-02): every visit to
https://portal.medinube.examplenegotiates a symmetric session key via asymmetric exchange (ECDHE) and encrypts traffic with AES-GCM or ChaCha20-Poly1305. It's exactly this lesson, with certificate-based authentication. - PGP/GPG and age: file and email encryption. They generate a session key per message and protect it with the recipient's public key — textbook digital envelope.
ageis the modern, minimalist version, built on X25519 like your ECIES variant. - KMS envelope encryption (a preview of 06-01): cloud key management services encrypt your data with a symmetric "data encryption key" (DEK) and, in turn, encrypt that DEK with a "key encryption key" (KEK) that never leaves the service. The vocabulary changes (DEK/KEK), the pattern is identical: encrypt the data with a symmetric key, protect that key with other cryptography. You'll see it again in module 6.
- End-to-end messaging (Signal, WhatsApp): they use much more sophisticated variants (the double-ratchet protocol) that go beyond the simple envelope, but their foundation is the same — X25519 to agree on keys, symmetric crypto for the messages. We only mention it here; its design is worth a course of its own.
Recognizing this pattern under all those names is one of the course's goals: they aren't ten different technologies, it's one idea applied ten times.
The module's decision map
Close your eyes and review the whole module: which asymmetric primitive do you use for what you need? This table is the operational summary of the five lessons:
| I need to... | Goal (module 1) | Primitive | Lesson |
|---|---|---|---|
| Encrypt toward someone a small piece of data (a key) | Confidentiality | RSA-OAEP | 04-01 |
| Encrypt toward someone large data | Confidentiality | Hybrid: AES-GCM + (RSA-OAEP or X25519/ECIES) | 04-05 |
| Sign something anyone can verify, with non-repudiation | Authenticity + non-repudiation | Ed25519 (or RSA-PSS) | 04-03 |
| Agree on a key over a public channel | (enables confidentiality) | ECDH with X25519 (+ HKDF) | 04-04 |
| Small, fast keys for all of the above | — | Elliptic curve (Ed25519 / X25519) | 04-02 |
Pocket rule for choosing from memory: encrypt-toward-someone → hybrid (envelope); prove who issued it → signature (Ed25519); agree on a key → exchange (X25519). Three verbs, three tools. And all of them, underneath, leaning on the symmetric crypto and hashes from modules 2 and 3: nothing was thrown away, everything got combined.
Common Mistakes and Tips
- Chunking large data with RSA instead of using the hybrid pattern. The anti-pattern we already warned about in 04-01: encrypting a file in 318-byte blocks with RSA is agonizingly slow and cryptographically fragile. The digital envelope is the answer — one session key, one AES encryption.
- Reusing the session key across messages. It must be random and single-use per envelope. Reusing it reintroduces determinism (two identical exports would give identical ciphertexts) and risks AES-GCM's nonce rule.
- Forgetting the envelope's AAD. It binds the ciphertext to its context (destination, format) just like in 02-03. Without AAD, an attacker could redirect a valid envelope to a different recipient or format without decryption detecting it.
- Storing the envelope without a version. The version byte up front isn't decorative: it's what lets you add ECIES or post-quantum tomorrow without breaking today's envelopes. An unversioned format means an impossible migration.
- Believing the envelope authenticates the sender. Hybrid encryption gives confidentiality, not authenticity: anyone with your public key can fabricate you an envelope. If you need to know who sent it, you must sign as well as encrypt (04-03) — sign-then-encrypt, or combined schemes; the envelope alone doesn't give it to you.
- Using the RSA variant and expecting forward secrecy. RSA-OAEP doesn't have it: if the RSA private key is ever stolen, every recorded past envelope opens. If forward secrecy matters, use ephemeral X25519.
- And the usual one: trusting the recipient's public key at face value. The whole hybrid scheme assumes Clínica Sol's public key really is Clínica Sol's. We still don't know that for sure — it's the debt that closes the module, right below.
Exercises
-
Round-trip versioned envelope. Using
encrypt_hybrid_x25519and the format frommedinube/envelope.py: encrypt the exportb"RECORD ana.perez\n" * 1000with AADb"destino=clinica-sol;formato=v1", pack it into ablobof bytes, print its first byte and its length, and unpack it, checking that you recover the original. Then change one byte of the blob's encrypted data and check which exception is raised when unpacking, and why (hint: AES-GCM, module 2). -
The two envelopes, compared. Encrypt the same export with the RSA-OAEP variant and with the X25519 one. (a) Compare the size of the material that travels to "open the envelope" in each case. (b) Encrypt the same export twice with the X25519 variant and check that the ephemeral public keys (and hence the envelopes) differ. (c) Explain which security property that difference gives, and which of the two variants has it.
-
Confidentiality is not authenticity. MediNube receives a hybrid X25519 envelope that decrypts without error and contains an order: "register a new doctor." Reason it out (no code needed): (a) does successful decryption prove Clínica Sol sent the envelope? (b) If not, who could have fabricated it? (c) Which primitive from this module would need to be added for MediNube to trust the sender, and which pending problem would resurface by adding it?
Solutions
export = b"RECORD ana.perez\n" * 1000
aad = b"destino=clinica-sol;formato=v1"
blob = pack_x25519(encrypt_hybrid_x25519(export, aad, priv_clinic.public_key()))
print(blob[0], len(blob)) # 2 <length>
assert unpack_x25519(blob, aad, priv_clinic) == export
corrupted = blob[:-1] + bytes([blob[-1] ^ 1]) # we alter one byte at the end (data)
try:
unpack_x25519(corrupted, aad, priv_clinic)
except Exception as e:
print(type(e).__name__) # InvalidTagIt raises InvalidTag: AES-256-GCM's authentication tag (module 2) detects that a byte of the ciphertext changed and refuses to decrypt. The hybrid envelope inherits the integrity of its inner AEAD: it's not just confidential, it also detects tampering with the data.
priv_rsa = rsa.generate_private_key(public_exponent=65537, key_size=3072) env_rsa = encrypt_hybrid_rsa(export, aad, priv_rsa.public_key()) env_ec1 = encrypt_hybrid_x25519(export, aad, priv_clinic.public_key()) env_ec2 = encrypt_hybrid_x25519(export, aad, priv_clinic.public_key()) print(len(env_rsa["encrypted_key"])) # (a) 384 bytes print(len(env_ec1["ephemeral_pub"])) # (a) 32 bytes print(env_ec1["ephemeral_pub"] != env_ec2["ephemeral_pub"]) # (b) True: different ephemerals
(a) RSA sends 384 bytes over the wire (the encrypted session key); X25519, only 32 (the ephemeral public key). (b) Each X25519 encryption generates a fresh ephemeral pair, so the public keys — and the entire envelopes — differ even though the export is identical. (c) That renewal per envelope gives forward secrecy: the ephemeral X25519 variant has it, RSA-OAEP doesn't (whose single private key, if stolen, opens every past envelope).
- (a) No. Hybrid encryption only proves that someone with MediNube's public key built the envelope — and that key is public. Decrypting correctly proves the data's confidentiality and integrity, not the sender's identity. (b) Anyone: MediNube's public key is published, so an attacker can build a perfectly valid envelope with whatever order they want. (c) You'd need to sign the content with the sender's private key (Ed25519, 04-03) and have MediNube verify it with the clinic's public key: that adds authenticity and non-repudiation. But doing so brings back the question of the module: how does MediNube know that verification public key really is Clínica Sol's? The answer — certificates and trusted authorities — is module 5.
Conclusion
You've closed the circle. Hybrid encryption combines the best of the course's two families: a random symmetric session key encrypts the data with AES-256-GCM (fast, no size limit), and asymmetric cryptography protects only that key (where its slowness and its limit don't matter). You built it in both its forms — classic RSA-OAEP and the modern ephemeral X25519 (ECIES) pattern, the latter with forward secrecy thrown in — in a versioned envelope format (0x01/0x02, the version always up front) consistent with the whole course, and you recognized the same pattern beating under TLS, PGP/age, KMS envelope encryption, and end-to-end messaging. This closes out Module 4, and with it your toolbox is complete: you have symmetric crypto (module 2) to encrypt fast, hashes and MACs (module 3) for integrity and authenticity with a shared secret, and asymmetric crypto (this module) to sign, agree on keys, and encrypt toward strangers with no prior secret. You can encrypt, sign, verify, and agree on keys over a public channel. And yet, lesson after lesson — 04-01, 04-03, 04-04, and just now in the last exercise — the same crack has kept reappearing: all the mathematics is flawless, but it rests on an unverified assumption, that the public key you have belongs to who you think it does. MalloryClinic is still out there, waiting for someone to trust a key without checking it. We're not missing new primitives: we're missing trust — a way to know, with verifiable backing, whose public key each one is. That's the one piece left, and it's exactly what Module 5: PKI, Certificates, and TLS builds, starting with X.509 certificates and certificate authorities in lesson 05-01. There, at last, we'll close the crack we've carried through the whole module. See you in module 5.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
