RADIUSTests::executeEapolTest()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 24
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
c 0
b 0
f 0
dl 0
loc 24
rs 9.6333
cc 2
nc 2
nop 8

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
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;
0 ignored issues
show
Bug introduced by
The type \Exception was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
37
38
/**
39
 * Test suite to verify that an EAP setup is actually working as advertised in
40
 * the real world. Can only be used if \config\Diagnostics::RADIUSTESTS is configured.
41
 *
42
 * @author Stefan Winter <[email protected]>
43
 * @author Tomasz Wolniewicz <[email protected]>
44
 *
45
 * @license see LICENSE file in root directory
46
 *
47
 * @package Developer
48
 */
49
class RADIUSTests extends AbstractTest {
50
51
    /**
52
     * Was the reachability check executed already?
53
     * 
54
     * @var integer
55
     */
56
    private $UDP_reachability_executed;
57
58
    /**
59
     * the issues we found
60
     * 
61
     * @var array 
62
     */
63
    private $errorlist;
64
65
    /**
66
     * This private variable contains the realm to be checked. Is filled in the
67
     * class constructor.
68
     * 
69
     * @var string
70
     */
71
    private $realm;
72
73
    /**
74
     * which username to use as outer identity
75
     * 
76
     * @var string
77
     */
78
    private $outerUsernameForChecks;
79
80
    /**
81
     * list of CAs we expect incoming server certs to be from
82
     * 
83
     * @var array
84
     */
85
    private $expectedCABundle;
86
87
    /**
88
     * list of expected server names
89
     * 
90
     * @var array
91
     */
92
    private $expectedServerNames;
93
94
    /**
95
     * the list of EAP types which the IdP allegedly supports.
96
     * 
97
     * @var array
98
     */
99
    private $supportedEapTypes;
100
101
    /**
102
     * Do we run thorough or shallow checks?
103
     * 
104
     * @var integer
105
     */
106
    private $opMode;
107
108
    /**
109
     * result of the reachability tests
110
     * 
111
     * @var array
112
     */
113
    public $UDP_reachability_result;
114
115
    const RADIUS_TEST_OPERATION_MODE_SHALLOW = 1;
116
    const RADIUS_TEST_OPERATION_MODE_THOROUGH = 2;
117
118
    /**
119
     * Constructor for the EAPTests class. The single mandatory parameter is the
120
     * realm for which the tests are to be carried out.
121
     * 
122
     * @param string $realm                  the realm to check
123
     * @param string $outerUsernameForChecks outer username to use
124
     * @param array  $supportedEapTypes      array of integer representations of EAP types
125
     * @param array  $expectedServerNames    array of strings
126
     * @param array  $expectedCABundle       array of PEM blocks
127
     * @throws Exception
128
     */
129
    public function __construct($realm, $outerUsernameForChecks, $supportedEapTypes = [], $expectedServerNames = [], $expectedCABundle = []) {
130
        parent::__construct();   
131
        $this->realm = $realm;
132
        $this->outerUsernameForChecks = $outerUsernameForChecks;
133
        $this->expectedCABundle = $expectedCABundle;
134
        $this->expectedServerNames = $expectedServerNames;
135
        $this->supportedEapTypes = $supportedEapTypes;
136
137
        $this->opMode = self::RADIUS_TEST_OPERATION_MODE_SHALLOW;
138
139
        $caNeeded = FALSE;
140
        $serverNeeded = FALSE;
141
        foreach ($supportedEapTypes as $oneEapType) {
142
            if ($oneEapType->needsServerCACert()) {
143
                $caNeeded = TRUE;
144
            }
145
            if ($oneEapType->needsServerName()) {
146
                $serverNeeded = TRUE;
147
            }
148
        }
149
150
        if ($caNeeded) {
151
            // we need to have info about at least one CA cert and server names
152
            if (count($this->expectedCABundle) == 0) {
153
                throw new Exception("Thorough checks for an EAP type needing CAs were requested, but the required parameters were not given.");
154
            } else {
155
                $this->opMode = self::RADIUS_TEST_OPERATION_MODE_THOROUGH;
156
            }
157
        }
158
159
        if ($serverNeeded) {
160
            if (count($this->expectedServerNames) == 0) {
161
                throw new Exception("Thorough checks for an EAP type needing server names were requested, but the required parameter was not given.");
162
            } else {
163
                $this->opMode = self::RADIUS_TEST_OPERATION_MODE_THOROUGH;
164
            }
165
        }
166
167
        $this->loggerInstance->debug(4, "RADIUSTests is in opMode " . $this->opMode . ", parameters were: $realm, $outerUsernameForChecks, " . /** @scrutinizer ignore-type */ print_r($supportedEapTypes, true));
168
        $this->loggerInstance->debug(4, /** @scrutinizer ignore-type */ print_r($expectedServerNames, true));
169
        $this->loggerInstance->debug(4, /** @scrutinizer ignore-type */ print_r($expectedCABundle, true));
170
171
        $this->UDP_reachability_result = [];
172
        $this->errorlist = [];
173
    }
174
175
    /**
176
     * creates a string with the DistinguishedName (comma-separated name=value fields)
177
     * 
178
     * @param array $distinguishedName the components of the DN
179
     * @return string
180
     */
181
    private function printDN($distinguishedName) {
182
        $out = '';
183
        foreach (array_reverse($distinguishedName) as $nameType => $nameValue) { // to give an example: "CN" => "some.host.example" 
184
            if (!is_array($nameValue)) { // single-valued: just a string
185
                $nameValue = ["$nameValue"]; // convert it to a multi-value attrib with just one value :-) for unified processing later on
186
            }
187
            foreach ($nameValue as $oneValue) {
188
                if ($out) {
189
                    $out .= ',';
190
                }
191
                $out .= "$nameType=$oneValue";
192
            }
193
        }
194
        return($out);
195
    }
196
197
    /**
198
     * prints a timestamp in gmdate formatting
199
     * 
200
     * @param int $time time in UNIX timestamp
201
     * @return string
202
     */
203
    private function printTm($time) {
204
        return(gmdate(\DateTime::COOKIE, $time));
205
    }
206
207
    /**
208
     * This function parses a X.509 server cert and checks if it finds client device incompatibilities
209
     * 
210
     * @param array $servercert the properties of the certificate as returned by
211
     *                          processCertificate(), $servercert is modified, 
212
     *                          if CRL is defied, it is downloaded and added to
213
     *                          the array incoming_server_names, sAN_DNS and CN 
214
     *                          array values are also defined
215
     * @return array of oddities; the array is empty if everything is fine
216
     */
217
    private function propertyCheckServercert(&$servercert) {
218
// we share the same checks as for CAs when it comes to signature algorithm and basicconstraints
219
// so call that function and memorise the outcome
220
        $returnarray = $this->propertyCheckIntermediate($servercert, TRUE);
221
        $sANdns = [];
222
        if (!isset($servercert['full_details']['extensions'])) {
223
            $returnarray[] = RADIUSTests::CERTPROB_NO_TLS_WEBSERVER_OID;
224
            $returnarray[] = RADIUSTests::CERTPROB_NO_CDP_HTTP;
225
        } else { // Extensions are present...
226
            if (!isset($servercert['full_details']['extensions']['extendedKeyUsage']) || !preg_match("/TLS Web Server Authentication/", $servercert['full_details']['extensions']['extendedKeyUsage'])) {
227
                $returnarray[] = RADIUSTests::CERTPROB_NO_TLS_WEBSERVER_OID;
228
            }
229
            if (isset($servercert['full_details']['extensions']['subjectAltName'])) {
230
                $sANlist = explode(", ", $servercert['full_details']['extensions']['subjectAltName']);
231
                foreach ($sANlist as $subjectAltName) {
232
                    if (preg_match("/^DNS:/", $subjectAltName)) {
233
                        $sANdns[] = substr($subjectAltName, 4);
234
                    }
235
                }
236
            }
237
        }
238
239
        // often, there is only one name, so we store it in an array of one member
240
        $commonName = [$servercert['full_details']['subject']['CN']];
241
        // if we got an array of names instead, then that is already an array, so override
242
        if (isset($servercert['full_details']['subject']['CN']) && is_array($servercert['full_details']['subject']['CN'])) {
243
            $commonName = $servercert['full_details']['subject']['CN'];
244
            $returnarray[] = RADIUSTests::CERTPROB_MULTIPLE_CN;
245
        }
246
        $allnames = array_values(array_unique(array_merge($commonName, $sANdns)));
247
// check for wildcards
248
// check for real hostnames, and whether there is a wildcard in a name
249
        foreach ($allnames as $onename) {
250
            if (preg_match("/\*/", $onename)) {
251
                $returnarray[] = RADIUSTests::CERTPROB_WILDCARD_IN_NAME;
252
                continue; // otherwise we'd ALSO complain that it's not a real hostname
253
            }
254
            if ($onename != "" && filter_var("foo@" . idn_to_ascii($onename), FILTER_VALIDATE_EMAIL) === FALSE) {
255
                $returnarray[] = RADIUSTests::CERTPROB_NOT_A_HOSTNAME;
256
            }
257
        }
258
        $servercert['incoming_server_names'] = $allnames;
259
        $servercert['sAN_DNS'] = $sANdns;
260
        $servercert['CN'] = $commonName;
261
        return $returnarray;
262
    }
263
264
    /**
265
     * This function parses a X.509 intermediate CA cert and checks if it finds client device incompatibilities
266
     * 
267
     * @param array   $intermediateCa the properties of the certificate as returned by processCertificate()
268
     * @param boolean $serverCert     treat as servercert?
269
     * @return array of oddities; the array is empty if everything is fine
270
     */
271
    private function propertyCheckIntermediate(&$intermediateCa, $serverCert = FALSE) {
272
        $returnarray = [];
273
        if (preg_match("/md5/i", $intermediateCa['full_details']['signatureTypeSN'])) {
274
            $returnarray[] = RADIUSTests::CERTPROB_MD5_SIGNATURE;
275
        }
276
        if (preg_match("/sha1/i", $intermediateCa['full_details']['signatureTypeSN'])) {
277
            $probValue = RADIUSTests::CERTPROB_SHA1_SIGNATURE;
278
            $returnarray[] = $probValue;
279
        }
280
        $this->loggerInstance->debug(4, "CERT IS: " . /** @scrutinizer ignore-type */ print_r($intermediateCa, TRUE));
281
        if ($intermediateCa['basicconstraints_set'] == 0) {
282
            $returnarray[] = RADIUSTests::CERTPROB_NO_BASICCONSTRAINTS;
283
        }
284
        if ($intermediateCa['full_details']['public_key_algorithm'] == \core\common\X509::KNOWN_PUBLIC_KEY_ALGORITHMS[0] && $intermediateCa['full_details']['public_key_length'] < 2048) {
285
            $returnarray[] = RADIUSTests::CERTPROB_LOW_KEY_LENGTH;
286
        }
287
        if (!in_array($intermediateCa['full_details']['public_key_algorithm'], \core\common\X509::KNOWN_PUBLIC_KEY_ALGORITHMS)) {
288
            $returnarray[] = RADIUSTests::CERTPROB_UNKNOWN_PUBLIC_KEY_ALGORITHM;
289
        }
290
        $validFrom = $intermediateCa['full_details']['validFrom_time_t'];
291
        $now = time();
292
        $validTo = $intermediateCa['full_details']['validTo_time_t'];
293
        if ($validFrom > $now || $validTo < $now) {
294
            $returnarray[] = RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD;
295
        }
296
        $addCertCrlResult = $this->addCrltoCert($intermediateCa);
297
        if ($addCertCrlResult !== 0 && $serverCert) {
298
            $returnarray[] = $addCertCrlResult;
299
        }
300
301
        return $returnarray;
302
    }
303
304
    /**
305
     * This function returns an array of errors which were encountered in all the tests.
306
     * 
307
     * @return array all the errors
308
     */
309
    public function listerrors() {
310
        return $this->errorlist;
311
    }
312
313
    /**
314
     * This function performs actual authentication checks with MADE-UP credentials.
315
     * Its purpose is to check if a RADIUS server is reachable and speaks EAP.
316
     * The function fills array RADIUSTests::UDP_reachability_result[$probeindex] with all check detail
317
     * in case more than the return code is needed/wanted by the caller
318
     * 
319
     * @param int     $probeindex  refers to the specific UDP-host in the config that should be checked
320
     * @param boolean $opnameCheck should we check choking on Operator-Name?
321
     * @param boolean $frag        should we cause UDP fragmentation? (Warning: makes use of Operator-Name!)
322
     * @return integer returncode
323
     * @throws Exception
324
     */
325
    public function udpReachability($probeindex, $opnameCheck = TRUE, $frag = TRUE) {
326
        // for EAP-TLS to be a viable option, we need to pass a random client cert to make eapol_test happy
327
        // the following PEM data is one of the SENSE EAPLab client certs (not secret at all)
328
        $clientcert = file_get_contents(dirname(__FILE__) . "/clientcert.p12");
329
        if ($clientcert === FALSE) {
330
            throw new Exception("A dummy client cert is part of the source distribution, but could not be loaded!");
331
        }
332
        // if we are in thorough opMode, use our knowledge for a more clever check
333
        // otherwise guess
334
        if ($this->opMode == self::RADIUS_TEST_OPERATION_MODE_THOROUGH) {
335
            return $this->udpLogin($probeindex, $this->supportedEapTypes[0]->getArrayRep(), $this->outerUsernameForChecks, 'eaplab', $opnameCheck, $frag, $clientcert);
336
        }
337
        return $this->udpLogin($probeindex, \core\common\EAP::EAPTYPE_ANY, "cat-connectivity-test@" . $this->realm, 'eaplab', $opnameCheck, $frag, $clientcert);
338
    }
339
340
    /**
341
     * There is a CRL Distribution Point URL in the certificate. So download the
342
     * CRL and attach it to the cert structure so that we can later find out if
343
     * the cert was revoked
344
     * @param array $cert by-reference: the cert data we are writing into
345
     * @return integer result code whether we were successful in retrieving the CRL
346
     * @throws Exception
347
     */
348
    private function addCrltoCert(&$cert) {
349
        $crlUrl = [];
350
        $returnresult = 0;
351
        if (!isset($cert['full_details']['extensions']['crlDistributionPoints'])) {
352
            return RADIUSTests::CERTPROB_NO_CDP;
353
        }
354
        if (!preg_match("/^.*URI\:(http)(.*)$/", str_replace(["\r", "\n"], ' ', $cert['full_details']['extensions']['crlDistributionPoints']), $crlUrl)) {
355
            return RADIUSTests::CERTPROB_NO_CDP_HTTP;
356
        }
357
        // first and second sub-match is the full URL... check it
358
        $crlcontent = \core\common\OutsideComm::downloadFile(trim($crlUrl[1] . $crlUrl[2]), \config\Diagnostics::TIMEOUTS['crl_download']);
359
        if ($crlcontent === FALSE) {
360
            return RADIUSTests::CERTPROB_NO_CRL_AT_CDP_URL;
361
        }
362
        /* CRLs are always in DER form, so need encoding
363
         * note that what we ACTUALLY got can be arbitrary junk; we just deposit
364
         * it on the filesystem and let openssl figure out if it is usable or not
365
         *
366
         * Unfortunately, that freaks out Scrutinizer because we write unvetted
367
         * data to the filesystem. Let's see if we can make things better.
368
         */
369
370
        // $pem = chunk_split(base64_encode($crlcontent), 64, "\n");
371
        // inspired by https://stackoverflow.com/questions/2390604/how-to-pass-variables-as-stdin-into-command-line-from-php
372
373
        $proc = \config\Master::PATHS['openssl'] . " crl -inform der";
374
        $descriptorspec = [
375
            0 => ["pipe", "r"],
376
            1 => ["pipe", "w"],
377
            2 => ["pipe", "w"],
378
        ];
379
        $process = proc_open($proc, $descriptorspec, $pipes);
380
        if (!is_resource($process)) {
381
            throw new Exception("Unable to execute openssl cmdline for CRL conversion!");
382
        }
383
        fwrite($pipes[0], $crlcontent);
384
        fclose($pipes[0]);
385
        $pem = stream_get_contents($pipes[1]);
386
        fclose($pipes[1]);
387
        fclose($pipes[2]);
388
        $retval = proc_close($process);
389
        if ($retval != 0 || !preg_match("/BEGIN X509 CRL/", $pem)) {
390
            // this was not a real CRL
391
            return RADIUSTests::CERTPROB_NO_CRL_AT_CDP_URL;
392
        }
393
        $cert['CRL'] = [];
394
        $cert['CRL'][] = $pem;
395
        return $returnresult;
396
    }
397
398
    /**
399
     * We don't want to write passwords of the live login test to our logs. Filter them out
400
     * @param string $stringToRedact what should be redacted
401
     * @param array  $inputarray     array of strings (outputs of eapol_test command)
402
     * @return string[] the output of eapol_test with the password redacted
403
     */
404
    private function redact($stringToRedact, $inputarray) {
405
        $temparray = preg_replace("/^.*$stringToRedact.*$/", "LINE CONTAINING PASSWORD REDACTED", $inputarray);
406
        $hex = bin2hex($stringToRedact);
407
        $spaced = "";
408
        $origLength = strlen($hex);
409
        for ($i = 1; $i < $origLength; $i++) {
410
            if ($i % 2 == 1 && $i != strlen($hex)) {
411
                $spaced .= $hex[$i] . " ";
412
            } else {
413
                $spaced .= $hex[$i];
414
            }
415
        }
416
        return preg_replace("/$spaced/", " HEX ENCODED PASSWORD REDACTED ", $temparray);
417
    }
418
419
    /**
420
     * Filters eapol_test output and finds out the packet codes out of which the conversation was comprised of
421
     * 
422
     * @param array $inputarray array of strings (outputs of eapol_test command)
423
     * @return array the packet codes which were exchanged, in sequence
424
     */
425
    private function filterPackettype($inputarray) {
426
        $retarray = [];
427
        foreach ($inputarray as $line) {
428
            if (preg_match("/RADIUS message:/", $line)) {
429
                $linecomponents = explode(" ", $line);
430
                $packettypeExploded = explode("=", $linecomponents[2]);
431
                $packettype = $packettypeExploded[1];
432
                $retarray[] = $packettype;
433
            }
434
        }
435
        return $retarray;
436
    }
437
438
    const LINEPARSE_CHECK_REJECTIGNORE = 1;
439
    const LINEPARSE_CHECK_691 = 2;
440
    const LINEPARSE_EAPACK = 3;
441
    const LINEPARSE_TLSVERSION = 4;
442
    const TLS_VERSION_ANCIENT = "OTHER";
443
    const TLS_VERSION_1_0 = "TLSv1";
444
    const TLS_VERSION_1_1 = "TLSv1.1";
445
    const TLS_VERSION_1_2 = "TLSv1.2";
446
    const TLS_VERSION_1_3 = "TLSv1.3";
447
448
    /**
449
     * this function checks for various special conditions which can be found 
450
     * only by parsing eapol_test output line by line. Checks currently 
451
     * implemented are:
452
     * * if the ETLRs sent back an Access-Reject because there appeared to
453
     *   be a timeout further downstream
454
     * * did the server send an MSCHAP Error 691 - Retry Allowed in a Challenge
455
     *   instead of an outright reject?
456
     * * was an EAP method ever acknowledged by both sides during the EAP
457
     *   conversation
458
     * 
459
     * @param array   $inputarray   array of strings (outputs of eapol_test command)
460
     * @param integer $desiredCheck which test should be run (see constants above)
461
     * @return boolean|string returns TRUE if ETLR Reject logic was detected; FALSE if not; strings are returned for TLS versions
462
     * @throws Exception
463
     */
464
    private function checkLineparse($inputarray, $desiredCheck) {
465
        foreach ($inputarray as $lineid => $line) {
466
            switch ($desiredCheck) {
467
                case self::LINEPARSE_CHECK_REJECTIGNORE:
468
                    if (preg_match("/Attribute 18 (Reply-Message)/", $line) && preg_match("/Reject instead of Ignore at eduroam.org/", $inputarray[$lineid + 1])) {
469
                        return TRUE;
470
                    }
471
                    break;
472
                case self::LINEPARSE_CHECK_691:
473
                    if (preg_match("/MSCHAPV2: error 691/", $line) && preg_match("/MSCHAPV2: retry is allowed/", $inputarray[$lineid + 1])) {
474
                        return TRUE;
475
                    }
476
                    break;
477
                case self::LINEPARSE_EAPACK:
478
                    if (preg_match("/CTRL-EVENT-EAP-PROPOSED-METHOD/", $line) && !preg_match("/NAK$/", $line)) {
479
                        return TRUE;
480
                    }
481
                    break;
482
                case self::LINEPARSE_TLSVERSION:
483
                    break;
484
                default:
485
                    throw new Exception("This lineparse test does not exist.");
486
            }
487
        }
488
        // for TLS version checks, we need to search from bottom to top 
489
        // eapol_test will always try its highest version first, and can be
490
        // persuaded later on to do less. So look at the end result.
491
        for ($counter = count($inputarray); $counter > 0; $counter--) {
492
            switch ($desiredCheck) {
493
                case self::LINEPARSE_TLSVERSION:
494
                    $version = [];
495
                    if (isset($inputarray[$counter]) && preg_match("/Using TLS version (.*)$/", $inputarray[$counter], $version)) {
496
                        switch (trim($version[1])) {
497
                            case self::TLS_VERSION_1_3:
498
                                return self::TLS_VERSION_1_3;
499
                            case self::TLS_VERSION_1_2:
500
                                return self::TLS_VERSION_1_2;
501
                            case self::TLS_VERSION_1_1:
502
                                return self::TLS_VERSION_1_1;
503
                            case self::TLS_VERSION_1_0:
504
                                return self::TLS_VERSION_1_0;
505
                            default:
506
                                return self::TLS_VERSION_ANCIENT;
507
                        }
508
                    }
509
                    break;
510
                case self::LINEPARSE_CHECK_691:
511
                /* fall-through intentional */
512
                case self::LINEPARSE_CHECK_REJECTIGNORE:
513
                /* fall-through intentional */
514
                case self::LINEPARSE_EAPACK:
515
                    /* fall-through intentional */
516
                    break;
517
                default:
518
                    throw new Exception("This lineparse test does not exist.");
519
            }
520
        }
521
        return FALSE;
522
    }
523
524
    /**
525
     * 
526
     * @param array  $eaptype  array representation of the EAP type
527
     * @param string $inner    inner username
528
     * @param string $outer    outer username
529
     * @param string $password the password
530
     * @return string[] [0] is the actual config for wpa_supplicant, [1] is a redacted version for logs
531
     */
532
    private function wpaSupplicantConfig(array $eaptype, string $inner, string $outer, string $password) {
533
        $eapText = \core\common\EAP::eapDisplayName($eaptype);
534
        $config = '
535
network={
536
  ssid="' . \config\Master::APPEARANCE['productname'] . ' testing"
537
  key_mgmt=WPA-EAP
538
  proto=WPA2
539
  pairwise=CCMP
540
  group=CCMP
541
  ';
542
// phase 1
543
        $config .= 'eap=' . $eapText['OUTER'] . "\n";
544
        $logConfig = $config;
545
// phase 2 if applicable; all inner methods have passwords
546
        if (isset($eapText['INNER']) && $eapText['INNER'] != "") {
547
            $config .= '  phase2="auth=' . $eapText['INNER'] . "\"\n";
548
            $logConfig .= '  phase2="auth=' . $eapText['INNER'] . "\"\n";
549
        }
550
// all methods set a password, except EAP-TLS
551
        if ($eaptype != \core\common\EAP::EAPTYPE_TLS) {
552
            $config .= "  password=\"$password\"\n";
553
            $logConfig .= "  password=\"not logged for security reasons\"\n";
554
        }
555
// for methods with client certs, add a client cert config block
556
        if ($eaptype == \core\common\EAP::EAPTYPE_TLS || $eaptype == \core\common\EAP::EAPTYPE_ANY) {
557
            $config .= "  private_key=\"./client.p12\"\n";
558
            $logConfig .= "  private_key=\"./client.p12\"\n";
559
            $config .= "  private_key_passwd=\"$password\"\n";
560
            $logConfig .= "  private_key_passwd=\"not logged for security reasons\"\n";
561
        }
562
563
// inner identity
564
        $config .= '  identity="' . $inner . "\"\n";
565
        $logConfig .= '  identity="' . $inner . "\"\n";
566
// outer identity, may be equal
567
        $config .= '  anonymous_identity="' . $outer . "\"\n";
568
        $logConfig .= '  anonymous_identity="' . $outer . "\"\n";
569
// done
570
        $config .= "}";
571
        $logConfig .= "}";
572
573
        return [$config, $logConfig];
574
    }
575
576
    /**
577
     * Checks whether the packets received are as expected in numbers
578
     * 
579
     * @param array $testresults by-reference array of the testresults so far
580
     *                           function adds its own findings to that array
581
     * @param array $packetcount the count of incoming packets
582
     * @return int
583
     */
584
    private function packetCountEvaluation(&$testresults, $packetcount) {
585
        $reqs = $packetcount[1] ?? 0;
586
        $accepts = $packetcount[2] ?? 0;
587
        $rejects = $packetcount[3] ?? 0;
588
        $challenges = $packetcount[11] ?? 0;
589
        $testresults['packetflow_sane'] = TRUE;
590
        if ($reqs - $accepts - $rejects - $challenges != 0 || $accepts > 1 || $rejects > 1) {
591
            $testresults['packetflow_sane'] = FALSE;
592
        }
593
594
        $this->loggerInstance->debug(5, "XYZ: Counting req, acc, rej, chal: $reqs, $accepts, $rejects, $challenges");
595
596
// calculate the main return values that this test yielded
597
598
        $finalretval = RADIUSTests::RETVAL_INVALID;
599
        if ($accepts + $rejects == 0) { // no final response. hm.
600
            if ($challenges > 0) { // but there was an Access-Challenge
601
                $finalretval = RADIUSTests::RETVAL_SERVER_UNFINISHED_COMM;
602
            } else {
603
                $finalretval = RADIUSTests::RETVAL_NO_RESPONSE;
604
            }
605
        } else // either an accept or a reject
606
// rejection without EAP is fishy
607
        if ($rejects > 0) {
608
            if ($challenges == 0) {
609
                $finalretval = RADIUSTests::RETVAL_IMMEDIATE_REJECT;
610
            } else { // i.e. if rejected with challenges
611
                $finalretval = RADIUSTests::RETVAL_CONVERSATION_REJECT;
612
            }
613
        } else if ($accepts > 0) {
614
            $finalretval = RADIUSTests::RETVAL_OK;
615
        }
616
617
        return $finalretval;
618
    }
619
620
    /**
621
     * generate an eapol_test command-line config for the fixed config filename 
622
     * ./udp_login_test.conf
623
     * @param int     $probeindex number of the probe to check against
624
     * @param boolean $opName     include Operator-Name in request?
625
     * @param boolean $frag       make request so large that fragmentation is needed?
626
     * @return string the command-line for eapol_test
627
     */
628
    private function eapolTestConfig($probeindex, $opName, $frag) {
629
        $cmdline = \config\Diagnostics::PATHS['eapol_test'] .
630
                " -a " . \config\Diagnostics::RADIUSTESTS['UDP-hosts'][$probeindex]['ip'] .
631
                " -s " . \config\Diagnostics::RADIUSTESTS['UDP-hosts'][$probeindex]['secret'] .
632
                " -o serverchain.pem" .
633
                " -c ./udp_login_test.conf" .
634
                " -M 22:44:66:CA:20:" . sprintf("%02d", $probeindex) . " " .
635
                " -t " . \config\Diagnostics::RADIUSTESTS['UDP-hosts'][$probeindex]['timeout'] . " ";
636
        if ($opName) {
637
            $cmdline .= '-N126:s:"1cat.eduroam.org" ';
638
        }
639
        if ($frag) {
640
            for ($i = 0; $i < 6; $i++) { // 6 x 250 bytes means UDP fragmentation will occur - good!
641
                $cmdline .= '-N26:x:0000625A0BF961616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161 ';
642
            }
643
        }
644
        return $cmdline;
645
    }
646
647
    /**
648
     * collects CA certificates, both from the incoming EAP chain and from CAT
649
     * config. Writes the root CAs into a trusted root CA dir and intermediate 
650
     * and first server cert into a PEM file for later chain validation
651
     * 
652
     * @param string $tmpDir              working directory
653
     * @param array  $intermOdditiesCAT   by-reference array of already found 
654
     *                                    oddities; adds its own
655
     * @param array  $servercert          the servercert to validate
656
     * @param array  $eapIntermediates    list of intermediate CA certs that came
657
     *                                    in via EAP
658
     * @param array  $eapIntermediateCRLs list of CRLs for the EAP-supplied
659
     *                                    intermediate CAs
660
     * @return string
661
     * @throws Exception
662
     */
663
    private function createCArepository($tmpDir, &$intermOdditiesCAT, $servercert, $eapIntermediates, $eapIntermediateCRLs) {
664
        if (!mkdir($tmpDir . "/root-ca-allcerts/", 0700, true)) {
665
            throw new Exception("unable to create root CA directory (RADIUS Tests): $tmpDir/root-ca-allcerts/\n");
666
        }
667
        if (!mkdir($tmpDir . "/root-ca-eaponly/", 0700, true)) {
668
            throw new Exception("unable to create root CA directory (RADIUS Tests): $tmpDir/root-ca-eaponly/\n");
669
        }
670
// make a copy of the EAP-received chain and add the configured intermediates, if any
671
        $catIntermediates = [];
672
        $catRoots = [];
673
        foreach ($this->expectedCABundle as $oneCA) {
674
            $x509 = new \core\common\X509();
675
            $decoded = $x509->processCertificate($oneCA);
676
            if (is_bool($decoded)) {
677
                throw new Exception("Unable to parse an expected CA certificate.");
678
            }
679
            if ($decoded['ca'] == 1) {
680
                if ($decoded['root'] == 1) { // save CAT roots to the root directory
681
                    file_put_contents($tmpDir . "/root-ca-eaponly/configuredroot" . count($catRoots) . ".pem", $decoded['pem']);
682
                    file_put_contents($tmpDir . "/root-ca-allcerts/configuredroot" . count($catRoots) . ".pem", $decoded['pem']);
683
                    $catRoots[] = $decoded['pem'];
684
                } else { // save the intermediates to allcerts directory
685
                    file_put_contents($tmpDir . "/root-ca-allcerts/cat-intermediate" . count($catIntermediates) . ".pem", $decoded['pem']);
686
                    $intermOdditiesCAT = array_merge($intermOdditiesCAT, $this->propertyCheckIntermediate($decoded));
687
                    if (isset($decoded['CRL']) && isset($decoded['CRL'][0])) {
688
                        $this->loggerInstance->debug(4, "got an intermediate CRL; adding them to the chain checks. (Remember: checking end-entity cert only, not the whole chain");
689
                        file_put_contents($tmpDir . "/root-ca-allcerts/crl_cat" . count($catIntermediates) . ".pem", $decoded['CRL'][0]);
690
                    }
691
                    $catIntermediates[] = $decoded['pem'];
692
                }
693
            }
694
        }
695
        // save all intermediate certificates and CRLs to separate files in 
696
        // both root-ca directories
697
        foreach ($eapIntermediates as $index => $onePem) {
698
            file_put_contents($tmpDir . "/root-ca-eaponly/intermediate$index.pem", $onePem);
699
            file_put_contents($tmpDir . "/root-ca-allcerts/intermediate$index.pem", $onePem);
700
        }
701
        foreach ($eapIntermediateCRLs as $index => $onePem) {
702
            file_put_contents($tmpDir . "/root-ca-eaponly/intermediateCRL$index.pem", $onePem);
703
            file_put_contents($tmpDir . "/root-ca-allcerts/intermediateCRL$index.pem", $onePem);
704
        }
705
706
        $checkstring = "";
707
        if (isset($servercert['CRL']) && isset($servercert['CRL'][0])) {
708
            $this->loggerInstance->debug(4, "got a server CRL; adding them to the chain checks. (Remember: checking end-entity cert only, not the whole chain");
709
            $checkstring = "-crl_check_all";
710
            file_put_contents($tmpDir . "/root-ca-eaponly/crl-server.pem", $servercert['CRL'][0]);
711
            file_put_contents($tmpDir . "/root-ca-allcerts/crl-server.pem", $servercert['CRL'][0]);
712
        }
713
714
715
// now c_rehash the root CA directory ...
716
        system(\config\Diagnostics::PATHS['c_rehash'] . " $tmpDir/root-ca-eaponly/ > /dev/null");
717
        system(\config\Diagnostics::PATHS['c_rehash'] . " $tmpDir/root-ca-allcerts/ > /dev/null");
718
        return $checkstring;
719
    }
720
721
    /**
722
     * for checks which have a known trust root CA (i.e. a valid CAT profile 
723
     * exists), check against those known-good roots
724
     * 
725
     * @param array  $testresults         by-reference list of testresults so far
726
     *                                    Function adds its own.
727
     * @param array  $intermOdditiesCAT   by-reference list of oddities in the CA
728
     *                                    certs which are configured in CAT
729
     * @param string $tmpDir              working directory
730
     * @param array  $servercert          the server certificate to validate
731
     * @param array  $eapIntermediates    list of intermediate CA certs that came
732
     *                                    in via EAP
733
     * @param array  $eapIntermediateCRLs list of CRLs for the EAP-supplied
734
     *                                    intermediate CAs
735
     * @return int
736
     * @throws Exception
737
     */
738
    private function thoroughChainChecks(&$testresults, &$intermOdditiesCAT, $tmpDir, $servercert, $eapIntermediates, $eapIntermediateCRLs) {
739
740
        $crlCheckString = $this->createCArepository($tmpDir, $intermOdditiesCAT, $servercert, $eapIntermediates, $eapIntermediateCRLs);
741
// ... and run the verification test
742
        $verifyResultEaponly = [];
743
        $verifyResultAllcerts = [];
744
// the error log will complain if we run this test against an empty file of certs
745
// so test if there's something PEMy in the file at all
746
// serverchain.pem is the output from eapol_test; incomingserver.pem is written by extractIncomingCertsfromEAP() if there was at least one server cert.
747
        if (filesize("$tmpDir/serverchain.pem") > 10 && filesize("$tmpDir/incomingserver.pem") > 10) {
748
            $cmdString = \config\Master::PATHS['openssl'] . " verify $crlCheckString  -no-CAstore -no-CApath -CApath $tmpDir/root-ca-eaponly/ -purpose any $tmpDir/incomingserver.pem 2>&1";
749
            exec($cmdString, $verifyResultEaponly);
750
            $this->loggerInstance->debug(4, $cmdString."\n");
751
            $this->loggerInstance->debug(4, "Chain verify pass 1: " . /** @scrutinizer ignore-type */ print_r($verifyResultEaponly, TRUE) . "\n");
752
            $cmdString = \config\Master::PATHS['openssl'] . " verify $crlCheckString  -no-CAstore -no-CApath -CApath $tmpDir/root-ca-allcerts/ -purpose any $tmpDir/incomingserver.pem 2>&1";
753
            exec($cmdString, $verifyResultAllcerts);
754
            $this->loggerInstance->debug(4, $cmdString."\n");
755
            $this->loggerInstance->debug(4, "Chain verify pass 2: " . /** @scrutinizer ignore-type */ print_r($verifyResultAllcerts, TRUE) . "\n");
756
        }
757
758
// now we do certificate verification against the collected parents
759
// this is done first for the server and then for each of the intermediate CAs
760
// any oddities observed will 
761
// openssl should havd returned exactly one line of output,
762
// and it should have ended with the string "OK", anything else is fishy
763
// The result can also be an empty array - this means there were no
764
// certificates to check. Don't complain about chain validation errors
765
// in that case.
766
// we have the following test result possibilities:
767
// 1. test against allcerts failed
768
// 2. test against allcerts succeeded, but against eaponly failed - warn admin
769
// 3. test against eaponly succeeded, in this case critical errors about expired certs
770
//    need to be changed to notices, since these certs obviously do tot participate
771
//    in server certificate validation.
772
        if (count($verifyResultAllcerts) == 0 || count($verifyResultEaponly) == 0) {
773
            throw new Exception("No output at all from openssl?");
774
        }
775
        if (!isset($testresults['cert_oddities'])) {
776
            $testresults['cert_oddities'] = array();
777
        }
778
        if (!preg_match("/OK$/", $verifyResultAllcerts[0])) { // case 1
779
            if (preg_match("/certificate revoked$/", $verifyResultAllcerts[1])) {
780
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED;
781
            } elseif (preg_match("/unable to get certificate CRL/", $verifyResultAllcerts[1])) {
782
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL;
783
            } else {
784
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED;
785
            }
786
            return 1;
787
        }
788
        if (!preg_match("/OK$/", $verifyResultEaponly[0])) { // case 2
789
            if (preg_match("/certificate revoked$/", $verifyResultEaponly[1])) {
790
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED;
791
            } elseif (preg_match("/unable to get certificate CRL/", $verifyResultEaponly[1])) {
792
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL;
793
            } else {
794
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES;
795
            }
796
            return 2;
797
        }
798
        return 3;
799
    }
800
801
    /**
802
     * check the incoming hostname (both Subject:CN and subjectAltName:DNS
803
     * against what is configured in the profile; it's a significant error
804
     * if there is no match!
805
     * 
806
     * FAIL if none of the configured names show up in the server cert
807
     * WARN if the configured name is only in either CN or sAN:DNS
808
     * 
809
     * @param array $servercert  the server certificate to check
810
     * @param array $testresults by-reference the existing testresults. Function
811
     *                           adds its own findings.
812
     * @return void
813
     */
814
    private function thoroughNameChecks($servercert, &$testresults) {
815
        // Strategy for checks: we are TOTALLY happy if any one of the
816
        // configured names shows up in both the CN and a sAN
817
        // This is the primary check.
818
        // If that was not the case, we are PARTIALLY happy if any one of
819
        // the configured names was in either of the CN or sAN lists.
820
        // we are UNHAPPY if no names match!
821
        $happiness = "UNHAPPY";
822
        foreach ($this->expectedServerNames as $expectedName) {
823
            $this->loggerInstance->debug(4, "Managing expectations for $expectedName: " . /** @scrutinizer ignore-type */ print_r($servercert['CN'], TRUE) . /** @scrutinizer ignore-type */ print_r($servercert['sAN_DNS'], TRUE));
824
            if (array_search($expectedName, $servercert['CN']) !== FALSE && array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) {
825
                $this->loggerInstance->debug(4, "Totally happy!");
826
                $happiness = "TOTALLY";
827
                break;
828
            } else {
829
                if (array_search($expectedName, $servercert['CN']) !== FALSE || array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) {
830
                    $happiness = "PARTIALLY";
831
// keep trying with other expected names! We could be happier!
832
                }
833
            }
834
        }
835
        switch ($happiness) {
836
            case "UNHAPPY":
837
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_MISMATCH;
838
                return;
839
            case "PARTIALLY":
840
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_PARTIAL_MATCH;
841
                return;
842
            default: // nothing to complain about!
843
                return;
844
        }
845
    }
846
847
    /**
848
     * run eapol_test
849
     * 
850
     * @param string  $tmpDir      working directory
851
     * @param int     $probeindex  number of the probe this test should run through
852
     * @param array   $eaptype     EAP type in array representation
853
     * @param string  $innerUser   EAP method inner username to use
854
     * @param string  $password    password to use
855
     * @param boolean $opnameCheck inject Operator-Name?
856
     * @param boolean $frag        provoke UDP fragmentation?
857
     * @return array timing information of the executed eapol_test run
858
     * @throws Exception
859
     */
860
    private function executeEapolTest($tmpDir, $probeindex, $eaptype, $outerUser, $innerUser, $password, $opnameCheck, $frag) {
861
        $finalInner = $innerUser;
862
        $finalOuter = $outerUser;
863
        $theconfigs = $this->wpaSupplicantConfig($eaptype, $finalInner, $finalOuter, $password);
864
        // the config intentionally does not include CA checking. We do this
865
        // ourselves after getting the chain with -o.
866
        file_put_contents($tmpDir . "/udp_login_test.conf", $theconfigs[0]);
867
868
        $cmdline = $this->eapolTestConfig($probeindex, $opnameCheck, $frag);
869
        $this->loggerInstance->debug(4, "Shallow reachability check cmdline: $cmdline\n");
870
        $this->loggerInstance->debug(4, "Shallow reachability check config: $tmpDir\n" . $theconfigs[1] . "\n");
871
        $time_start = microtime(true);
872
        $pflow = [];
873
        exec($cmdline, $pflow);
874
        if ($pflow === NULL) {
875
            throw new Exception("The output of an exec() call really can't be NULL!");
876
        }
877
        $time_stop = microtime(true);
878
        $output = print_r($this->redact($password, $pflow), TRUE);
879
        file_put_contents($tmpDir . "/eapol_test_output_redacted_$probeindex.txt", $output);
880
        $this->loggerInstance->debug(5, "eapol_test output saved to eapol_test_output_redacted_$probeindex.txt\n");
881
        return [
882
            "time" => ($time_stop - $time_start) * 1000,
883
            "output" => $pflow,
884
        ];
885
    }
886
887
    /**
888
     * checks if the RADIUS packets were coming in in the order they are 
889
     * expected. The function massages the raw result for some known oddities.
890
     * 
891
     * @param array $testresults     by-reference array of test results so far.
892
     *                               function adds its own.
893
     * @param array $packetflow_orig original flow of packets
894
     * @return int
895
     */
896
    private function checkRadiusPacketFlow(&$testresults, $packetflow_orig) {
897
898
        $packetflow = $this->filterPackettype($packetflow_orig);
899
900
// when MS-CHAPv2 allows retry, we never formally get a reject (just a 
901
// Challenge that PW was wrong but and we should try a different one; 
902
// but that effectively is a reject
903
// so change the flow results to take that into account
904
        if ($packetflow[count($packetflow) - 1] == 11 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_691)) {
905
            $packetflow[count($packetflow) - 1] = 3;
906
        }
907
// also, the ETLRs sometimes send a reject when the server is not 
908
// responding. This should not be considered a real reject; it's a middle
909
// box unduly altering the end-to-end result. Do not consider this final
910
// Reject if it comes from ETLR
911
        if ($packetflow[count($packetflow) - 1] == 3 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_REJECTIGNORE)) {
912
            array_pop($packetflow);
913
        }
914
        $this->loggerInstance->debug(5, "Packetflow: " . /** @scrutinizer ignore-type */ print_r($packetflow, TRUE));
915
        $packetcount = array_count_values($packetflow);
916
        $testresults['packetcount'] = $packetcount;
917
        $testresults['packetflow'] = $packetflow;
918
919
// calculate packet counts and see what the overall flow was
920
        return $this->packetCountEvaluation($testresults, $packetcount);
921
    }
