Passed
Push — release_2_0 ( 2b9a09...993113 )
by Stefan
08:43
created

RADIUSTests::wasModernTlsNegotiated()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 11
rs 10
cc 4
nc 3
nop 2
1
<?php
0 ignored issues
show
Coding Style Compatibility introduced by
For compatibility and reusability of your code, PSR1 recommends that a file should introduce either new symbols (like classes, functions, etc.) or have side-effects (like outputting something, or including other files), but not both at the same time. The first symbol is defined on line 51 and the first side effect is on line 38.

The PSR-1: Basic Coding Standard recommends that a file should either introduce new symbols, that is classes, functions, constants or similar, or have side effects. Side effects are anything that executes logic, like for example printing output, changing ini settings or writing to a file.

The idea behind this recommendation is that merely auto-loading a class should not change the state of an application. It also promotes a cleaner style of programming and makes your code less prone to errors, because the logic is not spread out all over the place.

To learn more about the PSR-1, please see the PHP-FIG site on the PSR-1.

Loading history...
2
3
/*
4
 * *****************************************************************************
5
 * Contributions to this work were made on behalf of the GÉANT project, a 
6
 * project that has received funding from the European Union’s Framework 
7
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
8
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
9
 * 691567 (GN4-1) and No. 731122 (GN4-2).
10
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
11
 * of the copyright in all material which was developed by a member of the GÉANT
12
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
13
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
14
 * UK as a branch of GÉANT Vereniging.
15
 * 
16
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
17
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
18
 *
19
 * License: see the web/copyright.inc.php file in the file structure or
20
 *          <base_url>/copyright.php after deploying the software
21
 */
22
23
/**
24
 * This file contains code for testing EAP servers
25
 *
26
 * @author Stefan Winter <[email protected]>
27
 * @author Tomasz Wolniewicz <[email protected]>
28
 * @author Maja Gorecka-Wolniewicz <[email protected]>
29
 *
30
 * @package Developer
31
 * 
32
 */
33
34
namespace core\diag;
35
36
use \Exception;
37
38
require_once dirname(dirname(__DIR__)) . "/config/_config.php";
39
40
/**
41
 * Test suite to verify that an EAP setup is actually working as advertised in
42
 * the real world. Can only be used if CONFIG_DIAGNOSTICS['RADIUSTESTS'] is configured.
43
 *
44
 * @author Stefan Winter <[email protected]>
45
 * @author Tomasz Wolniewicz <[email protected]>
46
 *
47
 * @license see LICENSE file in root directory
48
 *
49
 * @package Developer
50
 */
