Hybrid Public Key Encryption (HPKE)

HPKE (RFC 9180) is a non-interactive protocol that enables secure communication using a combination of public key cryptography (a Key Encapsulation Mechanism, KEM) and symmetric encryption (Authenticated Encryption with Associated Data, AEAD).

It allows a sender that knows the public key of the intended recipient to encrypt one or more messages, ensuring confidentiality and integrity, without any interaction with the recipient.

There are no significant limits on the size of a single message or on the number of messages in the sequence. Along with each encrypted message (ciphertext), the sender can also deliver a piece of non-sensitive data (e.g., a header), which is authenticated together with the matching message (additional authenticated data, AAD).

Before or together with the first message, the sender must deliver the encapsulated session secret (enc) to the receiver.

../../_images/hpke.png

In the basic scheme above, the receiver gets no proof of the sender’s identity. Variants of the protocol exist to enable the receiver to ascertain the origin of the message.

First variant: Auth mode. The sender can contribute its own private key to the encryption. The receiver must have the matching public key:

../../_images/hpkeAUTH.png

Second variant: PSK mode. The sender and the receiver can agree on a secret key (a pre-shared key, PSK, of at least 32 random bytes) and use it for both encryption and decryption.

../../_images/hpkePSK.png

Finally, the two authentication modes can also be combined (AuthPSK mode).

Note

The PSK is only used to authenticate the messages; it is not a replacement for the asymmetric key that the receiver must provide to the sender.

Note

Future versions of HPKE will drop support for Auth and AuthPSK modes.

Examples

This is how the sender can encrypt two messages:

from Crypto.Protocol import HPKE

# Let's assume that the recipient has delivered its public key,
# which is already loaded in a variable called their_pub_key
#
# For the sake of clarity, it could be simulated with:
#
#    from Crypto.PublicKey import ECC
#    their_key = ECC.generate(curve='p256')
#    their_pub_key = their_key.public_key()

encryptor = HPKE.new(receiver_key=their_pub_key,
                     aead_id=HPKE.AEAD.AES128_GCM)

ct_1 = encryptor.seal(b'Message 1')
ct_2 = encryptor.seal(b'Message 2')

# The sender will deliver:
# - encryptor.enc as enc
# - ct_1
# - ct_2

And this is how the receiver can decrypt them:

from Crypto.Protocol import HPKE

# Let's assume that the recipient has its own private key,
# which is already loaded in a variable called my_key
#
# To continue the simulation, assign:
#
#   my_key = their_key

decryptor = HPKE.new(receiver_key=my_key,
                     aead_id=HPKE.AEAD.AES128_GCM,
                     enc=enc)

# Any of the calls to unseal() can raise ValueError
# if it detects a key mismatch, a modified message, or messages out of order
pt1 = decryptor.unseal(ct_1)
pt2 = decryptor.unseal(ct_2)

HPKE can also derive shared secret material without encrypting messages:

from Crypto.Protocol import HPKE

exporter = HPKE.new(receiver_key=their_pub_key,
                    aead_id=HPKE.AEAD.EXPORT_ONLY)

secret = exporter.export(b'example context', 32)

# The sender will deliver exporter.enc as enc.
# The receiver will create the same context with its private key
# and call export() with the same context and length.

Post-quantum HPKE

HPKE based on ECC keys (DHKEM) is not secure against quantum computers. An extension of HPKE, still at the draft stage (draft-ietf-hpke-pq), defines how to use post-quantum cryptographic keys:

  • an ML-KEM key, or

  • a hybrid key of the hybrid KEM X-Wing, which combines ML-KEM and X25519 and remains secure as long as at least one of them is secure.

The KEM (and with it the KDF) is selected by the type of the receiver key:

Receiver key

KEM

KDF

Size of enc

ECC, NIST P-256

DHKEM(P-256)

HKDF-SHA256

65 bytes

ECC, NIST P-384

DHKEM(P-384)

HKDF-SHA384

97 bytes

ECC, NIST P-521

DHKEM(P-521)

HKDF-SHA512

133 bytes

ECC, Curve25519

DHKEM(X25519)

HKDF-SHA256

32 bytes

ECC, Curve448

DHKEM(X448)

HKDF-SHA512

56 bytes

ML-KEM, MLKEM.ML_KEM_512

ML-KEM-512

HKDF-SHA256

768 bytes

ML-KEM, MLKEM.ML_KEM_768

ML-KEM-768

HKDF-SHA256

1088 bytes

ML-KEM, MLKEM.ML_KEM_1024

ML-KEM-1024

HKDF-SHA384

1568 bytes

Hybrid, Hybrid.MLKEM768_X25519

MLKEM768-X25519

HKDF-SHA256

1120 bytes

The hybrid KEMs MLKEM768-P256 and MLKEM1024-P384 of draft-ietf-hpke-pq are not supported yet.

With ML-KEM and X-Wing, only the Base and PSK modes are available: the Auth and AuthPSK modes are not supported (i.e., sender_key must not be specified). The rest of the API is the same:

from Crypto.Protocol import HPKE
from Crypto.PublicKey import Hybrid

# The receiver
my_key = Hybrid.generate(param_set=Hybrid.MLKEM768_X25519)
their_pub_key = my_key.public_key()

# The sender
encryptor = HPKE.new(receiver_key=their_pub_key,
                     aead_id=HPKE.AEAD.CHACHA20_POLY1305)