922
923
    /**
924
     * parses the eapol_test output to determine whether we got to a point where
925
     * an EAP type was mutually agreed
926
     * 
927
     * @param array $testresults     by-reference, we add our findings if 
928
     *                               something is noteworthy
929
     * @param array $packetflow_orig the array of text output from eapol_test
930
     * @return bool
931
     * @throws Exception
932
     */
933
    private function wasEapTypeNegotiated(&$testresults, $packetflow_orig) {
934
        $negotiatedEapType = $this->checkLineparse($packetflow_orig, self::LINEPARSE_EAPACK);
935
        if (!is_bool($negotiatedEapType)) {
936
            throw new Exception("checkLineparse should only ever return a boolean in this case!");
937
        }
938
        if (!$negotiatedEapType) {
939
            $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_COMMON_EAP_METHOD;
940
        }
941
942
        return $negotiatedEapType;
943
    }
944
945
    /**
946
     * parses eapol_test to find the TLS version used during the EAP conversation
947
     * @param array $testresults     by-reference, we add our findings if 
948
     *                               something is noteworthy
949
     * @param array $packetflow_orig the array of text output from eapol_test
950
     * @return string|bool the version as a string or FALSE if TLS version could not be determined
951
     */
952
    private function wasModernTlsNegotiated(&$testresults, $packetflow_orig) {
953
        $negotiatedTlsVersion = $this->checkLineparse($packetflow_orig, self::LINEPARSE_TLSVERSION);
954
        $this->loggerInstance->debug(4, "TLS version found is: $negotiatedTlsVersion" . "\n");
955
        if ($negotiatedTlsVersion === FALSE) {
956
            $testresults['cert_oddities'][] = RADIUSTests::TLSPROB_UNKNOWN_TLS_VERSION;
957
        } elseif ($negotiatedTlsVersion != self::TLS_VERSION_1_2 && $negotiatedTlsVersion != self::TLS_VERSION_1_3) {
958
            $testresults['cert_oddities'][] = RADIUSTests::TLSPROB_DEPRECATED_TLS_VERSION;
959
        }
960
961
        return $negotiatedTlsVersion;
962
    }
