HPKE (Hybrid Public Key Encryption, RFC 9180)

Hybrid Public Key Encryption (RFC 9180): the standard construction behind TLS Encrypted Client Hello, MLS, and OHTTP that turns a recipient KEM public key into an encapsulated key plus an AEAD-encrypting context. Three KEM families run the RFC 9180 key schedule: the DHKEM suites P-256, P-384, P-521, X25519, and X448 in all four modes (base / psk / auth / auth-psk), proven against the RFC 9180 Appendix A known-answer vectors, the post-quantum ML-KEM-512 / -768 / -1024 KEMs (IANA code points 0x0040 to 0x0042, draft-ietf-hpke-pq) in the base and psk modes, proven against that draft's Appendix A vectors, and the PQ/T hybrid KEMs MLKEM768-P256 (0x0050), MLKEM1024-P384 (0x0051) and MLKEM768-X25519 (0x647a, X-Wing) in the base and psk modes, each pairing an ML-KEM with a nominal group under the CFRG C2PRI combiner and proven against the draft-irtf-cfrg-concrete-hybrid-kems Appendix B vectors. The KDFs are HKDF-SHA256 / HKDF-SHA384 / HKDF-SHA512, which run the two-stage key schedule, and SHAKE128 (0x0010) / SHAKE256 (0x0011) of draft-ietf-hpke-pq, which are single-stage and run the one-stage key schedule of draft-ietf-hpke-hpke, proven against Appendix A.7, A.8, A.11 and A.12 of the first draft. On a single-stage KDF the psk, psk_id and info inputs are capped at 65535 bytes and an export is capped at the same, each length being encoded in two bytes. TurboSHAKE128 (0x0012) and TurboSHAKE256 (0x0013) are registered in the same table and not offered, because no released OpenSSL exposes the XOF; a request for either fails closed. The AEADs are AES-128-GCM, AES-256-GCM, ChaCha20Poly1305, and export-only. deriveKeyPair, generateKeyPair, encap and decap reach a KEM on its own, for a caller driving its own key schedule. Pure composition over node:crypto: no ASN.1, no schema engine. The RFC 9180 sec. 7 registry code points live in pki.hpke.suites (KEM / KDF / AEAD / MODE), passed to the setup functions to select a suite.

pki.hpke.setupS

since 0.2.2 stable
pki.hpke.setupS(suiteIds, recipientPublicKey, opts?) -> { enc, context, sharedSecret }

Establish a sender HPKE context for a recipient KEM public key: encapsulate a shared secret and run the key schedule, returning the encapsulated key enc (send it to the recipient) and a context whose .seal(aad, pt) / .export(ctx, L) encrypt and derive further secrets. suiteIds is { kem, kdf, aead } from pki.hpke.suites; recipientPublicKey is a node KeyObject or the serialized public key bytes (the uncompressed point, the raw Montgomery coordinate, or the ML-KEM encapsulation key). opts.mode selects base / psk / auth / auth-psk (default base); opts.info, opts.psk/opts.pskId, and opts.senderKey (auth modes) supply the corresponding inputs; an option that belongs to another mode, such as senderKey in base mode, is refused with hpke/bad-input rather than left unused. The auth modes exist for the DHKEM suites only: an ML-KEM suite throws hpke/auth-unsupported. On a single-stage KDF (SHAKE128, SHAKE256) opts.info, opts.psk and opts.pskId are capped at 65535 bytes each and a longer one throws hpke/input-length.

Example

var s = pki.hpke.suites, ids = { kem: s.KEM.DHKEM_X25519_HKDF_SHA256, kdf: s.KDF.HKDF_SHA256, aead: s.AEAD.AES_128_GCM };
var pkR = Buffer.from("8c7781768956b9dd38997c5a83ab5b9315270a9f73d87d676573c5bca74e3e48", "hex");
var sender = pki.hpke.setupS(ids, pkR, { info: Buffer.from("app") });
var ct = sender.context.seal(Buffer.from("aad"), Buffer.from("secret"));

References

pki.hpke.setupR

since 0.2.2 stable
pki.hpke.setupR(suiteIds, enc, recipientPrivateKey, opts?) -> context

Establish the recipient HPKE context from the sender's encapsulated key enc and the recipient KEM private key, recovering the same shared secret and key schedule. The private key is a node KeyObject, or { skm, pkm } where skm is the raw private scalar of a DHKEM suite or the 64-byte seed of an ML-KEM suite; pkm is optional, and when given it must be the public key derived from skm or the call throws hpke/bad-key. The returned context .open(aad, ct) decrypts and .export(ctx, L) derives secrets. opts mirrors setupS (mode / info / psk / pskId), with opts.senderPublicKey for the auth modes (DHKEM suites only). A ciphertext whose tag does not verify throws hpke/open-failed.

Example

var s = pki.hpke.suites, ids = { kem: s.KEM.DHKEM_X25519_HKDF_SHA256, kdf: s.KDF.HKDF_SHA256, aead: s.AEAD.AES_128_GCM };
var pkR = Buffer.from("8c7781768956b9dd38997c5a83ab5b9315270a9f73d87d676573c5bca74e3e48", "hex");
var skR = Buffer.from("009f2181fba5f8908632c10ea1137c40a849728fde016c4602458b943a5dc048", "hex");
var sender = pki.hpke.setupS(ids, pkR);
var recipient = pki.hpke.setupR(ids, sender.enc, { skm: skR, pkm: pkR });
var pt = recipient.open(Buffer.alloc(0), sender.context.seal(Buffer.alloc(0), Buffer.from("hi")));

References

pki.hpke.seal

