]> eyrie.org Git - kerberos/krb5-strength.git/blob - tests/tools/heimdal-strength-t
Update to rra-c-util 10.5
[kerberos/krb5-strength.git] / tests / tools / heimdal-strength-t
1 #!/usr/bin/perl
2 #
3 # Test suite for basic Heimdal external strength checking functionality.
4 #
5 # Written by Russ Allbery <eagle@eyrie.org>
6 # Copyright 2016-2017, 2020, 2023 Russ Allbery <eagle@eyrie.org>
7 # Copyright 2009, 2012-2014
8 #     The Board of Trustees of the Leland Stanford Junior University
9 #
10 # SPDX-License-Identifier: MIT
11
12 use 5.010;
13 use strict;
14 use warnings;
15
16 use lib "$ENV{SOURCE}/tap/perl";
17
18 use File::Copy qw(copy);
19 use Test::RRA qw(use_prereq);
20 use Test::RRA::Automake qw(test_file_path);
21
22 use_prereq('IPC::Run', 'run');
23 use_prereq('JSON');
24 use_prereq('Perl6::Slurp', 'slurp');
25 use_prereq('Test::More', '0.87_01');
26
27 # Data directory to use for dictionaries.
28 my $DATADIR = $ENV{BUILD} ? "$ENV{BUILD}/data" : 'tests/data';
29
30 # This data structure drives most of our tests.  Each list element is a block
31 # of tests to run together with a specific Kerberos configuration.  The keys
32 # are:
33 #
34 # title  - Title of the tests for test output
35 # config - Hash of Kerberos configuration to use
36 # needs  - Dictionary type name we have to have to run this test
37 # tests  - List of classes of tests to run (JSON files in tests/data/passwords)
38 #<<<
39 my @TESTS = (
40     {
41         title  => 'Generic tests',
42         config => {},
43         tests  => [qw(principal)],
44     },
45     {
46         title  => 'CrackLib tests',
47         config => { password_dictionary => "$DATADIR/dictionary" },
48         needs  => 'CrackLib',
49         tests  => [qw(cracklib principal)],
50     },
51     {
52         title  => 'Password length tests',
53         config => { minimum_length => 12 },
54         tests  => [qw(length)],
55     },
56     {
57         title  => 'Password length tests with cracklib_maxlen',
58         config => {
59             password_dictionary => "$DATADIR/dictionary",
60             minimum_length      => 12,
61             cracklib_maxlen     => 11,
62         },
63         needs => 'CrackLib',
64         tests => [qw(length)],
65     },
66     {
67         title  => 'Simple password character class tests',
68         config => {
69             minimum_different       => 8,
70             require_ascii_printable => 'true',
71             require_non_letter      => 'true',
72         },
73         tests => [qw(letter)],
74     },
75     {
76         title  => 'Complex password character class tests',
77         config => {
78             require_classes =>
79               '8-19:lower,upper 8-15:digit 8-11:symbol 24-24:3',
80         },
81         tests => [qw(classes)],
82     },
83     {
84         title => 'CDB tests',
85         config =>
86           { password_dictionary_cdb => test_file_path('data/wordlist.cdb') },
87         needs => 'CDB',
88         tests => [qw(cdb principal)],
89     },
90     {
91         title  => 'SQLite tests',
92         config => {
93             password_dictionary_sqlite =>
94               test_file_path('data/wordlist.sqlite'),
95         },
96         needs => 'SQLite',
97         tests => [qw(sqlite principal)],
98     },
99 );
100 #>>>
101
102 # Run the newly-built heimdal-strength command and return the status, output,
103 # and error output as a list.  If told to expect an immediate error, does not
104 # pass input to the process.
105 #
106 # $principal - Principal to pass to the command
107 # $password  - Password to pass to the command
108 # $error     - Whether to expect an immediate error
109 #
110 # Returns: The exit status, standard output, and standard error as a list
111 #  Throws: Text exception on failure to run the test program
112 sub run_heimdal_strength {
113     my ($principal, $password, $error) = @_;
114
115     # Build the input to the strength checking program.
116     my $in = q{};
117     if (!$error) {
118         $in .= "principal: $principal\n";
119         $in .= "new-password: $password\n";
120         $in .= "end\n";
121     }
122
123     # Find the newly-built password checking program.
124     my $program = test_file_path('../tools/heimdal-strength');
125
126     # Run the password strength checker.
127     my ($out, $err);
128     my $harness = run([$program, $principal], \$in, \$out, \$err);
129     my $status = $? >> 8;
130
131     # Return the results.
132     return ($status, $out, $err);
133 }
134
135 # Run the newly-built heimdal-strength command to check a password and reports
136 # the results using Test::More.  This uses the standard protocol for Heimdal
137 # external password strength checking programs.
138 #
139 # $test_ref - Reference to hash of test parameters
140 #   name      - The name of the test case
141 #   principal - The principal changing its password
142 #   password  - The new password
143 #   status    - If present, the exit status (otherwise, it should be 0)
144 #   error     - If present, the expected rejection error
145 #
146 # Returns: undef
147 #  Throws: Text exception on failure to run the test program
148 sub check_password {
149     my ($test_ref) = @_;
150     my $principal = $test_ref->{principal};
151     my $password = $test_ref->{password};
152
153     # Run the heimdal-strength command.
154     my ($status, $out, $err) = run_heimdal_strength($principal, $password);
155     chomp($out, $err);
156
157     # Check the results.  If there is an error in the password, it should come
158     # on standard error; otherwise, standard output should be APPROVED.  If
159     # there is a non-zero exit status, we expect the error on standard error
160     # and use that field to check for system errors.
161     is($status, $test_ref->{status} || 0, "$test_ref->{name} (status)");
162     if (defined($test_ref->{error})) {
163         is($err, $test_ref->{error}, '...error message');
164         is($out, q{}, '...no output');
165     } else {
166         is($err, q{}, '...no errors');
167         is($out, 'APPROVED', '...approved');
168     }
169     return;
170 }
171
172 # Create a new krb5.conf file that includes arbitrary settings passed in via
173 # a hash reference.
174 #
175 # $settings_ref - Hash of keys and values to put into [appdefaults]
176 #
177 # Returns: Path to the new krb5.conf file
178 #  Throws: Text exception if the new krb5.conf file cannot be created
179 sub create_krb5_conf {
180     my ($settings_ref) = @_;
181
182     # Paths for krb5.conf creation.
183     my $old = test_file_path('data/krb5.conf');
184     my $tmpdir = $ENV{BUILD} ? "$ENV{BUILD}/tmp" : 'tests/tmp';
185     my $new = "$tmpdir/krb5.conf";
186
187     # Create a temporary directory for the new file.
188     if (!-d $tmpdir) {
189         mkdir($tmpdir, 0777) or die "Cannot create $tmpdir: $!\n";
190     }
191
192     # Start with the testing krb5.conf file shipped in the package.
193     copy($old, $new) or die "Cannot copy $old to $new: $!\n";
194
195     # Append the local configuration.
196     open(my $config, '>>', $new) or die "Cannot append to $new: $!\n";
197     print {$config} "\n[appdefaults]\n    krb5-strength = {\n"
198       or die "Cannot append to $new: $!\n";
199     for my $key (keys %{$settings_ref}) {
200         print {$config} q{ } x 8, $key, ' = ', $settings_ref->{$key}, "\n"
201           or die "Cannot append to $new: $!\n";
202     }
203     print {$config} "    }\n"
204       or die "Cannot append to $new: $!\n";
205     close($config) or die "Cannot append to $new: $!\n";
206
207     # Return the path to the new file.
208     return $new;
209 }
210
211 # Load a set of password test cases and return them as a list.  The given file
212 # name is relative to data/passwords in the test suite.
213 #
214 # $file - The file name containing the test data in JSON
215 #
216 # Returns: List of anonymous hashes representing password test cases
217 #  Throws: Text exception on failure to load the test data
218 sub load_password_tests {
219     my ($file) = @_;
220     my $path = test_file_path("data/passwords/$file");
221
222     # Load the test file data into memory.
223     my $testdata = slurp($path);
224
225     # Decode the JSON into Perl objects and return them.
226     my $json = JSON->new->utf8;
227     return $json->decode($testdata);
228 }
229
230 # Run a block of password tests, handling krb5.conf setup and skipping tests
231 # if required dictionary support isn't available.
232 #
233 # $spec_ref  - Test specification (from @TESTS)
234 # $tests_ref - Hash structure containing all loaded password tests
235 #
236 # Returns: undef
237 sub run_password_tests {
238     my ($spec_ref, $tests_ref) = @_;
239     my $krb5_conf = create_krb5_conf($spec_ref->{config});
240     local $ENV{KRB5_CONFIG} = $krb5_conf;
241     note($spec_ref->{title});
242
243     # If we need support for a type of dictionary, check for that and skip the
244     # tests if that dictionary wasn't supported.
245   SKIP: {
246         if ($spec_ref->{needs}) {
247             my $type = $spec_ref->{needs};
248             my ($status, undef, $err) = run_heimdal_strength('test', 'pass');
249             my $err_regex = qr{ not [ ] built [ ] with [ ] \Q$type\E }xms;
250             if ($status == 1 && $err =~ $err_regex) {
251                 my $total = 0;
252                 for my $block (@{ $spec_ref->{tests} }) {
253                     $total += scalar(@{ $tests_ref->{$block} });
254                 }
255                 skip("not built with $type support", $total * 3);
256             }
257         }
258
259         # Run the tests.
260         for my $block (@{ $spec_ref->{tests} }) {
261             if (scalar(@{ $spec_ref->{tests} }) > 1) {
262                 note('... ', $block);
263             }
264             for my $test (@{ $tests_ref->{$block} }) {
265                 check_password($test);
266             }
267         }
268     }
269     return;
270 }
271
272 # Test a required_classes syntax error.  Takes the string for required_classes
273 # and verifies that the appropriate error message is returned.
274 #
275 # $bad_class - Bad class specification
276 #
277 # Returns: undef
278 sub test_require_classes_syntax {
279     my ($bad_class) = @_;
280     my $error_prefix = 'Cannot initialize strength checking';
281     my $bad_message = 'bad character class requirement in configuration';
282     my $bad_minimum = 'bad character class minimum in configuration';
283
284     # Run heimdal-strength.
285     my $krb5_conf = create_krb5_conf({ require_classes => $bad_class });
286     local $ENV{KRB5_CONFIG} = $krb5_conf;
287     my ($status, $output, $err) = run_heimdal_strength('test', 'password', 1);
288
289     # Check the results.
290     is($status, 1, "Bad class specification '$bad_class' (status)");
291     is($output, q{}, '...no output');
292     my $expected;
293     if ($bad_class =~ m{ \A (\d+ [^-]*) \z | : (\d+) \z }xms) {
294         my $minimum = $1 || $2;
295         $expected = "$error_prefix: $bad_minimum: $minimum\n";
296     } else {
297         $expected = "$error_prefix: $bad_message: $bad_class\n";
298     }
299     is($err, $expected, '...correct error');
300     return;
301 }
302
303 # Load the password tests from JSON, removing the CrackLib tests that may fail
304 # if we were built with the system CrackLib.  We don't have an easy way of
305 # knowing which CrackLib heimdal-strength was linked against, so we have to
306 # ignore them unconditionally.  The separate plugin tests will exercise that
307 # code.
308 my %tests;
309 for my $type (qw(cdb classes cracklib length letter principal sqlite)) {
310     my $tests = load_password_tests("$type.json");
311     if ($type eq 'cracklib') {
312         my @tests = grep { !$_->{skip_for_system_cracklib} } @{$tests};
313         $tests = [@tests];
314     }
315     $tests{$type} = $tests;
316 }
317
318 # Determine our plan based on the test blocks we run (there are three test
319 # results for each password test), plus 27 additional tests for error
320 # handling.
321 my $count = 0;
322 for my $spec_ref (@TESTS) {
323     for my $block (@{ $spec_ref->{tests} }) {
324         $count += scalar(@{ $tests{$block} });
325     }
326 }
327 plan(tests => $count * 3 + 27);
328
329 # Run all the tests.
330 for my $spec_ref (@TESTS) {
331     run_password_tests($spec_ref, \%tests);
332 }
333
334 # Test error for an unknown character class.
335 my $krb5_conf = create_krb5_conf({ require_classes => 'bogus' });
336 local $ENV{KRB5_CONFIG} = $krb5_conf;
337 my $error_prefix = 'Cannot initialize strength checking';
338 my ($status, $output, $err) = run_heimdal_strength('test', 'password', 1);
339 is($status, 1, 'Bad character class (status)');
340 is($output, q{}, '...no output');
341 is($err, "$error_prefix: unknown character class bogus\n", '...correct error');
342
343 # Test a variety of configuration syntax errors in require_classes.
344 my @bad_classes = qw(
345     8 8bogus 8:bogus 4-:bogus 4-bogus 4-8bogus 10:3 10-11:5
346 );
347 for my $bad_class (@bad_classes) {
348     test_require_classes_syntax($bad_class);
349 }
350
351 # Clean up our temporary krb5.conf file on any exit.
352 END {
353     my $tmpdir = $ENV{BUILD} ? "$ENV{BUILD}/tmp" : 'tests/tmp';
354     my $config = "$tmpdir/krb5.conf";
355     if (-e $config) {
356         unlink($config) or warn "Cannot remove $config\n";
357         rmdir($tmpdir);
358     }
359 }