963
964
    const SERVER_NO_CA_EXTENSION = 1;
965
    const SERVER_CA_SELFSIGNED = 2;
966
    const CA_INTERMEDIATE = 3;
967
    const CA_ROOT = 4;
968
969
    /**
970
     * what is the incoming certificate - root, intermediate, or server?
971
     * @param array $cert           the certificate to check
972
     * @param int   $totalCertCount number of certs in total in chain
973
     * @return int
974
     */
975
    private function determineCertificateType(&$cert, $totalCertCount) {
976
        if ($cert['ca'] == 0 && $cert['root'] == 0) {
977
            return RADIUSTests::SERVER_NO_CA_EXTENSION;
978
        }
979
        if ($cert['ca'] == 1 && $cert['root'] == 1) {
980
            if ($totalCertCount == 1) {
981
                $cert['full_details']['type'] = 'totally_selfsigned';
982
                return RADIUSTests::SERVER_CA_SELFSIGNED;
983
            } else {
984
                return RADIUSTests::CA_ROOT;
985
            }
986
        }
987
        return RADIUSTests::CA_INTERMEDIATE;
988
    }
989
990
    /**
991
     * pull out the certificates that were sent during the EAP conversation
992
     * 
993
     * @param array  $testresults by-reference, add our findings if any
994
     * @param string $tmpDir      working directory
995
     * @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
996
     * @throws Exception
997
     */