51
class RADIUSTests extends AbstractTest
52
{
53
54
    /**
55
     * Was the reachability check executed already?
56
     * 
57
     * @var integer
58
     */
59
    private $UDP_reachability_executed;
60
61
    /**
62
     * the issues we found
63
     * 
64
     * @var array 
65
     */
66
    private $errorlist;
67
68
    /**
69
     * This private variable contains the realm to be checked. Is filled in the
70
     * class constructor.
71
     * 
72
     * @var string
73
     */
74
    private $realm;
75
76
    /**
77
     * which username to use as outer identity
78
     * 
79
     * @var string
80
     */
81
    private $outerUsernameForChecks;
82
83
    /**
84
     * list of CAs we expect incoming server certs to be from
85
     * 
86
     * @var array
87
     */
88
    private $expectedCABundle;
89
90
    /**
91
     * list of expected server names
92
     * 
93
     * @var array
94
     */
95
    private $expectedServerNames;
96
97
    /**
98
     * the list of EAP types which the IdP allegedly supports.
99
     * 
100
     * @var array
101
     */
102
    private $supportedEapTypes;
103
104
    /**
105
     * Do we run throrough or shallow checks?
106
     * 
107
     * @var integer
108
     */
109
    private $opMode;
110
111
    /**
112
     * result of the reachability tests
113
     * 
114
     * @var array
115
     */
116
    public $UDP_reachability_result;
117
118
    const RADIUS_TEST_OPERATION_MODE_SHALLOW = 1;
119
    const RADIUS_TEST_OPERATION_MODE_THOROUGH = 2;
120
121
    /**
122
     * Constructor for the EAPTests class. The single mandatory parameter is the
123
     * realm for which the tests are to be carried out.
124
     * 
125
     * @param string $realm                  the realm to check
126
     * @param string $outerUsernameForChecks outer username to use
127
     * @param array  $supportedEapTypes      array of integer representations of EAP types
128
     * @param array  $expectedServerNames    array of strings
129
     * @param array  $expectedCABundle       array of PEM blocks
130
     */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
131
    public function __construct($realm, $outerUsernameForChecks, $supportedEapTypes = [], $expectedServerNames = [], $expectedCABundle = [])
132
    {
133
        parent::__construct();
134
135
        $this->realm = $realm;
136
        $this->outerUsernameForChecks = $outerUsernameForChecks;
137
        $this->expectedCABundle = $expectedCABundle;
138
        $this->expectedServerNames = $expectedServerNames;
139
        $this->supportedEapTypes = $supportedEapTypes;
140
141
        $this->opMode = self::RADIUS_TEST_OPERATION_MODE_SHALLOW;
142
143
        $caNeeded = FALSE;
144
        $serverNeeded = FALSE;
145
        foreach ($supportedEapTypes as $oneEapType) {
146
            if ($oneEapType->needsServerCACert()) {
147
                $caNeeded = TRUE;
148
            }
149
            if ($oneEapType->needsServerName()) {
150
                $serverNeeded = TRUE;
151
            }
152
        }
153
154
        if ($caNeeded) {
155
            // we need to have info about at least one CA cert and server names
156
            if (count($this->expectedCABundle) == 0) {
157
                Throw new Exception("Thorough checks for an EAP type needing CAs were requested, but the required parameters were not given.");
158
            } else {
159
                $this->opMode = self::RADIUS_TEST_OPERATION_MODE_THOROUGH;
160
            }
161
        }
162
163
        if ($serverNeeded) {
164
            if (count($this->expectedServerNames) == 0) {
165
                Throw new Exception("Thorough checks for an EAP type needing server names were requested, but the required parameter was not given.");
166
            } else {
167
                $this->opMode = self::RADIUS_TEST_OPERATION_MODE_THOROUGH;
168
            }
169
        }
170
171
        $this->loggerInstance->debug(4, "RADIUSTests is in opMode " . $this->opMode . ", parameters were: $realm, $outerUsernameForChecks, " . print_r($supportedEapTypes, true));
172
        $this->loggerInstance->debug(4, print_r($expectedServerNames, true));
173
        $this->loggerInstance->debug(4, print_r($expectedCABundle, true));
174
175
        $this->UDP_reachability_result = [];
176
        $this->errorlist = [];
177
    }
178
179
    /**
180
     * creates a string with the DistinguishedName (comma-separated name=value fields)
181
     * 
182
     * @param array $distinguishedName the components of the DN
183
     * @return string
184
     */
185
    private function printDN($distinguishedName)
186
    {
187
        $out = '';
188
        foreach (array_reverse($distinguishedName) as $nameType => $nameValue) { // to give an example: "CN" => "some.host.example" 
189
            if (!is_array($nameValue)) { // single-valued: just a string
190
                $nameValue = ["$nameValue"]; // convert it to a multi-value attrib with just one value :-) for unified processing later on
191
            }
192
            foreach ($nameValue as $oneValue) {
193
                if ($out) {
194
                    $out .= ',';
195
                }
196
                $out .= "$nameType=$oneValue";
197
            }
198
        }
199
        return($out);
200
    }
201
202
    /**
203
     * prints a timestamp in gmdate formatting
204
     * 
205
     * @param int $time time in UNIX timestamp
206
     * @return string
207
     */
208
    private function printTm($time)
209
    {
210
        return(gmdate(\DateTime::COOKIE, $time));
211
    }
212
213
    /**
214
     * This function parses a X.509 server cert and checks if it finds client device incompatibilities
215
     * 
216
     * @param array $servercert the properties of the certificate as returned by
217
     *                          processCertificate(), $servercert is modified, 
218
     *                          if CRL is defied, it is downloaded and added to
219
     *                          the array incoming_server_names, sAN_DNS and CN 
220
     *                          array values are also defined
221
     * @return array of oddities; the array is empty if everything is fine
222
     */
223
    private function propertyCheckServercert(&$servercert)
224
    {
225
// we share the same checks as for CAs when it comes to signature algorithm and basicconstraints
226
// so call that function and memorise the outcome
227
        $returnarray = $this->propertyCheckIntermediate($servercert, TRUE);
228
        $sANdns = [];
229
        if (!isset($servercert['full_details']['extensions'])) {
230
            $returnarray[] = RADIUSTests::CERTPROB_NO_TLS_WEBSERVER_OID;
231
            $returnarray[] = RADIUSTests::CERTPROB_NO_CDP_HTTP;
232
        } else { // Extensions are present...
233
            if (!isset($servercert['full_details']['extensions']['extendedKeyUsage']) || !preg_match("/TLS Web Server Authentication/", $servercert['full_details']['extensions']['extendedKeyUsage'])) {
234
                $returnarray[] = RADIUSTests::CERTPROB_NO_TLS_WEBSERVER_OID;
235
            }
236
            if (isset($servercert['full_details']['extensions']['subjectAltName'])) {
237
                $sANlist = explode(", ", $servercert['full_details']['extensions']['subjectAltName']);
238
                foreach ($sANlist as $subjectAltName) {
239
                    if (preg_match("/^DNS:/", $subjectAltName)) {
240
                        $sANdns[] = substr($subjectAltName, 4);
241
                    }
242
                }
243
            }
244
        }
245
246
        // often, there is only one name, so we store it in an array of one member
247
        $commonName = [$servercert['full_details']['subject']['CN']];
248
        // if we got an array of names instead, then that is already an array, so override
249
        if (isset($servercert['full_details']['subject']['CN']) && is_array($servercert['full_details']['subject']['CN'])) {
250
            $commonName = $servercert['full_details']['subject']['CN'];
251
            $returnarray[] = RADIUSTests::CERTPROB_MULTIPLE_CN;
252
        }
253
        $allnames = array_values(array_unique(array_merge($commonName, $sANdns)));
254
// check for wildcards
255
// check for real hostnames, and whether there is a wildcard in a name
256
        foreach ($allnames as $onename) {
257
            if (preg_match("/\*/", $onename)) {
258
                $returnarray[] = RADIUSTests::CERTPROB_WILDCARD_IN_NAME;
259
                continue; // otherwise we'd ALSO complain that it's not a real hostname
260
            }
261
            if ($onename != "" && filter_var("foo@" . idn_to_ascii($onename), FILTER_VALIDATE_EMAIL) === FALSE) {
262
                $returnarray[] = RADIUSTests::CERTPROB_NOT_A_HOSTNAME;
263
            }
264
        }
265
        $servercert['incoming_server_names'] = $allnames;
266
        $servercert['sAN_DNS'] = $sANdns;
267
        $servercert['CN'] = $commonName;
268
        return $returnarray;
269
    }
270
271
    /**
272
     * This function parses a X.509 intermediate CA cert and checks if it finds client device incompatibilities
273
     * 
274
     * @param array   $intermediateCa the properties of the certificate as returned by processCertificate()
275
     * @param boolean $serverCert     treat as servercert?
276
     * @return array of oddities; the array is empty if everything is fine
277
     */
278
    private function propertyCheckIntermediate(&$intermediateCa, $serverCert = FALSE)
279
    {
280
        $returnarray = [];
281
        if (preg_match("/md5/i", $intermediateCa['full_details']['signatureTypeSN'])) {
282
            $returnarray[] = RADIUSTests::CERTPROB_MD5_SIGNATURE;
283
        }
284
        if (preg_match("/sha1/i", $intermediateCa['full_details']['signatureTypeSN'])) {
285
            $returnarray[] = RADIUSTests::CERTPROB_SHA1_SIGNATURE;
286
        }
287
        $this->loggerInstance->debug(4, "CERT IS: " . print_r($intermediateCa, TRUE));
288
        if ($intermediateCa['basicconstraints_set'] == 0) {
289
            $returnarray[] = RADIUSTests::CERTPROB_NO_BASICCONSTRAINTS;
290
        }
291
        if ($intermediateCa['full_details']['public_key_algorithm'] == \core\common\X509::KNOWN_PUBLIC_KEY_ALGORITHMS[0] && $intermediateCa['full_details']['public_key_length'] < 2048) {
292
            $returnarray[] = RADIUSTests::CERTPROB_LOW_KEY_LENGTH;
293
        }
294
        if (!in_array($intermediateCa['full_details']['public_key_algorithm'], \core\common\X509::KNOWN_PUBLIC_KEY_ALGORITHMS)) {
295
            $returnarray[] = RADIUSTests::CERTPROB_UNKNOWN_PUBLIC_KEY_ALGORITHM;
296
        }
297
        $validFrom = $intermediateCa['full_details']['validFrom_time_t'];
298
        $now = time();
299
        $validTo = $intermediateCa['full_details']['validTo_time_t'];
300
        if ($validFrom > $now || $validTo < $now) {
301
            $returnarray[] = RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD;
302
        }
303
        $addCertCrlResult = $this->addCrltoCert($intermediateCa);
304
        if ($addCertCrlResult !== 0 && $serverCert) {
305
            $returnarray[] = $addCertCrlResult;
306
        }
307
308
        return $returnarray;
309
    }
310
311
    /**
312
     * This function returns an array of errors which were encountered in all the tests.
313
     * 
314
     * @return array all the errors
315
     */
316
    public function listerrors()
317
    {
318
        return $this->errorlist;
319
    }
320
321
    /**
322
     * This function performs actual authentication checks with MADE-UP credentials.
323
     * Its purpose is to check if a RADIUS server is reachable and speaks EAP.
324
     * The function fills array RADIUSTests::UDP_reachability_result[$probeindex] with all check detail
325
     * in case more than the return code is needed/wanted by the caller
326
     * 
327
     * @param int     $probeindex  refers to the specific UDP-host in the config that should be checked
328
     * @param boolean $opnameCheck should we check choking on Operator-Name?
329
     * @param boolean $frag        should we cause UDP fragmentation? (Warning: makes use of Operator-Name!)
330
     * @return int returncode
331
     */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
332
    public function udpReachability($probeindex, $opnameCheck = TRUE, $frag = TRUE)
333
    {
334
        // for EAP-TLS to be a viable option, we need to pass a random client cert to make eapol_test happy
335
        // the following PEM data is one of the SENSE EAPLab client certs (not secret at all)
336
        $clientcert = file_get_contents(dirname(__FILE__) . "/clientcert.p12");
337
        if ($clientcert === FALSE) {
338
            throw new Exception("A dummy client cert is part of the source distribution, but could not be loaded!");
339
        }
340
        // if we are in thorough opMode, use our knowledge for a more clever check
341
        // otherwise guess
342
        if ($this->opMode == self::RADIUS_TEST_OPERATION_MODE_THOROUGH) {
343
            return $this->udpLogin($probeindex, $this->supportedEapTypes[0]->getArrayRep(), $this->outerUsernameForChecks, 'eaplab', $opnameCheck, $frag, $clientcert);
344
        }
345
        return $this->udpLogin($probeindex, \core\common\EAP::EAPTYPE_ANY, "cat-connectivity-test@" . $this->realm, 'eaplab', $opnameCheck, $frag, $clientcert);
346
    }
347
348
    /**
349
     * There is a CRL Distribution Point URL in the certificate. So download the
350
     * CRL and attach it to the cert structure so that we can later find out if
351
     * the cert was revoked
352
     * @param array $cert by-reference: the cert data we are writing into
353
     * @return int result code whether we were successful in retrieving the CRL
354
     */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
355
    private function addCrltoCert(&$cert)
356
    {
357
        $crlUrl = [];
358
        $returnresult = 0;
359
        if (!isset($cert['full_details']['extensions']['crlDistributionPoints'])) {
360
            return RADIUSTests::CERTPROB_NO_CDP;
361
        }
362
        if (!preg_match("/^.*URI\:(http)(.*)$/", str_replace(["\r", "\n"], ' ', $cert['full_details']['extensions']['crlDistributionPoints']), $crlUrl)) {
363
            return RADIUSTests::CERTPROB_NO_CDP_HTTP;
364
        }
365
        // first and second sub-match is the full URL... check it
366
        $crlcontent = \core\common\OutsideComm::downloadFile(trim($crlUrl[1] . $crlUrl[2]));
367
        if ($crlcontent === FALSE) {
368
            return RADIUSTests::CERTPROB_NO_CRL_AT_CDP_URL;
369
        }
370
        /* CRLs are always in DER form, so need encoding
371
         * note that what we ACTUALLY got can be arbitrary junk; we just deposit
372
         * it on the filesystem and let openssl figure out if it is usable or not
373
         *
374
         * Unfortunately, that freaks out Scrutinizer because we write unvetted
375
         * data to the filesystem. Let's see if we can make things better.
376
         */
377
378
        // $pem = chunk_split(base64_encode($crlcontent), 64, "\n");
379
        // inspired by https://stackoverflow.com/questions/2390604/how-to-pass-variables-as-stdin-into-command-line-from-php
380
        $proc = CONFIG['PATHS']['openssl'] . " crl -inform der";
381
        $descriptorspec = [
382
            0 => ["pipe", "r"],
383
            1 => ["pipe", "w"],
384
            2 => ["pipe", "w"],
385
        ];
386
        $process = proc_open($proc, $descriptorspec, $pipes);
387
        if (!is_resource($process)) {
388
            throw new Exception("Unable to execute openssl cmdline for CRL conversion!");
389
        }
390
        fwrite($pipes[0], $crlcontent);
391
        fclose($pipes[0]);
392
        $pem = stream_get_contents($pipes[1]);
393
        fclose($pipes[1]);
394
        fclose($pipes[2]);
395
        $retval = proc_close($process);
396
        if ($retval != 0 || !preg_match("/BEGIN X509 CRL/", $pem)) {
397
            // this was not a real CRL
398
            return RADIUSTests::CERTPROB_NO_CRL_AT_CDP_URL;
399
        }
400
        $cert['CRL'] = [];
401
        $cert['CRL'][] = $pem;
402
        return $returnresult;
403
    }
404
405
    /**
406
     * We don't want to write passwords of the live login test to our logs. Filter them out
407
     * @param string $stringToRedact what should be redacted
408
     * @param array  $inputarray     array of strings (outputs of eapol_test command)
409
     * @return string[] the output of eapol_test with the password redacted
410
     */
411
    private function redact($stringToRedact, $inputarray)
412
    {
413
        $temparray = preg_replace("/^.*$stringToRedact.*$/", "LINE CONTAINING PASSWORD REDACTED", $inputarray);
414
        $hex = bin2hex($stringToRedact);
415
        $spaced = "";
416
        $origLength = strlen($hex);
417
        for ($i = 1; $i < $origLength; $i++) {
418
            if ($i % 2 == 1 && $i != strlen($hex)) {
419
                $spaced .= $hex[$i] . " ";
420
            } else {
421
                $spaced .= $hex[$i];
422
            }
423
        }
424
        return preg_replace("/$spaced/", " HEX ENCODED PASSWORD REDACTED ", $temparray);
425
    }
426
427
    /**
428
     * Filters eapol_test output and finds out the packet codes out of which the conversation was comprised of
429
     * 
430
     * @param array $inputarray array of strings (outputs of eapol_test command)
431
     * @return array the packet codes which were exchanged, in sequence
432
     */
433
    private function filterPackettype($inputarray)
434
    {
435
        $retarray = [];
436
        foreach ($inputarray as $line) {
437
            if (preg_match("/RADIUS message:/", $line)) {
438
                $linecomponents = explode(" ", $line);
439
                $packettypeExploded = explode("=", $linecomponents[2]);
440
                $packettype = $packettypeExploded[1];
441
                $retarray[] = $packettype;
442
            }
443
        }
444
        return $retarray;
445
    }
446
447
    const LINEPARSE_CHECK_REJECTIGNORE = 1;
448
    const LINEPARSE_CHECK_691 = 2;
449
    const LINEPARSE_EAPACK = 3;
450
    const LINEPARSE_TLSVERSION = 4;
451
    const TLS_VERSION_ANCIENT = "OTHER";
452
    const TLS_VERSION_1_0 = "TLSv1";
453
    const TLS_VERSION_1_1 = "TLSv1.1";
454
    const TLS_VERSION_1_2 = "TLSv1.2";
455
    const TLS_VERSION_1_3 = "TLSv1.3";
456
457
    /**
458
     * this function checks for various special conditions which can be found 
459
     * only by parsing eapol_test output line by line. Checks currently 
460
     * implemented are:
461
     * * if the ETLRs sent back an Access-Reject because there appeared to
462
     *   be a timeout further downstream
463
     * * did the server send an MSCHAP Error 691 - Retry Allowed in a Challenge
464
     *   instead of an outright reject?
465
     * * was an EAP method ever acknowledged by both sides during the EAP
466
     *   conversation
467
     * 
468
     * @param array $inputarray   array of strings (outputs of eapol_test command)
469
     * @param int   $desiredCheck which test should be run (see constants above)
470
     * @return boolean returns TRUE if ETLR Reject logic was detected; FALSE if not
471
     */
0 ignored issues
show
Coding Style Documentation introduced by
Missing @throws tag in function comment
Loading history...
472
    private function checkLineparse($inputarray, $desiredCheck)
473
    {
474
        foreach ($inputarray as $lineid => $line) {
475
            switch ($desiredCheck) {
476
                case self::LINEPARSE_CHECK_REJECTIGNORE:
477
                    if (preg_match("/Attribute 18 (Reply-Message)/", $line) && preg_match("/Reject instead of Ignore at eduroam.org/", $inputarray[$lineid + 1])) {
478
                        return TRUE;
479
                    }
480
                    break;
481
                case self::LINEPARSE_CHECK_691:
482
                    if (preg_match("/MSCHAPV2: error 691/", $line) && preg_match("/MSCHAPV2: retry is allowed/", $inputarray[$lineid + 1])) {
483
                        return TRUE;
484
                    }
485
                    break;
486
                case self::LINEPARSE_EAPACK:
487
                    if (preg_match("/CTRL-EVENT-EAP-PROPOSED-METHOD/", $line) && !preg_match("/NAK$/", $line)) {
488
                        return TRUE;
489
                    }
490
                    break;
491
                case self::LINEPARSE_TLSVERSION:
492
                    break;
493
                default:
494
                    throw new Exception("This lineparse test does not exist.");
495
            }
496
        }
497
        // for TLS version checks, we need to search from bottom to top 
498
        // eapol_test will always try its highest version first, and can be
499
        // pursuaded later on to do less. So look at the end result.
500
        for ($counter = count($inputarray); $counter > 0; $counter--) {
501
            switch ($desiredCheck) {
502
                case self::LINEPARSE_TLSVERSION:
503
                    $version = [];
504
                    if (preg_match("/Using TLS version (.*)$/", $inputarray[$counter], $version)) {
505
                        switch (trim($version[1])) {
506
                            case self::TLS_VERSION_1_3:
507
                                return self::TLS_VERSION_1_3;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::TLS_VERSION_1_3 returns the type string which is incompatible with the documented return type boolean.
Loading history...
508
                            case self::TLS_VERSION_1_2:
509
                                return self::TLS_VERSION_1_2;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::TLS_VERSION_1_2 returns the type string which is incompatible with the documented return type boolean.
Loading history...
510
                            case self::TLS_VERSION_1_1:
511
                                return self::TLS_VERSION_1_1;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::TLS_VERSION_1_1 returns the type string which is incompatible with the documented return type boolean.
Loading history...
512
                            case self::TLS_VERSION_1_0:
513
                                return self::TLS_VERSION_1_0;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::TLS_VERSION_1_0 returns the type string which is incompatible with the documented return type boolean.
Loading history...
514
                            default:
515
                                return self::TLS_VERSION_ANCIENT;
0 ignored issues
show
Bug Best Practice introduced by
The expression return self::TLS_VERSION_ANCIENT returns the type string which is incompatible with the documented return type boolean.
Loading history...
516
                        }
517
                    }
518
                    break;
519
                case self::LINEPARSE_CHECK_691: /** fall-through intentional * */
0 ignored issues
show
Coding Style introduced by
The case body in a switch statement must start on the line following the statement.

According to the PSR-2, the body of a case statement must start on the line immediately following the case statement.

switch ($expr) {
case "A":
    doSomething(); //right
    break;
case "B":

    doSomethingElse(); //wrong
    break;

}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
Coding Style introduced by
There must be a comment when fall-through is intentional in a non-empty case body
Loading history...
520
                case self::LINEPARSE_CHECK_REJECTIGNORE: /** fall-through intentional * */
0 ignored issues
show
Coding Style introduced by
The case body in a switch statement must start on the line following the statement.

According to the PSR-2, the body of a case statement must start on the line immediately following the case statement.

switch ($expr) {
case "A":
    doSomething(); //right
    break;
case "B":

    doSomethingElse(); //wrong
    break;

}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
Coding Style introduced by
There must be a comment when fall-through is intentional in a non-empty case body
Loading history...
521
                case self::LINEPARSE_EAPACK: /** fall-through intentional * */
0 ignored issues
show
Coding Style introduced by
The case body in a switch statement must start on the line following the statement.

According to the PSR-2, the body of a case statement must start on the line immediately following the case statement.

switch ($expr) {
case "A":
    doSomething(); //right
    break;
case "B":

    doSomethingElse(); //wrong
    break;

}

To learn more about the PSR-2 coding standard, please refer to the PHP-Fig.

Loading history...
522
                    break;
523
                default:
524
                    throw new Exception("This lineparse test does not exist.");
525
            }
526
        }
527
        return FALSE;
528
    }
529
530
    /**
531
     * 
532
     * @param array  $eaptype  array representation of the EAP type
533
     * @param string $inner    inner username
534
     * @param string $outer    outer username
535
     * @param string $password the password
536
     * @return string[] [0] is the actual config for wpa_supplicant, [1] is a redacted version for logs
537
     */
538
    private function wpaSupplicantConfig(array $eaptype, string $inner, string $outer, string $password)
539
    {
540
        $eapText = \core\common\EAP::eapDisplayName($eaptype);
541
        $config = '
542
network={
543
  ssid="' . CONFIG['APPEARANCE']['productname'] . ' testing"
544
  key_mgmt=WPA-EAP
545
  proto=WPA2
546
  pairwise=CCMP
547
  group=CCMP
548
  ';
549
// phase 1
550
        $config .= 'eap=' . $eapText['OUTER'] . "\n";
551
        $logConfig = $config;
552
// phase 2 if applicable; all inner methods have passwords
553
        if (isset($eapText['INNER']) && $eapText['INNER'] != "") {
554
            $config .= '  phase2="auth=' . $eapText['INNER'] . "\"\n";
555
            $logConfig .= '  phase2="auth=' . $eapText['INNER'] . "\"\n";
556
        }
557
// all methods set a password, except EAP-TLS
558
        if ($eaptype != \core\common\EAP::EAPTYPE_TLS) {
559
            $config .= "  password=\"$password\"\n";
560
            $logConfig .= "  password=\"not logged for security reasons\"\n";
561
        }
562
// for methods with client certs, add a client cert config block
563
        if ($eaptype == \core\common\EAP::EAPTYPE_TLS || $eaptype == \core\common\EAP::EAPTYPE_ANY) {
564
            $config .= "  private_key=\"./client.p12\"\n";
565
            $logConfig .= "  private_key=\"./client.p12\"\n";
566
            $config .= "  private_key_passwd=\"$password\"\n";
567
            $logConfig .= "  private_key_passwd=\"not logged for security reasons\"\n";
568
        }
569
570
// inner identity
571
        $config .= '  identity="' . $inner . "\"\n";
572
        $logConfig .= '  identity="' . $inner . "\"\n";
573
// outer identity, may be equal
574
        $config .= '  anonymous_identity="' . $outer . "\"\n";
575
        $logConfig .= '  anonymous_identity="' . $outer . "\"\n";
576
// done
577
        $config .= "}";
578
        $logConfig .= "}";
579
580
        return [$config, $logConfig];
581
    }
582
583
    /**
584
     * Checks whether the packets received are as expected in numbers
585
     * 
586
     * @param array $testresults by-reference array of the testresults so far
587
     *                           function adds its own findings to that array
588
     * @param array $packetcount the count of incoming packets
589
     * @return int
590
     */
591
    private function packetCountEvaluation(&$testresults, $packetcount)
592
    {
593
        $reqs = $packetcount[1] ?? 0;
594
        $accepts = $packetcount[2] ?? 0;
595
        $rejects = $packetcount[3] ?? 0;
596
        $challenges = $packetcount[11] ?? 0;
597
        $testresults['packetflow_sane'] = TRUE;
598
        if ($reqs - $accepts - $rejects - $challenges != 0 || $accepts > 1 || $rejects > 1) {
599
            $testresults['packetflow_sane'] = FALSE;
600
        }
601
602
        $this->loggerInstance->debug(5, "XYZ: Counting req, acc, rej, chal: $reqs, $accepts, $rejects, $challenges");
603
604
// calculate the main return values that this test yielded
605
606
        $finalretval = RADIUSTests::RETVAL_INVALID;
607
        if ($accepts + $rejects == 0) { // no final response. hm.
608
            if ($challenges > 0) { // but there was an Access-Challenge
609
                $finalretval = RADIUSTests::RETVAL_SERVER_UNFINISHED_COMM;
610
            } else {
611
                $finalretval = RADIUSTests::RETVAL_NO_RESPONSE;
612
            }
613
        } else // either an accept or a reject
614
// rejection without EAP is fishy
615
        if ($rejects > 0) {
616
            if ($challenges == 0) {
617
                $finalretval = RADIUSTests::RETVAL_IMMEDIATE_REJECT;
618
            } else { // i.e. if rejected with challenges
619
                $finalretval = RADIUSTests::RETVAL_CONVERSATION_REJECT;
620
            }
621
        } else if ($accepts > 0) {
622
            $finalretval = RADIUSTests::RETVAL_OK;
623
        }
624
625
        return $finalretval;
626
    }
627
628
    /**
629
     * generate an eapol_test command-line config for the fixed config filename 
630
     * ./udp_login_test.conf
631
     * @param int     $probeindex number of the probe to check against
632
     * @param boolean $opName     include Operator-Name in request?
633
     * @param boolean $frag       make request so large that fragmentation is needed?
634
     * @return string the command-line for eapol_test
635
     */
636
    private function eapolTestConfig($probeindex, $opName, $frag)
637
    {
638
        $cmdline = CONFIG_DIAGNOSTICS['PATHS']['eapol_test'] .
639
                " -a " . CONFIG_DIAGNOSTICS['RADIUSTESTS']['UDP-hosts'][$probeindex]['ip'] .
640
                " -s " . CONFIG_DIAGNOSTICS['RADIUSTESTS']['UDP-hosts'][$probeindex]['secret'] .
641
                " -o serverchain.pem" .
642
                " -c ./udp_login_test.conf" .
643
                " -M 22:44:66:CA:20:" . sprintf("%02d", $probeindex) . " " .
644
                " -t " . CONFIG_DIAGNOSTICS['RADIUSTESTS']['UDP-hosts'][$probeindex]['timeout'] . " ";
645
        if ($opName) {
646
            $cmdline .= '-N126:s:"1cat.eduroam.org" ';
647
        }
648
        if ($frag) {
649
            for ($i = 0; $i < 6; $i++) { // 6 x 250 bytes means UDP fragmentation will occur - good!
650
                $cmdline .= '-N26:x:0000625A0BF961616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161 ';
651
            }
652
        }
653
        return $cmdline;
654
    }
655
656
    /**
657
     * collects CA certificates, both from the incoming EAP chain and from CAT
658
     * config. Writes the root CAs into a trusted root CA dir and intermediate 
659
     * and first server cert into a PEM file for later chain validation
660
     * 
661
     * @param string $tmpDir              working directory
662
     * @param array  $intermOdditiesCAT   by-reference array of already found 
663
     *                                    oddities; adds its own
664
     * @param array  $servercert          the servercert to validate
665
     * @param array  $eapIntermediates    list of intermediate CA certs that came
666
     *                                    in via EAP
667
     * @param array  $eapIntermediateCRLs list of CRLs for the EAP-supplied
668
     *                                    intermediate CAs
669
     * @return string
670
     * @throws Exception
671
     */
672
    private function createCArepository($tmpDir, &$intermOdditiesCAT, $servercert, $eapIntermediates, $eapIntermediateCRLs)
673
    {
674
        if (!mkdir($tmpDir . "/root-ca-allcerts/", 0700, true)) {
675
            throw new Exception("unable to create root CA directory (RADIUS Tests): $tmpDir/root-ca-allcerts/\n");
676
        }
677
        if (!mkdir($tmpDir . "/root-ca-eaponly/", 0700, true)) {
678
            throw new Exception("unable to create root CA directory (RADIUS Tests): $tmpDir/root-ca-eaponly/\n");
679
        }
680
// make a copy of the EAP-received chain and add the configured intermediates, if any
681
        $catIntermediates = [];
682
        $catRoots = [];
683
        foreach ($this->expectedCABundle as $oneCA) {
684
            $x509 = new \core\common\X509();
685
            $decoded = $x509->processCertificate($oneCA);
686
            if (is_bool($decoded)) {
687
                throw new Exception("Unable to parse an expected CA certificate.");
688
            }
689
            if ($decoded['ca'] == 1) {
690
                if ($decoded['root'] == 1) { // save CAT roots to the root directory
691
                    file_put_contents($tmpDir . "/root-ca-eaponly/configuredroot" . count($catRoots) . ".pem", $decoded['pem']);
692
                    file_put_contents($tmpDir . "/root-ca-allcerts/configuredroot" . count($catRoots) . ".pem", $decoded['pem']);
693
                    $catRoots[] = $decoded['pem'];
694
                } else { // save the intermediates to allcerts directory
695
                    file_put_contents($tmpDir . "/root-ca-allcerts/cat-intermediate" . count($catIntermediates) . ".pem", $decoded['pem']);
696
                    $intermOdditiesCAT = array_merge($intermOdditiesCAT, $this->propertyCheckIntermediate($decoded));
697
                    if (isset($decoded['CRL']) && isset($decoded['CRL'][0])) {
698
                        $this->loggerInstance->debug(4, "got an intermediate CRL; adding them to the chain checks. (Remember: checking end-entity cert only, not the whole chain");
699
                        file_put_contents($tmpDir . "/root-ca-allcerts/crl_cat" . count($catIntermediates) . ".pem", $decoded['CRL'][0]);
700
                    }
701
                    $catIntermediates[] = $decoded['pem'];
702
                }
703
            }
704
        }
705
        // save all intermediate certificates and CRLs to separate files in 
706
        // both root-ca directories
707
        foreach ($eapIntermediates as $index => $onePem) {
708
            file_put_contents($tmpDir . "/root-ca-eaponly/intermediate$index.pem", $onePem);
709
            file_put_contents($tmpDir . "/root-ca-allcerts/intermediate$index.pem", $onePem);
710
        }
711
        foreach ($eapIntermediateCRLs as $index => $onePem) {
712
            file_put_contents($tmpDir . "/root-ca-eaponly/intermediateCRL$index.pem", $onePem);
713
            file_put_contents($tmpDir . "/root-ca-allcerts/intermediateCRL$index.pem", $onePem);
714
        }
715
716
        $checkstring = "";
717
        if (isset($servercert['CRL']) && isset($servercert['CRL'][0])) {
718
            $this->loggerInstance->debug(4, "got a server CRL; adding them to the chain checks. (Remember: checking end-entity cert only, not the whole chain");
719
            $checkstring = "-crl_check_all";
720
            file_put_contents($tmpDir . "/root-ca-eaponly/crl-server.pem", $servercert['CRL'][0]);
721
            file_put_contents($tmpDir . "/root-ca-allcerts/crl-server.pem", $servercert['CRL'][0]);
722
        }
723
724
725
// now c_rehash the root CA directory ...
726
        system(CONFIG_DIAGNOSTICS['PATHS']['c_rehash'] . " $tmpDir/root-ca-eaponly/ > /dev/null");
727
        system(CONFIG_DIAGNOSTICS['PATHS']['c_rehash'] . " $tmpDir/root-ca-allcerts/ > /dev/null");
728
        return $checkstring;
729
    }
730
731
    /**
732
     * for checks which have a known trust root CA (i.e. a valid CAT profile 
733
     * exists), check against those known-good roots
734
     * 
735
     * @param array  $testresults         by-reference list of testresults so far
736
     *                                    Function adds its own.
737
     * @param array  $intermOdditiesCAT   by-reference list of oddities in the CA
738
     *                                    certs which are configured in CAT
739
     * @param string $tmpDir              working directory
740
     * @param array  $servercert          the server certificate to validate
741
     * @param array  $eapIntermediates    list of intermediate CA certs that came
742
     *                                    in via EAP
743
     * @param array  $eapIntermediateCRLs list of CRLs for the EAP-supplied
744
     *                                    intermediate CAs
745
     * @return int
746
     * @throws Exception
747
     */
748
    private function thoroughChainChecks(&$testresults, &$intermOdditiesCAT, $tmpDir, $servercert, $eapIntermediates, $eapIntermediateCRLs)
749
    {
750
751
        $crlCheckString = $this->createCArepository($tmpDir, $intermOdditiesCAT, $servercert, $eapIntermediates, $eapIntermediateCRLs);
752
753
// ... and run the verification test
754
        $verifyResultEaponly = [];
755
        $verifyResultAllcerts = [];
756
// the error log will complain if we run this test against an empty file of certs
757
// so test if there's something PEMy in the file at all
758
        if (filesize("$tmpDir/serverchain.pem") > 10) {
759
            exec(CONFIG['PATHS']['openssl'] . " verify $crlCheckString -CApath $tmpDir/root-ca-eaponly/ -purpose any $tmpDir/incomingserver.pem", $verifyResultEaponly);
760
            $this->loggerInstance->debug(4, CONFIG['PATHS']['openssl'] . " verify $crlCheckString -CApath $tmpDir/root-ca-eaponly/ -purpose any $tmpDir/serverchain.pem\n");
761
            $this->loggerInstance->debug(4, "Chain verify pass 1: " . print_r($verifyResultEaponly, TRUE) . "\n");
762
            exec(CONFIG['PATHS']['openssl'] . " verify $crlCheckString -CApath $tmpDir/root-ca-allcerts/ -purpose any $tmpDir/incomingserver.pem", $verifyResultAllcerts);
763
            $this->loggerInstance->debug(4, CONFIG['PATHS']['openssl'] . " verify $crlCheckString -CApath $tmpDir/root-ca-allcerts/ -purpose any $tmpDir/serverchain.pem\n");
764
            $this->loggerInstance->debug(4, "Chain verify pass 2: " . print_r($verifyResultAllcerts, TRUE) . "\n");
765
        }
766
767
768
// now we do certificate verification against the collected parents
769
// this is done first for the server and then for each of the intermediate CAs
770
// any oddities observed will 
771
// openssl should havd returned exactly one line of output,
772
// and it should have ended with the string "OK", anything else is fishy
773
// The result can also be an empty array - this means there were no
774
// certificates to check. Don't complain about chain validation errors
775
// in that case.
776
// we have the following test result possibilities:
777
// 1. test against allcerts failed
778
// 2. test against allcerts succeded, but against eaponly failed - warn admin
779
// 3. test against eaponly succeded, in this case critical errors about expired certs
780
//    need to be changed to notices, since these certs obviously do tot participate
781
//    in server certificate validation.
782
        if (count($verifyResultAllcerts) == 0 || count($verifyResultEaponly) == 0) {
783
            throw new Exception("No output at all from openssl?");
784
        }
785
        if (!preg_match("/OK$/", $verifyResultAllcerts[0])) { // case 1
786
            if (preg_match("/certificate revoked$/", $verifyResultAllcerts[1])) {
787
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED;
788
            } elseif (preg_match("/unable to get certificate CRL/", $verifyResultAllcerts[1])) {
789
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL;
790
            } else {
791
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED;
792
            }
793
            return 1;
794
        }
795
        if (!preg_match("/OK$/", $verifyResultEaponly[0])) { // case 2
796
            if (preg_match("/certificate revoked$/", $verifyResultEaponly[1])) {
797
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED;
798
            } elseif (preg_match("/unable to get certificate CRL/", $verifyResultEaponly[1])) {
799
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL;
800
            } else {
801
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES;
802
            }
803
            return 2;
804
        }
805
        return 3;
806
    }
807
808
    /**
809
     * check the incoming hostname (both Subject:CN and subjectAltName:DNS
810
     * against what is configured in the profile; it's a significant error
811
     * if there is no match!
812
     * 
813
     * FAIL if none of the configured names show up in the server cert
814
     * WARN if the configured name is only in either CN or sAN:DNS
815
     * 
816
     * @param array $servercert  the server certificate to check
817
     * @param array $testresults by-reference the existing testresults. Function
818
     *                           adds its own findings.
819
     * @return void
820
     */
821
    private function thoroughNameChecks($servercert, &$testresults)
822
    {
823
        // Strategy for checks: we are TOTALLY happy if any one of the
824
        // configured names shows up in both the CN and a sAN
825
        // This is the primary check.
826
        // If that was not the case, we are PARTIALLY happy if any one of
827
        // the configured names was in either of the CN or sAN lists.
828
        // we are UNHAPPY if no names match!
829
        $happiness = "UNHAPPY";
830
        foreach ($this->expectedServerNames as $expectedName) {
831
            $this->loggerInstance->debug(4, "Managing expectations for $expectedName: " . print_r($servercert['CN'], TRUE) . print_r($servercert['sAN_DNS'], TRUE));
832
            if (array_search($expectedName, $servercert['CN']) !== FALSE && array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) {
833
                $this->loggerInstance->debug(4, "Totally happy!");
834
                $happiness = "TOTALLY";
835
                break;
836
            } else {
837
                if (array_search($expectedName, $servercert['CN']) !== FALSE || array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) {
838
                    $happiness = "PARTIALLY";
839
// keep trying with other expected names! We could be happier!
840
                }
841
            }
842
        }
843
        switch ($happiness) {
844
            case "UNHAPPY":
845
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_MISMATCH;
846
                return;
847
            case "PARTIALLY":
848
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_PARTIAL_MATCH;
849
                return;
850
            default: // nothing to complain about!
851
                return;
852
        }
853
    }
854
855
    /**
856
     * run eapol_test
857
     * 
858
     * @param string  $tmpDir      working directory
859
     * @param int     $probeindex  number of the probe this test should run through
860
     * @param array   $eaptype     EAP type in array representation
861
     * @param string  $innerUser   EAP method inner username to use
862
     * @param string  $password    password to use
863
     * @param boolean $opnameCheck inject Operator-Name?
864
     * @param boolean $frag        provoke UDP fragmentation?
865
     * @return array timing information of the executed eapol_test run
866
     * @throws Exception
867
     */
868
    private function executeEapolTest($tmpDir, $probeindex, $eaptype, $innerUser, $password, $opnameCheck, $frag)
869
    {
870
        $finalInner = $innerUser;
871
        $finalOuter = $this->outerUsernameForChecks;
872
873
        $theconfigs = $this->wpaSupplicantConfig($eaptype, $finalInner, $finalOuter, $password);
874
        // the config intentionally does not include CA checking. We do this
875
        // ourselves after getting the chain with -o.
876
        file_put_contents($tmpDir . "/udp_login_test.conf", $theconfigs[0]);
877
878
        $cmdline = $this->eapolTestConfig($probeindex, $opnameCheck, $frag);
879
        $this->loggerInstance->debug(4, "Shallow reachability check cmdline: $cmdline\n");
880
        $this->loggerInstance->debug(4, "Shallow reachability check config: $tmpDir\n" . $theconfigs[1] . "\n");
881
        $time_start = microtime(true);
882
        $pflow = [];
883
        exec($cmdline, $pflow);
884
        if ($pflow === NULL) {
885
            throw new Exception("The output of an exec() call really can't be NULL!");
886
        }
887
        $time_stop = microtime(true);
888
        $this->loggerInstance->debug(5, print_r($this->redact($password, $pflow), TRUE));
889
        return [
890
            "time" => ($time_stop - $time_start) * 1000,
891
            "output" => $pflow,
892
        ];
893
    }
894
895
    /**
896
     * checks if the RADIUS packets were coming in in the order they are 
897
     * expected. The function massages the raw result for some known oddities.
898
     * 
899
     * @param array $testresults     by-reference array of test results so far.
900
     *                               function adds its own.
901
     * @param array $packetflow_orig original flow of packets
902
     * @return int
903
     */
904
    private function checkRadiusPacketFlow(&$testresults, $packetflow_orig)
905
    {
906
907
        $packetflow = $this->filterPackettype($packetflow_orig);
908
909
910
// when MS-CHAPv2 allows retry, we never formally get a reject (just a 
911
// Challenge that PW was wrong but and we should try a different one; 
912
// but that effectively is a reject
913
// so change the flow results to take that into account
914
        if ($packetflow[count($packetflow) - 1] == 11 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_691)) {
915
            $packetflow[count($packetflow) - 1] = 3;
916
        }
917
// also, the ETLRs sometimes send a reject when the server is not 
918
// responding. This should not be considered a real reject; it's a middle
919
// box unduly altering the end-to-end result. Do not consider this final
920
// Reject if it comes from ETLR
921
        if ($packetflow[count($packetflow) - 1] == 3 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_REJECTIGNORE)) {
922
            array_pop($packetflow);
923
        }
924
        $this->loggerInstance->debug(5, "Packetflow: " . print_r($packetflow, TRUE));
925
        $packetcount = array_count_values($packetflow);
926
        $testresults['packetcount'] = $packetcount;
927
        $testresults['packetflow'] = $packetflow;
928
929
// calculate packet counts and see what the overall flow was
930
        return $this->packetCountEvaluation($testresults, $packetcount);
931
    }
