diff --git a/CHANGELOG.md b/CHANGELOG.md index 15899d8..cedf8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,35 @@ # VaptVupt Changelog +## [5.0.0] — unreleased — genuine FIPS 203 ML-KEM-768 (breaking PQ change) + +### Security / correctness — ML-KEM-768 is now FIPS 203-conformant + +- The in-tree ML-KEM-768 was **round-3 CRYSTALS-Kyber, not final FIPS 203**, and + therefore not interoperable with a compliant ML-KEM despite the "FIPS 203" + label. Three deviations were found and fixed: + 1. **Matrix  transpose convention** — K-PKE.KeyGen must use `SampleNTT(ρ‖j‖i)` + and K-PKE.Encrypt `SampleNTT(ρ‖i‖j)`; the implementation had both swapped. + Self-consistent (round-trips passed) but non-standard, which is exactly why + a self-consistency-only test never caught it. + 2. **Encaps/decaps KDF** — FIPS 203 outputs `K` from `G(m‖H(ek))` directly; the + round-3 final `K = KDF(K̄‖H(c))` step was removed. + 3. **Implicit rejection** — now `K̄ = J(z‖c)` (SHAKE256 over the full + ciphertext) instead of `KDF(z‖H(c))`. +- **Validated for genuine conformance against OpenSSL 3.5's FIPS 203 ML-KEM-768** + (`tests/test_mlkem_fips203.sh`, wired into `make check`): deterministic keygen + produces byte-identical `ek`, and the shared secret matches in **both** + cross-decapsulation directions (our encaps ↔ OpenSSL decaps, and vice-versa). + This replaces the previous self-consistency-only round-trip test. + +### BREAKING + +- **`--pq` and `--pq-only` keys and archives created by ≤ 4.2.1 are not + readable by this release** (the KEM math changed). Regenerate keys + (`keygen`/`keygen --pq-only`) and re-encrypt affected archives. Password mode + (`-p`) and plain compression are unaffected. Wire format stays v1.6. + + ## [4.2.1] — 2026-07-10 — `info` correctly reports the post-quantum mode ### Fixed diff --git a/Makefile b/Makefile index ec10014..8069e83 100644 --- a/Makefile +++ b/Makefile @@ -418,6 +418,7 @@ test: $(TARGET) $(Q)bash tests/test_arg_order.sh $(Q)bash tests/test_block_swap.sh $(Q)bash tests/test_dedup_nonce.sh + $(Q)bash tests/test_mlkem_fips203.sh $(Q)bash tests/test_f08_topmac.sh $(Q)bash tests/test_f09_preface.sh $(Q)bash tests/test_f10_kdf_default.sh @@ -477,6 +478,7 @@ check: $(TARGET) test-vectors $(Q)bash tests/test_arg_order.sh $(Q)bash tests/test_block_swap.sh $(Q)bash tests/test_dedup_nonce.sh + $(Q)bash tests/test_mlkem_fips203.sh $(Q)bash tests/test_f08_topmac.sh $(Q)bash tests/test_f10_kdf_default.sh $(Q)bash tests/test_f11_authfail_message.sh diff --git a/src/zupt_mlkem.c b/src/zupt_mlkem.c index 7bfdc2b..12ddfba 100644 --- a/src/zupt_mlkem.c +++ b/src/zupt_mlkem.c @@ -339,9 +339,11 @@ static void kpke_keygen(uint8_t pk[1184], uint8_t sk_pke[1152], const uint8_t d[ /* Generate matrix A (in NTT domain) from rho */ polyvec Ahat[MLKEM_K]; + /* FIPS 203 Algorithm 13 (K-PKE.KeyGen): Â[i][j] ← SampleNTT(XOF(ρ, j, i)). + * The XOF seed appends the COLUMN index j then the ROW index i. */ for (int i = 0; i < MLKEM_K; i++) for (int j = 0; j < MLKEM_K; j++) - poly_uniform(Ahat[i][j], rho, (uint8_t)i, (uint8_t)j); + poly_uniform(Ahat[i][j], rho, (uint8_t)j, (uint8_t)i); /* Sample secret vector s */ polyvec s; @@ -392,9 +394,11 @@ static void kpke_encrypt(uint8_t ct[1088], const uint8_t pk[1184], /* Regenerate A^T from rho (transposed) */ polyvec AT[MLKEM_K]; + /* FIPS 203 Algorithm 14 (K-PKE.Encrypt): Â[i][j] ← SampleNTT(XOF(ρ, i, j)). + * Encrypt uses the transpose of KeyGen's matrix: seed appends ROW i then COL j. */ for (int i = 0; i < MLKEM_K; i++) for (int j = 0; j < MLKEM_K; j++) - poly_uniform(AT[i][j], rho, (uint8_t)j, (uint8_t)i); + poly_uniform(AT[i][j], rho, (uint8_t)i, (uint8_t)j); /* Sample r_vec, e1, e2 */ polyvec r_vec; @@ -541,18 +545,14 @@ int zupt_mlkem768_encaps(uint8_t ct[1088], uint8_t ss[32], /* Encrypt m under pk with randomness r */ kpke_encrypt(ct, pk, m, kr + 32); - /* K = KDF(kr[0:32] ‖ H(ct)) */ - uint8_t h_ct[32]; - zupt_sha3_256(ct, 1088, h_ct); - uint8_t kdf_in[64]; - memcpy(kdf_in, kr, 32); - memcpy(kdf_in + 32, h_ct, 32); - zupt_shake256(kdf_in, 64, ss, 32); + /* FIPS 203, Algorithm 17 (ML-KEM.Encaps_internal): the shared secret K is + * the first 32 bytes of (K, r) = G(m ‖ H(ek)) DIRECTLY. Round-3 Kyber + * applied a final K = KDF(K̄ ‖ H(c)); FIPS 203 removed that step. */ + memcpy(ss, kr, 32); zupt_secure_wipe(m, 32); zupt_secure_wipe(kr, 64); zupt_secure_wipe(kr_input, 64); - zupt_secure_wipe(kdf_in, 64); return 0; } @@ -601,22 +601,20 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088], * equal (ct matches → success), 0 otherwise. */ int ct_equal = zupt_ct_memeq(ct, ct_prime, 1088); - /* Compute success key: K = KDF(kr[0:32] ‖ H(ct)) */ - uint8_t h_ct[32]; - zupt_sha3_256(ct, 1088, h_ct); - - uint8_t kdf_success[64]; - memcpy(kdf_success, kr, 32); - memcpy(kdf_success + 32, h_ct, 32); + /* FIPS 203, Algorithm 18 (ML-KEM.Decaps_internal): + * success key K' = first 32 bytes of (K', r') = G(m' ‖ h) [no final KDF] + * reject key K̄ = J(z ‖ c) = SHAKE256(z ‖ full-ciphertext, 32) + * Both are computed unconditionally; the constant-time select below picks + * the reject key iff the re-encryption comparison fails. (Round-3 Kyber + * used K = KDF(K̄' ‖ H(c)) and K̄ = KDF(z ‖ H(c)); FIPS 203 changed both.) */ uint8_t ss_success[32]; - zupt_shake256(kdf_success, 64, ss_success, 32); + memcpy(ss_success, kr, 32); - /* Compute rejection key: K_bar = KDF(z ‖ H(ct)) */ - uint8_t kdf_reject[64]; + uint8_t kdf_reject[32 + 1088]; memcpy(kdf_reject, z, 32); - memcpy(kdf_reject + 32, h_ct, 32); + memcpy(kdf_reject + 32, ct, 1088); uint8_t ss_reject[32]; - zupt_shake256(kdf_reject, 64, ss_reject, 32); + zupt_shake256(kdf_reject, 32 + 1088, ss_reject, 32); /* CT-REQUIRED: Select success or reject key without branching. * ct_equal == 1 (ct matches): use ss_success → fail = 0. @@ -635,8 +633,7 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088], zupt_secure_wipe(kr, 64); zupt_secure_wipe(kr_input, 64); zupt_secure_wipe(ct_prime, sizeof(ct_prime)); - zupt_secure_wipe(kdf_success, 64); - zupt_secure_wipe(kdf_reject, 64); + zupt_secure_wipe(kdf_reject, sizeof(kdf_reject)); zupt_secure_wipe(ss_success, 32); zupt_secure_wipe(ss_reject, 32); return 0; diff --git a/tests/mlkem_fips203_harness.c b/tests/mlkem_fips203_harness.c new file mode 100644 index 0000000..ae78eb7 --- /dev/null +++ b/tests/mlkem_fips203_harness.c @@ -0,0 +1,65 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later + * Deterministic ML-KEM-768 harness for FIPS 203 conformance testing against an + * external reference (OpenSSL 3.5+). Uses the project's PUBLIC KEM API over raw + * FIPS 203 byte strings (ek=1184, dk=2400, ct=1088, ss=32). + * + * keygen -> ek.bin, dk.bin (d,z consumed from MLKEM_RAND if set) + * encaps -> ct.bin, ss.bin (m consumed from MLKEM_RAND if set) + * decaps -> ss.bin + * + * When env MLKEM_RAND names a file, zupt_random_bytes() consumes it SEQUENTIALLY + * (keygen reads d then z; encaps reads m), so the same FIPS 203 seed fed to a + * reference implementation produces byte-identical ek/dk/ct/ss. + * + * Built by tests/test_mlkem_fips203.sh against src/zupt_mlkem.c + src/zupt_keccak.c + * (no -DZUPT_USE_JASMIN, so the portable constant-time select is used). */ +#include +#include +#include +#include +#include "zupt_mlkem.h" + +static FILE *g_rand; static int g_rand_init; +void zupt_random_bytes(uint8_t *buf, size_t len) { + if (!g_rand_init) { + const char *p = getenv("MLKEM_RAND"); + g_rand = fopen(p ? p : "/dev/urandom", "rb"); + g_rand_init = 1; + } + if (!g_rand || fread(buf, 1, len, g_rand) != len) { fprintf(stderr, "rand fail\n"); exit(2); } +} +int zupt_ct_memeq(const void *a, const void *b, size_t n) { + const uint8_t *x = a, *y = b; uint8_t d = 0; + for (size_t i = 0; i < n; i++) d |= (uint8_t)(x[i] ^ y[i]); + return d == 0 ? 1 : 0; +} +static void wr(const char *p, const uint8_t *b, size_t n) { + FILE *f = fopen(p, "wb"); + if (!f || fwrite(b, 1, n, f) != n) { fprintf(stderr, "write %s\n", p); exit(2); } fclose(f); +} +static size_t rd(const char *p, uint8_t *b, size_t n) { + FILE *f = fopen(p, "rb"); if (!f) { fprintf(stderr, "open %s\n", p); exit(2); } + size_t g = fread(b, 1, n, f); fclose(f); return g; +} +int main(int argc, char **argv) { + if (argc >= 2 && !strcmp(argv[1], "keygen")) { + uint8_t ek[1184], dk[2400]; + if (zupt_mlkem768_keygen(ek, dk)) return 2; + wr("ek.bin", ek, 1184); wr("dk.bin", dk, 2400); return 0; + } + if (argc == 3 && !strcmp(argv[1], "encaps")) { + uint8_t ek[1184], ct[1088], ss[32]; + if (rd(argv[2], ek, 1184) != 1184) return 2; + if (zupt_mlkem768_encaps(ct, ss, ek)) return 2; + wr("ct.bin", ct, 1088); wr("ss.bin", ss, 32); return 0; + } + if (argc == 4 && !strcmp(argv[1], "decaps")) { + uint8_t dk[2400], ct[1088], ss[32]; + if (rd(argv[2], dk, 2400) != 2400) return 2; + if (rd(argv[3], ct, 1088) != 1088) return 2; + if (zupt_mlkem768_decaps(ss, ct, dk)) return 2; + wr("ss.bin", ss, 32); return 0; + } + fprintf(stderr, "usage: keygen | encaps | decaps \n"); + return 1; +} diff --git a/tests/test_mlkem_fips203.sh b/tests/test_mlkem_fips203.sh new file mode 100755 index 0000000..494f4fb --- /dev/null +++ b/tests/test_mlkem_fips203.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# FIPS 203 CONFORMANCE test for the in-tree ML-KEM-768. +# +# Self-consistency (encaps/decaps round-trip) does NOT prove conformance: a +# transposed matrix convention round-trips fine but is not interoperable. This +# test validates against an EXTERNAL FIPS 203 reference — OpenSSL 3.5+, which +# ships ML-KEM-768 — three ways: +# 1. deterministic keygen: our ek == OpenSSL's ek for the same seed (d||z) +# 2. our encaps -> OpenSSL decap: shared secrets match +# 3. OpenSSL encap -> our decaps: shared secrets match +# +# Skips gracefully (exit 0) when the toolchain or an ML-KEM-capable OpenSSL is +# unavailable, so it is safe inside distro package builds. +set -u +echo "ML-KEM-768 FIPS 203 conformance (interop vs OpenSSL)" +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +CC="${CC:-cc}" + +command -v openssl >/dev/null 2>&1 || { echo " - skipped: no openssl"; exit 0; } +if ! openssl list -kem-algorithms 2>/dev/null | grep -qiE "ML-KEM-768|MLKEM768"; then + echo " - skipped: openssl has no ML-KEM-768 (need 3.5+)"; exit 0 +fi +command -v "$CC" >/dev/null 2>&1 || CC=gcc +command -v "$CC" >/dev/null 2>&1 || { echo " - skipped: no C compiler"; exit 0; } +command -v od >/dev/null 2>&1 || { echo " - skipped: no od"; exit 0; } + +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +H="$T/harness" +if ! "$CC" -O2 -I"$ROOT/include" -I"$ROOT/src" "$HERE/mlkem_fips203_harness.c" \ + "$ROOT/src/zupt_mlkem.c" "$ROOT/src/zupt_keccak.c" -o "$H" 2>"$T/cc.err"; then + echo " - skipped: harness build failed"; sed 's/^/ /' "$T/cc.err" | head -3; exit 0 +fi +hx(){ od -A n -v -t x1 "$1" | tr -d ' \n'; } +P=0; F=0; ok(){ echo " ✓ $1"; P=$((P+1)); }; bad(){ echo " ✗ $1"; F=$((F+1)); } +cd "$T" + +# 1) deterministic keygen ek match +head -c 64 /dev/urandom > dz.bin +SEED=$(hx dz.bin) +openssl genpkey -algorithm ML-KEM-768 -pkeyopt hexseed:"$SEED" -out osl.pem 2>/dev/null +openssl pkey -in osl.pem -pubout -outform DER -out osl_pub.der 2>/dev/null +tail -c 1184 osl_pub.der > osl_ek.bin +MLKEM_RAND="$T/dz.bin" "$H" keygen +cmp -s ek.bin osl_ek.bin && ok "keygen ek == OpenSSL (byte-for-byte, same seed)" || bad "keygen ek differs from OpenSSL" + +# 2) my encaps -> openssl decap +unset MLKEM_RAND +"$H" encaps osl_ek.bin >/dev/null 2>&1; cp ss.bin ss_mine.bin +openssl pkeyutl -decap -inkey osl.pem -in ct.bin -secret ss_osl.bin 2>/dev/null +cmp -s ss_mine.bin ss_osl.bin && ok "my encaps -> OpenSSL decap: shared secret matches" || bad "my encaps not interoperable" + +# 3) openssl encap -> my decap +HDR=$(( $(stat -c%s osl_pub.der) - 1184 )); head -c "$HDR" osl_pub.der > hdr.bin +"$H" keygen +cat hdr.bin ek.bin > my_pub.der +openssl pkeyutl -encap -pubin -inkey my_pub.der -secret ss_osl2.bin -out ct2.bin 2>/dev/null +"$H" decaps dk.bin ct2.bin >/dev/null 2>&1; cp ss.bin ss_mine2.bin +cmp -s ss_mine2.bin ss_osl2.bin && ok "OpenSSL encap -> my decap: shared secret matches" || bad "my decap not interoperable" + +echo " Conformance: $P passed, $F failed" +[ "$F" -eq 0 ] && exit 0 || exit 1