HPKE is a relatively new IETF standard (RFC 9180) for hybrid encryption, where hybrid means combining public key encryption and symmetric encryption. It is now essentially the right answer for how to do public key encryption, and if it existed in 2019 e.g. age would have used it instead of having essentially its own flavor of it.
There are three modes of HPKE: base, asymmetric authenticated, and symmetric authenticated. I am proposing adding only the base mode. Authentication with a public key is potentially being removed in a -bis document (see draft-ietf-hpke-hpke-01), is a difficult primitive to wield (often encouraging poor hand-rolled protocols that would have been better served by using TLS), and is not supported by the upcoming post-quantum KEMs. Authentication with a symmetric secret requires high-entropy secrets due to partition oracle attacks. Anyway, the proposed API can be extended to support auth modes (either with new KEMs for public key auth, or with NewRecipientWithPSK
/NewSenderWithPSK
for symmetric auth).
There is a post-quantum KEMs draft, draft-ietf-hpke-pq, which is blocked on the CFRG hybrid KEMs work, which is taking an excruciatingly long time. Apple already shipped the X25519 + ML-KEM-768 hybrid (X-Wing), and there is consensus among implementers on not changing anything about it, so this proposal includes that variant, with space to support the P-256 + ML-KEM-768 and P-384 + ML-KEM-1024 ones and pure ML-KEM ones in the future.
HPKE is already used in crypto/tls for Encrypted Client Hello, so we already have a crypto/internal/hpke package. CL 701435 replaces its API with this proposal, providing a full implementation.
filippo.io/hpke implements this API, plus the unstable draft-ietf-hpke-pq hybrids.
Proposed API
At the top level, a sending or receiving context is instantiated from a (KEM, KDF, AEAD) tuple, where each of those is an interface type with private methods. (At least for now, allowing external implementations seems more trouble than it's worth. We can always go back and make methods public later.)
The Sender has a Seal method and the Recipient has a Open method. Names and argument order match the RFC.
This matches the RFC nomenclature but is annoyingly the opposite nomenclature of age, which calls a private key identity, and a public key recipient, since it's something you encrypt files to. I am probably never using "recipient" again.
// Open instantiates a single-use HPKE receiving HPKE context like [NewRecipient],
// and then decrypts the provided ciphertext like [Recipient.Open] (with no aad).
func Open(enc []byte, kem KEMRecipient, kdf KDF, aead AEAD, info, ciphertext []byte) ([]byte, error)
// Seal instantiates a single-use HPKE sending HPKE context like [NewSender],
// and then encrypts the provided plaintext like [Sender.Seal] (with no aad).
func Seal(kem KEMSender, kdf KDF, aead AEAD, info, plaintext []byte) (enc, ct []byte, err error)
// Recipient is a receiving HPKE context. It is instantiated with a specific KEM
// decapsulation key (i.e. the secret key), and it is stateful, incrementing the
// nonce counter for each successful [Recipient.Open] call.
type Recipient struct {
// contains filtered or unexported methods
}
// NewRecipient returns a receiving HPKE context for the provided KEM
// decapsulation key (i.e. the secret key), and using the ciphersuite defined by
// the combination of KEM, KDF, and AEAD.
//
// The enc parameter must have been produced by a matching sending HPKE context
// with the corresponding KEM encapsulation key. The info parameter is
// additional public information that must match between sender and recipient.
func NewRecipient(enc []byte, kem KEMRecipient, kdf KDF, aead AEAD, info []byte) (*Recipient, error)
// Export produces a secret value derived from the shared key between sender and
// recipient. length must be at most 65,535.
func (r *Recipient) Export(exporterContext string, length int) ([]byte, error)
// Open decrypts the provided ciphertext, optionally binding to the additional
// public data aad, or returns an error if decryption fails.
//
// Open uses incrementing counters for each successful call, and must be called
// in the same order as Seal on the sending side.
func (r *Recipient) Open(aad, ciphertext []byte) ([]byte, error)
// Sender is a sending HPKE context. It is instantiated with a specific KEM
// encapsulation key (i.e. the public key), and it is stateful, incrementing the
// nonce counter for each [Sender.Seal] call.
type Sender struct {
// contains filtered or unexported methods
}
// NewSender returns a sending HPKE context for the provided KEM encapsulation
// key (i.e. the public key), and using the ciphersuite defined by the
// combination of KEM, KDF, and AEAD.
//
// The info parameter is additional public information that must match between
// sender and recipient.
//
// The returned enc ciphertext can be used to instantiate a matching receiving
// HPKE context with the corresponding KEM decapsulation key.
func NewSender(kem KEMSender, kdf KDF, aead AEAD, info []byte) (enc []byte, s *Sender, err error)
// Export produces a secret value derived from the shared key between sender and
// recipient. length must be at most 65,535.
func (s *Sender) Export(exporterContext string, length int) ([]byte, error)
// Seal encrypts the provided plaintext, optionally binding to the additional
// public data aad.
//
// Seal uses incrementing counters for each call, and Open on the receiving side
// must be called in the same order as Seal.
func (s *Sender) Seal(aad, plaintext []byte) ([]byte, error)
KDFs and AEADs are typed singletons. There are functions that returns specific ones, and generic NewAEAD
/NewKDF
functions that take IDs. There is no list of supported IDs or named constants, but AES128GCM().ID()
is easy enough. This API intentionally encourages the use of hard-coded KDFs and AEADs: compile-time selection of ciphersuites is preferable for security, and it even allows govulncheck to recognize unused algorithms as unreachable.
// The AEAD is one of the three components of an HPKE ciphersuite, implementing
// symmetric encryption.
type AEAD interface {
ID() uint16
// contains filtered or unexported methods
}
// AES128GCM returns an AES-128-GCM AEAD implementation.
func AES128GCM() AEAD
// AES256GCM returns an AES-256-GCM AEAD implementation.
func AES256GCM() AEAD
// ChaCha20Poly1305 returns a ChaCha20Poly1305 AEAD implementation.
func ChaCha20Poly1305() AEAD
// ExportOnly returns a placeholder AEAD implementation that cannot encrypt or
// decrypt, but only export secrets with [Sender.Export] or [Recipient.Export].
//
// When this is used, [Sender.Seal] and [Recipient.Open] return errors.
func ExportOnly() AEAD
// NewAEAD returns the AEAD implementation for the given AEAD ID.
//
// Applications are encouraged to use specific implementations like [AES128GCM]
// or [ChaCha20Poly1305] instead, unless runtime agility is required.
func NewAEAD(id uint16) (AEAD, error)
// The KDF is one of the three components of an HPKE ciphersuite, implementing
// key derivation.
type KDF interface {
ID() uint16
// contains filtered or unexported methods
}
// HKDFSHA256 returns an HKDF-SHA256 KDF implementation.
func HKDFSHA256() KDF
// HKDFSHA384 returns an HKDF-SHA384 KDF implementation.
func HKDFSHA384() KDF
// HKDFSHA512 returns an HKDF-SHA512 KDF implementation.
func HKDFSHA512() KDF
// NewKDF returns the KDF implementation for the given KDF ID.
//
// Applications are encouraged to use specific implementations like [HKDFSHA256]
// instead, unless runtime agility is required.
func NewKDF(id uint16) (KDF, error)
KEM types are instantiated with the public/private key.
// A KEMRecipient is an instantiation of a KEM (one of the three components of
// an HPKE ciphersuite) with a decapsulation key (i.e. the secret key).
type KEMRecipient interface {
// ID returns the HPKE KEM identifier.
ID() uint16
// Bytes returns the private key as the output of SerializePrivateKey.
//
// Note that for X25519 this might not match the input to NewPrivateKey.
// This is a requirement of RFC 9180, Section 7.1.2.
Bytes() ([]byte, error)
// KEMSender returns the corresponding KEMSender for this recipient.
KEMSender() KEMSender
// contains filtered or unexported methods
}
// NewDHKEMRecipient returns a KEMRecipient implementing one of
//
// - DHKEM(P-256, HKDF-SHA256)
// - DHKEM(P-384, HKDF-SHA384)
// - DHKEM(P-521, HKDF-SHA512)
// - DHKEM(X25519, HKDF-SHA256)
//
// depending on the underlying curve of the provided private key.
func NewDHKEMRecipient(priv ecdh.KeyExchanger) (KEMRecipient, error)
// NewQSFRecipient returns a KEMRecipient implementing
// QSF-X25519-MLKEM768-SHA3256-SHAKE256 (a.k.a. X-Wing) from draft-ietf-hpke-pq.
func NewQSFRecipient(t ecdh.KeyExchanger, pq crypto.Decapsulator) (KEMRecipient, error)
// NewKEMRecipient implements DeserializePrivateKey and returns a KEMRecipient
// for the given KEM ID and private key bytes.
//
// Applications are encouraged to use [ecdh.Curve.NewPrivateKey] with
// [NewDHKEMRecipient] instead, unless runtime agility is required.
func NewKEMRecipient(id uint16, priv []byte) (KEMRecipient, error)
// NewKEMRecipientFromSeed implements DeriveKeyPair and returns a KEMRecipient
// for the given KEM ID and private key seed.
func NewKEMRecipientFromSeed(id uint16, seed []byte) (KEMRecipient, error)
// A KEMSender is an instantiation of a KEM (one of the three components of an
// HPKE ciphersuite) with an encapsulation key (i.e. the public key).
type KEMSender interface {
// ID returns the HPKE KEM identifier.
ID() uint16
// Bytes returns the public key as the output of SerializePublicKey.
Bytes() []byte
// contains filtered or unexported methods
}
// NewDHKEMSender returns a KEMSender implementing one of
//
// - DHKEM(P-256, HKDF-SHA256)
// - DHKEM(P-384, HKDF-SHA384)
// - DHKEM(P-521, HKDF-SHA512)
// - DHKEM(X25519, HKDF-SHA256)
//
// depending on the underlying curve of the provided public key.
func NewDHKEMSender(pub *ecdh.PublicKey) (KEMSender, error)
// NewQSFSender returns a KEMSender implementing QSF-X25519-MLKEM768-SHA3256-SHAKE256
// (a.k.a. X-Wing) from draft-ietf-hpke-pq.
func NewQSFSender(t *ecdh.PublicKey, pq crypto.Encapsulator) (KEMSender, error)
// NewKEMSender implements DeserializePublicKey and returns a KEMSender
// for the given KEM ID and public key bytes.
//
// Applications are encouraged to use [ecdh.Curve.NewPublicKey] with
// [NewDHKEMSender] instead, unless runtime agility is required.
func NewKEMSender(id uint16, pub []byte) (KEMSender, error)
Finally, we need to add some interfaces for the crypto/ecdh and crypto/mlkem types, which we had punted on until such a time in which we needed to consume them. That time is now. These interfaces let us take hardware implementations of ECDH and ML-KEM keys.
package ecdh
// KeyExchanger is an interface for an opaque private key that can be used for
// key exchange operations. For example, an ECDH key kept in a hardware module.
//
// It is implemented by [PrivateKey].
type KeyExchanger interface {
PublicKey() *PublicKey
Curve() Curve
ECDH(*PublicKey) ([]byte, error)
}
package crypto
// Decapsulator is an interface for an opaque private KEM key that can be used for
// decapsulation operations. For example, an ML-KEM key kept in a hardware module.
//
// It is implemented, for example, by [crypto/mlkem.DecapsulationKey768].
type Decapsulator interface {
Encapsulator() Encapsulator
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
}
// Encapsulator is an interface for a public KEM key that can be used for
// encapsulation operations.
//
// It is implemented, for example, by [crypto/mlkem.EncapsulationKey768].
type Encapsulator interface {
Bytes() []byte
Encapsulate() (sharedKey, ciphertext []byte)
}
package mlkem
// Encapsulator returns the encapsulation key, like
// [DecapsulationKey768.EncapsulationKey].
//
// It implements [crypto.Decapsulator].
func (*DecapsulationKey768) Encapsulator() crypto.Encapsulator
// Encapsulator returns the encapsulation key, like
// [DecapsulationKey1024.EncapsulationKey].
//
// It implements [crypto.Decapsulator].
func (*DecapsulationKey1024) Encapsulator() crypto.Encapsulator
A few small open questions on the KEM APIs:
- ~~Should it be
DHKEMRecipient
etc. (mirroring the KDFs and AEADs) orNewDHKEMRecipient
(acknowledging these are not singletons)?~~ Added New prefixes. - ~~Should it be
QSFSender
/QSFRecipient
orQSFSenderMLKEM768
/QSFRecipientMLKEM768
since it takes a*mlkem.EncapsulationKey768
/*mlkem.DecapsulationKey768
? Alternatives include taking an interface so it can take a*mlkem.EncapsulationKey1024
/*mlkem.DecapsulationKey1024
too, or just saying "ML-KEM-1024 is rarely used, just useNewKEMSender
/NewKEMRecipient
for that".~~ Taking an interface now. - ~~Should we even have the
DHKEMRecipient
orQSFSender
etc.? They make more sense for ECDH where it's easy to construct crypto/ecdh types (withCurve.NewPrivateKey
andCurve.NewPublicKey
) but maybe less for the PQ hybrids where keys need to be split up or even expanded first.~~ They are useful for hardware implementations. - ~~Should we take an interface instead of
*ecdh.PrivateKey
, to allow keys implemented in hardware? If so, where should that interface live? We had punted on this for crypto/ecdh because we had no use case yet, but now we do.~~ Taking interfaces for hardware implementations now. - Should we use the QSF name, which unlike the on-the-wire encoding might still change?
/cc @golang/security
Comment From: gabyhelp
Related Code Changes
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Comment From: FiloSottile
- Should we take an interface instead of
*ecdh.PrivateKey
, to allow keys implemented in hardware? If so, where should that interface live? We had punted on this for crypto/ecdh because we had no use case yet, but now we do.
This API works pretty well with an interface like
type ECDHPrivateKey interface {
PublicKey() *ecdh.PublicKey
Curve() ecdh.Curve
ECDH(*ecdh.PublicKey) ([]byte, error)
}
which is satisfied by *ecdh.PrivateKey
and can be implemented by hardware keys.
Doing this is trickier for the ML-KEM decapsulation keys, though, because EncapsulationKey()
returns a sized type.
One solution would be to use generics. https://pkg.go.dev/filippo.io/hpke@v0.1.2-0.20250907100611-e2334837b0a5 experiments with the API below.
type MLKEMEncapsulationKey interface {
*mlkem.EncapsulationKey768 | *mlkem.EncapsulationKey1024
Bytes() []byte
Encapsulate() (sharedKey []byte, ciphertext []byte)
}
type MLKEMDecapsulationKey[E MLKEMEncapsulationKey] interface {
EncapsulationKey() E
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
}
func QSFRecipient[E MLKEMEncapsulationKey](t ECDHPrivateKey, pq MLKEMDecapsulationKey[E]) (KEMRecipient, error)
But maybe instead we should add a method to the crypto/mlkem decapsulation key types that returns an interface value for the encapsulation key? (Not sure what we'd call the method, or again, where we would put the interface definition.)
If we do add such a method, we could make the encapsulation and decapsulation key interfaces not specific to ML-KEM, but generically applicable to any KEM, which would be nice.
Comment From: FiloSottile
Here's a potential set of interfaces and how we'd implement them in crypto/mlkem and use them in this package.
package ecdh
type KeyExchanger interface {
PublicKey() *PublicKey
Curve() Curve
ECDH(*PublicKey) ([]byte, error)
}
package crypto
type Encapsulator interface {
Bytes() []byte
Encapsulate() (sharedKey, ciphertext []byte)
}
type Decapsulator interface {
Encapsulator() Encapsulator
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
}
package mlkem
func (*DecapsulationKey768) Encapsulator() crypto.Encapsulator
func (*DecapsulationKey1024) Encapsulator() crypto.Encapsulator
package hpke
func DHKEMRecipient(priv ecdh.KeyExchanger) (KEMRecipient, error)
func QSFRecipient(t ecdh.KeyExchanger, pq crypto.Decapsulator) (KEMRecipient, error)
func QSFSender(t *ecdh.PublicKey, pq crypto.Encapsulator) (KEMSender, error)
Edited: renamed ECDH to KeyExchanger, EncapsulationKey to Encapsulator, DecapsulationKey to Decapsulator.
~~I don't love the DecapsulationKey.PublicKey()
name, but DecapsulationKey768.EncapsulationKey
is taken.~~
This could go in a separate proposal, and maybe eventually it will, but I think it's most useful to reflect on these in the context of a use case like crypto/hpke.
I also played with the idea of making the crypto/mlkem DecapsulationKeys implement the informal crypto.PrivateKey interface with a Public() crypto.PublicKey
method. We could still do that, but crypto.EncapsulationKey felt useful in its own right. He's how it would look like, for completeness.
package ecdh
type ECDH interface {
PublicKey() *PublicKey
Curve() Curve
ECDH(*PublicKey) ([]byte, error)
}
package crypto
type DecapsulationKey interface {
Public() PublicKey
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
}
package mlkem
func (*DecapsulationKey768) Public() crypto.PublicKey
func (*DecapsulationKey768) Equal(x crypto.PrivateKey) bool
func (*EncapsulationKey768) Equal(x crypto.PublicKey) bool
func (*DecapsulationKey1024) Public() crypto.PublicKey
func (*DecapsulationKey1024) Equal(x crypto.PrivateKey) bool
func (*EncapsulationKey1024) Equal(x crypto.PublicKey) bool
package hpke
func DHKEMRecipient(priv crypto.ECDH) (KEMRecipient, error)
func QSFRecipient(t crypto.ECDH, pq crypto.DecapsulationKey) (KEMRecipient, error)
func QSFSender(t *ecdh.PublicKey, pq crypto.PublicKey) (KEMSender, error)
Comment From: complexspaces
Anyway, the proposed API can be extended to support auth modes (either with new KEMs for public key auth, or with NewRecipientWithPSK/NewSenderWithPSK for symmetric auth).
If reasonable I'd like to ask that the PSK modes are implemented in the proposed crypto/hpke
package, even if that's just making sure the proposed API could accommodate their addition in a first-class manner for now.
FIDO is currently using HPKE in the Credential Exchange Protocol (CXP)'s in-progress spec definition. The public draft is currently using the PSK for the "self" mode (for backups or possibly involving an intermediate authorizing party). It would be really nice if the APIs were available in this package so that any credential provider (or interested developer!) could write a fully-featured CXP implementation in Go with a well-tested/reputable cryptographic library instead of needing a third-party package or to roll their own.
Comment From: SalusaSecondus
I would support adding hpke
to Go and have the following 2 comments:
1. Generally I believe we should use interfaces that support non-exportable keys (such as HSM-backed ones). I do not have opinions on the proper way to do this in Go.
2. Several of my use-cases for HPKE require the authenticated modes. I understand the limitations of them but I would not be able to adopt this implementation without the auth modes being present.
Comment From: FiloSottile
I have updated the proposal in the issue to introduce and use interfaces for ECDH and KEM keys.