998
    private function extractIncomingCertsfromEAP(&$testresults, $tmpDir) {
999
        /*
1000
         *  EAP's house rules:
1001
         * 1) it is unnecessary to include the root CA itself (adding it has
1002
         *    detrimental effects on performance)
1003
         * 2) TLS Web Server OID presence (Windows OSes need that)
1004
         * 3) MD5 signature algorithm disallowed (iOS barks if so)
1005
         * 4) CDP URL (Windows Phone 8 barks if not present)
1006
         * 5) there should be exactly one server cert in the chain
1007
         */
1008
1009
        $x509 = new \core\common\X509();
1010
// $eap_certarray holds all certs received in EAP conversation
1011
        $incomingData = file_get_contents($tmpDir . "/serverchain.pem");
1012
        if ($incomingData !== FALSE && strlen($incomingData) > 0) {
1013
            $eapCertArray = $x509->splitCertificate($incomingData);
1014
        } else {
1015
            $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_CERTIFICATE_IN_CONVERSATION;
1016
            return FALSE;
1017
        }
1018
        $rootIncluded = [];
1019
        $eapIntermediates = [];
1020
        $eapIntermediateCRLs = [];
1021
        $servercert = [];
1022
        $intermOdditiesEAP = [];
1023
1024
        $testresults['certdata'] = [];
1025
1026
        foreach ($eapCertArray as $certPem) {
1027
            $cert = $x509->processCertificate($certPem);
1028
            if ($cert === FALSE) {
1029
                continue;
1030
            }
1031
// consider the certificate a server cert 
1032
// a) if it is not a CA and is not a self-signed root
1033
// b) if it is a CA, and self-signed, and it is the only cert in
1034
//    the incoming cert chain
1035
//    (meaning the self-signed is itself the server cert)
1036
            switch ($this->determineCertificateType($cert, count($eapCertArray))) {
1037
                case RADIUSTests::SERVER_NO_CA_EXTENSION: // both are handled same, fall-through
1038
                case RADIUSTests::SERVER_CA_SELFSIGNED:
1039
                    $servercert[] = $cert;
1040
                    if (count($servercert) == 1) {
1041
                        if (file_put_contents($tmpDir . "/incomingserver.pem", $cert['pem'] . "\n") === FALSE) {
1042
                            $this->loggerInstance->debug(4, "The (first) server certificate could not be written to $tmpDir/incomingserver.pem!\n");
1043
                        }
1044
                        $this->loggerInstance->debug(4, "This is the (first) server certificate, with CRL content if applicable: " . /** @scrutinizer ignore-type */ print_r($servercert[0], true));
1045
                    } elseif (!in_array(RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS, $testresults['cert_oddities'])) {
1046
                        $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS;
1047
                    }
1048
                    break;
1049
                case RADIUSTests::CA_ROOT:
1050
                    if (!in_array(RADIUSTests::CERTPROB_ROOT_INCLUDED, $testresults['cert_oddities'])) {
1051
                        $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_ROOT_INCLUDED;
1052
                    }
1053
// chain checks need to be against the UPLOADED CA of the
1054
// IdP/profile, not against an EAP-discovered CA
1055
                    // save it anyway, but only for feature "root CA autodetection" is executed
1056
                    $rootIncluded[] = $cert['pem'];
1057
                    break;
1058
                case RADIUSTests::CA_INTERMEDIATE:
1059
                    $intermOdditiesEAP = array_merge($intermOdditiesEAP, $this->propertyCheckIntermediate($cert));
1060
                    $eapIntermediates[] = $cert['pem'];
1061
1062
                    if (isset($cert['CRL']) && isset($cert['CRL'][0])) {
1063
                        $eapIntermediateCRLs[] = $cert['CRL'][0];
1064
                    }
1065
                    break;
1066
                default:
1067
                    throw new Exception("Status of certificate could not be determined!");
1068
            }
1069
            $testresults['certdata'][] = $cert['full_details'];
1070
        }
1071
        switch (count($servercert)) {
1072
            case 0:
1073
                $testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_SERVER_CERT;
1074
                break;
1075
            default:
1076
// check (first) server cert's properties
1077
                $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $this->propertyCheckServercert($servercert[0]));
1078
                $testresults['incoming_server_names'] = $servercert[0]['incoming_server_names'];
1079
        }
