hpke: draft-ietf-hpke-pq support#1539
Conversation
Implement the post-quantum algorithms of draft-ietf-hpke-pq-04, gated behind a new `draft-ietf-hpke-pq` cargo feature in the libcrux provider only (rust_crypto is untouched beyond declining the new algorithms). Algorithms: - SHAKE128 (0x0010) and SHAKE256 (0x0011) one-stage KDFs - ML-KEM-768 / ML-KEM-1024 - hybrids MLKEM768-P256 (0x0050) and MLKEM1024-P384 (0x0051) - MLKEM768-X25519 (0x647a) via libcrux XWingKemDraft06 Core changes (feature-gated): - One-stage (SHAKE) HPKE key schedule and Export: single-stage KDFs are not Extract/Expand, so `key_schedule` and `Context::export` branch on `kdf::is_one_stage` and use `LabeledDerive` (src/kdf.rs, src/lib.rs). - DeriveKeyPair for ML-KEM/hybrid/X-Wing always uses SHAKE256.LabeledDerive, independent of the HPKE KDF, computed directly via libcrux_sha3 so it is provider-independent (src/kem.rs). Provider (libcrux_provider): - SHAKE128/256 via shake128/256_ema; ML-KEM private key is the seed (decaps re-expands). - Hybrids match concrete-hybrid-kem GC: seed-as-dk, SHAKE256 PRG expansion, RustCrypto p256/p384 rejection sampling + uncompressed SEC1, x-coord ss_T, SHA3-256 C2PRI combiner. - Sender-side encaps is derandomized (encapsulate_derand) so the KAT can inject the vector's ikmE without routing through the libcrux RNG. Deprecate `draft-connolly-cfrg-hpke-mlkem` (superseded; build.rs warning) and forbid enabling it together with `draft-ietf-hpke-pq` via compile_error!. Because the two are mutually exclusive, `--all-features` does not build; docs.rs metadata pins a working feature set in both Cargo.tomls.
Check for the exact list of supported cipersuites.
This is not implemented by any provider in the repo.
keks
left a comment
There was a problem hiding this comment.
I think this is a pretty good start! I left some comments. Most things are pretty minor, but I think the handling of singe stage KDFs could be improved, and that might be a little bit more involved.
| /// Benchmark the post-quantum ciphersuites. | ||
| /// | ||
| /// Restricted to `Base` mode: the PQ KEMs return `UnsupportedKemOperation` for the | ||
| /// `Auth`/`AuthPsk` modes, and PSK is out of scope here. Key generation is timed in | ||
| /// addition to the usual operations. | ||
| fn benchmark_post_quantum<Crypto: HpkeCrypto + 'static>(c: &mut Criterion) { | ||
| for aead_mode in AEAD_IDS { | ||
| if Crypto::supports_aead(aead_mode).is_err() { | ||
| continue; | ||
| } | ||
| for kdf_mode in PQ_KDF_IDS { | ||
| if Crypto::supports_kdf(kdf_mode).is_err() { | ||
| continue; | ||
| } | ||
| for &kem_mode in PQ_KEM_IDS { | ||
| if Crypto::supports_kem(kem_mode).is_err() { | ||
| continue; | ||
| } | ||
| bench_suite::<Crypto>(c, HpkeMode::Base, kem_mode, kdf_mode, aead_mode, true); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
I am not 100% sure, but it seems bench.rs and bench-pq.rs are the same except that the IDs that are being iterated over are different and the non-pq version does not have an option to benchmark key generation.
Are there deeper differences? Otherwise I would be in favour of merging the files and just have different top-level functions for pq and non-pq (where non-pq can use the flag to disable benchmarking key generation).
There was a problem hiding this comment.
Yeah, I thought this could make sense but you're right that it ended up with too much duplicate code.
| #[inline] | ||
| /// Build the `LabeledDerive` input for the single-stage KDFs: | ||
| /// `ikm ‖ "HPKE-v1" ‖ suite_id ‖ I2OSP(len(label),2) ‖ label ‖ I2OSP(L,2) ‖ context`. | ||
| /// | ||
| /// requires: len <= u16::MAX. | ||
| pub(crate) fn shake256_labeled_ikm( | ||
| suite_id: &[u8], | ||
| label: &str, | ||
| context: &[u8], | ||
| ikm: &[u8], | ||
| len: usize, | ||
| ) -> Vec<u8> { | ||
| concat(&[ | ||
| ikm, | ||
| HPKE_VERSION, | ||
| suite_id, | ||
| &length_prefixed(label.as_bytes()), | ||
| &(len as u16).to_be_bytes(), | ||
| context, | ||
| ]) | ||
| } |
There was a problem hiding this comment.
I am not convinced that this function is needed. In the draft they also just call concat from labeled_derive. I would just inline it.
https://datatracker.ietf.org/doc/html/draft-ietf-hpke-hpke-03#name-labeled-derivation-function
There was a problem hiding this comment.
yeah, it's a bit more verbose than needed.
| fn kdf_extract(alg: KdfAlgorithm, salt: &[u8], ikm: &[u8]) -> Result<Vec<u8>, Error> { | ||
| let alg = kdf_algorithm_to_libcrux_hkdf_algorithm(alg); | ||
| // SHAKE256 is a single-stage (XOF) KDF: there is no separate extraction | ||
| // step, so `Extract(salt, ikm)` derives `Nh` bytes from `salt || ikm`. | ||
| // See draft-ietf-hpke-pq Section 5. | ||
| #[cfg(feature = "draft-ietf-hpke-pq")] | ||
| if let Some(nh) = shake_nh(alg) { | ||
| return Ok(shake_derive(alg, &[salt, ikm], nh)); | ||
| } | ||
|
|
||
| let alg = kdf_algorithm_to_libcrux_hkdf_algorithm(alg)?; |
There was a problem hiding this comment.
The draft defines separate interfaces for these kinds of algorithms, and I don't think we should force them all into a single API. So maybe we should instead
- define a new enum SingleStageKdfAlgorithm for the Shake algorithms (not a nice name, maybe there is something nicer)
- define a new enum TwoStageKdfAlgorithm for the HKDF-based et al.
- change the trait methods
kdf_extractandkdf_expandto take the TwoStageKdfAlgorithm - define a new trait method
kdf_derive(alg:SingleStageKdfAlgorithm, ikm: &[u8], l: usize) -> Result<Vec<u8>, Error>;and implement that here, and use that inlabeled_derive - Give the caller a way to convert the KdfAlgorithm into either SingleStageKdfAlgorithm or a TwoStageKdfAlgorithm -- or maybe just define
enum KdfAlgorithm { SingleStage(SingleStageKdfAlgorithm), TwoStage(TwoStageKdfAlgorithm), }
- otherwise leave these functions as-is
- make the callers pick the function to call based on whether it's single-stage or two-stage
Of course changing the trait breaks compatibility, but so does changing the enum, so I think it's fine.
There was a problem hiding this comment.
Yeah this could be a bit cleaner. I don't want to change wire types and make that too complicated. But pulling this apart a bit more makes sense.
This is much more churn though. So I'm not sure if it's actually better. I tried some options and aren't super happy with either.
There was a problem hiding this comment.
We could also just add a kdf_derive and we return an error if the wrong kind of method is called. Then at least we are not squeezing it into the wrong interface.
|
|
||
| /// The nominal group (traditional component) of a hybrid KEM. | ||
| #[derive(Clone, Copy)] | ||
| enum NistCurve { |
There was a problem hiding this comment.
Should this be called NominalGroup? That would leave the door for X25519 open. Actually, why is X25519 not in here? Because it is in a separate X-Wing impl? Maybe we could merge these? 🤔
There was a problem hiding this comment.
Yes we can use NominalGroup. x25519 isn't used in any combiner like this and is handled pretty differently. So no need for it in here.
There was a problem hiding this comment.
Oh okay. In the RFC it looked like doing the normal combiner with X25519 would just be XWing, but I didn't look to closely.
| ### Changed | ||
|
|
||
| - [#154](https://github.com/celabshq/hpke-rs/pull/154): `HpkeCrypto::HpkePrng` now has `rand` v0.10 bound `CryptoRng`, instead of `rand` v0.9 bound `RngCore + CryptoRng` | ||
| - Add `KemAlgorithm::MlKem512 = 0x0040` (per `draft-ietf-hpke-pq`). Repoint the |
There was a problem hiding this comment.
I think this needs a PR number? Also, aren't we adding more algorithms?
| /// | ||
| /// Single-stage KDFs use [`labeled_derive`] and a different key-schedule shape | ||
| /// than the two-stage HKDF KDFs (which use [`labeled_extract`]/[`labeled_expand`]). | ||
| pub fn is_one_stage(&self) -> bool { |
There was a problem hiding this comment.
This should be is_single_stage (or made obsolete by refactoring the whole single-stage vs two-stage mechanism)
Co-authored-by: Jan Winkelmann <[email protected]>
…b.com:cryspen/libcrux into franziskus/hpke-pq
keks
left a comment
There was a problem hiding this comment.
I think this generally looks good. There seems to be another missing PR number in the change logs, and I have one question about whether something should be change where I expect the answer is "no", so approving with a caveat 👍
|
|
||
| ### Deprecated | ||
|
|
||
| - The `draft-connolly-cfrg-hpke-mlkem` feature is deprecated in favour of |
There was a problem hiding this comment.
Should this also have a PR number?
| let kdf_alg: KdfAlgorithm = alg.into(); | ||
| let kdf_alg = two_stage_kdf(alg)?; |
There was a problem hiding this comment.
A comment in the From impl says that the caller can override the KDF algorithm. Can/should we respect that override here?
libcrux/crates/protocols/hpke/traits/src/types.rs
Lines 401 to 404 in 83670ff
Adds the post-quantum and PQ/T-hybrid HPKE algorithms from draft-ietf-hpke-pq-05, behind a new
draft-ietf-hpke-pqfeature (libcrux provider only).Added
Shake128 = 0x0010andShake256 = 0x0011, with the one-stage HPKE key schedule (KeySchedule, Export, DeriveKeyPair).MlKem512 = 0x0040(plus existing MlKem768/MlKem1024 repointed to the IETF draft).MlKem768P256 = 0x0050,MlKem1024P384 = 0x0051, andMLKEM768-X25519 = 0x647a, per draft-irtf-cfrg-concrete-hybrid-kems-04.Deprecated
Notes
rustcrypto-p-curvesfor now.bench_pqbenchmark andtest_hpke_pqsuite with the draft's vectors.To test the new code run
cargo test -p hpke-rs -F draft-ietf-hpke-pq,rustcrypto-p-curves.Claude helped push some of the code around.