Go's crypto/hpke: Anonymous Encryption Without Pre-Shared Keys
Here's a problem that sounds trivial and isn't: you have someone's public key. You want to send them a secret. That's the whole requirement. No prior handshake, no session state, no shared password, and ideally no fresh dependency pulled in just to glue an ECDH exchange to an AEAD cipher by hand. Roll it yourself and you'll spend an afternoon deciding how to derive the AEAD key from the ECDH output, what to use as the nonce, and whether your KDF context binding is actually doing anything. Get any of that wrong and you've built a plausible-looking scheme with a quiet hole in it.
This is exactly the gap RFC 9180, Hybrid Public Key Encryption (HPKE), was written to close. Go has had an internal HPKE implementation since Encrypted Client Hello needed one, and as of Go 1.26 it's been promoted to a public, stable package: crypto/hpke. No more copying x/crypto/hpke into vendor directories or reaching for a third-party implementation of your own.
What HPKE actually bundles together
HPKE isn't a new primitive. It's a standard way of wiring three existing ones together: a KEM (key encapsulation mechanism, the thing that turns a public key into a shared secret), a KDF (to stretch that shared secret into proper key material) and an AEAD (to actually encrypt). The point of standardising the wiring is that the wiring is where people get creative in the wrong ways.
The one-shot API is about as small as public-key encryption gets:
package main
import (
"crypto/ecdh"
"crypto/hpke"
"fmt"
"log"
)
func main() {
kem := hpke.DHKEM(ecdh.X25519())
recipientKey, err := kem.GenerateKey()
if err != nil {
log.Fatal(err)
}
kdf := hpke.HKDFSHA256()
aead := hpke.AES256GCM()
info := []byte("dixon.cx tip box v1")
sealed, err := hpke.Seal(recipientKey.PublicKey(), kdf, aead, info,
[]byte("the password is on the whiteboard"))
if err != nil {
log.Fatal(err)
}
plaintext, err := hpke.Open(recipientKey, kdf, aead, info, sealed)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(plaintext))
}
Seal generates an ephemeral KEM keypair internally, encapsulates a shared secret against the recipient's public key, derives an AEAD key from it via the KDF, and returns the encapsulated key concatenated with the ciphertext. Open reverses all of that using the recipient's private key. There's no round trip, no negotiation, no session. The sender needed exactly one thing from the recipient in advance: their public key.
The anonymous bit
The word doing the real work in "anonymous encryption" here is base mode. RFC 9180 defines four modes: base, PSK, auth and auth-PSK, and Go's initial implementation only ships base mode. In base mode, the ciphertext proves nothing whatsoever about who sent it. Anyone who can obtain the recipient's public key, which is by definition public, can produce a message the recipient will happily decrypt. There's no equivalent of a client certificate, no proof of possession of a matching private key on the sender's side.
That's a feature, not an oversight, for the right use case. Think of a whistleblower drop box: you publish a public key, and anyone can send you something encrypted against it without registering, authenticating, or leaving any cryptographic fingerprint of who they are. The confidentiality guarantee is solid; the sender-authenticity guarantee is deliberately absent, because forcing sender identity would defeat the point. If you do need to know who sent something, HPKE base mode is the wrong layer to enforce that in. You'd sign the plaintext before encrypting it, or wait for auth mode to land, rather than trying to bolt authentication onto base mode after the fact.
Worth contrasting this against TLS, where mutual authentication and confidentiality get tangled together in the handshake by default and you have to consciously peel them apart (client certs, SNI, ALPN all leaking metadata about who's talking to whom). HPKE keeps confidentiality and authentication as separate, composable decisions from the start.
More than one message: the stateful API
The one-shot functions are fine for a single sealed blob, but they set up a fresh KEM operation every call, which is wasteful if you're exchanging several messages in one logical session. NewSender and NewRecipient give you a context that keeps the derived key material around and increments the AEAD nonce automatically:
enc, sender, err := hpke.NewSender(recipientKey.PublicKey(), kdf, aead, info)
if err != nil {
log.Fatal(err)
}
recipient, err := hpke.NewRecipient(enc, recipientKey, kdf, aead, info)
if err != nil {
log.Fatal(err)
}
for _, msg := range []string{"first", "second", "third"} {
ct, err := sender.Seal(nil, []byte(msg))
if err != nil {
log.Fatal(err)
}
pt, err := recipient.Open(nil, ct)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(pt))
}
The enc value, the encapsulated key, only needs to be sent once, up front. After that Sender.Seal and Recipient.Open just need to agree on message order, since the nonce is derived from an internal counter rather than passed explicitly. Get the order wrong on either side and decryption fails loudly rather than silently reusing a nonce, which is the failure mode you actually want.
Both Sender and Recipient also expose Export(exporterContext string, length int), which derives extra key material from the same shared secret without spending an AEAD key on it. Useful if you want to key a separate construction, an HMAC for message ordering, say, from the same HPKE handshake rather than running a second key agreement.
Compile-time menus, not runtime negotiation
One design choice in the Go proposal is worth calling out because it's a little contrarian: there's no "parse a ciphersuite ID off the wire and pick an algorithm" path built into the package. You choose DHKEM(ecdh.X25519()), HKDFSHA256() and AES256GCM() at compile time, as concrete Go values, not as integers read from an incoming message. The stated reasoning is that runtime algorithm agility is how you end up with downgrade attacks: an attacker who can influence which weaker option gets picked doesn't need to break any individual algorithm.
It's a deliberate rejection of the "let both sides negotiate the strongest thing they both support" pattern that TLS and PGP both lean on, and it does mean two Go services need to agree out of band on which ciphersuite they're using, the same way they'd agree on a wire format. That's a reasonable trade for a library, less so for a protocol like TLS that genuinely needs to interoperate with software it doesn't control. HPKE the RFC does define numeric IDs for exactly that reason (NewKEM, NewKDF and NewAEAD take them if you need to parse a suite off the wire yourself), Go's package just doesn't make that the easy path.
Post-quantum, without changing the shape of the code
The KEM slot is where the interesting future-proofing sits. Alongside the elliptic-curve KEMs, crypto/hpke ships pure ML-KEM (MLKEM768, MLKEM1024) and hybrid constructions that combine a post-quantum KEM with a classical one, including MLKEM768X25519, which is the X-Wing hybrid. Swapping to it is a one-line change to the earlier example:
kem := hpke.MLKEM768X25519()
Everything downstream, key generation, Seal, Open, the stateful contexts, stays identical, because the KEM is just an interface value. That's the actual payoff of RFC 9180's separation of concerns: swapping the part of the scheme most exposed to a future quantum computer doesn't touch the KDF, the AEAD, or any of your calling code.
Go's crypto/tls already used an internal version of this package for Encrypted Client Hello, encrypting the real SNI to a fronting server's public key with exactly this base-mode, no-prior-relationship pattern before the handshake proper even starts. It's a good sign when a cryptographic scheme is boring enough that it quietly ends up underneath a protocol most people never think about, rather than living only in academic papers or bespoke implementations that each get audited once and then left alone.