1080
        return [
1081
            "SERVERCERT" => $servercert,
1082
            "INTERMEDIATE_CA" => $eapIntermediates,
1083
            "INTERMEDIATE_CRL" => $eapIntermediateCRLs,
1084
            "INTERMEDIATE_OBSERVED_ODDITIES" => $intermOdditiesEAP,
1085
            "UNTRUSTED_ROOT_INCLUDED" => $rootIncluded,
1086
        ];
1087
    }
1088
1089
    private function udpLoginPreliminaries($probeindex, $eaptype, $clientcertdata) {
1090
        /** preliminaries */
1091
        $eapText = \core\common\EAP::eapDisplayName($eaptype);
1092
        // no host to send probes to? Nothing to do then
1093
        if (!isset(\config\Diagnostics::RADIUSTESTS['UDP-hosts'][$probeindex])) {
1094
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1095
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1096
        }
1097
1098
        // if we need client certs but don't have one, return
1099
        if (($eaptype == \core\common\EAP::EAPTYPE_ANY || $eaptype == \core\common\EAP::EAPTYPE_TLS) && $clientcertdata === NULL) {
1100
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1101
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1102
        }
1103
        // if we don't have a string for outer EAP method name, give up
1104
        if (!isset($eapText['OUTER'])) {
1105
            $this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED;
1106
            return RADIUSTests::RETVAL_NOTCONFIGURED;
1107
        }
1108
        return TRUE;
1109
    }