since 0.2.2 stable
pki.hpke.seal(suiteIds, recipientPublicKey, opts, aad, pt) -> { enc, ct }

Single-shot HPKE encryption (RFC 9180 sec. 6): set up a sender context and encrypt one plaintext, returning the encapsulated key enc and ciphertext ct. Equivalent to setupS followed by one context.seal. opts is the setupS options object (mode / info / psk / senderKey).

Example

// requires: mlKemPkR -- the recipient's ML-KEM-768 encapsulation key (1184 bytes)
var s = pki.hpke.suites, ids = { kem: s.KEM.ML_KEM_768, kdf: s.KDF.HKDF_SHA256, aead: s.AEAD.AES_256_GCM };
var out = pki.hpke.seal(ids, mlKemPkR, {}, Buffer.from("aad"), Buffer.from("msg"));

References

pki.hpke.open

since 0.2.2 stable
pki.hpke.open(suiteIds, enc, recipientPrivateKey, opts, aad, ct) -> pt

Single-shot HPKE decryption (RFC 9180 sec. 6): set up a recipient context from enc and decrypt one ciphertext, returning the plaintext. A tag that does not verify throws hpke/open-failed and returns no plaintext.

Example

// requires: mlKemPkR -- the recipient's ML-KEM-768 encapsulation key (1184 bytes)
// requires: mlKemSkR -- { skm } holding the recipient's 64-byte ML-KEM-768 seed
var s = pki.hpke.suites, ids = { kem: s.KEM.ML_KEM_768, kdf: s.KDF.HKDF_SHA256, aead: s.AEAD.AES_256_GCM };
var o = pki.hpke.seal(ids, mlKemPkR, {}, Buffer.alloc(0), Buffer.from("m"));
var pt = pki.hpke.open(ids, o.enc, mlKemSkR, {}, Buffer.alloc(0), o.ct);

References

pki.hpke.deriveKeyPair

since 0.8.45 stable
pki.hpke.deriveKeyPair(kemId, ikm) -> { publicKey, privateKey }

The KEM's DeriveKeyPair: a key pair derived deterministically from input keying material, returned as raw byte strings in the encodings the KEM registers. The same ikm always gives the same pair, so a key can be rebuilt from backed-up material instead of stored in full, and the pair matches what any other conforming implementation derives from the same input.

ikm is keying material, not a private key. Each KEM runs it through its own derivation first: a DHKEM through LabeledExtract and LabeledExpand, with the prime curves rejection-sampling candidates until one lands in [1, order) (RFC 9180 section 7.1.3); an ML-KEM and a PQ/T hybrid through SHAKE256.LabeledDerive to the KEM's seed, which is then expanded (draft-ietf-hpke-pq sections 3 and 4). To use a private key you already hold, pass it as { skm } to decap, setupR or open instead; that door takes the KEM's own private encoding and checks its width.

Any length of ikm is accepted. The hybrid suites state a 32-byte minimum as a SHOULD, which is not enforced here, and an ML-KEM takes input of any length.

privateKey is the KEM's private encoding: the 32-byte seed for a hybrid suite, the 64-byte seed for a bare ML-KEM, and the scalar for a DHKEM.

Example

var ikm = Buffer.alloc(32, 7);
var kp = pki.hpke.deriveKeyPair(pki.hpke.suites.KEM.MLKEM768_X25519, ikm);
kp.publicKey.length;    // -> 1216, the ML-KEM-768 key then the X25519 key
kp.privateKey.length;   // -> 32, the seed the ikm derived

References

pki.hpke.generateKeyPair

since 0.8.45 stable
pki.hpke.generateKeyPair(kemId) -> { publicKey, privateKey }

A fresh KEM key pair, as raw byte strings in the encodings the KEM registers. For a hybrid suite the private key is a 32-byte seed the whole pair expands from, and for the others it is the KEM's own private encoding.

Example

var kp = pki.hpke.generateKeyPair(pki.hpke.suites.KEM.MLKEM768_P256);
kp.publicKey.length;    // -> 1249
kp.privateKey.length;   // -> 32

References

pki.hpke.encap

since 0.8.45 stable
pki.hpke.encap(kemId, recipientPublicKey) -> { enc, sharedSecret }

The KEM half of HPKE on its own: encapsulate to a recipient public key, returning the encapsulated key to send and the shared secret it establishes. pki.hpke.setupS runs this and then the key schedule; this verb is for a caller driving its own.

For a hybrid KEM the encapsulated key is the PQ ciphertext followed by the traditional group element, and the shared secret is the two component secrets combined with the traditional ciphertext, the traditional public key and the suite label, which is the C2PRI combiner that framework specifies.

Example

var kp = pki.hpke.generateKeyPair(pki.hpke.suites.KEM.MLKEM768_X25519);
var e = pki.hpke.encap(pki.hpke.suites.KEM.MLKEM768_X25519, kp.publicKey);
e.enc.length;            // -> 1120
e.sharedSecret.length;   // -> 32

References

pki.hpke.decap

since 0.8.45 stable
pki.hpke.decap(kemId, enc, recipientPrivateKey) -> sharedSecret

The receiving half of the KEM: recover the shared secret from an encapsulated key. A KEM does not report failure by throwing where the specification says it returns a value, so a ciphertext this key did not receive yields a different secret rather than an error, and the AEAD above it is what refuses. A structurally wrong input, such as an encapsulated key of the wrong length, is refused.

Example

var id = pki.hpke.suites.KEM.MLKEM768_X25519;
var kp = pki.hpke.generateKeyPair(id);
var e = pki.hpke.encap(id, kp.publicKey);
pki.hpke.decap(id, e.enc, { skm: kp.privateKey }).equals(e.sharedSecret);   // -> true

References