From 57d867be367346e629548782023cb567d3633678 Mon Sep 17 00:00:00 2001 From: Paul Guyot Date: Sat, 19 Sep 2026 06:41:50 +0200 Subject: [PATCH] otp_crypto: Add ML-KEM-768 key encapsulation - generic_unix: add an AVM_STATIC_LIBSODIUM option - otp_crypto: add the ML-KEM-768 encapsulation NIF (requires libsodium >= 1.0.22) - test_crypto_pk: cover ML-KEM-768 encapsulation Signed-off-by: Paul Guyot --- CHANGELOG.md | 2 + CMakeLists.txt | 1 + doc/src/programmers-guide.md | 8 +- libs/estdlib/src/crypto.erl | 68 +++++- src/libAtomVM/otp_crypto.c | 221 +++++++++++++++++- src/platforms/generic_unix/lib/CMakeLists.txt | 22 +- tests/erlang_tests/test_crypto_pk.erl | 85 ++++++- 7 files changed, 398 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8faafe6a..5bb43ee4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for `process_info/1` and `process_info/2` with list argument - Added `erlang:term_to_binary/2`, `erlang:is_builtin/3` and `erlang:bitstring_to_list/1` - Added `lists:mapfoldr/3` +- Added ML-KEM-768 (FIPS 203) key encapsulation to `crypto`, requiring libsodium >= 1.0.22 +- Added `AVM_STATIC_LIBSODIUM` CMake option to statically link libsodium - Added `emscripten:run_script_tracked/1` and `emscripten:get_tracked/2` to hold handles to JavaScript values from Erlang, tying the JavaScript value lifetime to the Erlang term lifetime. The emscripten module object gained `trackedObjectsMap`, `nextTrackedObjectKey()` and the diff --git a/CMakeLists.txt b/CMakeLists.txt index 85e8ea201d..0da5ec51b5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,7 @@ option(AVM_BUILD_RUNTIME_ONLY "Only build the AtomVM runtime" OFF) option(COVERAGE "Build for code coverage" OFF) option(AVM_PRINT_PROCESS_CRASH_DUMPS "Print crash reports when processes die with non-standard reasons" ON) option(AVM_USE_LIBSODIUM "Enable optional libsodium backend for Ed25519 curve" OFF) +option(AVM_STATIC_LIBSODIUM "Static link libsodium." OFF) option(AVM_MINIMAL_OPCODES "Reduce VM size by excluding opcodes for optional compiler flags (no_bs_match, no_ssa_opt_bs_ensure)" OFF) # JIT & execution of precompiled code diff --git a/doc/src/programmers-guide.md b/doc/src/programmers-guide.md index 8ef1f1d13c..5b4f0629b4 100644 --- a/doc/src/programmers-guide.md +++ b/doc/src/programmers-guide.md @@ -1212,7 +1212,13 @@ for details on how to ensure the entropy source is properly initialized before p cryptographic operations. ``` -When AtomVM is built with `-DAVM_USE_LIBSODIUM=ON`, Ed25519 signing and verification (`crypto:sign/4`, `crypto:verify/5`) and X25519 key agreement (`crypto:generate_key/2`, `crypto:compute_key/4`) are also available. This option requires libsodium to be installed on the build host (e.g. `libsodium-dev` on Debian/Ubuntu), or the `espressif/libsodium` component on ESP32. +When AtomVM is built with `-DAVM_USE_LIBSODIUM=ON`, Ed25519 signing and verification +(`crypto:sign/4`, `crypto:verify/5`), X25519 key agreement (`crypto:generate_key/2`, +`crypto:compute_key/4`) and, with libsodium 1.0.22 or later, ML-KEM-768 key encapsulation +(`crypto:encapsulate_key/2`, `crypto:decapsulate_key/3`) are also available. This option requires +libsodium to be installed on the build host (e.g. `libsodium-dev` on Debian/Ubuntu), or the +`espressif/libsodium` component on ESP32. On generic UNIX, `-DAVM_STATIC_LIBSODIUM=ON` links +`libsodium.a` into the AtomVM binary instead of depending on the shared library at run time. ```{important} **Increase the ESP32 task stack size when using libsodium.** diff --git a/libs/estdlib/src/crypto.erl b/libs/estdlib/src/crypto.erl index 7b5c69ac41..304d30b9af 100644 --- a/libs/estdlib/src/crypto.erl +++ b/libs/estdlib/src/crypto.erl @@ -45,12 +45,17 @@ pbkdf2_hmac/5, hash_equals/2, strong_rand_bytes/1, + encapsulate_key/2, + decapsulate_key/3, info_lib/0 ]). -type hash_algorithm() :: md5 | sha | sha224 | sha256 | sha384 | sha512. +-type kem() :: mlkem512 | mlkem768 | mlkem1024. -type digest() :: binary(). +-export_type([kem/0]). + -export_type([hash_state/0]). -opaque hash_state() :: reference(). @@ -379,6 +384,7 @@ crypto_final(_State) -> %% * `ecdh' with `x25519 | secp256k1 | secp256r1 | secp384r1 | secp521r1 | %% brainpoolP256r1 | brainpoolP384r1 | brainpoolP512r1' %% * `eddsa' with `ed25519' +%% * `mlkem768' with `[]' (requires libsodium >= 1.0.22) %% %% Keys are returned as raw key material, not PEM, DER, or `public_key' %% records. @@ -388,10 +394,9 @@ crypto_final(_State) -> %% seeded before generating keys. %% @end %%----------------------------------------------------------------------------- --spec generate_key( - Type :: ecdh | eddh | eddsa, - Param :: ecdh_params() | eddsa_params() -) -> {binary(), binary()}. +-spec generate_key + (Type :: ecdh | eddh | eddsa, Param :: ecdh_params() | eddsa_params()) -> {binary(), binary()}; + (Type :: kem(), Param :: []) -> {binary(), binary()}. generate_key(_Type, _Param) -> erlang:nif_error(undefined). @@ -424,6 +429,61 @@ generate_key(_Type, _Param) -> compute_key(_Type, _OtherPublicKey, _MyPrivateKey, _Param) -> erlang:nif_error(undefined). +%%----------------------------------------------------------------------------- +%% @param PublicKey the ML-KEM-768 encapsulation (public) key, 1184 bytes +%% @param Type key encapsulation mechanism, `mlkem768' +%% @param OthersPublicKey the other party's encapsulation key (1184 bytes for +%% `mlkem768') +%% @returns `{Secret, EncapSecret}' where `Secret' is the 32-byte shared secret +%% and `EncapSecret' is its 1088-byte encapsulated form +%% @doc ML-KEM (FIPS 203) key encapsulation. +%% +%% Generates a shared secret and the encapsulated form to send to the +%% owner of `OthersPublicKey', who recovers the same secret with their +%% private key. Used to implement post-quantum hybrid SSH key exchange +%% (`mlkem768x25519-sha256'). +%% +%% Of the mechanisms OTP names, AtomVM implements `mlkem768' only, +%% because that is the one libsodium provides; `mlkem512' and +%% `mlkem1024' raise `badarg'. +%% +%% Only available on builds with libsodium >= 1.0.22. +%% @end +%%----------------------------------------------------------------------------- +-spec encapsulate_key(Type :: kem(), OthersPublicKey :: binary()) -> + {Secret :: binary(), EncapSecret :: binary()}. +encapsulate_key(_Type, _OthersPublicKey) -> + erlang:nif_error(undefined). + +%%----------------------------------------------------------------------------- +%% @param Type key encapsulation mechanism, `mlkem768' +%% @param MyPrivKey our decapsulation key (2400 bytes for `mlkem768') +%% @param EncapSecret the encapsulated secret received from the peer +%% (1088 bytes for `mlkem768') +%% @returns the 32-byte shared secret +%% @doc ML-KEM (FIPS 203) key decapsulation. +%% +%% Recovers the secret a peer encapsulated to our public key with +%% {@link encapsulate_key/2}. +%% +%% A ciphertext that does not belong to this key is not reported as an +%% error: FIPS 203 specifies implicit rejection, so decapsulation +%% returns a pseudorandom secret instead, and the mismatch only shows +%% up when the two sides disagree about what they derived. +%% +%% Of the mechanisms OTP names, AtomVM implements `mlkem768' only, +%% because that is the one libsodium provides; `mlkem512' and +%% `mlkem1024' raise `badarg'. +%% +%% Only available on builds with libsodium >= 1.0.22. +%% @end +%%----------------------------------------------------------------------------- +-spec decapsulate_key( + Type :: kem(), MyPrivKey :: binary(), EncapSecret :: binary() +) -> Secret :: binary(). +decapsulate_key(_Type, _MyPrivKey, _EncapSecret) -> + erlang:nif_error(undefined). + %%----------------------------------------------------------------------------- %% @param Algorithm signing algorithm (`ecdsa' or `eddsa') %% @param DigestType hash algorithm identifier for `ecdsa', or `none' for `eddsa' diff --git a/src/libAtomVM/otp_crypto.c b/src/libAtomVM/otp_crypto.c index 224c93a432..d1ae8b1b3f 100644 --- a/src/libAtomVM/otp_crypto.c +++ b/src/libAtomVM/otp_crypto.c @@ -63,6 +63,10 @@ #include #endif +#if defined(HAVE_LIBSODIUM) && defined(crypto_kem_mlkem768_PUBLICKEYBYTES) +#define CRYPTO_MLKEM768_AVAILABLE 1 +#endif + // mbedtls_ct_memcmp is available in 2.28.x+ and 3.1.x+ (absent in 3.0.x) #if (MBEDTLS_VERSION_NUMBER >= 0x021C0000 && MBEDTLS_VERSION_NUMBER < 0x03000000) \ || MBEDTLS_VERSION_NUMBER >= 0x03010000 @@ -634,7 +638,8 @@ enum pk_type_t InvalidPkType = 0, Eddh, Eddsa, - Ecdh + Ecdh, + Mlkem768 }; // not working with latest mbedtls (yet): PSA_KEY_TYPE_ECC_KEY_PAIR(PSA_ECC_FAMILY_TWISTED_EDWARDS)) @@ -643,6 +648,7 @@ static const AtomStringIntPair pk_type_table[] = { { ATOM_STR("\x4", "eddh"), Eddh }, { ATOM_STR("\x5", "eddsa"), Eddsa }, { ATOM_STR("\x4", "ecdh"), Ecdh }, + { ATOM_STR("\x8", "mlkem768"), Mlkem768 }, SELECT_INT_DEFAULT(InvalidPkType) }; @@ -730,6 +736,63 @@ static void do_sodium_init(void) } } +#ifdef CRYPTO_MLKEM768_AVAILABLE +// term_from_literal_binary() cannot report a failed refc-binary allocation. +static term make_mlkem768_binary(Context *ctx, const unsigned char *data, size_t size) +{ + term binary = term_create_uninitialized_binary(size, &ctx->heap, ctx->global); + if (UNLIKELY(term_is_invalid_term(binary))) { + memory_heap_trim(&ctx->heap, TERM_BOXED_REFC_BINARY_SIZE); + return term_invalid_term(); + } + memcpy((void *) term_binary_data(binary), data, size); + return binary; +} + +static term do_mlkem768_keypair(Context *ctx, term params) +{ + // Like OTP, only accept an empty list + if (UNLIKELY(!term_is_nil(params))) { + RAISE_ERROR(BADARG_ATOM); + } + + unsigned char pk[crypto_kem_mlkem768_PUBLICKEYBYTES]; + unsigned char sk[crypto_kem_mlkem768_SECRETKEYBYTES]; + + do_sodium_init(); + if (UNLIKELY(crypto_kem_mlkem768_keypair(pk, sk) != 0)) { + sodium_memzero(sk, sizeof sk); + RAISE_ERROR(make_crypto_error(__FILE__, __LINE__, "ML-KEM-768 keygen failed", ctx)); + } + + if (UNLIKELY(memory_ensure_free(ctx, + TERM_BINARY_HEAP_SIZE(sizeof pk) + TERM_BINARY_HEAP_SIZE(sizeof sk) + + TUPLE_SIZE(2)) + != MEMORY_GC_OK)) { + sodium_memzero(sk, sizeof sk); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term pub_term = make_mlkem768_binary(ctx, pk, sizeof pk); + if (UNLIKELY(term_is_invalid_term(pub_term))) { + sodium_memzero(sk, sizeof sk); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + term priv_term = make_mlkem768_binary(ctx, sk, sizeof sk); + if (UNLIKELY(term_is_invalid_term(priv_term))) { + sodium_memzero(sk, sizeof sk); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term result = term_alloc_tuple(2, &ctx->heap); + term_put_tuple_element(result, 0, pub_term); + term_put_tuple_element(result, 1, priv_term); + + sodium_memzero(sk, sizeof sk); + return result; +} +#endif + static term sodium_try_generate_key( Context *ctx, enum pk_type_t key_type, enum pk_param_t pk_param, bool *is_handled) { @@ -1016,6 +1079,12 @@ static term nif_crypto_generate_key(Context *ctx, int argc, term argv[]) enum pk_type_t key_type = interop_atom_term_select_int(pk_type_table, argv[0], glb); enum pk_param_t pk_param = interop_atom_term_select_int(pk_param_table, argv[1], glb); +#ifdef CRYPTO_MLKEM768_AVAILABLE + if (key_type == Mlkem768) { + return do_mlkem768_keypair(ctx, argv[1]); + } +#endif + #ifdef HAVE_LIBSODIUM bool sodium_handled; term sodium_result = sodium_try_generate_key(ctx, key_type, pk_param, &sodium_handled); @@ -3248,6 +3317,136 @@ term nif_crypto_strong_rand_bytes(Context *ctx, int argc, term argv[]) return out_bin; } +#ifdef CRYPTO_MLKEM768_AVAILABLE +static term nif_crypto_decapsulate_key(Context *ctx, int argc, term argv[]) +{ + UNUSED(argc); + GlobalContext *glb = ctx->global; + + term type_term = argv[0]; + if (UNLIKELY(!term_is_atom(type_term))) { + RAISE_ERROR(BADARG_ATOM); + } + if (UNLIKELY(!globalcontext_is_term_equal_to_atom_string( + glb, type_term, ATOM_STR("\x8", "mlkem768")))) { + RAISE_ERROR(BADARG_ATOM); + } + + term sk_term = argv[1]; + if (UNLIKELY(!term_is_binary(sk_term) + || term_binary_size(sk_term) != crypto_kem_mlkem768_SECRETKEYBYTES)) { + RAISE_ERROR(BADARG_ATOM); + } + term ct_term = argv[2]; + if (UNLIKELY(!term_is_binary(ct_term) + || term_binary_size(ct_term) != crypto_kem_mlkem768_CIPHERTEXTBYTES)) { + RAISE_ERROR(BADARG_ATOM); + } + + const unsigned char *sk = (const unsigned char *) term_binary_data(sk_term); + const unsigned char *ct = (const unsigned char *) term_binary_data(ct_term); + + unsigned char ss[crypto_kem_mlkem768_SHAREDSECRETBYTES]; + + do_sodium_init(); + // The expanded private key ends with ek || H(ek) || z. libsodium trusts + // the stored hash, so validate it before decapsulation (FIPS 203). + const unsigned char *embedded_pk = sk + crypto_kem_mlkem768_SECRETKEYBYTES + - crypto_kem_mlkem768_PUBLICKEYBYTES - (2 * crypto_hash_sha3256_BYTES); + const unsigned char *stored_pk_hash + = sk + crypto_kem_mlkem768_SECRETKEYBYTES - (2 * crypto_hash_sha3256_BYTES); + unsigned char computed_pk_hash[crypto_hash_sha3256_BYTES]; + if (UNLIKELY(crypto_hash_sha3256(computed_pk_hash, embedded_pk, + crypto_kem_mlkem768_PUBLICKEYBYTES) + != 0)) { + sodium_memzero(computed_pk_hash, sizeof computed_pk_hash); + RAISE_ERROR(make_crypto_error(__FILE__, __LINE__, "ML-KEM-768 key validation failed", ctx)); + } + if (UNLIKELY(sodium_memcmp(computed_pk_hash, stored_pk_hash, sizeof computed_pk_hash) != 0)) { + sodium_memzero(computed_pk_hash, sizeof computed_pk_hash); + RAISE_ERROR(BADARG_ATOM); + } + sodium_memzero(computed_pk_hash, sizeof computed_pk_hash); + + if (UNLIKELY(crypto_kem_mlkem768_dec(ss, ct, sk) != 0)) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(make_crypto_error(__FILE__, __LINE__, "ML-KEM-768 decapsulation failed", ctx)); + } + + if (UNLIKELY(memory_ensure_free(ctx, TERM_BINARY_HEAP_SIZE(sizeof ss)) != MEMORY_GC_OK)) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term ss_term = make_mlkem768_binary(ctx, ss, sizeof ss); + sodium_memzero(ss, sizeof ss); + if (UNLIKELY(term_is_invalid_term(ss_term))) { + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + return ss_term; +} + +static term nif_crypto_encapsulate_key(Context *ctx, int argc, term argv[]) +{ + UNUSED(argc); + GlobalContext *glb = ctx->global; + + term type_term = argv[0]; + if (UNLIKELY(!term_is_atom(type_term))) { + RAISE_ERROR(BADARG_ATOM); + } + if (UNLIKELY(!globalcontext_is_term_equal_to_atom_string( + glb, type_term, ATOM_STR("\x8", "mlkem768")))) { + RAISE_ERROR(BADARG_ATOM); + } + + term pk_term = argv[1]; + if (UNLIKELY(!term_is_binary(pk_term))) { + RAISE_ERROR(BADARG_ATOM); + } + if (UNLIKELY(term_binary_size(pk_term) != crypto_kem_mlkem768_PUBLICKEYBYTES)) { + RAISE_ERROR(BADARG_ATOM); + } + const unsigned char *pk = (const unsigned char *) term_binary_data(pk_term); + + unsigned char ct[crypto_kem_mlkem768_CIPHERTEXTBYTES]; + unsigned char ss[crypto_kem_mlkem768_SHAREDSECRETBYTES]; + + do_sodium_init(); + if (UNLIKELY(crypto_kem_mlkem768_enc(ct, ss, pk) != 0)) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(make_crypto_error(__FILE__, __LINE__, "ML-KEM-768 encapsulation failed", ctx)); + } + + if (UNLIKELY(memory_ensure_free(ctx, + TERM_BINARY_HEAP_SIZE(sizeof ct) + TERM_BINARY_HEAP_SIZE(sizeof ss) + + TUPLE_SIZE(2)) + != MEMORY_GC_OK)) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term ct_term = make_mlkem768_binary(ctx, ct, sizeof ct); + if (UNLIKELY(term_is_invalid_term(ct_term))) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + term ss_term = make_mlkem768_binary(ctx, ss, sizeof ss); + if (UNLIKELY(term_is_invalid_term(ss_term))) { + sodium_memzero(ss, sizeof ss); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + // {Secret, EncapSecret}: the shared secret comes first, as in OTP. + term result = term_alloc_tuple(2, &ctx->heap); + term_put_tuple_element(result, 0, ss_term); + term_put_tuple_element(result, 1, ct_term); + + sodium_memzero(ss, sizeof ss); + return result; +} +#endif + static const char *get_mbedtls_version_string_full(char *buf, size_t buf_size) { #if defined(MBEDTLS_VERSION_C) @@ -3434,6 +3633,16 @@ static const struct Nif crypto_strong_rand_bytes_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_crypto_strong_rand_bytes }; +#ifdef CRYPTO_MLKEM768_AVAILABLE +static const struct Nif crypto_encapsulate_key_nif = { + .base.type = NIFFunctionType, + .nif_ptr = nif_crypto_encapsulate_key +}; +static const struct Nif crypto_decapsulate_key_nif = { + .base.type = NIFFunctionType, + .nif_ptr = nif_crypto_decapsulate_key +}; +#endif static const struct Nif crypto_info_lib = { .base.type = NIFFunctionType, .nif_ptr = nif_crypto_info_lib @@ -3547,6 +3756,16 @@ const struct Nif *otp_crypto_nif_get_nif(const char *nifname) TRACE("Resolved platform nif %s ...\n", nifname); return &crypto_strong_rand_bytes_nif; } +#ifdef CRYPTO_MLKEM768_AVAILABLE + if (strcmp("encapsulate_key/2", rest) == 0) { + TRACE("Resolved platform nif %s ...\n", nifname); + return &crypto_encapsulate_key_nif; + } + if (strcmp("decapsulate_key/3", rest) == 0) { + TRACE("Resolved platform nif %s ...\n", nifname); + return &crypto_decapsulate_key_nif; + } +#endif if (strcmp("info_lib/0", rest) == 0) { TRACE("Resolved platform nif %s ...\n", nifname); return &crypto_info_lib; diff --git a/src/platforms/generic_unix/lib/CMakeLists.txt b/src/platforms/generic_unix/lib/CMakeLists.txt index 4ec0e9e527..e329aaaef2 100644 --- a/src/platforms/generic_unix/lib/CMakeLists.txt +++ b/src/platforms/generic_unix/lib/CMakeLists.txt @@ -116,8 +116,26 @@ if (AVM_USE_LIBSODIUM) pkg_check_modules(LIBSODIUM REQUIRED libsodium) target_include_directories(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_INCLUDE_DIRS}) - target_link_directories(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_LIBRARY_DIRS}) - target_link_libraries(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_LIBRARIES}) + if (AVM_STATIC_LIBSODIUM) + find_library(LIBSODIUM_STATIC_LIB NAMES libsodium.a + PATHS ${LIBSODIUM_LIBRARY_DIRS} ${LIBSODIUM_LIBDIR} + NO_DEFAULT_PATH) + if (LIBSODIUM_STATIC_LIB STREQUAL "LIBSODIUM_STATIC_LIB-NOTFOUND") + message(FATAL_ERROR "AVM_STATIC_LIBSODIUM=ON but libsodium.a was not found in ${LIBSODIUM_LIBRARY_DIRS};${LIBSODIUM_LIBDIR}") + endif() + message(STATUS "Found static libsodium ${LIBSODIUM_STATIC_LIB}") + # Link the archive by absolute path so the shared library is not picked + # instead, but keep the private dependencies pkg-config reports for a + # static link (pthread, and whatever else a given package needs). + set(LIBSODIUM_STATIC_DEPS ${LIBSODIUM_STATIC_LIBRARIES}) + list(REMOVE_ITEM LIBSODIUM_STATIC_DEPS sodium libsodium) + target_link_directories(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_STATIC_LIBRARY_DIRS}) + target_link_libraries(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_STATIC_LIB} ${LIBSODIUM_STATIC_DEPS}) + target_link_options(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_STATIC_LDFLAGS_OTHER}) + else() + target_link_directories(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_LIBRARY_DIRS}) + target_link_libraries(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC ${LIBSODIUM_LIBRARIES}) + endif() target_compile_definitions(libAtomVM${PLATFORM_LIB_SUFFIX} PUBLIC HAVE_LIBSODIUM) endif() diff --git a/tests/erlang_tests/test_crypto_pk.erl b/tests/erlang_tests/test_crypto_pk.erl index 7c0bafc910..d2c62425e6 100644 --- a/tests/erlang_tests/test_crypto_pk.erl +++ b/tests/erlang_tests/test_crypto_pk.erl @@ -34,7 +34,8 @@ test_ed25519_verify_malformed_key/0, test_ed25519_sign_bad_digest/0, test_ed25519_verify_bad_digest/0, - test_x25519_mutual_key_agreement/0 + test_x25519_mutual_key_agreement/0, + test_encapsulate_key/0 ]). start() -> @@ -57,6 +58,7 @@ start() -> ok = libsodium_conditional_run(test_ed25519_sign_bad_digest), ok = libsodium_conditional_run(test_ed25519_verify_bad_digest), ok = libsodium_conditional_run(test_x25519_mutual_key_agreement), + ok = test_encapsulate_key(), 0. otp_version() -> @@ -425,3 +427,84 @@ test_x25519_mutual_key_agreement() -> 32 = byte_size(ThirdShared), ok. + +%% ML-KEM-768 encapsulation is only present when AtomVM was built with a +%% libsodium that provides it (>= 1.0.22), and on BEAM only from OTP 28.1; +%% otherwise the call raises and this self-skips. +test_encapsulate_key() -> + Pk = <<0:(1184 * 8)>>, + case mlkem768_available(Pk) of + false -> + ok; + true -> + {Ss, Ct} = crypto:encapsulate_key(mlkem768, Pk), + 32 = byte_size(Ss), + 1088 = byte_size(Ct), + %% Encapsulation is randomized: a second call yields a fresh + %% shared secret and ciphertext. + {Ss2, Ct2} = crypto:encapsulate_key(mlkem768, Pk), + true = (Ss =/= Ss2), + true = (Ct =/= Ct2), + %% A wrong-size key is rejected: badarg on AtomVM, an OpenSSL + %% error struct on OTP, so only require that it raises. + ok = expect_error(fun() -> crypto:encapsulate_key(mlkem768, <<0:8>>) end), + %% So is a key of the right size that is not a valid encoding: the + %% implementation's own validation has to be propagated, not just + %% the size check above. + ok = expect_error( + fun() -> crypto:encapsulate_key(mlkem768, binary:copy(<<255>>, 1184)) end + ), + %% So is a mechanism this build does not implement. + ok = expect_error(fun() -> crypto:encapsulate_key(mlkem512, Pk) end), + ok = test_kem_round_trip(), + ok + end. + +%% generate_key/encapsulate/decapsulate against each other. +test_kem_round_trip() -> + {Pub, Priv} = crypto:generate_key(mlkem768, []), + 1184 = byte_size(Pub), + 2400 = byte_size(Priv), + {Ss, Ct} = crypto:encapsulate_key(mlkem768, Pub), + Ss = crypto:decapsulate_key(mlkem768, Priv, Ct), + + %% FIPS 203 specifies implicit rejection: decapsulating someone else's + %% ciphertext yields a pseudorandom secret rather than an error. + {_OtherPub, OtherPriv} = crypto:generate_key(mlkem768, []), + Other = crypto:decapsulate_key(mlkem768, OtherPriv, Ct), + 32 = byte_size(Other), + true = (Other =/= Ss), + %% Likewise for a ciphertext of the right size that is simply not one. + Zero = crypto:decapsulate_key(mlkem768, Priv, <<0:(1088 * 8)>>), + 32 = byte_size(Zero), + true = (Zero =/= Ss), + + %% Rejected arguments: badarg on AtomVM, an OpenSSL error struct or + %% function_clause on OTP. + ok = expect_error(fun() -> crypto:decapsulate_key(mlkem512, Priv, Ct) end), + ok = expect_error(fun() -> crypto:decapsulate_key(mlkem768, <<0:8>>, Ct) end), + ok = expect_error(fun() -> crypto:decapsulate_key(mlkem768, Priv, <<0:8>>) end), + %% A malformed private key must raise, unlike ciphertext implicit rejection. + <> = Priv, + BadPriv = <>, + ok = expect_error(fun() -> crypto:decapsulate_key(mlkem768, BadPriv, Ct) end), + <> = Priv, + BadEmbeddedPub = <>, + ok = expect_error(fun() -> crypto:decapsulate_key(mlkem768, BadEmbeddedPub, Ct) end), + ok = expect_error(fun() -> crypto:generate_key(mlkem768, x25519) end), + ok. + +mlkem768_available(Pk) -> + try crypto:encapsulate_key(mlkem768, Pk) of + {Ss, Ct} when byte_size(Ss) =:= 32, byte_size(Ct) =:= 1088 -> true; + _ -> false + catch + _:_ -> false + end. + +expect_error(F) -> + try F() of + _ -> error + catch + error:_ -> ok + end.