ct = encryptor.seal(b'Message 1')

# The receiver again
decryptor = HPKE.new(receiver_key=my_key,
                     aead_id=HPKE.AEAD.CHACHA20_POLY1305,
                     enc=encryptor.enc)
pt = decryptor.unseal(ct)

Specification

class Crypto.Protocol.HPKE.AEAD(value)

The symmetric ciphers of HPKE (Authenticated Encryption with Associated Data, AEAD), for the parameter aead_id of new(). The value of each member is its HPKE identifier.

AES128_GCM = 1

AES-128 in GCM mode

AES256_GCM = 2

AES-256 in GCM mode

CHACHA20_POLY1305 = 3

ChaCha20-Poly1305

EXPORT_ONLY = 65535

No cipher, so the HPKE context can only export secrets (see HPKE_Cipher.export())

Crypto.Protocol.HPKE.new(*, receiver_key, aead_id, enc=None, sender_key=None, psk=None, info=None)

Create an HPKE context which can be used:

  • by the sender to seal (encrypt) a message,

  • by the receiver to unseal (decrypt) it, or

  • by either party to export shared secret material.

As a minimum, the two parties agree on the receiver’s asymmetric key (of which the sender will only know the public half).

Additionally, for authentication purposes, they may also agree on:

  • the sender’s asymmetric key (of which the receiver will only know the public half)

  • a shared secret (e.g., a symmetric key derived from a password)

Parameters:
  • receiver_key (Union[EccKey, MLKEMKey, HybridKey]) –

    The key of the receiver, which determines the KEM. One of:

    If this is a public key, the HPKE context can only be used to seal (encrypt) or export a secret.

    If this is a private key, the HPKE context can only be used to unseal (decrypt) or export a secret.

  • aead_id (AEAD) –

    The HPKE identifier of the symmetric cipher. The possible values are:

    • HPKE.AEAD.AES128_GCM

    • HPKE.AEAD.AES256_GCM

    • HPKE.AEAD.CHACHA20_POLY1305

    • HPKE.AEAD.EXPORT_ONLY

  • enc (Optional[bytes]) –

    The encapsulated key (the KEM ciphertext) that the sender delivered, available to the sender as HPKE_Cipher.enc.

    The receiver must always specify this parameter.

    The sender must always omit this parameter.

  • sender_key (Optional[EccKey]) – The ECC key of the sender. It must be on the same curve as the receiver_key. If the receiver_key is a public key, sender_key must be a private key, and vice versa. It cannot be used with ML-KEM or hybrid keys (they have no authenticated mode).

  • psk (Optional[tuple[bytes, bytes]]) –

    A Pre-Shared Key (PSK) as a 2-tuple of non-empty byte strings: the identifier and the actual secret value. Sender and receiver must use the same PSK (or none).

    The secret value must be at least 32 bytes long, but it must not be a low-entropy password (use a KDF like PBKDF2 or scrypt to derive a secret from a password).

  • info (Optional[bytes]) – A non-secret parameter that contributes to the generation of all session keys. Sender and receiver must use the same info parameter (or none).

Return type:

HPKE_Cipher

Returns:

An object that can be used for sealing (if receiver_key is a public key) or unsealing (if receiver_key is a private key), and in both cases for exporting secrets (with HPKE.AEAD.EXPORT_ONLY, only for that). For the receiver, correctness of all the keys and parameters will only be assessed with the first call to unseal(), and never by export().

class Crypto.Protocol.HPKE.HPKE_Cipher(receiver_key, enc, sender_key, psk_pair, info, aead_id, mode)

An HPKE context, to seal or unseal messages, or to export secrets.

Do not instantiate directly: use new() instead.

enc: bytes

The encapsulated session key.

export(exporter_context, length)

Export a secret from this HPKE context.

This method can be invoked multiple times, with different contexts, to derive independent secrets.

Parameters:
  • exporter_context (bytes) – Context for the exported secret (it can be empty).

  • length (int) – The desired length of the secret, in bytes. It must not be larger than 255 times the digest size of the hash of the KDF (e.g., 8160 bytes for NIST P-256).

Return type:

bytes

Returns:

The exported secret.

Warning

Unlike unseal(), this method never detects if sender and receiver used different keys or parameters: it simply returns a different secret. If needed, the application must confirm that both parties derived the same secret.

seal(plaintext, auth_data=None)

Encrypt and authenticate a message.

This method can be invoked multiple times to seal an ordered sequence of messages.

Parameters:
  • plaintext (bytes) – The message to seal.

  • auth_data (Optional[bytes]) – Optional. Additional authenticated data (AAD) that is not encrypted, but is also covered by the authentication tag.

Return type:

bytes

Returns:

The ciphertext concatenated with the authentication tag.

unseal(ciphertext, auth_data=None)

Decrypt a message and validate its authenticity.

This method can be invoked multiple times to unseal an ordered sequence of messages.

Parameters:
  • ciphertext (bytes) – The message to unseal.

  • auth_data (Optional[bytes]) – Optional. Additional authenticated data (AAD) that was also covered by the authentication tag.

Return type:

bytes

Returns:

The original plaintext.

Raises:

ValueError – if the ciphertext (in combination with the AAD) is not valid. At the first call of unseal(), it can also mean that one of the keys or parameters used to set up the context is wrong or missing.