Skip to content

crypto/hpke: new package #75300

@FiloSottile

Description

@FiloSottile

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 being removed in a -bis document (see draft-ietf-hpke-hpke), 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 new 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 (e.g. with NewRecipientWithPSK/NewSenderWithPSK for symmetric auth).

There is a post-quantum KEMs draft, draft-ietf-hpke-pq, which was blocked on the CFRG hybrid KEMs work for an excruciatingly long time, but should finally be stable. Apple already shipped the X25519 + ML-KEM-768 hybrid (X-Wing), and I was promised that there will be no changes to draft-ietf-hpke-hpke or draft-ietf-hpke-pq if not to fix security issues.

HPKE is already used in crypto/tls for Encrypted Client Hello, so we already have a crypto/internal/hpke package. CL 701435 and the following stack replaces its API with this proposal, providing a full implementation.

filippo.io/hpke implements this API.

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.

// Seal instantiates a single-use HPKE sending HPKE context like [NewSender],
// and then encrypts the provided plaintext like [Sender.Seal] (with no aad).
// Seal returns the concatenation of the encapsulated key and the ciphertext.
func Seal(pk PublicKey, kdf KDF, aead AEAD, info, plaintext []byte) ([]byte, error)

// Open instantiates a single-use HPKE receiving HPKE context like [NewRecipient],
// and then decrypts the provided ciphertext like [Recipient.Open] (with no aad).
// ciphertext must be the concatenation of the encapsulated key and the actual ciphertext.
func Open(k PrivateKey, kdf KDF, aead AEAD, info, ciphertext []byte) ([]byte, 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 PrivateKey, 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 PublicKey, 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

// SHAKE128 returns a SHAKE128 KDF implementation.
func SHAKE128() KDF

// SHAKE256 returns a SHAKE256 KDF implementation.
func SHAKE256() 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)

KEMs are interfaces with methods to instantiate public and private keys, according to the RFC 9180 interface. They are also singletons. [Updated Nov 11]

// A KEM is a Key Encapsulation Mechanism, one of the three components of an
// HPKE ciphersuite.
type KEM interface {
	// ID returns the HPKE KEM identifier.
	ID() uint16

	// GenerateKey generates a new key pair.
	GenerateKey() (PrivateKey, error)

	// NewPublicKey deserializes a public key from bytes.
	//
	// It implements DeserializePublicKey, as defined in RFC 9180.
	NewPublicKey([]byte) (PublicKey, error)

	// NewPrivateKey deserializes a private key from bytes.
	//
	// It implements DeserializePrivateKey, as defined in RFC 9180.
	NewPrivateKey([]byte) (PrivateKey, error)

	// DeriveKeyPair derives a key pair from the given input keying material.
	//
	// It implements DeriveKeyPair, as defined in RFC 9180.
	DeriveKeyPair(ikm []byte) (PrivateKey, error)

	// contains filtered or unexported methods
}

// A PublicKey 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 PublicKey interface {
	// KEM returns the instantiated KEM.
	KEM() KEM

	// Bytes returns the public key as the output of SerializePublicKey.
	Bytes() []byte

	// contains filtered or unexported methods
}

// A PrivateKey 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 PrivateKey interface {
	// KEM returns the instantiated KEM.
	KEM() KEM

	// Bytes returns the private key as the output of SerializePrivateKey, as
	// defined in RFC 9180.
	//
	// 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)

	// PublicKey returns the corresponding PublicKey.
	PublicKey() PublicKey

	// contains filtered or unexported methods
}

// DHKEM returns a KEM implementing one of
//
//   - DHKEM(P-256, HKDF-SHA256)
//   - DHKEM(P-384, HKDF-SHA384)
//   - DHKEM(P-521, HKDF-SHA512)
//   - DHKEM(X25519, HKDF-SHA256)
//
// depending on curve.
func DHKEM(curve ecdh.Curve) KEM

// MLKEM768 returns a KEM implementing ML-KEM-768 from draft-ietf-hpke-pq.
func MLKEM768() KEM

// MLKEM1024 returns a KEM implementing ML-KEM-1024 from draft-ietf-hpke-pq.
func MLKEM1024() KEM

// MLKEM768X25519 returns a KEM implementing MLKEM768-X25519 (a.k.a. X-Wing)
// from draft-ietf-hpke-pq.
func MLKEM768X25519() KEM

// MLKEM768P256 returns a KEM implementing MLKEM768-P256 from draft-ietf-hpke-pq.
func MLKEM768P256() KEM

// MLKEM1024P384 returns a KEM implementing MLKEM1024-P384 from draft-ietf-hpke-pq.
func MLKEM1024P384() KEM

