4242
4343-export ([
4444 interpolate /1 ,
45+ mlkem768x25519_hybrid_secret_encoding /1 ,
4546 simple_connect /1 ,
4647 simple_exec /1 ,
4748 simple_exec_groups /0 ,
@@ -61,9 +62,9 @@ suite() ->
6162 [{ct_hooks ,[ts_install_cth ]},
6263 {timetrap ,{seconds ,120 }}].
6364
64- all () ->
65+ all () ->
6566 % % [{group,kex},{group,cipher}... etc
66- [{group ,C } || C <- tags ()].
67+ [mlkem768x25519_hybrid_secret_encoding | [ {group ,C } || C <- tags ()] ].
6768
6869
6970groups () ->
@@ -290,6 +291,14 @@ init_per_testcase(sshc_simple_exec_os_cmd, _, Config) ->
290291 start_pubkey_daemon ([proplists :get_value (pref_algs ,Config )],
291292 [{extra_daemon ,true }|Config ]);
292293
294+ init_per_testcase (mlkem768x25519_hybrid_secret_encoding , _ , Config ) ->
295+ case lists :member (x25519 , crypto :supports (curves ))
296+ andalso lists :member (mlkem768 , crypto :supports (kems ))
297+ of
298+ false -> {skip , " X25519 or ML-KEM768 not supported" };
299+ true -> Config
300+ end ;
301+
293302init_per_testcase (_ , _ , Config ) ->
294303 Config .
295304
@@ -311,6 +320,35 @@ end_per_testcase(_TC, Config) ->
311320
312321% %--------------------------------------------------------------------
313322% % Test Cases --------------------------------------------------------
323+ % %--------------------------------------------------------------------
324+ % % Regression test for the mlkem768x25519-sha256 hybrid key exchange: the
325+ % % classical (X25519) shared secret must be hashed as a fixed-width 32-byte
326+ % % octet string. Encoding it as a trimmed mpint dropped a genuine leading 0x00
327+ % % byte ~1/512 of the time (when the secret's most significant byte is 0x00 and
328+ % % the next byte is < 0x80), so the two peers derived different exchange hashes
329+ % % and the handshake failed with "incorrect signature".
330+ % %
331+ % % The keys below produce a shared secret starting with <<0x00, 0x42, ...>>
332+ % % which triggers the edge case (0x42 < 0x80).
333+ mlkem768x25519_hybrid_secret_encoding (_Config ) ->
334+ PeerPublic = <<16#94 ,16#5E ,16#6F ,16#5A ,16#CA ,16#B1 ,16#AD ,16#A6 ,
335+ 16#31 ,16#CF ,16#12 ,16#F5 ,16#47 ,16#A4 ,16#25 ,16#6B ,
336+ 16#6A ,16#E5 ,16#3A ,16#A2 ,16#48 ,16#6A ,16#0D ,16#08 ,
337+ 16#C6 ,16#D6 ,16#73 ,16#2C ,16#B3 ,16#E2 ,16#0E ,16#5B >>,
338+ MyPrivate = <<16#58 ,16#EF ,16#AB ,16#DA ,16#4C ,16#C5 ,16#6B ,16#8E ,
339+ 16#B2 ,16#43 ,16#8A ,16#86 ,16#92 ,16#2C ,16#5D ,16#73 ,
340+ 16#83 ,16#98 ,16#B4 ,16#38 ,16#0E ,16#A3 ,16#91 ,16#37 ,
341+ 16#F9 ,16#38 ,16#5B ,16#E5 ,16#BA ,16#B5 ,16#94 ,16#6D >>,
342+ % % Verify the shared secret triggers the edge case
343+ <<0 , B1 , _ /binary >> = crypto :compute_key (ecdh , PeerPublic , MyPrivate , x25519 ),
344+ true = B1 < 16#80 ,
345+ % % A spec-conformant peer (e.g. OpenSSH) hashes K_pq concatenated with the
346+ % % X25519 secret as a fixed-width 32-byte string, preserving the leading 0x00.
347+ K_pq = crypto :strong_rand_bytes (32 ),
348+ Raw = crypto :compute_key (ecdh , PeerPublic , MyPrivate , x25519 ),
349+ Expected = crypto :hash (sha256 , <<K_pq /binary , Raw /binary >>),
350+ Expected = ssh_transport :hybrid_common (K_pq , x25519 , PeerPublic , MyPrivate ).
351+
314352% %--------------------------------------------------------------------
315353% % A simple sftp transfer
316354simple_sftp (Config ) ->
0 commit comments