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_dsa_codecs"); 22 23my @algs = qw(44 65 87); 24my @formats = qw(seed-priv priv-only seed-only oqskeypair bare-seed bare-priv); 25 26plan skip_all => "ML-DSA isn't supported in this build" 27 if disabled("ml-dsa"); 28 29plan tests => @algs * (23 + 10 * @formats); 30my $seed = join ("", map {sprintf "%02x", $_} (0..31)); 31my $weed = join ("", map {sprintf "%02x", $_} (1..32)); 32my $ikme = join ("", map {sprintf "%02x", $_} (0..31)); 33my %alg = ("44" => [4, 4, 2560], "65" => [6, 5, 4032], "87" => [8, 7, 4896]); 34 35foreach my $alg (@algs) { 36 my $pub = sprintf("pub-%s.pem", $alg); 37 my %formats = map { ($_, sprintf("prv-%s-%s.pem", $alg, $_)) } @formats; 38 my ($k, $l, $sk_len) = @{$alg{$alg}}; 39 # The number of low-bits |d| in t_0 is 13 across all the variants 40 my $t0_len = $k * 13 * 32; 41 42 # (1 + 6 * @formats) tests 43 my $i = 0; 44 my $in0 = data_file($pub); 45 my $der0 = sprintf("pub-%s.%d.der", $alg, $i++); 46 ok(run(app(['openssl', 'pkey', '-pubin', '-in', $in0, 47 '-outform', 'DER', '-out', $der0]))); 48 foreach my $f (keys %formats) { 49 my $kf = $formats{$f}; 50 my %pruned = %formats; 51 delete $pruned{$f}; 52 my $rest = join(", ", keys %pruned); 53 my $in = data_file($kf); 54 my $der = sprintf("pub-%s.%d.der", $alg, $i); 55 # 56 # Compare expected DER public key with DER public key of private 57 ok(run(app(['openssl', 'pkey', '-in', $in, '-pubout', 58 '-outform', 'DER', '-out', $der]))); 59 ok(!compare($der0, $der), 60 sprintf("pubkey DER match: %s, %s", $alg, $f)); 61 # 62 # Compare expected PEM private key with regenerated key 63 my $pem = sprintf("prv-%s-%s.%d.pem", $alg, $f, $i++); 64 ok(run(app(['openssl', 'genpkey', '-out', $pem, 65 '-pkeyopt', "hexseed:$seed", '-algorithm', "ml-dsa-$alg", 66 '-provparam', "ml-dsa.output_formats=$f"]))); 67 ok(!compare_text($in, $pem), 68 sprintf("prvkey PEM match: %s, %s", $alg, $f)); 69 70 ok(run(app(['openssl', 'pkey', '-in', $in, '-noout', 71 '-provparam', "ml-dsa.input_formats=$f"]))); 72 ok(!run(app(['openssl', 'pkey', '-in', $in, '-noout', 73 '-provparam', "ml-dsa.input_formats=$rest"]))); 74 } 75 76 # (1 + 2 * @formats) tests 77 # Perform sign/verify PCT 78 $i = 0; 79 my $refsig = data_file(sprintf("sig-%s.dat", $alg)); 80 my $sig = sprintf("sig-%s.%d.dat", $alg, $i); 81 ok(run(app([qw(openssl pkeyutl -verify -rawin -pubin -inkey), 82 $in0, '-in', $der0, '-sigfile', $refsig], 83 sprintf("Signature verify with pubkey: %s", $alg)))); 84 while (my ($f, $kf) = each %formats) { 85 my $sk = data_file($kf); 86 my $s = sprintf("sig-%s.%d.dat", $alg, $i++); 87 ok(run(app([qw(openssl pkeyutl -sign -rawin -inkey), $sk, '-in', $der0, 88 qw(-pkeyopt deterministic:1 -out), $s]))); 89 ok(!compare($s, $refsig), 90 sprintf("Signature blob match %s with %s", $alg, $f)); 91 } 92 93 # 6 tests 94 # Test keygen seed suppression via the command-line and config file. 95 my $seedless = sprintf("seedless-%s.gen.cli.pem", $alg); 96 ok(run(app([qw(openssl genpkey -provparam ml-dsa.retain_seed=no), 97 '-algorithm', "ml-dsa-$alg", '-pkeyopt', "hexseed:$seed", 98 '-out', $seedless]))); 99 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 100 sprintf("seedless via cli key match: %s", $alg)); 101 { 102 local $ENV{'OPENSSL_CONF'} = data_file("ml-dsa.cnf"); 103 local $ENV{'RETAIN_SEED'} = "no"; 104 $seedless = sprintf("seedless-%s.gen.cnf.pem", $alg); 105 ok(run(app(['openssl', 'genpkey', 106 '-algorithm', "ml-dsa-$alg", '-pkeyopt', "hexseed:$seed", 107 '-out', $seedless]))); 108 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 109 sprintf("seedless via config match: %s", $alg)); 110 111 my $seedfull = sprintf("seedfull-%s.gen.conf+cli.pem", $alg); 112 ok(run(app(['openssl', 'genpkey', '-provparam', 'ml-dsa.retain_seed=yes', 113 '-algorithm', "ml-dsa-$alg", '-pkeyopt', "hexseed:$seed", 114 '-out', $seedfull]))); 115 ok(!compare_text(data_file($formats{'seed-priv'}), $seedfull), 116 sprintf("seedfull via cli vs. conf key match: %s", $alg)); 117 } 118 119 # 6 tests 120 # Test decoder seed suppression via the config file and command-line. 121 $seedless = sprintf("seedless-%s.dec.cli.pem", $alg); 122 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-dsa.retain_seed=no', 123 '-in', data_file($formats{'seed-only'}), '-out', $seedless]))); 124 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 125 sprintf("seedless via provparam key match: %s", $alg)); 126 { 127 local $ENV{'OPENSSL_CONF'} = data_file("ml-dsa.cnf"); 128 local $ENV{'RETAIN_SEED'} = "no"; 129 $seedless = sprintf("seedless-%s.dec.cnf.pem", $alg); 130 ok(run(app(['openssl', 'pkey', 131 '-in', data_file($formats{'seed-only'}), '-out', $seedless]))); 132 ok(!compare_text(data_file($formats{'priv-only'}), $seedless), 133 sprintf("seedless via config match: %s", $alg)); 134 135 my $seedfull = sprintf("seedfull-%s.dec.conf+cli.pem", $alg); 136 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-dsa.retain_seed=yes', 137 '-in', data_file($formats{'seed-only'}), '-out', $seedfull]))); 138 ok(!compare_text(data_file($formats{'seed-priv'}), $seedfull), 139 sprintf("seedfull via cli vs. conf key match: %s", $alg)); 140 } 141 142 # 2 tests 143 # Test decoder seed non-preference via the command-line. 144 my $privpref = sprintf("privpref-%s.dec.cli.pem", $alg); 145 ok(run(app(['openssl', 'pkey', '-provparam', 'ml-dsa.prefer_seed=no', 146 '-in', data_file($formats{'seed-priv'}), '-out', $privpref]))); 147 ok(!compare_text(data_file($formats{'priv-only'}), $privpref), 148 sprintf("seed non-preference via provparam key match: %s", $alg)); 149 150 # (2 * @formats) tests 151 # Check text encoding 152 while (my ($f, $kf) = each %formats) { 153 my $txt = sprintf("prv-%s-%s.txt", $alg, 154 ($f =~ m{seed}) ? 'seed' : 'priv'); 155 my $out = sprintf("prv-%s-%s.txt", $alg, $f); 156 ok(run(app(['openssl', 'pkey', '-in', data_file($kf), 157 '-noout', '-text', '-out', $out]))); 158 ok(!compare_text(data_file($txt), $out), 159 sprintf("text form private key: %s with %s", $alg, $f)); 160 } 161 162 # (8 tests): Test import/load seed/priv consistency checks 163 my $real = sprintf('real-%s.der', $alg); 164 my $fake = sprintf('fake-%s.der', $alg); 165 my $mixt = sprintf('mixt-%s.der', $alg); 166 my $mash = sprintf('mash-%s.der', $alg); 167 ok(run(app([qw(openssl genpkey -algorithm), "ml-dsa-$alg", 168 qw(-provparam ml-dsa.output_formats=seed-priv -pkeyopt), 169 "hexseed:$seed", qw(-outform DER -out), $real])), 170 sprintf("create real private key: %s", $alg)); 171 ok(run(app([qw(openssl genpkey -algorithm), "ml-dsa-$alg", 172 qw(-provparam ml-dsa.output_formats=seed-priv -pkeyopt), 173 "hexseed:$weed", qw(-outform DER -out), $fake])), 174 sprintf("create fake private key: %s", $alg)); 175 my $realfh = IO::File->new($real, "<:raw"); 176 my $fakefh = IO::File->new($fake, "<:raw"); 177 local $/ = undef; 178 my $realder = <$realfh>; 179 $realfh->close(); 180 my $fakeder = <$fakefh>; 181 $fakefh->close(); 182 # 183 # - 20 bytes PKCS8 fixed overhead, 184 # - 4 byte private key octet string tag + length 185 # - 4 byte seed + key sequence tag + length 186 # - 2 byte seed tag + length 187 # - 32 byte seed 188 # - 4 byte key tag + length 189 # - $sk_len private key, ending in t0. 190 # 191 my $p8_len = 28 + (2 + 32) + (4 + $sk_len); 192 ok((length($realder) == $p8_len && length($fakeder) == $p8_len), 193 sprintf("Got expected DER lengths of %s seed-priv key", $alg)); 194 my $mixtder = substr($realder, 0, 28 + 34) 195 . substr($fakeder, 28 + 34); 196 my $mixtfh = IO::File->new($mixt, ">:raw"); 197 print $mixtfh $mixtder; 198 $mixtfh->close(); 199 ok(run(app([qw(openssl pkey -inform DER -noout -in), $real])), 200 sprintf("accept valid keypair: %s", $alg)); 201 ok(!run(app([qw(openssl pkey -inform DER -noout -in), $mixt])), 202 sprintf("Using seed reject mismatched private %s", $alg)); 203 ok(run(app([qw(openssl pkey -provparam ml-dsa.prefer_seed=no), 204 qw(-inform DER -noout -in), $mixt])), 205 sprintf("Ignoring seed accept mismatched private %s", $alg)); 206 # Mutate the t0 vector 207 my $mashder = $realder; 208 substr($mashder, -$t0_len, 1) =~ s{(.)}{chr(ord($1)^1)}es; 209 my $mashfh = IO::File->new($mash, ">:raw"); 210 print $mashfh $mashder; 211 $mashfh->close(); 212 ok(!run(app([qw(openssl pkey -provparam ml-dsa.prefer_seed=no), 213 qw(-inform DER -noout -in), $mash])), 214 sprintf("reject real private and mutated public: %s", $alg)); 215} 216