// NewKEM returns the KEM implementation for the given KEM ID.
//
// Applications are encouraged to use specific implementations like [DHKEM] or
// [MLKEM768X25519] instead, unless runtime agility is required.
func NewKEM(id uint16) (KEM, error)

There are also top-level functions to instantiate specific public/private keys from crypto/ecdh and crypto/mlkem types. This allows using existing keys, or using hardware keys abstracted by the relevant interfaces (see below), but they are not the primary intended constructors. [Added Nov 11]

// NewDHKEMPublicKey returns a PublicKey for the given ECDH public key.
//
// This function is meant for applications that already have an instantiated
// crypto/ecdh public key. Otherwise, applications should use the
// [KEM.NewPublicKey] method of [DHKEM].
func NewDHKEMPublicKey(pub *ecdh.PublicKey) (PublicKey, error)

// NewDHKEMPrivateKey returns a PrivateKey for the given ECDH private key.
//
// This function is meant for applications that already have an instantiated
// crypto/ecdh private key, or another implementation of a [ecdh.KeyExchanger]
// (e.g. a hardware key). Otherwise, applications should use the
// [KEM.NewPrivateKey] method of [DHKEM].
func NewDHKEMPrivateKey(priv ecdh.KeyExchanger) (PrivateKey, error)

// NewHybridPublicKey returns a PublicKey implementing one of
//
//   - MLKEM768-X25519 (a.k.a. X-Wing)
//   - MLKEM768-P256
//   - MLKEM1024-P384
//
// from draft-ietf-hpke-pq, depending on the underlying curve of t
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq (either
// *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have instantiated
// crypto/ecdh and crypto/mlkem public keys. Otherwise, applications should use
// the [KEM.NewPublicKey] method of e.g. [MLKEM768X25519].
func NewHybridPublicKey(pq crypto.Encapsulator, t *ecdh.PublicKey) (PublicKey, error)

// NewHybridPrivateKey returns a PrivateKey implementing
//
//   - MLKEM768-X25519 (a.k.a. X-Wing)
//   - MLKEM768-P256
//   - MLKEM1024-P384
//
// from draft-ietf-hpke-pq, depending on the underlying curve of t
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq.Encapsulator()
// (either *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
//
// This function is meant for applications that already have instantiated
// crypto/ecdh and crypto/mlkem private keys, or another implementation of a
// [ecdh.KeyExchanger] and [crypto.Decapsulator] (e.g. a hardware key).
// Otherwise, applications should use the [KEM.NewPrivateKey] method of e.g.
// [MLKEM768X25519].
func NewHybridPrivateKey(pq crypto.Decapsulator, t ecdh.KeyExchanger) (PrivateKey, error)

// NewMLKEMPublicKey returns a KEMPublicKey implementing ML-KEM-768 or ML-KEM-1024
// from draft-ietf-hpke-pq. pq must be either a *[mlkem.EncapsulationKey768] or a
// *[mlkem.EncapsulationKey1024].
//
// This function is meant for applications that already have an instantiated
// crypto/mlkem public key. Otherwise, applications should use the
// [KEM.NewPublicKey] method of e.g. [MLKEM768].
func NewMLKEMPublicKey(pub crypto.Encapsulator) (PublicKey, error)

// NewMLKEMPrivateKey returns a KEMPrivateKey implementing ML-KEM-768 or
// ML-KEM-1024 from draft-ietf-hpke-pq. priv.Encapsulator() must be either a
// *[mlkem.EncapsulationKey768] or a *[mlkem.EncapsulationKey1024].
//
// This function is meant for applications that already have an instantiated
// crypto/mlkem private key. Otherwise, applications should use the
// [KEM.NewPrivateKey] method of e.g. [MLKEM768].
func NewMLKEMPrivateKey(priv crypto.Decapsulator) (PrivateKey, 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) or NewDHKEMRecipient (acknowledging these are not singletons)? Added New prefixes.
  • Should it be QSFSender/QSFRecipient or QSFSenderMLKEM768/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 use NewKEMSender/NewKEMRecipient for that". Taking an interface now.
  • Should we even have the DHKEMRecipient or QSFSender etc.? They make more sense for ECDH where it's easy to construct crypto/ecdh types (with Curve.NewPrivateKey and Curve.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? Renamed to Hybrid.

/cc @golang/security

Metadata

Metadata

Assignees

No one assigned

    Labels

    LibraryProposalIssues describing a requested change to the Go standard library or x/ libraries, but not to a toolProposalProposal-AcceptedProposal-CryptoProposal related to crypto packages or other security issues

    Type

    No type

    Projects

    Status

    Accepted

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions