Hybrid Public Key Encryption (HPKE)¶
Added in version 3.14.0.
HPKE (RFC 9180) encrypts messages to a public key, by combining a key encapsulation mechanism (KEM), a key derivation function (KDF), and an AEAD. It is the encryption scheme used by, among others, TLS Encrypted Client Hello (RFC 9849), Messaging Layer Security (RFC 9420), and Oblivious HTTP (RFC 9458).
An HPKE exchange starts with the sender encapsulating a fresh shared secret to the recipient’s public key. Both sides then derive an encryption context from the shared secret, an application-provided binding string (the info), and the negotiated ciphersuite. The sender’s context can encrypt (“seal”) a sequence of messages which the recipient’s context decrypts (“opens”) in the same order, and both contexts can also export additional shared secrets for use elsewhere in the application.
HPKE defines four modes. In base mode the sender is anonymous. The psk mode additionally authenticates the sender via a pre-shared key, auth mode via the sender’s own KEM key pair, and auth_psk combines both. The auth and auth_psk modes are only supported by the Diffie-Hellman based KEMs.
All functionality is in botan/hpke.h, within the namespace
Botan::HPKE.
Ciphersuites¶
An HPKE ciphersuite is a triple of KEM, KDF, and AEAD, which are negotiated or configured as 16-bit IANA codepoints. The identifier types below are thin value types around these codepoints. They can represent any 16-bit value, including codepoints unknown to the library; this is necessary when parsing protocol structures such as an ECH configuration, which may contain suites (including GREASE values) that the local build does not support. Whether an identifier is usable is a separate question from whether it is representable:
is_knownis true if this version of the library knows the codepoint.is_availableis true if the required algorithms are compiled into the build.
The known KEMs:
KEM |
Codepoint |
Auth modes |
|---|---|---|
DHKEM(P-256, HKDF-SHA256) |
0x0010 |
yes |
DHKEM(P-384, HKDF-SHA384) |
0x0011 |
yes |
DHKEM(P-521, HKDF-SHA512) |
0x0012 |
yes |
DHKEM(X25519, HKDF-SHA256) |
0x0020 |
yes |
DHKEM(X448, HKDF-SHA512) |
0x0021 |
yes |
The known KDFs are HKDF-SHA256 (0x0001), HKDF-SHA384 (0x0002), and HKDF-SHA512 (0x0003). The known AEADs are AES-128-GCM (0x0001), AES-256-GCM (0x0002), ChaCha20Poly1305 (0x0003), and the special export-only AEAD (0xFFFF), which produces contexts that cannot seal or open messages but can still export secrets.
-
enum class HPKE::KEM_Code : uint16_t¶
The known KEM codepoints:
DHKEM_P256,DHKEM_P384,DHKEM_P521,DHKEM_X25519, andDHKEM_X448.
-
enum class HPKE::KDF_Code : uint16_t¶
The known KDF codepoints:
HKDF_SHA256,HKDF_SHA384, andHKDF_SHA512.
-
enum class HPKE::AEAD_Code : uint16_t¶
The known AEAD codepoints:
AES_128_GCM,AES_256_GCM,ChaCha20Poly1305, andExportOnly.
-
class HPKE::KEM_Id¶
Identifies a KEM. Implicitly constructible from a
HPKE::KEM_Codeenumerator or from a rawuint16_tread off the wire. The enumerators are also reachable through this type, as inKEM_Id::DHKEM_X25519.-
uint16_t wire_code() const¶
The IANA codepoint.
-
bool is_known() const¶
True if this codepoint is known to this version of the library.
-
bool is_available() const¶
True if the algorithms this KEM requires are available in this build.
-
bool supports_auth_modes() const¶
True if this KEM supports the auth and auth_psk modes; only the DH-based KEMs do.
-
size_t encapsulation_length() const¶
-
size_t public_key_length() const¶
-
size_t private_key_length() const¶
The RFC 9180 constants Nsecret, Nenc, Npk, and Nsk. These are usable for any known codepoint, even one that is not available in the build (as needed, for instance, to emit an ECH GREASE extension of plausible size). They throw
Invalid_Statefor unknown codepoints.
-
std::optional<std::string> to_string() const¶
A human readable name, or
nulloptfor unknown codepoints.
-
uint16_t wire_code() const¶
-
class HPKE::KDF_Id¶
Identifies a KDF; the same shape as
HPKE::KEM_Id, plus-
size_t output_length() const¶
The KDF output length Nh.
-
size_t output_length() const¶
-
class HPKE::AEAD_Id¶
Identifies an AEAD; the same shape as
HPKE::KEM_Id, plus-
bool is_export_only() const¶
True for the export-only AEAD (0xFFFF).
-
size_t key_length() const¶
-
size_t nonce_length() const¶
-
size_t tag_length() const¶
The AEAD constants Nk, Nn, and Nt. These throw
Invalid_Statefor the export-only AEAD, for which they are undefined.
-
bool is_export_only() const¶
-
class HPKE::Suite¶
The ciphersuite triple.
-
bool is_known() const¶
-
bool is_available() const¶
-
bool is_export_only() const¶
-
size_t ciphertext_overhead() const¶
The number of bytes
HPKE::Sender_Context::sealadds to a plaintext (the AEAD tag length). ThrowsInvalid_Statefor an export-only suite, which cannot seal.
-
bool is_known() const¶
Keys¶
HPKE keys are represented by dedicated types, unrelated by inheritance to
each other and to Botan::Public_Key/Botan::Private_Key. Both are
cheaply copyable value types.
A key is checked against a specific KEM when it is created: its algorithm
(and, for the NIST curves, its group) must match the KEM, and a key
deserialized from the wire must be well formed, which for the NIST curves
includes being a point on the curve. Private keys are fully validated at
this point. For public keys one check is deferred: RFC 9180 specifies the
rejection of low order X25519/X448 points in terms of the Diffie-Hellman
output, so such a key can be constructed, and is rejected (with
Invalid_Argument) by any context setup that uses it. Holding an
HPKE::Public_Key therefore guarantees that the key matches its KEM and
is well formed, but not that an untrusted peer’s key will be accepted when
a context is set up.
-
class HPKE::Public_Key¶
-
static Public_Key deserialize(KEM_Id kem, std::span<const uint8_t> bytes)¶
DeserializePublicKey from RFC 9180. Accepts exactly the wire format the KEM defines (raw bytes for X25519/X448, an uncompressed SEC1 point for the NIST curves), throwing
Decoding_Errorif the length or encoding is wrong or a NIST curve point is not on the curve.
-
static Public_Key from_key(KEM_Id kem, std::unique_ptr<Botan::Public_Key> key)¶
Adopts an existing key, for example one taken from an X.509 structure. For the NIST curves both ECDH and ECDSA keys are accepted, since keys carrying the
id-ecPublicKeyalgorithm identifier load as ECDSA keys; an ECDSA key is converted, sounderlying()always returns an ECDH key. Keys of other EC algorithms (SM2, ECGDSA, and so on) are rejected. ThrowsInvalid_Argumentunless the key’s algorithm (and, for the NIST curves, its group) matches the KEM.
-
std::vector<uint8_t> serialize() const¶
SerializePublicKey from RFC 9180; the format used on the wire by ECH, MLS, etc.
-
const Botan::Public_Key &underlying() const¶
Access to the wrapped key, for example for encoding as an X.509
SubjectPublicKeyInfo. For the NIST curves this is an ECDH key, whose encoding uses theid-ecDHalgorithm identifier.
-
static Public_Key deserialize(KEM_Id kem, std::span<const uint8_t> bytes)¶
-
class HPKE::Private_Key¶
-
static Private_Key generate(KEM_Id kem, RandomNumberGenerator &rng)¶
-
static Private_Key derive(KEM_Id kem, std::span<const uint8_t> ikm)¶
DeriveKeyPair from RFC 9180: deterministically derives a key pair from full-entropy secret keying material of at least Nsk bytes. As an exception, DHKEM(P-521) accepts 64 bytes, because MLS (RFC 9420) derives the P-521 node keys of its ratchet tree from 64-byte secrets.
-
static Private_Key deserialize(KEM_Id kem, std::span<const uint8_t> bytes)¶
DeserializePrivateKey from RFC 9180: accepts Nsk raw bytes. The scalar is range-checked for the NIST curves, and clamped (RFC 7748 section 5) for X25519 and X448.
-
static Private_Key from_key(KEM_Id kem, std::unique_ptr<Botan::Private_Key> key)¶
Adopts an existing key, for example one loaded from PKCS #8, with the same rules as
HPKE::Public_Key::from_key: ECDSA keys on the NIST curves are accepted and converted to ECDH.
-
Public_Key public_key() const¶
The corresponding public key.
-
secure_vector<uint8_t> serialize() const¶
SerializePrivateKey from RFC 9180, suitable for persisting eg an ECH server key. For X25519 and X448 the output is clamped, as the RFC requires. PKCS #8 encoding remains available via
underlying().
-
const Botan::Private_Key &underlying() const¶
-
static Private_Key generate(KEM_Id kem, RandomNumberGenerator &rng)¶
Encryption contexts¶
Contexts are created by static factories that correspond one to one with
the RFC 9180 setup functions (SetupBaseS, SetupPSKR, and so on).
All of the factories throw Invalid_Argument if a provided key does not
match the suite’s KEM, if a peer’s key is a low order X25519/X448 point, or
if an authenticated mode is requested for a KEM that does not support it,
and throw Not_Implemented or Lookup_Error if the suite is not
available in the build. Both context types are movable but not copyable.
-
enum class HPKE::Mode : uint8_t¶
The mode of a context:
Base,PSK,Auth, orAuthPSK, with the values being the RFC 9180 codepoints.
-
class HPKE::Sender_Context¶
-
static Sender_Context setup_base(const Suite &suite, const Public_Key &recipient_key, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
-
static Sender_Context setup_psk(const Suite &suite, const Public_Key &recipient_key, RandomNumberGenerator &rng, const PSK &psk, std::span<const uint8_t> info = {})¶
-
static Sender_Context setup_auth(const Suite &suite, const Public_Key &recipient_key, const Private_Key &sender_identity_key, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
-
static Sender_Context setup_auth_psk(const Suite &suite, const Public_Key &recipient_key, const Private_Key &sender_identity_key, RandomNumberGenerator &rng, const PSK &psk, std::span<const uint8_t> info = {})¶
Encapsulate a fresh shared secret to
recipient_keyand derive an encryption context bound toinfo.
-
const std::vector<uint8_t> &encapsulated_key() const¶
The KEM encapsulation (enc), which must be transmitted to the recipient.
-
std::vector<uint8_t> seal(std::span<const uint8_t> aad, std::span<const uint8_t> ptext)¶
Encrypt a message using the next sequence number. The additional data
aadis authenticated but not encrypted, and the recipient must present the same value. ThrowsInvalid_Statefor an export-only suite.
-
secure_vector<uint8_t> export_secret(std::span<const uint8_t> exporter_context, size_t length) const¶
The RFC 9180 secret export interface. Derives a secret bound to this context and to
exporter_context; the recipient’s context derives the same value.lengthmay be at most 255 times the KDF output length.
-
uint64_t next_sequence() const¶
The sequence number the next call to
sealwill use.
-
static Sender_Context setup_base(const Suite &suite, const Public_Key &recipient_key, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
-
class HPKE::Recipient_Context¶
-
static Recipient_Context setup_base(const Suite &suite, const Private_Key &recipient_key, std::span<const uint8_t> enc, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
-
static Recipient_Context setup_psk(const Suite &suite, const Private_Key &recipient_key, std::span<const uint8_t> enc, RandomNumberGenerator &rng, const PSK &psk, std::span<const uint8_t> info = {})¶
-
static Recipient_Context setup_auth(const Suite &suite, const Private_Key &recipient_key, std::span<const uint8_t> enc, const Public_Key &sender_identity_key, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
-
static Recipient_Context setup_auth_psk(const Suite &suite, const Private_Key &recipient_key, std::span<const uint8_t> enc, const Public_Key &sender_identity_key, RandomNumberGenerator &rng, const PSK &psk, std::span<const uint8_t> info = {})¶
Decapsulate
encwithrecipient_keyand derive the matching decryption context. ThrowsDecoding_Errorifencis malformed (wrong length or encoding, or not a point on the curve), andInvalid_Argumentif it is a low order X25519/X448 point. The RNG is required by the underlying decapsulation operations, eg for blinding.
-
secure_vector<uint8_t> open(std::span<const uint8_t> aad, std::span<const uint8_t> ctext)¶
Decrypt a message using the next sequence number. Decryption fails in
Invalid_Authentication_Tagif the ciphertext is truncated or fails authentication. A failed open does not consume the sequence number. Export-only contexts and exhausted sequence numbers causeInvalid_State.
-
secure_vector<uint8_t> open_at_sequence(uint64_t seq, std::span<const uint8_t> aad, std::span<const uint8_t> ctext)¶
Decrypt the message with an explicit sequence number, without reading or modifying the counter used by
open. This supports out-of-order transports and stateless processing; for example, an ECH server handling a retried ClientHello after a stateless HelloRetryRequest recreates the context by decapsulating the (unchanged)encagain and opens the second inner ClientHello at sequence number 1.No sealing equivalent is provided, since explicitly choosing the sequence number on the sending side invites catastrophic nonce reuse.
-
secure_vector<uint8_t> export_secret(std::span<const uint8_t> exporter_context, size_t length) const¶
-
uint64_t next_sequence() const¶
-
static Recipient_Context setup_base(const Suite &suite, const Private_Key &recipient_key, std::span<const uint8_t> enc, RandomNumberGenerator &rng, std::span<const uint8_t> info = {})¶
Code Example¶
The example below encrypts messages to a recipient known only by its published public key, then uses the secret export interface.
#include <botan/hpke.h>
#include <botan/system_rng.h>
#include <iostream>
#include <string_view>
namespace {
std::vector<uint8_t> as_bytes(std::string_view s) {
return std::vector<uint8_t>(s.begin(), s.end());
}
} // namespace
int main() {
Botan::System_RNG rng;
const Botan::HPKE::Suite suite(
Botan::HPKE::KEM_Code::DHKEM_X25519, Botan::HPKE::KDF_Code::HKDF_SHA256, Botan::HPKE::AEAD_Code::AES_128_GCM);
// The recipient generates a key pair and publishes the serialized
// public key, for example in a DNS record or a key package
const auto recipient_key = Botan::HPKE::Private_Key::generate(suite.kem(), rng);
const std::vector<uint8_t> published_key = recipient_key.public_key().serialize();
// Both sides must agree on the application context ("info")
const auto info = as_bytes("doc example v1");
// Sender: parse the published key and set up an encryption context.
// The encapsulated key is transmitted along with the ciphertexts.
const auto pk = Botan::HPKE::Public_Key::deserialize(suite.kem(), published_key);
auto sender = Botan::HPKE::Sender_Context::setup_base(suite, pk, rng, info);
const auto aad = as_bytes("message header");
const auto ctext1 = sender.seal(aad, as_bytes("hello"));
const auto ctext2 = sender.seal({}, as_bytes("world"));
// Recipient: decapsulate the shared secret and open the messages,
// in the order they were sealed
auto recipient =
Botan::HPKE::Recipient_Context::setup_base(suite, recipient_key, sender.encapsulated_key(), rng, info);
const auto ptext1 = recipient.open(aad, ctext1);
const auto ptext2 = recipient.open({}, ctext2);
std::cout << std::string(ptext1.begin(), ptext1.end()) << " " << std::string(ptext2.begin(), ptext2.end()) << "\n";
// Both contexts can also export shared secrets for use elsewhere
// in the application
const auto sender_export = sender.export_secret(as_bytes("session key"), 32);
const auto recipient_export = recipient.export_secret(as_bytes("session key"), 32);
if(sender_export != recipient_export) {
std::cerr << "Exported secrets differ\n";
return 1;
}
return 0;
}