1110
1111
    public function autodetectCAWithProbe($outerId) {
1112
        // for EAP-TLS to be a viable option, we need to pass a random client cert to make eapol_test happy
1113
        // the following PEM data is one of the SENSE EAPLab client certs (not secret at all)
1114
        $clientcert = file_get_contents(dirname(__FILE__) . "/clientcert.p12");
1115
        if ($clientcert === FALSE) {
1116
            throw new Exception("A dummy client cert is part of the source distribution, but could not be loaded!");
1117
        }
1118
        // which probe should we use? First is probably okay...
1119
        $probeindex = 0;
1120
        $preliminaries = $this->udpLoginPreliminaries($probeindex, \core\common\EAP::EAPTYPE_ANY, $clientcert);
1121
        if ($preliminaries !== TRUE) {
1122
            return $preliminaries;
1123
        }
1124
        // we will need a config blob for wpa_supplicant, in a temporary directory
1125
        $temporary = \core\common\Entity::createTemporaryDirectory('test');
1126
        $tmpDir = $temporary['dir'];
1127
        chdir($tmpDir);
1128
        $this->loggerInstance->debug(4, "temp dir: $tmpDir\n");
1129
        file_put_contents($tmpDir . "/client.p12", $clientcert);
1130
        $testresults = ['cert_oddities' => []];
1131
        $runtime_results = $this->executeEapolTest($tmpDir, $probeindex, \core\common\EAP::EAPTYPE_ANY, $outerId, $outerId, "eaplab", FALSE, FALSE);
1132
        $packetflow_orig = $runtime_results['output'];
1133
        $radiusResult = $this->checkRadiusPacketFlow($testresults, $packetflow_orig);
1134
        $negotiatedEapType = FALSE;
1135
        if ($radiusResult != RADIUSTests::RETVAL_IMMEDIATE_REJECT) {
1136
            $negotiatedEapType = $this->wasEapTypeNegotiated($testresults, $packetflow_orig);
1137
            $testresults['negotiated_eaptype'] = $negotiatedEapType;
1138
            $negotiatedTlsVersion = $this->wasModernTlsNegotiated($testresults, $packetflow_orig);
1139
            $testresults['tls_version_eap'] = $negotiatedTlsVersion;
1140
        }
1141
        // now let's look at the server cert+chain, if we got a cert at all
1142
        // that's not the case if we do EAP-pwd or could not negotiate an EAP method at
1143
        // all
1144
        // in that case: no server CA guess possible
1145
        if (!
1146
                ($radiusResult == RADIUSTests::RETVAL_CONVERSATION_REJECT && $negotiatedEapType) || $radiusResult == RADIUSTests::RETVAL_OK
1147
        ) {
1148
            return RADIUSTests::RETVAL_INVALID;
1149
        }
1150
        $bundle = $this->extractIncomingCertsfromEAP($testresults, $tmpDir);
1151
        // we need to check if we know the issuer of the server cert
1152
        // assume we have only one server cert - anything else is a 
1153
        // misconfiguration on the EAP server side
1154
        $previousHighestKnownIssuer = [];
1155
        $currentHighestKnownIssuer = $bundle['SERVERCERT'][0]['full_details']['issuer'];
1156
        $serverName = $bundle['SERVERCERT'][0]['CN'][0];
1157
        // maybe there is an intermediate and the EAP server sent it. If so,
1158
        // go and look at that, going one level higher
1159
        $x509 = new \core\common\X509();
1160
        $allCACerts = array_merge($bundle['INTERMEDIATE_CA'], $bundle['UNTRUSTED_ROOT_INCLUDED']);
1161
        while ($previousHighestKnownIssuer != $currentHighestKnownIssuer) {
1162
            $previousHighestKnownIssuer = $currentHighestKnownIssuer;
1163
            foreach ($allCACerts as $oneCACert) {
1164
                $certDetails = $x509->processCertificate($oneCACert);
1165
                if ($certDetails['full_details']['subject'] == $previousHighestKnownIssuer) {
1166
                    $currentHighestKnownIssuer = $certDetails['full_details']['issuer'];
1167
                }
1168
                if ($certDetails['full_details']['subject'] == $certDetails['full_details']['issuer']) {
1169
                    // if we see a subject == issuer, then the EAP server even
1170
                    // sent a root certificate. We'll propose that then.
1171
                    return [
1172
                        "NAME" => $serverName,
1173
                        "INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'],
1174
                        "HIGHEST_ISSUER" => $currentHighestKnownIssuer,
1175
                        "ROOT_CA" => $certDetails['pem'],
1176
                    ];
1177
                }
1178
            }
1179
        }
1180
        // we now know the "highest" issuer name we got from the EAP 
1181
        // conversation - ideally the name of a root CA we know. Let's look at 
1182
        // our own system store to get a list of all commercial CAs with browser
1183
        // trust, and custom ones we may have configured
1184
        $ourRoots = file_get_contents(\config\ConfAssistant::PATHS['trust-store-custom']);
1185
        $mozillaRoots = file_get_contents(\config\ConfAssistant::PATHS['trust-store-mozilla']);
1186
        $allRoots = $x509->splitCertificate($ourRoots . "\n" . $mozillaRoots);
1187
        foreach ($allRoots as $oneRoot) {
1188
            $processedRoot = $x509->processCertificate($oneRoot);
1189
            if ($processedRoot['full_details']['subject'] == $currentHighestKnownIssuer) {
1190
                return [
1191
            "NAME" => $serverName,
1192
            "INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'],
1193
            "HIGHEST_ISSUER" => $currentHighestKnownIssuer,
1194
            "ROOT_CA" => $oneRoot,
1195
        ];
1196
            }
1197
        }
1198
        return [
1199
            "NAME" => $serverName,
1200
            "INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'],
1201
            "HIGHEST_ISSUER" => $currentHighestKnownIssuer,
1202
            "ROOT_CA" => NULL,
1203
        ];
1204
    }