932
933
    /**
934
     * parses the eapol_test output to determine whether we got to a point where
935
     * an EAP type was mutually agreed
936
     * 
937
     * @param array $testresults     by-reference, we add our findings if 
938
     *                               something is noteworthy
939
     * @param array $packetflow_orig the array of text output from eapol_test
940
     * @return bool
941
     */
942
    private function wasEapTypeNegotiated(&$testresults, $packetflow_orig)
943
    {
944
        $negotiatedEapType = $this->checkLineparse($packetflow_orig, self::LINEPARSE_EAPACK);
945
        if (!$negotiatedEapType) {
946
            $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_COMMON_EAP_METHOD;
947
        }
948
949
        return $negotiatedEapType;
950
    }
951
952
    private function wasModernTlsNegotiated(&$testresults, $packetflow_orig)
0 ignored issues
show
Coding Style introduced by
Missing function doc comment
Loading history...
953
    {
954
        $negotiatedTlsVersion = $this->checkLineparse($packetflow_orig, self::LINEPARSE_TLSVERSION);
955
        $this->loggerInstance->debug(4,"TLS version found is: $negotiatedTlsVersion"."\n");
956
        if ($negotiatedTlsVersion == FALSE) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
957
            $testresults['cert_oddities'][] = RADIUSTests::TLSPROB_UNKNOWN_TLS_VERSION;
958
        } elseif ($negotiatedTlsVersion != self::TLS_VERSION_1_2 && $negotiatedTlsVersion != self::TLS_VERSION_1_3) {
959
            $testresults['cert_oddities'][] = RADIUSTests::TLSPROB_DEPRECATED_TLS_VERSION;
960
        }
