1#! /usr/bin/env perl 2# Copyright 2025 The OpenSSL Project Authors. All Rights Reserved. 3# 4# Licensed under the Apache License 2.0 (the "License"). You may not use 5# this file except in compliance with the License. You can obtain a copy 6# in the file LICENSE in the source distribution or at 7# https://www.openssl.org/source/license.html 8 9 10use strict; 11use warnings; 12 13use File::Spec; 14use File::Copy; 15use File::Compare qw/compare_text compare/; 16use IO::File; 17use OpenSSL::Glob; 18use OpenSSL::Test qw/:DEFAULT data_file srctop_file bldtop_dir/; 19use OpenSSL::Test::Utils; 20 21setup("test_ml_kem_codecs"); 22 23my @algs = qw(512 768 1024); 24my @formats = qw(seed-priv priv-only seed-only oqskeypair bare-seed bare-priv); 25 26plan skip_all => "ML-KEM isn't supported in this build" 27 if disabled("ml-kem"); 28 29plan tests => @algs * (25 + 10 * @formats); 30my $seed = join ("", map {sprintf "%02x", $_} (0..63)); 31my $weed = join ("", map {sprintf "%02x", $_} (1..64)); 32my $ikme = join ("", map {sprintf "%02x", $_} (0..31)); 33 34foreach my $alg (@algs) { 35 my $pub = sprintf("pub-%s.pem", $alg); 36 my %formats = map { ($_, sprintf("prv-%s-%s.pem", $alg, $_)) } @formats; 37 38 # (1 + 6 * @formats) tests 39 my $i = 0; 40 my $in0 = data_file($pub); 41 my $der0 = sprintf("pub-%s.%d.der", $alg, $i++); 42 ok(run(app(['openssl', 'pkey', '-pubin', '-in', $in0, 43 '-outform', 'DER', '-out', $der0]))); 44 foreach my $f (keys %formats) { 45 my $k = $formats{$f}; 46 my %pruned = %formats; 47 delete $pruned{$f}; 48 my $rest = join(", ", keys %pruned); 49 my $in = data_file($k); 50 my $der = sprintf("pub-%s.%d.der", $alg, $i); 51 # 52 # Compare expected DER public key with DER public key of private 53 ok(run(app(['openssl', 'pkey', '-in', $in, '-pubout', 54 '-outform', 'DER', '-out', $der]))); 55 ok(!compare($der0, $der), 56 sprintf("pubkey DER match: %s, %s", $alg, $f)); 57 # 58 # Compare expected PEM private key with regenerated key 59 my $pem = sprintf("prv-%s-%s.%d.pem", $alg, $f, $i++); 60 ok(run(app(['openssl', 'genpkey', '-out', $pem, 61 '-pkeyopt', "hexseed:$seed", '-algorithm', "ml-kem-$alg", 62 '-provparam', "ml-kem.output_formats=$f"]))); 63 ok(!compare_text($in, $pem), 64 sprintf("prvkey PEM match: %s, %s", $alg, $f)); 65 66 ok(run(app(['openssl', 'pkey', '-in', $in, '-noout', 67 '-provparam', "ml-kem.input_formats=$f"]))); 68 ok(!run(app(['openssl', 'pkey', '-in', $in, '-noout', 69 '-provparam', "ml-kem.input_formats=$rest"]))); 70 } 71 72 # (3 + 2 * @formats) tests 73 # Check encap/decap ciphertext and shared secrets 74 $i = 0; 75 my $refct = sprintf("ct-%s.dat", $alg); 76 my $refss = sprintf("ss-%s.dat", $alg); 77 my $ct = sprintf("ct-%s.%d.dat", $alg, $i); 78 my $ss0 = sprintf("ss-%s.%d.dat", $alg, $i++); 79 ok(run(app(['openssl', 'pkeyutl', '-encap', '-inkey', $in0, 80 '-pkeyopt', "hexikme:$ikme", '-secret', 81 $ss0, '-out', $ct]))); 82 ok(!compare($ct, data_file($refct)), 83 sprintf("reference ciphertext match: %s", $pub)); 84 ok(!compare($ss0, data_file($refss)), 85 sprintf("reference secret match: %s", $pub)); 86 while (my ($f, $k) = each %formats) { 87 my $in = data_file($k); 88 my $ss = sprintf("ss-%s.%d.dat", $alg, $i++); 89 ok(run(app(['openssl', 'pkeyutl', '-decap', '-inkey', $in, 90 '-in', $ct, '-secret', $ss]))); 91 ok(!compare($ss0, $ss), 92 sprintf("shared secret match: %s with %s", $alg, $f)); 93 } 94 95 # 6 tests 96 # Test keygen seed suppression via the command-line and config file. 97 my $seedless = sprintf("seedless-%s.gen.cli.pem", $alg); 98 ok(run(app(['openssl', 'genpkey', '-provparam', 'ml-kem.retain_seed=no', 99 '-algorithm', "ml-kem-$alg", '-pkeyopt', "hexseed:$seed", 100 '-out', $seedless]))); 101 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 102 sprintf("seedless via cli key match: %s", $alg)); 103 { 104 local $ENV{'OPENSSL_CONF'} = data_file("ml-kem.cnf"); 105 local $ENV{'RETAIN_SEED'} = "no"; 106 $seedless = sprintf("seedless-%s.gen.cnf.pem", $alg); 107 ok(run(app(['openssl', 'genpkey', 108 '-algorithm', "ml-kem-$alg", '-pkeyopt', "hexseed:$seed", 109 '-out', $seedless]))); 110 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 111 sprintf("seedless via config match: %s", $alg)); 112 113 my $seedfull = sprintf("seedfull-%s.gen.conf+cli.pem", $alg); 114 ok(run(app(['openssl', 'genpkey', '-provparam', 'ml-kem.retain_seed=yes', 115 '-algorithm', "ml-kem-$alg", '-pkeyopt', "hexseed:$seed", 116 '-out', $seedfull]))); 117 ok(!compare_text(data_file($formats{'seed-priv'}), $seedfull), 118 sprintf("seedfull via cli vs. conf key match: %s", $alg)); 119 } 120 121 # 6 tests 122 # Test decoder seed suppression via the config file and command-line. 123 $seedless = sprintf("seedless-%s.dec.cli.pem", $alg); 124 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-kem.retain_seed=no', 125 '-in', data_file($formats{'seed-only'}), '-out', $seedless]))); 126 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 127 sprintf("seedless via provparam key match: %s", $alg)); 128 { 129 local $ENV{'OPENSSL_CONF'} = data_file("ml-kem.cnf"); 130 local $ENV{'RETAIN_SEED'} = "no"; 131 $seedless = sprintf("seedless-%s.dec.cnf.pem", $alg); 132 ok(run(app(['openssl', 'pkey', 133 '-in', data_file($formats{'seed-only'}), '-out', $seedless]))); 134 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 135 sprintf("seedless via config match: %s", $alg)); 136 137 my $seedfull = sprintf("seedfull-%s.dec.conf+cli.pem", $alg); 138 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-kem.retain_seed=yes', 139 '-in', data_file($formats{'seed-only'}), '-out', $seedfull]))); 140 ok(!compare_text(data_file($formats{'seed-priv'}), $seedfull), 141 sprintf("seedfull via cli vs. conf key match: %s", $alg)); 142 } 143 144 # 2 tests 145 # Test decoder seed non-preference via the command-line. 146 my $privpref = sprintf("privpref-%s.dec.cli.pem", $alg); 147 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-kem.prefer_seed=no', 148 '-in', data_file($formats{'seed-priv'}), '-out', $privpref]))); 149 ok(!compare_text(data_file($formats{'priv-only'}), $privpref), 150 sprintf("seed non-preference via provparam key match: %s", $alg)); 151 152 # (2 * @formats) tests 153 # Check text encoding 154 while (my ($f, $k) = each %formats) { 155 my $txt = sprintf("prv-%s-%s.txt", $alg, 156 ($f =~ m{seed}) ? 'seed' : 'priv'); 157 my $out = sprintf("prv-%s-%s.txt", $alg, $f); 158 ok(run(app(['openssl', 'pkey', '-in', data_file($k), 159 '-noout', '-text', '-out', $out]))); 160 ok(!compare_text(data_file($txt), $out), 161 sprintf("text form private key: %s with %s", $alg, $f)); 162 } 163 164 # (6 tests): Test import/load PCT failure 165 my $real = sprintf('real-%s.der', $alg); 166 my $fake = sprintf('fake-%s.der', $alg); 167 my $mixt = sprintf('mixt-%s.der', $alg); 168 my $mash = sprintf('mash-%s.der', $alg); 169 my $slen = $alg * 3 / 2; # Secret vector |s| 170 my $plen = $slen + 64; # Public |t|, |rho| and hash 171 my $zlen = 32; # FO implicit reject seed 172 ok(run(app([qw(openssl genpkey -algorithm), "ml-kem-$alg", 173 qw(-provparam ml-kem.output_formats=seed-priv -pkeyopt), 174 "hexseed:$seed", qw(-outform DER -out), $real])), 175 sprintf("create real private key: %s", $alg)); 176 ok(run(app([qw(openssl genpkey -algorithm), "ml-kem-$alg", 177 qw(-provparam ml-kem.output_formats=seed-priv -pkeyopt), 178 "hexseed:$weed", qw(-outform DER -out), $fake])), 179 sprintf("create fake private key: %s", $alg)); 180 my $realfh = IO::File->new($real, "<:raw"); 181 my $fakefh = IO::File->new($fake, "<:raw"); 182 local $/ = undef; 183 my $realder = <$realfh>; 184 my $fakeder = <$fakefh>; 185 $realfh->close(); 186 $fakefh->close(); 187 # 188 # - 20 bytes PKCS8 fixed overhead, 189 # - 4 byte private key octet string tag + length 190 # - 4 byte seed + key sequence tag + length 191 # - 2 byte seed tag + length 192 # - 64 byte seed 193 # - 4 byte key tag + length 194 # - |dk| 's' vector 195 # - |ek| public key ('t' vector || 'rho') 196 # - implicit rejection 'z' seed component 197 # 198 my $svec_off = 28 + (2 + 64) + 4; 199 my $p8_len = $svec_off + $slen + $plen + $zlen; 200 ok((length($realder) == $p8_len && length($fakeder) == $p8_len), 201 sprintf("Got expected DER lengths of %s seed-priv key", $alg)); 202 my $mixtder = substr($realder, 0, $svec_off + $slen) 203 . substr($fakeder, $svec_off + $slen, $plen) 204 . substr($realder, $svec_off + $slen + $plen, $zlen); 205 my $mixtfh = IO::File->new($mixt, ">:raw"); 206 print $mixtfh $mixtder; 207 $mixtfh->close(); 208 ok(run(app([qw(openssl pkey -inform DER -noout -in), $real])), 209 sprintf("accept valid keypair: %s", $alg)); 210 ok(!run(app([qw(openssl pkey -provparam ml-kem.prefer_seed=no), 211 qw(-inform DER -noout -in), $mixt])), 212 sprintf("reject real private and fake public: %s", $alg)); 213 ok(run(app([qw(openssl pkey -provparam ml-kem.prefer_seed=no), 214 qw(-provparam ml-kem.import_pct_type=none), 215 qw(-inform DER -noout -in), $mixt])), 216 sprintf("Absent PCT accept fake public: %s", $alg)); 217 # Mutate the first byte of the |s| vector 218 my $mashder = $realder; 219 substr($mashder, $svec_off, 1) =~ s{(.)}{chr(ord($1)^1)}es; 220 my $mashfh = IO::File->new($mash, ">:raw"); 221 print $mashfh $mashder; 222 $mashfh->close(); 223 ok(!run(app([qw(openssl pkey -inform DER -noout -in), $mash])), 224 sprintf("reject real private and mutated public: %s", $alg)); 225} 226