1205
1206
    /**
1207
     * The big Guy. This performs an actual login with EAP and records how far 
1208
     * it got and what oddities were observed along the way
1209
     * @param int     $probeindex     the probe we are connecting to (as set in product config)
1210
     * @param array   $eaptype        EAP type to use for connection
1211
     * @param string  $innerUser      inner username to try
1212
     * @param string  $password       password to try
1213
     * @param boolean $opnameCheck    whether or not we check with Operator-Name set
1214
     * @param boolean $frag           whether or not we check with an oversized packet forcing fragmentation
1215
     * @param string  $clientcertdata client certificate credential to try
1216
     * @return int overall return code of the login test
1217
     * @throws Exception
1218
     */
1219
    public function udpLogin($probeindex, $eaptype, $innerUser, $password, $opnameCheck = TRUE, $frag = TRUE, $clientcertdata = NULL) {
1220
        $preliminaries = $this->udpLoginPreliminaries($probeindex, $eaptype, $clientcertdata);
1221
        if ($preliminaries !== TRUE) {
1222
            return $preliminaries;
1223
        }
1224
        // we will need a config blob for wpa_supplicant, in a temporary directory
1225
        $temporary = \core\common\Entity::createTemporaryDirectory('test');
1226
        $tmpDir = $temporary['dir'];
1227
        chdir($tmpDir);
1228
        $this->loggerInstance->debug(4, "temp dir: $tmpDir\n");
1229
        if ($clientcertdata !== NULL) {
1230
            file_put_contents($tmpDir . "/client.p12", $clientcertdata);
1231
        }
1232
        $testresults = [];
1233
        // initialise the sub-array for cleaner parsing
1234
        $testresults['cert_oddities'] = [];
1235
        // execute RADIUS/EAP conversation
1236
        $runtime_results = $this->executeEapolTest($tmpDir, $probeindex, $eaptype, $this->outerUsernameForChecks, $innerUser, $password, $opnameCheck, $frag);
1237
        $testresults['time_millisec'] = $runtime_results['time'];
1238
        $packetflow_orig = $runtime_results['output'];
1239
        $radiusResult = $this->checkRadiusPacketFlow($testresults, $packetflow_orig);
1240
        // if the RADIUS conversation was immediately rejected, it is trivially
1241
        // true that no EAP type was negotiated, and that TLS didn't negotiate
1242
        // a version. Don't get excited about that then.
1243
        $negotiatedEapType = FALSE;
1244
        if ($radiusResult != RADIUSTests::RETVAL_IMMEDIATE_REJECT) {
1245
            $negotiatedEapType = $this->wasEapTypeNegotiated($testresults, $packetflow_orig);
1246
            $testresults['negotiated_eaptype'] = $negotiatedEapType;
1247
            $negotiatedTlsVersion = $this->wasModernTlsNegotiated($testresults, $packetflow_orig);
1248
            $testresults['tls_version_eap'] = $negotiatedTlsVersion;
1249
        }
1250
        // now let's look at the server cert+chain, if we got a cert at all
1251
        // that's not the case if we do EAP-pwd or could not negotiate an EAP method at
1252
        // all
1253
        if (
1254
                $eaptype != \core\common\EAP::EAPTYPE_PWD &&
1255
                (($radiusResult == RADIUSTests::RETVAL_CONVERSATION_REJECT && $negotiatedEapType) || $radiusResult == RADIUSTests::RETVAL_OK)
1256
        ) {
1257
            $bundle = $this->extractIncomingCertsfromEAP($testresults, $tmpDir);
1258
// FOR OWN REALMS check:
1259
// 1) does the incoming chain have a root in one of the configured roots
1260
//    if not, this is a significant configuration error
1261
// return this with one or more of the CERTPROB_ constants (see defs)
1262
// TRUST_ROOT_NOT_REACHED
1263
// TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES
1264
// then check the presented names
1265
// check intermediate ca cert properties
1266
// check trust chain for completeness
1267
// works only for thorough checks, not shallow, so:
1268
            $intermOdditiesCAT = [];
1269
            $verifyResult = 0;
1270
1271
            if ($this->opMode == self::RADIUS_TEST_OPERATION_MODE_THOROUGH && $bundle !== FALSE && !in_array(RADIUSTests::CERTPROB_NO_SERVER_CERT, $testresults['cert_oddities'])) {
1272
                $verifyResult = $this->thoroughChainChecks($testresults, $intermOdditiesCAT, $tmpDir, $bundle["SERVERCERT"], $bundle["INTERMEDIATE_CA"], $bundle["INTERMEDIATE_CRL"]);
1273
                $this->thoroughNameChecks($bundle["SERVERCERT"][0], $testresults);       
1274
            }
1275
1276
            $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $bundle["INTERMEDIATE_OBSERVED_ODDITIES"] ?? []);
1277
            if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT) && $verifyResult == 3) {
1278
                $key = array_search(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT);
1279
                $intermOdditiesCAT[$key] = RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD_WARN;
1280
            }