961
962
        return $negotiatedTlsVersion;
963
    }
964
965
    const SERVER_NO_CA_EXTENSION = 1;
966
    const SERVER_CA_SELFSIGNED = 2;
967
    const CA_INTERMEDIATE = 3;
968
    const CA_ROOT = 4;
969
970
    /**
971
     * what is the incoming certificate - root, intermediate, or server?
972
     * @param array $cert           the certificate to check
973
     * @param int   $totalCertCount number of certs in total in chain
974
     * @return int
975
     */
976
    private function determineCertificateType(&$cert, $totalCertCount)
977
    {
978
        if ($cert['ca'] == 0 && $cert['root'] == 0) {
979
            return RADIUSTests::SERVER_NO_CA_EXTENSION;
980
        }
981
        if ($cert['ca'] == 1 && $cert['root'] == 1) {
982
            if ($totalCertCount == 1) {
983
                $cert['full_details']['type'] = 'totally_selfsigned';
984
                return RADIUSTests::SERVER_CA_SELFSIGNED;
985
            } else {
986
                return RADIUSTests::CA_ROOT;
987
            }
988
        }
989
        return RADIUSTests::CA_INTERMEDIATE;
990
    }
991
992
    /**
993
     * pull out the certificates that were sent during the EAP conversation
994
     * 
995
     * @param array  $testresults by-reference, add our findings if any
996
     * @param string $tmpDir      working directory
997
     * @return array|FALSE an array with all the certs, CRLs and oddities, or FALSE if the EAP conversation did not yield a certificate at all
998
     * @throws Exception
999
     */
