3 # Test suite for basic Heimdal external strength checking functionality.
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
10 # SPDX-License-Identifier: MIT
16 use lib "$ENV{SOURCE}/tap/perl";
18 use File::Copy qw(copy);
19 use Test::RRA qw(use_prereq);
20 use Test::RRA::Automake qw(test_file_path);
22 use_prereq('IPC::Run', 'run');
24 use_prereq('Perl6::Slurp', 'slurp');
25 use_prereq('Test::More', '0.87_01');
27 # Data directory to use for dictionaries.
28 my $DATADIR = $ENV{BUILD} ? "$ENV{BUILD}/data" : 'tests/data';
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
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)
41 title => 'Generic tests',
43 tests => [qw(principal)],
46 title => 'CrackLib tests',
47 config => { password_dictionary => "$DATADIR/dictionary" },
49 tests => [qw(cracklib principal)],
52 title => 'Password length tests',
53 config => { minimum_length => 12 },
54 tests => [qw(length)],
57 title => 'Password length tests with cracklib_maxlen',
59 password_dictionary => "$DATADIR/dictionary",
61 cracklib_maxlen => 11,
64 tests => [qw(length)],
67 title => 'Simple password character class tests',
69 minimum_different => 8,
70 require_ascii_printable => 'true',
71 require_non_letter => 'true',
73 tests => [qw(letter)],
76 title => 'Complex password character class tests',
79 '8-19:lower,upper 8-15:digit 8-11:symbol 24-24:3',
81 tests => [qw(classes)],
86 { password_dictionary_cdb => test_file_path('data/wordlist.cdb') },
88 tests => [qw(cdb principal)],
91 title => 'SQLite tests',
93 password_dictionary_sqlite =>
94 test_file_path('data/wordlist.sqlite'),
97 tests => [qw(sqlite principal)],
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.
106 # $principal - Principal to pass to the command
107 # $password - Password to pass to the command
108 # $error - Whether to expect an immediate error
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) = @_;
115 # Build the input to the strength checking program.
118 $in .= "principal: $principal\n";
119 $in .= "new-password: $password\n";
123 # Find the newly-built password checking program.
124 my $program = test_file_path('../tools/heimdal-strength');
126 # Run the password strength checker.
128 my $harness = run([$program, $principal], \$in, \$out, \$err);
129 my $status = $? >> 8;
131 # Return the results.
132 return ($status, $out, $err);
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.
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
147 # Throws: Text exception on failure to run the test program
150 my $principal = $test_ref->{principal};
151 my $password = $test_ref->{password};
153 # Run the heimdal-strength command.
154 my ($status, $out, $err) = run_heimdal_strength($principal, $password);
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');
166 is($err, q{}, '...no errors');
167 is($out, 'APPROVED', '...approved');
172 # Create a new krb5.conf file that includes arbitrary settings passed in via
175 # $settings_ref - Hash of keys and values to put into [appdefaults]
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) = @_;
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";
187 # Create a temporary directory for the new file.
189 mkdir($tmpdir, 0777) or die "Cannot create $tmpdir: $!\n";
192 # Start with the testing krb5.conf file shipped in the package.
193 copy($old, $new) or die "Cannot copy $old to $new: $!\n";
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";
203 print {$config} " }\n"
204 or die "Cannot append to $new: $!\n";
205 close($config) or die "Cannot append to $new: $!\n";
207 # Return the path to the new file.
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.
214 # $file - The file name containing the test data in JSON
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 {
220 my $path = test_file_path("data/passwords/$file");
222 # Load the test file data into memory.
223 my $testdata = slurp($path);
225 # Decode the JSON into Perl objects and return them.
226 my $json = JSON->new->utf8;
227 return $json->decode($testdata);
230 # Run a block of password tests, handling krb5.conf setup and skipping tests
231 # if required dictionary support isn't available.
233 # $spec_ref - Test specification (from @TESTS)
234 # $tests_ref - Hash structure containing all loaded password tests
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});
243 # If we need support for a type of dictionary, check for that and skip the
244 # tests if that dictionary wasn't supported.
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) {
252 for my $block (@{ $spec_ref->{tests} }) {
253 $total += scalar(@{ $tests_ref->{$block} });
255 skip("not built with $type support", $total * 3);
260 for my $block (@{ $spec_ref->{tests} }) {
261 if (scalar(@{ $spec_ref->{tests} }) > 1) {
262 note('... ', $block);
264 for my $test (@{ $tests_ref->{$block} }) {
265 check_password($test);
272 # Test a required_classes syntax error. Takes the string for required_classes
273 # and verifies that the appropriate error message is returned.
275 # $bad_class - Bad class specification
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';
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);
290 is($status, 1, "Bad class specification '$bad_class' (status)");
291 is($output, q{}, '...no output');
293 if ($bad_class =~ m{ \A (\d+ [^-]*) \z | : (\d+) \z }xms) {
294 my $minimum = $1 || $2;
295 $expected = "$error_prefix: $bad_minimum: $minimum\n";
297 $expected = "$error_prefix: $bad_message: $bad_class\n";
299 is($err, $expected, '...correct error');
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
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};
315 $tests{$type} = $tests;
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
322 for my $spec_ref (@TESTS) {
323 for my $block (@{ $spec_ref->{tests} }) {
324 $count += scalar(@{ $tests{$block} });
327 plan(tests => $count * 3 + 27);
330 for my $spec_ref (@TESTS) {
331 run_password_tests($spec_ref, \%tests);
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');
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
347 for my $bad_class (@bad_classes) {
348 test_require_classes_syntax($bad_class);
351 # Clean up our temporary krb5.conf file on any exit.
353 my $tmpdir = $ENV{BUILD} ? "$ENV{BUILD}/tmp" : 'tests/tmp';
354 my $config = "$tmpdir/krb5.conf";
356 unlink($config) or warn "Cannot remove $config\n";