1281
1282
            $testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $intermOdditiesCAT);
1283
1284
// mention trust chain failure only if no expired cert was in the chain; otherwise path validation will trivially fail
1285
            if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $testresults['cert_oddities'])) {
1286
                $this->loggerInstance->debug(4, "Deleting trust chain problem report, if present.");
1287
                if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED, $testresults['cert_oddities'])) !== false) {
1288
                    unset($testresults['cert_oddities'][$key]);
1289
                }
1290
                if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES, $testresults['cert_oddities'])) !== false) {
1291
                    unset($testresults['cert_oddities'][$key]);
1292
                }
1293
            }
1294
        }
1295
        $this->UDP_reachability_result[$probeindex] = $testresults;
1296
        $this->UDP_reachability_executed = $radiusResult;
1297
        return $radiusResult;
1298
    }
1299
1300
    /**
1301
     * sets the outer identity to use in the checks
1302
     * 
1303
     * @param string $id the outer ID to use
1304
     * @return void
1305
     */
1306
    public function setOuterIdentity($id) {
1307
        $this->outerUsernameForChecks = $id;
1308
    }
1309
1310
    /**
1311
     * pull together all sub tests into a cohesive test result
1312
     * @param int $host index of the probe for which the results are collated
1313
     * @return array
1314
     */
1315
    public function consolidateUdpResult($host) {
1316
        \core\common\Entity::intoThePotatoes();
1317
        $ret = [];
1318
        $serverCert = [];
1319
        $udpResult = $this->UDP_reachability_result[$host];
1320
        if (isset($udpResult['certdata']) && count($udpResult['certdata'])) {
1321
            foreach ($udpResult['certdata'] as $certdata) {
1322
                if ($certdata['type'] != 'server' && $certdata['type'] != 'totally_selfsigned') {
1323
                    continue;
1324
                }
1325
                if (isset($certdata['extensions'])) {
1326
                    foreach ($certdata['extensions'] as $k => $v) {
1327
                        $certdata['extensions'][$k] = iconv('UTF-8', 'UTF-8//IGNORE', $certdata['extensions'][$k]);
1328
                    }
1329
                }
1330
                $serverCert = [
1331
                    'subject' => $this->printDN($certdata['subject']),
1332
                    'issuer' => $this->printDN($certdata['issuer']),
1333
                    'validFrom' => $this->printTm($certdata['validFrom_time_t']),
1334
                    'validTo' => $this->printTm($certdata['validTo_time_t']),
1335
                    'serialNumber' => $certdata['serialNumber'] . sprintf(" (0x%X)", $certdata['serialNumber']),
1336
                    'sha1' => $certdata['sha1'],
1337
                    'public_key_length' => $certdata['public_key_length'],
1338
                    'extensions' => $certdata['extensions']
1339
                ];
1340
            }
1341
        }
1342
        $ret['server_cert'] = $serverCert;
1343
        $ret['server'] = 0;
1344
        if (isset($udpResult['incoming_server_names'][0])) {
1345
            $ret['server'] = sprintf(_("Connected to %s."), $udpResult['incoming_server_names'][0]);
1346
        }
1347
        $ret['level'] = \core\common\Entity::L_OK;
1348
        $ret['time_millisec'] = sprintf("%d", $udpResult['time_millisec']);
1349
        if (empty($udpResult['cert_oddities'])) {
1350
            $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.");
1351
            \core\common\Entity::outOfThePotatoes();
1352
            return $ret;
1353
        }
1354
1355
        $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.");
1356
        $ret['cert_oddities'] = [];
1357
        foreach ($udpResult['cert_oddities'] as $oddity) {
1358
            $o = [];
1359
            $o['code'] = $oddity;
1360
            $o['message'] = isset($this->returnCodes[$oddity]["message"]) && $this->returnCodes[$oddity]["message"] ? $this->returnCodes[$oddity]["message"] : $oddity;
1361
            if (isset($this->returnCodes[$oddity]['severity'])) {
1362
                $o['level'] = $this->returnCodes[$oddity]["severity"];
1363
                $ret['level'] = max($ret['level'], $this->returnCodes[$oddity]["severity"]);
1364
            }
1365
            $ret['cert_oddities'][] = $o;
1366
        }
1367
        \core\common\Entity::outOfThePotatoes();
1368
        return $ret;
1369
    }
1370
1371
}
1372