1000
    private function extractIncomingCertsfromEAP(&$testresults, $tmpDir)
1001
    {
1002
1003
        /*
1004
         *  EAP's house rules:
1005
         * 1) it is unnecessary to include the root CA itself (adding it has
1006
         *    detrimental effects on performance)
1007
         * 2) TLS Web Server OID presence (Windows OSes need that)
1008
         * 3) MD5 signature algorithm disallowed (iOS barks if so)
1009
         * 4) CDP URL (Windows Phone 8 barks if not present)
1010
         * 5) there should be exactly one server cert in the chain
1011
         */
1012
1013
        $x509 = new \core\common\X509();
1014
// $eap_certarray holds all certs received in EAP conversation
1015
        $incomingData = file_get_contents($tmpDir . "/serverchain.pem");
1016
        if ($incomingData !== FALSE && strlen($incomingData) > 0) {
1017
            $eapCertArray = $x509->splitCertificate($incomingData);
1018
        } else {
1019
            $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_CERTIFICATE_IN_CONVERSATION;
1020
            return FALSE;
1021
        }
1022
        $eapIntermediates = [];
1023
        $eapIntermediateCRLs = [];
1024
        $servercert = [];
1025
        $intermOdditiesEAP = [];
1026
1027
        $testresults['certdata'] = [];
1028
1029
1030
        foreach ($eapCertArray as $certPem) {
1031
            $cert = $x509->processCertificate($certPem);
1032
            if ($cert === FALSE) {
1033
                continue;
1034
            }
1035
// consider the certificate a server cert 
1036
// a) if it is not a CA and is not a self-signed root
1037
// b) if it is a CA, and self-signed, and it is the only cert in
1038
//    the incoming cert chain
1039
//    (meaning the self-signed is itself the server cert)
1040
            switch ($this->determineCertificateType($cert, count($eapCertArray))) {
1041
                case RADIUSTests::SERVER_NO_CA_EXTENSION: // both are handled same, fall-through
1042
                case RADIUSTests::SERVER_CA_SELFSIGNED:
1043
                    $servercert[] = $cert;
1044
                    if (count($servercert) == 1) {
1045
                        if (file_put_contents($tmpDir . "/incomingserver.pem", $certPem . "\n") === FALSE) {
1046
                            $this->loggerInstance->debug(4, "The (first) server certificate could not be written to $tmpDir/incomingserver.pem!\n");
1047
                        }
1048
                        $this->loggerInstance->debug(4, "This is the (first) server certificate, with CRL content if applicable: " . print_r($servercert[0], true));
1049
                    } elseif (!in_array(RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS, $testresults['cert_oddities'])) {
1050
                        $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS;
1051
                    }
1052
                    break;
1053
                case RADIUSTests::CA_ROOT:
1054
                    if (!in_array(RADIUSTests::CERTPROB_ROOT_INCLUDED, $testresults['cert_oddities'])) {
1055
                        $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_ROOT_INCLUDED;
1056
                    }
1057
// do not save the root CA, it serves no purpose
1058
// chain checks need to be against the UPLOADED CA of the
1059
// IdP/profile, not against an EAP-discovered CA
1060
                    break;
1061
                case RADIUSTests::CA_INTERMEDIATE:
1062
                    $intermOdditiesEAP = array_merge($intermOdditiesEAP, $this->propertyCheckIntermediate($cert));
1063
                    $eapIntermediates[] = $certPem;
1064
1065
                    if (isset($cert['CRL']) && isset($cert['CRL'][0])) {
1066
                        $eapIntermediateCRLs[] = $cert['CRL'][0];
1067
                    }
1068
                    break;
1069
                default:
1070
                    throw new Exception("Status of certificate could not be determined!");
1071
            }
1072
            $testresults['certdata'][] = $cert['full_details'];
1073
        }
1074
        switch (count($servercert)) {
1075
            case 0:
1076
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_SERVER_CERT;
1077
                break;
1078
            default:
1079
// check (first) server cert's properties
1080
                $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $this->propertyCheckServercert($servercert[0]));
1081
                $testresults['incoming_server_names'] = $servercert[0]['incoming_server_names'];
1082
        }
