pqkem: derive PSK with HKDF-SHA256

Replace the raw SHA-256 concat combiner with HKDF-SHA256 (crypto/hkdf, Go 1.24):
IKM = ML-KEM_ss || X25519_ss (draft-ietf-tls-ecdhe-mlkem order), salt = the
domain-separation label, info = full transcript (offer || answer) || canonicalised
peer identities. Keeps the transcript + identity binding while using a proper KDF.
This commit is contained in:
riccardom
2026-08-03 12:48:47 +02:00
parent dcb6e52c03
commit 9d34b8bb0f
+32 -21
View File
@@ -14,13 +14,15 @@
// //
// Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on // Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on
// the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for // the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for
// this group), and ML-KEM_ss ‖ X25519_ss fed into the KDF. The spike uses SHA-256 // this group), and ML-KEM_ss ‖ X25519_ss as the KDF input. The PSK is derived with
// (also binding the transcript and peer identities); a production version should // HKDF-SHA256 over that hybrid secret, salted with a domain-separation label and
// use HKDF — see TODO below. // bound (via the HKDF info) to the full transcript and the canonicalised peer
// identities.
package pqkem package pqkem
import ( import (
"crypto/ecdh" "crypto/ecdh"
"crypto/hkdf"
"crypto/mlkem" "crypto/mlkem"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@@ -98,7 +100,7 @@ func (i *Initiator) Finish(answer []byte, b Binding) (PSK, error) {
return PSK{}, fmt.Errorf("x25519 ecdh: %w", err) return PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
} }
return derivePSK(ssMLKEM, ssX, i.offer, answer, b), nil return derivePSK(ssMLKEM, ssX, i.offer, answer, b)
} }
// Respond consumes an initiator offer, produces the answer, and derives the PSK. // Respond consumes an initiator offer, produces the answer, and derives the PSK.
@@ -134,29 +136,38 @@ func Respond(offer []byte, b Binding) (answer []byte, psk PSK, err error) {
// derivePSK uses the same argument order on both sides; the responder's local // derivePSK uses the same argument order on both sides; the responder's local
// binding is the mirror of the initiator's, canonicalised inside derivePSK. // binding is the mirror of the initiator's, canonicalised inside derivePSK.
return answer, derivePSK(ssMLKEM, ssX, offer, answer, b), nil psk, err = derivePSK(ssMLKEM, ssX, offer, answer, b)
if err != nil {
return nil, PSK{}, err
}
return answer, psk, nil
} }
// derivePSK combines the two shared secrets and binds the result to the full // derivePSK runs HKDF-SHA256 over the hybrid shared secret (ML-KEM_ss ‖ X25519_ss,
// transcript (offer ‖ answer) and the canonicalised peer identities. // per draft-ietf-tls-ecdhe-mlkem), salted with the domain-separation label, and binds
// // the result — via the HKDF info — to the full transcript (offer ‖ answer) and the
// TODO(NET-1406): replace the SHA-256 concat with the RFC HKDF combiner // canonicalised peer identities, so the PSK cannot be transplanted to another peer
// (crypto/hkdf, Go 1.24+) and proper labels before this leaves spike status. // pair or a different exchange.
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) PSK { func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) (PSK, error) {
lo, hi := canonicalPair(b.LocalID, b.RemoteID) lo, hi := canonicalPair(b.LocalID, b.RemoteID)
h := sha256.New() ikm := make([]byte, 0, len(ssMLKEM)+len(ssX))
h.Write([]byte(pskLabel)) ikm = append(ikm, ssMLKEM...)
h.Write(ssMLKEM) ikm = append(ikm, ssX...)
h.Write(ssX)
h.Write(offer) info := make([]byte, 0, len(offer)+len(answer)+len(lo)+len(hi))
h.Write(answer) info = append(info, offer...)
h.Write(lo) info = append(info, answer...)
h.Write(hi) info = append(info, lo...)
info = append(info, hi...)
var psk PSK var psk PSK
copy(psk[:], h.Sum(nil)) key, err := hkdf.Key(sha256.New, ikm, []byte(pskLabel), string(info), len(psk))
return psk if err != nil {
return PSK{}, fmt.Errorf("hkdf derive psk: %w", err)
}
copy(psk[:], key)
return psk, nil
} }
func canonicalPair(a, b []byte) (lo, hi []byte) { func canonicalPair(a, b []byte) (lo, hi []byte) {