xref: /freebsd/crypto/openssl/test/recipes/15-test_ml_kem_codecs.t (revision e7be843b4a162e68651d3911f0357ed464915629)
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