1083
        return [
1084
            "SERVERCERT" => $servercert,
1085
            "INTERMEDIATE_CA" => $eapIntermediates,
1086
            "INTERMEDIATE_CRL" => $eapIntermediateCRLs,
1087
            "INTERMEDIATE_OBSERVED_ODDITIES" => $intermOdditiesEAP,
1088
        ];
1089
    }
1090
1091
    /**
1092
     * The big Guy. This performs an actual login with EAP and records how far 
1093
     * it got and what oddities were observed along the way
1094
     * @param int     $probeindex     the probe we are connecting to (as set in product config)
1095
     * @param array   $eaptype        EAP type to use for connection
1096
     * @param string  $innerUser      inner username to try
1097
     * @param string  $password       password to try
1098
     * @param boolean $opnameCheck    whether or not we check with Operator-Name set
1099
     * @param boolean $frag           whether or not we check with an oversized packet forcing fragmentation
1100
     * @param string  $clientcertdata client certificate credential to try
1101
     * @return int overall return code of the login test
1102
     * @throws Exception
1103
     */
1104
    public function udpLogin($probeindex, $eaptype, $innerUser, $password, $opnameCheck = TRUE, $frag = TRUE, $clientcertdata = NULL)
1105
    {
1106
        /** preliminaries */
1107
        $eapText = \core\common\EAP::eapDisplayName($eaptype);
1108
        // no host to send probes to? Nothing to do then
1109
        if (!isset(CONFIG_DIAGNOSTICS['RADIUSTESTS']['UDP-hosts'][$probeindex])) {
1110
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1111
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1112
        }
1113
        // if we need client certs but don't have one, return
1114
        if (($eaptype == \core\common\EAP::EAPTYPE_ANY || $eaptype == \core\common\EAP::EAPTYPE_TLS) && $clientcertdata === NULL) {
1115
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1116
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1117
        }
1118
        // if we don't have a string for outer EAP method name, give up
1119
        if (!isset($eapText['OUTER'])) {
1120
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1121
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1122
        }
1123
        // we will need a config blob for wpa_supplicant, in a temporary directory
1124
        $temporary = $this->createTemporaryDirectory('test');
1125
        $tmpDir = $temporary['dir'];
1126
        chdir($tmpDir);
1127
        $this->loggerInstance->debug(4, "temp dir: $tmpDir\n");
1128
        if ($clientcertdata !== NULL) {
1129
            file_put_contents($tmpDir . "/client.p12", $clientcertdata);
1130
        }
1131
        $testresults = [];
1132
        // initialise the sub-array for cleaner parsing
1133
        $testresults['cert_oddities'] = [];
1134
        // execute RADIUS/EAP converation
1135
        $runtime_results = $this->executeEapolTest($tmpDir, $probeindex, $eaptype, $innerUser, $password, $opnameCheck, $frag);
1136
        $testresults['time_millisec'] = $runtime_results['time'];
1137
        $packetflow_orig = $runtime_results['output'];
1138
        $radiusResult = $this->checkRadiusPacketFlow($testresults, $packetflow_orig);
1139
        $negotiatedEapType = $this->wasEapTypeNegotiated($testresults, $packetflow_orig);
1140
        $testresults['negotiated_eaptype'] = $negotiatedEapType;
1141
        $negotiatedTlsVersion = $this->wasModernTlsNegotiated($testresults, $packetflow_orig);
1142
        $testresults['tls_version_eap'] = $negotiatedTlsVersion;
1143
        // now let's look at the server cert+chain, if we got a cert at all
1144
        // that's not the case if we do EAP-pwd or could not negotiate an EAP method at
1145
        // all
1146
        if (
1147
                $eaptype != \core\common\EAP::EAPTYPE_PWD &&
1148
                (($radiusResult == RADIUSTests::RETVAL_CONVERSATION_REJECT && $negotiatedEapType) || $radiusResult == RADIUSTests::RETVAL_OK)
1149
        ) {
1150
            $bundle = $this->extractIncomingCertsfromEAP($testresults, $tmpDir);
1151
// FOR OWN REALMS check:
1152
// 1) does the incoming chain have a root in one of the configured roots
1153
//    if not, this is a signficant configuration error
1154
// return this with one or more of the CERTPROB_ constants (see defs)
1155
// TRUST_ROOT_NOT_REACHED
1156
// TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES
1157
// then check the presented names
1158
// check intermediate ca cert properties
1159
// check trust chain for completeness
1160
// works only for thorough checks, not shallow, so:
1161
            $intermOdditiesCAT = [];
1162
            $verifyResult = 0;
1163
1164
            if ($this->opMode == self::RADIUS_TEST_OPERATION_MODE_THOROUGH && $bundle !== FALSE) {
1165
                $verifyResult = $this->thoroughChainChecks($testresults, $intermOdditiesCAT, $tmpDir, $bundle["SERVERCERT"], $bundle["INTERMEDIATE_CA"], $bundle["INTERMEDIATE_CRL"]);
1166
                $this->thoroughNameChecks($bundle["SERVERCERT"][0], $testresults);
1167
            }
1168
1169
            $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $bundle["INTERMEDIATE_OBSERVED_ODDITIES"] ?? []);
1170
            if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT) && $verifyResult == 3) {
1171
                $key = array_search(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT);
1172
                $intermOdditiesCAT[$key] = RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD_WARN;
1173
            }
