Files
hpke/hpke-pq.md

263 lines
10 KiB
Markdown
Raw Normal View History

2026-03-06 23:52:07 +00:00
# HPKE Hybrid KEMs
[sources.truenas.cloud/code/hpke-pq](https://sources.truenas.cloud/code/hpke-pq)
This document is a simplified and self-contained implementation reference for
the MLKEM768-X25519, MLKEM768-P256, and MLKEM1024-P384 hybrid HPKE KEMs,
specified in [draft-ietf-hpke-pq-03][], [draft-irtf-cfrg-hybrid-kems-07][],
[draft-irtf-cfrg-concrete-hybrid-kems-02][], and [draft-ietf-hpke-hpke-02].
It compensates for the need to cross-reference four documents, with different
nomenclature (including functions and components with the same name but
different behavior), alternative irrelevant definitions (the UG, UK, and CK
frameworks), and multiple KEM abstraction layers.
## Conventions used in this document
`||` denotes concatenation. `[N:M]` denotes the byte slice from index N (inclusive)
to index M (exclusive). Strings quoted with `""` are encoded as ASCII. Values in
code blocks are hex encoded byte strings. `random(N)` denotes N bytes of CSPRNG
output. All lengths are in bytes.
ML-KEM.KeyGen_internal, ML-KEM.Encaps, ML-KEM.Encaps_internal, and ML-KEM.Decaps
are defined in [FIPS 203][]. `SHAKE256(s, L)` is an invocation of `SHAKE256(s, 8*L)`
defined in [FIPS 202][]. SHA3-256 is defined in [FIPS 202][].
## KEM definitions
| Parameter | MLKEM768-X25519 | MLKEM768-P256 | MLKEM1024-P384 |
| ----------------- | ------------------ | ------------------ | ------------------ |
| ML-KEM parameters | ML-KEM-768 | ML-KEM-768 | ML-KEM-1024 |
| Group | Curve25519 | P-256 | P-384 |
| KEM identifier | 0x647a | 0x0050 | 0x0051 |
| Nsecret | 32 | 32 | 32 |
| Nenc | 1120 | 1153 | 1665 |
| Npk | 1216 | 1249 | 1665 |
| Nsk | 32 | 32 | 32 |
| Nrandom | 64 | 160 | 80 |
| Label | `"\.//^\"` | `"MLKEM768-P256"` | `"MLKEM1024-P384"` |
| KEM.Nct | 1088 | 1088 | 1568 |
| KEM.Nek | 1184 | 1184 | 1568 |
| KEM.Nseed | 64 | 64 | 64 |
| KEM.Nrandom | 32 | 32 | 32 |
| Group.Nelem | 32 | 65 | 97 |
| Group.Nseed | 32 | 128 | 48 |
| Group.Nscalar | N/A | 32 | 48 |
The MLKEM768-X25519 Label is alternatively encoded as
5c2e2f2f5e5c
## KEM functions
```
def GenerateKeyPair():
seed = random(Nsk)
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return (seed, ek)
def DeriveKeyPair(ikm):
# SHAKE256.LabeledDerive is part of the single-stage KDF described in
# draft-ietf-hpke-hpke-02 and defined in draft-ietf-hpke-pq-03, but is
# reproduced below for convenience.
seed = SHAKE256.LabeledDerive(ikm, "DeriveKeyPair", "", Nsk)
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return (seed, ek)
def Encaps(ek):
ek_PQ = ek[0 : KEM.Nek]
ek_T = ek[KEM.Nek : KEM.Nek + Group.Nelem]
ss_PQ, ct_PQ = ML-KEM.Encaps(ek_PQ)
sk_E = Group.RandomScalar(random(Group.Nseed))
ct_T = Group.Exp(Group.G, sk_E)
ss_T = Group.ElementToSharedSecret(Group.Exp(ek_T, sk_E))
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
ct = ct_PQ || ct_T
return (ss, ct)
def Decaps(seed, ct):
ct_PQ = ct[0 : KEM.Nct]
ct_T = ct[KEM.Nct : KEM.Nct + Group.Nelem]
ek_PQ, ek_T, dk_PQ, dk_T = expandKey(seed)
ss_PQ = ML-KEM.Decaps(dk_PQ, ct_PQ)
ss_T = Group.ElementToSharedSecret(Group.Exp(ct_T, dk_T))
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
return ss
def expandKey(seed):
seed_full = SHAKE256(seed, KEM.Nseed + Group.Nseed)
seed_PQ = seed_full[0 : KEM.Nseed]
seed_T = seed_full[KEM.Nseed : KEM.Nseed + Group.Nseed]
# Note that even if expandKey returns the semi-expanded ML-KEM decapsulation
# key dk_PQ to use FIPS 203 definitions, that format should be avoided and
# instead seed_PQ should be expanded directly into the implementation's
# internal ML-KEM private representation.
(ek_PQ, dk_PQ) = ML-KEM.KeyGen_internal(seed_PQ)
dk_T = Group.RandomScalar(seed_T)
ek_T = Group.Exp(Group.G, dk_T)
return (ek_PQ, ek_T, dk_PQ, dk_T)
```
There is no distinction between a private/public key and its serialization:
there is no abstract key format, only byte strings. In practice, implementations
will probably want to load keys into pairs of internal representations, and
serialize them back to their byte string format when needed.
The IETF/IRTF documents lack a specified way to turn a private key into public
key, although it can be inferred from the key generation process. We define such
a process here as `PrivateKeyToPublicKey`.
```
def PrivateKeyToPublicKey(seed):
ek_PQ, ek_T, _, _ = expandKey(seed)
ek = ek_PQ || ek_T
return ek
```
### Deterministic Encapsulation
For testing purposes, implementations can provide Nrandom bytes of encapsulation
randomness to the deterministic internal function `EncapsDerand`.
```
def EncapsDerand(ek, randomness):
ek_PQ = ek[0 : KEM.Nek]
ek_T = ek[KEM.Nek : KEM.Nek + Group.Nelem]
randomness_PQ = randomness[0 : KEM.Nrandom]
randomness_T = randomness[KEM.Nrandom : KEM.Nrandom + Group.Nseed]
ss_PQ, ct_PQ = ML-KEM.Encaps_internal(ek_PQ, randomness_PQ)
sk_E = Group.RandomScalar(randomness_T)
ct_T = Group.Exp(Group.G, sk_E)
ss_T = Group.ElementToSharedSecret(Group.Exp(ek_T, sk_E))
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
ct = ct_PQ || ct_T
return (ss, ct)
```
## Group definitions
### Curve25519
Group.Exp is the X25519 function defined in [RFC 7748][].
Group.G is the canonical generator, which encodes to
0900000000000000000000000000000000000000000000000000000000000000
consistently with [RFC 7748, Section 4.1][] and [RFC 7748, Section 6.1][].
Group.RandomScalar and Group.ElementToSharedSecret are the identity.
### P-256 and P-384
The NIST P-256 and P-384 elliptic curves are defined in [SP800-186][].
`Group.Exp(p, x)` computes scalar multiplication between the input element p and
the scalar x. The input element p and the output element have length Group.Nelem
and are encoded in uncompressed representation using the
Elliptic-Curve-Point-to-Octet-String and Octet-String-to-Elliptic-Curve-Point
functions defined in [SEC 1, Version 2.0][]. The input scalar x has length
Group.Nscalar and is encoded in big-endian representation using the I2OSP and
OS2IP functions defined in [RFC 8017][].
Group.G is the canonical generator, which encodes to
046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c29
64fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
for P-256, and to
04aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab
73617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f
for P-384, consistently with [SP800-186][], Section 3.2.1.
```
def Group.RandomScalar(seed):
start = 0
end = Nscalar
sk = seed[start : end]
while OS2IP(sk) == 0 || OS2IP(sk) >= OS2IP(Group.N):
start = end
end = end + Nscalar
if end > len(seed):
# This happens with cryptographically negligible probability.
# The chance of a single rejection is < 2^-32 for P-256 and
# < 2^-192 for P-384. The chance of reaching this is thus
# < 2^-128 for P-256 and < 2^-192 for P-384.
raise Exception("Rejection sampling failed")
sk = seed[start : end]
return sk
```
Group.N is the order of the curve's group, which encodes to
ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
for P-256, and to
ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973
for P-384, consistently with [SP800-186][], Section 3.2.1.
Group.ElementToSharedSecret encodes the input element as an X coordinate using
the Field-Element-to-Octet-String function in [SEC 1, Version 2.0][].
> Note that since the scalar x is always derived uniformly at random, the chance
> of it being zero are cryptographically negligible. Moreover,
> Octet-String-to-Elliptic-Curve-Point never decodes the point at infinity from
> a string of Group.Nelem bytes. Since NIST P curves have prime order, this
> means that the output of Group.Exp and input to Group.ElementToSharedSecret is
> also never the point at infinity.
## SHAKE256.LabeledDerive
SHAKE256.LabeledDerive is used by DeriveKeyPair, and is part of the single-stage
KDF specified across [draft-ietf-hpke-hpke-02][] and [draft-ietf-hpke-pq-03][],
but is reproduced below for convenience.
```
def SHAKE256.LabeledDerive(ikm, label, context, L):
suite_id = concat("KEM", I2OSP(kem_id, 2))
prefixed_label = I2OSP(len(label), 2) || label
labeled_ikm = ikm || "HPKE-v1" || suite_id || prefixed_label || I2OSP(L, 2) || context
return SHAKE256(labeled_ikm, L)
```
I2OSP is defined in [RFC 8017][], and `kem_id` is the KEM identifier.
[draft-ietf-hpke-hpke-02]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-hpke-02
[draft-ietf-hpke-pq-03]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-pq-03
[draft-irtf-cfrg-hybrid-kems-07]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-hybrid-kems-07
[draft-irtf-cfrg-concrete-hybrid-kems-02]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-concrete-hybrid-kems-02
[RFC 7748]: https://rfc-editor.org/rfc/rfc7748.html
[RFC 7748, Section 4.1]: https://rfc-editor.org/rfc/rfc7748.html#section-4.1
[RFC 7748, Section 6.1]: https://rfc-editor.org/rfc/rfc7748.html#section-6.1
[RFC 8017]: https://datatracker.ietf.org/doc/html/rfc8017
[FIPS 202]: https://doi.org/10.6028/NIST.FIPS.202
[FIPS 203]: https://doi.org/10.6028/NIST.FIPS.203
[SP800-186]: https://doi.org/10.6028/NIST.SP.800-186
[SEC 1, Version 2.0]: https://www.secg.org/sec1-v2.pdf