Test Setup Failed
Push — master ( 1417dc...c04c22 )
by Stefan
06:40 queued 12s
created

RADIUSTests::executeEapolTest()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 25
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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