1174
1175
            $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $intermOdditiesCAT);
1176
1177
// mention trust chain failure only if no expired cert was in the chain; otherwise path validation will trivially fail
1178
            if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $testresults['cert_oddities'])) {
1179
                $this->loggerInstance->debug(4, "Deleting trust chain problem report, if present.");
1180
                if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED, $testresults['cert_oddities'])) !== false) {
1181
                    unset($testresults['cert_oddities'][$key]);
1182
                }
1183
                if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES, $testresults['cert_oddities'])) !== false) {
1184
                    unset($testresults['cert_oddities'][$key]);
1185
                }
1186
            }
1187
        }
1188
        $this->UDP_reachability_result[$probeindex] = $testresults;
1189
        $this->UDP_reachability_executed = $radiusResult;
1190
        return $radiusResult;
1191
    }
1192
1193
    /**
1194
     * sets the outer identity to use in the checks
1195
     * 
1196
     * @param string $id the outer ID to use
1197
     * @return void
1198
     */
1199
    public function setOuterIdentity($id)
1200
    {
1201
        $this->outerUsernameForChecks = $id;
1202
    }
1203
1204
    /**
1205
     * pull together all sub tests into a cohesive test result
1206
     * @param int $host index of the probe for which the results are collated
1207
     * @return array
1208
     */
