Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free intro, Pro: 4 of 20 minutes free
Free: Public-key encryption wraps a short key, and hybrid schemes encrypt the data under it. With a pass: the other 8 sections and 3 interview answers, about 16 minutes.

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:

The RSA-OAEP size limit, found by trying, and the hybrid way around it JavaScript · limit-js/oaep_limit.mjs
// 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:

Interactive · message by message Encrypt a 1 MiB file for someone who has only a public key Step through the hybrid pattern: one fresh key, one AEAD for the data and one RSA-OAEP operation for the key.
Encrypt a 1 MiB file for someone who has only a public key, step 6 of 6. Messages so far: Sender to Recipient: wrapped key + ciphertext.Senderkey wrapped: 256 bytesRecipienthas the AES keywrapped key + ciphertext

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A fresh key. The sender draws a random 32-byte AES-256 key. It will protect this one message and nothing else.
  2. 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.
  3. 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.
  4. Send both. The sender sends the wrapped key, a 12-byte nonce and the ciphertext: 1,048,860 bytes, 284 more than the file.
  5. 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.
  6. 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

  1. One question What is the largest message, in bytes, that RSA-OAEP with SHA-256 encrypts under a 4,096-bit key?

    Type a number.

    A hint

    The modulus has k = 512 bytes. The formula is k - 2 * hLen - 2, and SHA-256 has hLen = 32.

    Show the answer: One question

    Answer: 446 bytes

    512 − 2 × 32 − 2 = 446. The program found 190 and 318 for 2,048 and 3,072 bits by the same formula.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.