Applied Cryptography for Developers Module 10 – Public-key cryptography
Public-key encryption, hybrid schemes and HPKE
Wrap keys with RSA-OAEP within its size limit, build hybrid encryption from a key wrap and an AEAD, and reproduce HPKE base mode from RFC 9180 vectors.
What you will learn
- Use RSA-OAEP within its size limit and explain why PKCS#1 v1.5 encryption is avoided
- Describe hybrid encryption as a key encapsulation step followed by an AEAD
- Reproduce HPKE base mode from Web Crypto primitives and check it against the vectors of RFC 9180
- Choose a maintained HPKE or sealed-box library for production code
Before you start
What you’ll learn · Before you start
What you will learn
- Use RSA-OAEP within its size limit and explain why PKCS#1 v1.5 encryption is avoided
- Describe hybrid encryption as a key encapsulation step followed by an AEAD
- Reproduce HPKE base mode from Web Crypto primitives and check it against the vectors of RFC 9180
- Choose a maintained HPKE or sealed-box library for production code
Before you start
On this page
RSA-OAEP: RSAES-OAEP, RSA encryption with Optimal Asymmetric Encryption Padding (RFC 8017). Random padding makes every ciphertext different, and decryption refuses any ciphertext whose padding does not check. More in “Public-key cryptography: RSA and elliptic curves” with a 2,048-bit key and SHA-256 encrypts at most 190 bytes, so a 1 MiB file does not fit and a message of 191 bytes throws an error. Public-key encryption therefore never encrypts the data itself: it protects a short key, and a symmetric cipher protects the data under that key.
This lesson measures the limit and builds the pattern that every modern scheme follows, hybrid encryption: Encrypting data with a fresh symmetric key and delivering that key under the recipient's public key. The data is protected by an AEAD, so the message size does not depend on the public-key size limit.. It then rebuilds HPKE, the standard form of that pattern, from the test vector of RFC 9180, shows what goes wrong when the pieces are put together by hand, and ends with the choice between a standard, a library and an envelope of your own.
Public-key encryption wraps a short key, and hybrid schemes encrypt the data under it
RSA works on one number smaller than its modulus, and OAEP spends part of that room on a random seed, a hash of an optional label and two marker bytes. RFC 8017 leaves k − 2·hLen − 2 bytes for the message, for a modulus of k bytes and a hash of hLen bytes. The program finds the limit by trying, for a 2,048-bit and a 3,072-bit key with SHA-256, and then does the job the hybrid way:
// RSA-OAEP has a size limit: a message must fit in the padded block, k - 2 * hLen - 2 bytes for a modulus of k bytes and a
// hash of hLen bytes (RFC 8017 section 7.1.1). The program finds the limit by trying, for 2048-bit and 3072-bit keys with
// SHA-256, then does the job the hybrid way: one RSA-OAEP operation wraps a 32-byte AES key, and AES-GCM encrypts the data.
const { subtle } = globalThis.crypto;
const HASH_LENGTH = 32; // SHA-256
async function largestMessage(modulusLength) {
const pair = await subtle.generateKey({ name: 'RSA-OAEP', modulusLength, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, false, ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']);
const fits = (n) => subtle.encrypt({ name: 'RSA-OAEP' }, pair.publicKey, new Uint8Array(n)).then(() => true, (err) => err.name === 'OperationError' ? false : Promise.reject(err));
let [low, high] = [0, modulusLength / 8]; // low fits, high does not
while (high - low > 1) {
const mid = (low + high) >> 1;
if (await fits(mid)) low = mid;
else high = mid;
}
const error = await subtle.encrypt({ name: 'RSA-OAEP' }, pair.publicKey, new Uint8Array(low + 1)).catch((err) => err.name);
return { pair, low, error };
}
console.log('RSA-OAEP with SHA-256 (hLen = 32): the largest message that encrypts');
let pair2048;
for (const bits of [2048, 3072]) {
const k = bits / 8;
const { pair, low, error } = await largestMessage(bits);
if (bits === 2048) pair2048 = pair;
console.log(` ${bits}-bit key (k = ${k} bytes): ${low} bytes encrypt, ${low + 1} bytes throw ${error}; k - 2 * ${HASH_LENGTH} - 2 = ${k - 2 * HASH_LENGTH - 2}`);
}
const FILE = 1024 * 1024;
const chunk = 256 - 2 * HASH_LENGTH - 2;
const directOperations = Math.ceil(FILE / chunk);
console.log(`\na ${FILE.toLocaleString('en-US')}-byte file with the 2048-bit key`);
console.log(` directly: ${directOperations} RSA-OAEP operations and ${(directOperations * 256).toLocaleString('en-US')} bytes of ciphertext`);
const aesKey = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const wrapped = new Uint8Array(await subtle.wrapKey('raw', aesKey, pair2048.publicKey, { name: 'RSA-OAEP' }));
const iv = new Uint8Array(12); // one message under a fresh key, so a fixed nonce is safe here
const sealed = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, new Uint8Array(FILE)));
console.log(` hybrid: 1 RSA-OAEP operation wraps the 32-byte AES key into ${wrapped.length} bytes; AES-GCM seals the file into ${sealed.length.toLocaleString('en-US')} bytes`);
console.log(` hybrid total with the 12-byte nonce: ${(wrapped.length + iv.length + sealed.length).toLocaleString('en-US')} bytes, ${(wrapped.length + iv.length + sealed.length - FILE).toLocaleString('en-US')} more than the file`); Output
RSA-OAEP with SHA-256 (hLen = 32): the largest message that encrypts 2048-bit key (k = 256 bytes): 190 bytes encrypt, 191 bytes throw OperationError; k - 2 * 32 - 2 = 190 3072-bit key (k = 384 bytes): 318 bytes encrypt, 319 bytes throw OperationError; k - 2 * 32 - 2 = 318 a 1,048,576-byte file with the 2048-bit key directly: 5519 RSA-OAEP operations and 1,412,864 bytes of ciphertext hybrid: 1 RSA-OAEP operation wraps the 32-byte AES key into 256 bytes; AES-GCM seals the file into 1,048,592 bytes hybrid total with the 12-byte nonce: 1,048,860 bytes, 284 more than the file
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node oaep_limit.mjs
The limit is 190 bytes for the 2,048-bit key and 318 for the 3,072-bit key, one byte more throws OperationError, and both equal the formula. Encrypting a 1 MiB file directly with the 2,048-bit key would take 5,519 RSA operations and give 1,412,864 bytes. The hybrid way takes one operation to wrap a 32-byte key into 256 bytes and one AES-GCM call for the file, 1,048,860 bytes in all.
Hybrid schemes put each primitive where it belongs. Public-key encryption wraps a fresh symmetric key, and an AEAD: Authenticated encryption with associated data: one operation that encrypts a message and computes a tag over it and over associated data, so that opening returns the whole plaintext or fails. AES-GCM and ChaCha20-Poly1305 are AEADs. More in “Authenticated encryption: AES-GCM and ChaCha20” encrypts the data under it, so the cost and the size of the message follow the cipher and not the modulus. Step through the pattern:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A fresh key. The sender draws a random 32-byte AES-256 key. It will protect this one message and nothing else.
- Encrypt the data. AES-GCM seals the 1,048,576-byte file under that key and adds a 16-byte tag, 1,048,592 bytes in all. The cost of the data is the cost of AES, not of RSA.
- Wrap the key. RSA-OAEP with the recipient's 2048-bit public key wraps the 32-byte key into 256 bytes. The limit is 190 bytes, so a 32-byte key fits many times over.
- Send both. The sender sends the wrapped key, a 12-byte nonce and the ciphertext: 1,048,860 bytes, 284 more than the file.
- One RSA operation. The recipient unwraps the AES key with the private key. That is the only RSA operation, where encrypting the file in 190-byte parts would have needed 5,519.
- Open the file. AES-GCM checks the tag and returns the file. A change anywhere in the ciphertext, the nonce or the associated data makes opening fail, and nothing is returned.
Three names cover what follows. RSA-OAEP wraps a short key under an RSA public key, with random padding that decryption checks. Hybrid schemes in general key encapsulation: Delivering a fresh symmetric key under a recipient's public key, so that only the matching private key recovers it. RSA-OAEP key wrapping and ML-KEM do this; an AEAD then encrypts the data under that key. More in “Public-key cryptography: RSA and elliptic curves” for the recipient and encrypt the data with an AEAD under it. HPKE is the standard hybrid scheme of RFC 9180, with one specified key encapsulation, key derivation and AEAD, and test vectors for all of it.
Checkpoint
Check: The size limit of RSA-OAEP
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How much can RSA-OAEP encrypt directly?
RFC 8017 leaves k − 2·hLen − 2 bytes for the message, where k is the modulus length in bytes and hLen the hash length. Found by trying, the limit is 190 bytes for the 2,048-bit key and 318 for the 3,072-bit key with SHA-256; one byte more throws OperationError, so a 1 MiB file does not fit.
-
Why does public-key encryption wrap a key instead of encrypting the data?
It protects a short key and a symmetric cipher protects the data under it. Encrypting a 1 MiB file directly with a 2,048-bit RSA key would take 5,519 RSA operations and give 1,412,864 bytes. The hybrid way takes one operation to wrap a 32-byte key into 256 bytes and one AES-GCM call for the file, 1,048,860 bytes in all.
-
What is hybrid encryption?
Public-key encryption wraps a fresh symmetric key, and an AEAD encrypts the data under that key, so the cost and size of the message follow the cipher and not the modulus. Hybrid schemes in general encapsulate a fresh key for the recipient.
-
What is HPKE?
The standard hybrid scheme, defined in RFC 9180: it fixes one key encapsulation, one key derivation function and one AEAD, and comes with test vectors for all of them. RSA-OAEP, by contrast, only wraps a short key under an RSA public key, with random padding that decryption checks.
RSA-OAEP’s limit follows from its padding, and PKCS#1 v1.5 encryption stays out Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- A hybrid scheme is a key-encapsulation step followed by an AEAD, and the pieces must be bound together
- HPKE puts a KEM, a KDF and an AEAD behind one interface
- Rebuilding base mode from Web Crypto’s pieces, against the vector of RFC 9180
- Choose a maintained library and a standard, and check what it does
- Practise: write the HPKE key schedule
- Write the HPKE key schedule for base mode
- Interview questions
- Why is RSA not used to encrypt a whole file, and what is done instead?
- A service wraps customer tokens with RSA-OAEP and now fails with "data too large for key size". The tokens were 30 bytes and now carry a 200-byte claim set. Diagnose it and fix it without raising the RSA key size.
- A backup tool encrypts each 1 GB file with a fresh AES key in CTR mode, wraps the key with RSA-OAEP and stores the wrapped key, the IV and the algorithm name in an unauthenticated header. Review it and say what you would change.
- 100,000 devices send telemetry to a collector with HPKE base mode. The collector must now know which device sent each message. Compare an HPKE auth mode with a device signature, and say what changes in key management.
- Key takeaways
1 diagram · 2 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 34 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- HPKE
- Hybrid Public Key Encryption, RFC 9180: one specified key encapsulation, key derivation function and AEAD, with four modes and published test vectors. TLS Encrypted Client Hello, Oblivious HTTP and MLS use it.
- hybrid encryption
- Encrypting data with a fresh symmetric key and delivering that key under the recipient's public key. The data is protected by an AEAD, so the message size does not depend on the public-key size limit.
- sealed box
- The anonymous public-key encryption of libsodium: a new ephemeral X25519 key for each message, XSalsa20-Poly1305, and 48 bytes of overhead. The recipient cannot tell who sent it.
References
- RFC 9180: Hybrid Public Key Encryption (Internet Research Task Force (IRTF))
- RFC 8017: PKCS #1, RSA Cryptography Specifications Version 2.2 (section 7.1 on RSAES-OAEP) (Internet Engineering Task Force (IETF))
- RFC 7748: Elliptic Curves for Security (Internet Research Task Force (IRTF))
- RFC 8410: Algorithm Identifiers for Ed25519, Ed448, X25519, and X448 for Use in the Internet X.509 Public Key Infrastructure (Internet Engineering Task Force (IETF))
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirement 11.3.1) (OWASP Foundation)
- Node.js 24 documentation: Crypto (crypto.privateDecrypt and RSA_PKCS1_PADDING) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
- libsodium documentation: Sealed boxes (libsodium project)
- OWASP Cryptographic Storage Cheat Sheet (random padding, hybrid schemes and HPKE) (OWASP Foundation)
- RFC 9849: TLS Encrypted Client Hello (Internet Engineering Task Force (IETF))
- RFC 9458: Oblivious HTTP (Internet Engineering Task Force (IETF))
- RFC 9420: The Messaging Layer Security (MLS) Protocol (Internet Engineering Task Force (IETF))
Report a problem with this lesson
Kept only in this browser. Your Learn progress