1209
    public function consolidateUdpResult($host)
1210
    {
1211
        \core\common\Entity::intoThePotatoes();
1212
        $ret = [];
1213
        $serverCert = [];
1214
        $udpResult = $this->UDP_reachability_result[$host];
1215
        if (isset($udpResult['certdata']) && count($udpResult['certdata'])) {
1216
            foreach ($udpResult['certdata'] as $certdata) {
1217
                if ($certdata['type'] != 'server' && $certdata['type'] != 'totally_selfsigned') {
1218
                    continue;
1219
                }
1220
                if (isset($certdata['extensions'])) {
1221
                    foreach ($certdata['extensions'] as $k => $v) {
1222
                        $certdata['extensions'][$k] = iconv('UTF-8', 'UTF-8//IGNORE', $certdata['extensions'][$k]);
1223
                    }
1224
                }
1225
                $serverCert = [
1226
                    'subject' => $this->printDN($certdata['subject']),
1227
                    'issuer' => $this->printDN($certdata['issuer']),
1228
                    'validFrom' => $this->printTm($certdata['validFrom_time_t']),
1229
                    'validTo' => $this->printTm($certdata['validTo_time_t']),
1230
                    'serialNumber' => $certdata['serialNumber'] . sprintf(" (0x%X)", $certdata['serialNumber']),
1231
                    'sha1' => $certdata['sha1'],
1232
                    'extensions' => $certdata['extensions']
1233
                ];
1234
            }
1235
        }
1236
        $ret['server_cert'] = $serverCert;
1237
        $ret['server'] = 0;
1238
        if (isset($udpResult['incoming_server_names'][0])) {
1239
            $ret['server'] = sprintf(_("Connected to %s."), $udpResult['incoming_server_names'][0]);
1240
        }
1241
        $ret['level'] = \core\common\Entity::L_OK;
1242
        $ret['time_millisec'] = sprintf("%d", $udpResult['time_millisec']);
1243
        if (empty($udpResult['cert_oddities'])) {
1244
            $ret['message'] = _("<strong>Test successful</strong>: a bidirectional RADIUS conversation with multiple round-trips was carried out, and ended in an Access-Reject as planned.");
1245
            \core\common\Entity::outOfThePotatoes();
1246
            return $ret;
1247
        }
1248
1249
        $ret['message'] = _("<strong>Test partially successful</strong>: a bidirectional RADIUS conversation with multiple round-trips was carried out, and ended in an Access-Reject as planned. Some properties of the connection attempt were sub-optimal; the list is below.");
1250
        $ret['cert_oddities'] = [];
1251
        foreach ($udpResult['cert_oddities'] as $oddity) {
1252
            $o = [];
1253
            $o['code'] = $oddity;
1254
            $o['message'] = isset($this->returnCodes[$oddity]["message"]) && $this->returnCodes[$oddity]["message"] ? $this->returnCodes[$oddity]["message"] : $oddity;
1255
            $o['level'] = $this->returnCodes[$oddity]["severity"];
1256
            $ret['level'] = max($ret['level'], $this->returnCodes[$oddity]["severity"]);
1257
            $ret['cert_oddities'][] = $o;
1258
        }
1259
        \core\common\Entity::outOfThePotatoes();
1260
        return $ret;
1261
    }
1262
1263
}
1264