|
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 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
|
|
|
|
|
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]), \config\Diagnostics::TIMEOUTS['crl_download']); |
|
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
|
|
|
// persuaded 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 (isset($inputarray[$counter]) && 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
|
|
|
// ... and run the verification test |
|
743
|
|
|
$verifyResultEaponly = []; |
|
744
|
|
|
$verifyResultAllcerts = []; |
|
745
|
|
|
// the error log will complain if we run this test against an empty file of certs |
|
746
|
|
|
// so test if there's something PEMy in the file at all |
|
747
|
|
|
// serverchain.pem is the output from eapol_test; incomingserver.pem is written by extractIncomingCertsfromEAP() if there was at least one server cert. |
|
748
|
|
|
if (filesize("$tmpDir/serverchain.pem") > 10 && filesize("$tmpDir/incomingserver.pem") > 10) { |
|
749
|
|
|
$cmdString = \config\Master::PATHS['openssl'] . " verify $crlCheckString -no-CAstore -no-CApath -CApath $tmpDir/root-ca-eaponly/ -purpose any $tmpDir/incomingserver.pem 2>&1"; |
|
750
|
|
|
exec($cmdString, $verifyResultEaponly); |
|
751
|
|
|
$this->loggerInstance->debug(4, $cmdString."\n"); |
|
752
|
|
|
$this->loggerInstance->debug(4, "Chain verify pass 1: " . /** @scrutinizer ignore-type */ print_r($verifyResultEaponly, TRUE) . "\n"); |
|
753
|
|
|
$cmdString = \config\Master::PATHS['openssl'] . " verify $crlCheckString -no-CAstore -no-CApath -CApath $tmpDir/root-ca-allcerts/ -purpose any $tmpDir/incomingserver.pem 2>&1"; |
|
754
|
|
|
exec($cmdString, $verifyResultAllcerts); |
|
755
|
|
|
$this->loggerInstance->debug(4, $cmdString."\n"); |
|
756
|
|
|
$this->loggerInstance->debug(4, "Chain verify pass 2: " . /** @scrutinizer ignore-type */ print_r($verifyResultAllcerts, TRUE) . "\n"); |
|
757
|
|
|
} |
|
758
|
|
|
|
|
759
|
|
|
// now we do certificate verification against the collected parents |
|
760
|
|
|
// this is done first for the server and then for each of the intermediate CAs |
|
761
|
|
|
// any oddities observed will |
|
762
|
|
|
// openssl should havd returned exactly one line of output, |
|
763
|
|
|
// and it should have ended with the string "OK", anything else is fishy |
|
764
|
|
|
// The result can also be an empty array - this means there were no |
|
765
|
|
|
// certificates to check. Don't complain about chain validation errors |
|
766
|
|
|
// in that case. |
|
767
|
|
|
// we have the following test result possibilities: |
|
768
|
|
|
// 1. test against allcerts failed |
|
769
|
|
|
// 2. test against allcerts succeeded, but against eaponly failed - warn admin |
|
770
|
|
|
// 3. test against eaponly succeeded, in this case critical errors about expired certs |
|
771
|
|
|
// need to be changed to notices, since these certs obviously do tot participate |
|
772
|
|
|
// in server certificate validation. |
|
773
|
|
|
if (count($verifyResultAllcerts) == 0 || count($verifyResultEaponly) == 0) { |
|
774
|
|
|
throw new Exception("No output at all from openssl?"); |
|
775
|
|
|
} |
|
776
|
|
|
if (!isset($testresults['cert_oddities'])) { |
|
777
|
|
|
$testresults['cert_oddities'] = array(); |
|
778
|
|
|
} |
|
779
|
|
|
if (!preg_match("/OK$/", $verifyResultAllcerts[0])) { // case 1 |
|
780
|
|
|
if (preg_match("/certificate revoked$/", $verifyResultAllcerts[1])) { |
|
781
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED; |
|
782
|
|
|
} elseif (preg_match("/unable to get certificate CRL/", $verifyResultAllcerts[1])) { |
|
783
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL; |
|
784
|
|
|
} else { |
|
785
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED; |
|
786
|
|
|
} |
|
787
|
|
|
return 1; |
|
788
|
|
|
} |
|
789
|
|
|
if (!preg_match("/OK$/", $verifyResultEaponly[0])) { // case 2 |
|
790
|
|
|
if (preg_match("/certificate revoked$/", $verifyResultEaponly[1])) { |
|
791
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_CERT_REVOKED; |
|
792
|
|
|
} elseif (preg_match("/unable to get certificate CRL/", $verifyResultEaponly[1])) { |
|
793
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_UNABLE_TO_GET_CRL; |
|
794
|
|
|
} else { |
|
795
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES; |
|
796
|
|
|
} |
|
797
|
|
|
return 2; |
|
798
|
|
|
} |
|
799
|
|
|
return 3; |
|
800
|
|
|
} |
|
801
|
|
|
|
|
802
|
|
|
/** |
|
803
|
|
|
* check the incoming hostname (both Subject:CN and subjectAltName:DNS |
|
804
|
|
|
* against what is configured in the profile; it's a significant error |
|
805
|
|
|
* if there is no match! |
|
806
|
|
|
* |
|
807
|
|
|
* FAIL if none of the configured names show up in the server cert |
|
808
|
|
|
* WARN if the configured name is only in either CN or sAN:DNS |
|
809
|
|
|
* |
|
810
|
|
|
* @param array $servercert the server certificate to check |
|
811
|
|
|
* @param array $testresults by-reference the existing testresults. Function |
|
812
|
|
|
* adds its own findings. |
|
813
|
|
|
* @return void |
|
814
|
|
|
*/ |
|
815
|
|
|
private function thoroughNameChecks($servercert, &$testresults) { |
|
816
|
|
|
// Strategy for checks: we are TOTALLY happy if any one of the |
|
817
|
|
|
// configured names shows up in both the CN and a sAN |
|
818
|
|
|
// This is the primary check. |
|
819
|
|
|
// If that was not the case, we are PARTIALLY happy if any one of |
|
820
|
|
|
// the configured names was in either of the CN or sAN lists. |
|
821
|
|
|
// we are UNHAPPY if no names match! |
|
822
|
|
|
$happiness = "UNHAPPY"; |
|
823
|
|
|
foreach ($this->expectedServerNames as $expectedName) { |
|
824
|
|
|
$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)); |
|
825
|
|
|
if (array_search($expectedName, $servercert['CN']) !== FALSE && array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) { |
|
826
|
|
|
$this->loggerInstance->debug(4, "Totally happy!"); |
|
827
|
|
|
$happiness = "TOTALLY"; |
|
828
|
|
|
break; |
|
829
|
|
|
} else { |
|
830
|
|
|
if (array_search($expectedName, $servercert['CN']) !== FALSE || array_search($expectedName, $servercert['sAN_DNS']) !== FALSE) { |
|
831
|
|
|
$happiness = "PARTIALLY"; |
|
832
|
|
|
// keep trying with other expected names! We could be happier! |
|
833
|
|
|
} |
|
834
|
|
|
} |
|
835
|
|
|
} |
|
836
|
|
|
switch ($happiness) { |
|
837
|
|
|
case "UNHAPPY": |
|
838
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_MISMATCH; |
|
839
|
|
|
return; |
|
840
|
|
|
case "PARTIALLY": |
|
841
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_SERVER_NAME_PARTIAL_MATCH; |
|
842
|
|
|
return; |
|
843
|
|
|
default: // nothing to complain about! |
|
844
|
|
|
return; |
|
845
|
|
|
} |
|
846
|
|
|
} |
|
847
|
|
|
|
|
848
|
|
|
/** |
|
849
|
|
|
* run eapol_test |
|
850
|
|
|
* |
|
851
|
|
|
* @param string $tmpDir working directory |
|
852
|
|
|
* @param int $probeindex number of the probe this test should run through |
|
853
|
|
|
* @param array $eaptype EAP type in array representation |
|
854
|
|
|
* @param string $innerUser EAP method inner username to use |
|
855
|
|
|
* @param string $password password to use |
|
856
|
|
|
* @param boolean $opnameCheck inject Operator-Name? |
|
857
|
|
|
* @param boolean $frag provoke UDP fragmentation? |
|
858
|
|
|
* @return array timing information of the executed eapol_test run |
|
859
|
|
|
* @throws Exception |
|
860
|
|
|
*/ |
|
861
|
|
|
private function executeEapolTest($tmpDir, $probeindex, $eaptype, $outerUser, $innerUser, $password, $opnameCheck, $frag) { |
|
862
|
|
|
$finalInner = $innerUser; |
|
863
|
|
|
$finalOuter = $outerUser; |
|
864
|
|
|
|
|
865
|
|
|
$theconfigs = $this->wpaSupplicantConfig($eaptype, $finalInner, $finalOuter, $password); |
|
866
|
|
|
// the config intentionally does not include CA checking. We do this |
|
867
|
|
|
// ourselves after getting the chain with -o. |
|
868
|
|
|
file_put_contents($tmpDir . "/udp_login_test.conf", $theconfigs[0]); |
|
869
|
|
|
|
|
870
|
|
|
$cmdline = $this->eapolTestConfig($probeindex, $opnameCheck, $frag); |
|
871
|
|
|
$this->loggerInstance->debug(4, "Shallow reachability check cmdline: $cmdline\n"); |
|
872
|
|
|
$this->loggerInstance->debug(4, "Shallow reachability check config: $tmpDir\n" . $theconfigs[1] . "\n"); |
|
873
|
|
|
$time_start = microtime(true); |
|
874
|
|
|
$pflow = []; |
|
875
|
|
|
exec($cmdline, $pflow); |
|
876
|
|
|
if ($pflow === NULL) { |
|
877
|
|
|
throw new Exception("The output of an exec() call really can't be NULL!"); |
|
878
|
|
|
} |
|
879
|
|
|
$time_stop = microtime(true); |
|
880
|
|
|
$output = print_r($this->redact($password, $pflow), TRUE); |
|
881
|
|
|
file_put_contents($tmpDir . "/eapol_test_output_redacted_$probeindex.txt", $output); |
|
882
|
|
|
$this->loggerInstance->debug(5, "eapol_test output saved to eapol_test_output_redacted_$probeindex.txt\n"); |
|
883
|
|
|
return [ |
|
884
|
|
|
"time" => ($time_stop - $time_start) * 1000, |
|
885
|
|
|
"output" => $pflow, |
|
886
|
|
|
]; |
|
887
|
|
|
} |
|
888
|
|
|
|
|
889
|
|
|
/** |
|
890
|
|
|
* checks if the RADIUS packets were coming in in the order they are |
|
891
|
|
|
* expected. The function massages the raw result for some known oddities. |
|
892
|
|
|
* |
|
893
|
|
|
* @param array $testresults by-reference array of test results so far. |
|
894
|
|
|
* function adds its own. |
|
895
|
|
|
* @param array $packetflow_orig original flow of packets |
|
896
|
|
|
* @return int |
|
897
|
|
|
*/ |
|
898
|
|
|
private function checkRadiusPacketFlow(&$testresults, $packetflow_orig) { |
|
899
|
|
|
|
|
900
|
|
|
$packetflow = $this->filterPackettype($packetflow_orig); |
|
901
|
|
|
|
|
902
|
|
|
// when MS-CHAPv2 allows retry, we never formally get a reject (just a |
|
903
|
|
|
// Challenge that PW was wrong but and we should try a different one; |
|
904
|
|
|
// but that effectively is a reject |
|
905
|
|
|
// so change the flow results to take that into account |
|
906
|
|
|
if ($packetflow[count($packetflow) - 1] == 11 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_691)) { |
|
907
|
|
|
$packetflow[count($packetflow) - 1] = 3; |
|
908
|
|
|
} |
|
909
|
|
|
// also, the ETLRs sometimes send a reject when the server is not |
|
910
|
|
|
// responding. This should not be considered a real reject; it's a middle |
|
911
|
|
|
// box unduly altering the end-to-end result. Do not consider this final |
|
912
|
|
|
// Reject if it comes from ETLR |
|
913
|
|
|
if ($packetflow[count($packetflow) - 1] == 3 && $this->checkLineparse($packetflow_orig, self::LINEPARSE_CHECK_REJECTIGNORE)) { |
|
914
|
|
|
array_pop($packetflow); |
|
915
|
|
|
} |
|
916
|
|
|
$this->loggerInstance->debug(5, "Packetflow: " . /** @scrutinizer ignore-type */ print_r($packetflow, TRUE)); |
|
917
|
|
|
$packetcount = array_count_values($packetflow); |
|
918
|
|
|
$testresults['packetcount'] = $packetcount; |
|
919
|
|
|
$testresults['packetflow'] = $packetflow; |
|
920
|
|
|
|
|
921
|
|
|
// calculate packet counts and see what the overall flow was |
|
922
|
|
|
return $this->packetCountEvaluation($testresults, $packetcount); |
|
923
|
|
|
} |
|
924
|
|
|
|
|
925
|
|
|
/** |
|
926
|
|
|
* parses the eapol_test output to determine whether we got to a point where |
|
927
|
|
|
* an EAP type was mutually agreed |
|
928
|
|
|
* |
|
929
|
|
|
* @param array $testresults by-reference, we add our findings if |
|
930
|
|
|
* something is noteworthy |
|
931
|
|
|
* @param array $packetflow_orig the array of text output from eapol_test |
|
932
|
|
|
* @return bool |
|
933
|
|
|
* @throws Exception |
|
934
|
|
|
*/ |
|
935
|
|
|
private function wasEapTypeNegotiated(&$testresults, $packetflow_orig) { |
|
936
|
|
|
$negotiatedEapType = $this->checkLineparse($packetflow_orig, self::LINEPARSE_EAPACK); |
|
937
|
|
|
if (!is_bool($negotiatedEapType)) { |
|
938
|
|
|
throw new Exception("checkLineparse should only ever return a boolean in this case!"); |
|
939
|
|
|
} |
|
940
|
|
|
if (!$negotiatedEapType) { |
|
941
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_COMMON_EAP_METHOD; |
|
942
|
|
|
} |
|
943
|
|
|
|
|
944
|
|
|
return $negotiatedEapType; |
|
945
|
|
|
} |
|
946
|
|
|
|
|
947
|
|
|
/** |
|
948
|
|
|
* parses eapol_test to find the TLS version used during the EAP conversation |
|
949
|
|
|
* @param array $testresults by-reference, we add our findings if |
|
950
|
|
|
* something is noteworthy |
|
951
|
|
|
* @param array $packetflow_orig the array of text output from eapol_test |
|
952
|
|
|
* @return string|bool the version as a string or FALSE if TLS version could not be determined |
|
953
|
|
|
*/ |
|
954
|
|
|
private function wasModernTlsNegotiated(&$testresults, $packetflow_orig) { |
|
955
|
|
|
$negotiatedTlsVersion = $this->checkLineparse($packetflow_orig, self::LINEPARSE_TLSVERSION); |
|
956
|
|
|
$this->loggerInstance->debug(4, "TLS version found is: $negotiatedTlsVersion" . "\n"); |
|
957
|
|
|
if ($negotiatedTlsVersion === FALSE) { |
|
958
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::TLSPROB_UNKNOWN_TLS_VERSION; |
|
959
|
|
|
} elseif ($negotiatedTlsVersion != self::TLS_VERSION_1_2 && $negotiatedTlsVersion != self::TLS_VERSION_1_3) { |
|
960
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::TLSPROB_DEPRECATED_TLS_VERSION; |
|
961
|
|
|
} |
|
962
|
|
|
|
|
963
|
|
|
return $negotiatedTlsVersion; |
|
964
|
|
|
} |
|
965
|
|
|
|
|
966
|
|
|
const SERVER_NO_CA_EXTENSION = 1; |
|
967
|
|
|
const SERVER_CA_SELFSIGNED = 2; |
|
968
|
|
|
const CA_INTERMEDIATE = 3; |
|
969
|
|
|
const CA_ROOT = 4; |
|
970
|
|
|
|
|
971
|
|
|
/** |
|
972
|
|
|
* what is the incoming certificate - root, intermediate, or server? |
|
973
|
|
|
* @param array $cert the certificate to check |
|
974
|
|
|
* @param int $totalCertCount number of certs in total in chain |
|
975
|
|
|
* @return int |
|
976
|
|
|
*/ |
|
977
|
|
|
private function determineCertificateType(&$cert, $totalCertCount) { |
|
978
|
|
|
if ($cert['ca'] == 0 && $cert['root'] == 0) { |
|
979
|
|
|
return RADIUSTests::SERVER_NO_CA_EXTENSION; |
|
980
|
|
|
} |
|
981
|
|
|
if ($cert['ca'] == 1 && $cert['root'] == 1) { |
|
982
|
|
|
if ($totalCertCount == 1) { |
|
983
|
|
|
$cert['full_details']['type'] = 'totally_selfsigned'; |
|
984
|
|
|
return RADIUSTests::SERVER_CA_SELFSIGNED; |
|
985
|
|
|
} else { |
|
986
|
|
|
return RADIUSTests::CA_ROOT; |
|
987
|
|
|
} |
|
988
|
|
|
} |
|
989
|
|
|
return RADIUSTests::CA_INTERMEDIATE; |
|
990
|
|
|
} |
|
991
|
|
|
|
|
992
|
|
|
/** |
|
993
|
|
|
* pull out the certificates that were sent during the EAP conversation |
|
994
|
|
|
* |
|
995
|
|
|
* @param array $testresults by-reference, add our findings if any |
|
996
|
|
|
* @param string $tmpDir working directory |
|
997
|
|
|
* @return array|FALSE an array with all the certs, CRLs and oddities, or FALSE if the EAP conversation did not yield a certificate at all |
|
998
|
|
|
* @throws Exception |
|
999
|
|
|
*/ |
|
1000
|
|
|
private function extractIncomingCertsfromEAP(&$testresults, $tmpDir) { |
|
1001
|
|
|
/* |
|
1002
|
|
|
* EAP's house rules: |
|
1003
|
|
|
* 1) it is unnecessary to include the root CA itself (adding it has |
|
1004
|
|
|
* detrimental effects on performance) |
|
1005
|
|
|
* 2) TLS Web Server OID presence (Windows OSes need that) |
|
1006
|
|
|
* 3) MD5 signature algorithm disallowed (iOS barks if so) |
|
1007
|
|
|
* 4) CDP URL (Windows Phone 8 barks if not present) |
|
1008
|
|
|
* 5) there should be exactly one server cert in the chain |
|
1009
|
|
|
*/ |
|
1010
|
|
|
|
|
1011
|
|
|
$x509 = new \core\common\X509(); |
|
1012
|
|
|
// $eap_certarray holds all certs received in EAP conversation |
|
1013
|
|
|
$incomingData = file_get_contents($tmpDir . "/serverchain.pem"); |
|
1014
|
|
|
if ($incomingData !== FALSE && strlen($incomingData) > 0) { |
|
1015
|
|
|
$eapCertArray = $x509->splitCertificate($incomingData); |
|
1016
|
|
|
} else { |
|
1017
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_CERTIFICATE_IN_CONVERSATION; |
|
1018
|
|
|
return FALSE; |
|
1019
|
|
|
} |
|
1020
|
|
|
$rootIncluded = []; |
|
1021
|
|
|
$eapIntermediates = []; |
|
1022
|
|
|
$eapIntermediateCRLs = []; |
|
1023
|
|
|
$servercert = []; |
|
1024
|
|
|
$intermOdditiesEAP = []; |
|
1025
|
|
|
|
|
1026
|
|
|
$testresults['certdata'] = []; |
|
1027
|
|
|
|
|
1028
|
|
|
foreach ($eapCertArray as $certPem) { |
|
1029
|
|
|
$cert = $x509->processCertificate($certPem); |
|
1030
|
|
|
if ($cert === FALSE) { |
|
1031
|
|
|
continue; |
|
1032
|
|
|
} |
|
1033
|
|
|
// consider the certificate a server cert |
|
1034
|
|
|
// a) if it is not a CA and is not a self-signed root |
|
1035
|
|
|
// b) if it is a CA, and self-signed, and it is the only cert in |
|
1036
|
|
|
// the incoming cert chain |
|
1037
|
|
|
// (meaning the self-signed is itself the server cert) |
|
1038
|
|
|
switch ($this->determineCertificateType($cert, count($eapCertArray))) { |
|
1039
|
|
|
case RADIUSTests::SERVER_NO_CA_EXTENSION: // both are handled same, fall-through |
|
1040
|
|
|
case RADIUSTests::SERVER_CA_SELFSIGNED: |
|
1041
|
|
|
$servercert[] = $cert; |
|
1042
|
|
|
if (count($servercert) == 1) { |
|
1043
|
|
|
if (file_put_contents($tmpDir . "/incomingserver.pem", $cert['pem'] . "\n") === FALSE) { |
|
1044
|
|
|
$this->loggerInstance->debug(4, "The (first) server certificate could not be written to $tmpDir/incomingserver.pem!\n"); |
|
1045
|
|
|
} |
|
1046
|
|
|
$this->loggerInstance->debug(4, "This is the (first) server certificate, with CRL content if applicable: " . /** @scrutinizer ignore-type */ print_r($servercert[0], true)); |
|
1047
|
|
|
} elseif (!in_array(RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS, $testresults['cert_oddities'])) { |
|
1048
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_TOO_MANY_SERVER_CERTS; |
|
1049
|
|
|
} |
|
1050
|
|
|
break; |
|
1051
|
|
|
case RADIUSTests::CA_ROOT: |
|
1052
|
|
|
if (!in_array(RADIUSTests::CERTPROB_ROOT_INCLUDED, $testresults['cert_oddities'])) { |
|
1053
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_ROOT_INCLUDED; |
|
1054
|
|
|
} |
|
1055
|
|
|
// chain checks need to be against the UPLOADED CA of the |
|
1056
|
|
|
// IdP/profile, not against an EAP-discovered CA |
|
1057
|
|
|
// save it anyway, but only for feature "root CA autodetection" is executed |
|
1058
|
|
|
$rootIncluded[] = $cert['pem']; |
|
1059
|
|
|
break; |
|
1060
|
|
|
case RADIUSTests::CA_INTERMEDIATE: |
|
1061
|
|
|
$intermOdditiesEAP = array_merge($intermOdditiesEAP, $this->propertyCheckIntermediate($cert)); |
|
1062
|
|
|
$eapIntermediates[] = $cert['pem']; |
|
1063
|
|
|
|
|
1064
|
|
|
if (isset($cert['CRL']) && isset($cert['CRL'][0])) { |
|
1065
|
|
|
$eapIntermediateCRLs[] = $cert['CRL'][0]; |
|
1066
|
|
|
} |
|
1067
|
|
|
break; |
|
1068
|
|
|
default: |
|
1069
|
|
|
throw new Exception("Status of certificate could not be determined!"); |
|
1070
|
|
|
} |
|
1071
|
|
|
$testresults['certdata'][] = $cert['full_details']; |
|
1072
|
|
|
} |
|
1073
|
|
|
switch (count($servercert)) { |
|
1074
|
|
|
case 0: |
|
1075
|
|
|
$testresults['cert_oddities'][] = RADIUSTests::CERTPROB_NO_SERVER_CERT; |
|
1076
|
|
|
break; |
|
1077
|
|
|
default: |
|
1078
|
|
|
// check (first) server cert's properties |
|
1079
|
|
|
$testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $this->propertyCheckServercert($servercert[0])); |
|
1080
|
|
|
$testresults['incoming_server_names'] = $servercert[0]['incoming_server_names']; |
|
1081
|
|
|
} |
|
1082
|
|
|
return [ |
|
1083
|
|
|
"SERVERCERT" => $servercert, |
|
1084
|
|
|
"INTERMEDIATE_CA" => $eapIntermediates, |
|
1085
|
|
|
"INTERMEDIATE_CRL" => $eapIntermediateCRLs, |
|
1086
|
|
|
"INTERMEDIATE_OBSERVED_ODDITIES" => $intermOdditiesEAP, |
|
1087
|
|
|
"UNTRUSTED_ROOT_INCLUDED" => $rootIncluded, |
|
1088
|
|
|
]; |
|
1089
|
|
|
} |
|
1090
|
|
|
|
|
1091
|
|
|
private function udpLoginPreliminaries($probeindex, $eaptype, $clientcertdata) { |
|
1092
|
|
|
/** preliminaries */ |
|
1093
|
|
|
$eapText = \core\common\EAP::eapDisplayName($eaptype); |
|
1094
|
|
|
// no host to send probes to? Nothing to do then |
|
1095
|
|
|
if (!isset(\config\Diagnostics::RADIUSTESTS['UDP-hosts'][$probeindex])) { |
|
1096
|
|
|
$this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1097
|
|
|
return RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1098
|
|
|
} |
|
1099
|
|
|
|
|
1100
|
|
|
// if we need client certs but don't have one, return |
|
1101
|
|
|
if (($eaptype == \core\common\EAP::EAPTYPE_ANY || $eaptype == \core\common\EAP::EAPTYPE_TLS) && $clientcertdata === NULL) { |
|
1102
|
|
|
$this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1103
|
|
|
return RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1104
|
|
|
} |
|
1105
|
|
|
// if we don't have a string for outer EAP method name, give up |
|
1106
|
|
|
if (!isset($eapText['OUTER'])) { |
|
1107
|
|
|
$this->UDP_reachability_executed = RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1108
|
|
|
return RADIUSTests::RETVAL_NOTCONFIGURED; |
|
1109
|
|
|
} |
|
1110
|
|
|
return TRUE; |
|
1111
|
|
|
} |
|
1112
|
|
|
|
|
1113
|
|
|
public function autodetectCAWithProbe($outerId) { |
|
1114
|
|
|
// for EAP-TLS to be a viable option, we need to pass a random client cert to make eapol_test happy |
|
1115
|
|
|
// the following PEM data is one of the SENSE EAPLab client certs (not secret at all) |
|
1116
|
|
|
$clientcert = file_get_contents(dirname(__FILE__) . "/clientcert.p12"); |
|
1117
|
|
|
if ($clientcert === FALSE) { |
|
1118
|
|
|
throw new Exception("A dummy client cert is part of the source distribution, but could not be loaded!"); |
|
1119
|
|
|
} |
|
1120
|
|
|
// which probe should we use? First is probably okay... |
|
1121
|
|
|
$probeindex = 0; |
|
1122
|
|
|
$preliminaries = $this->udpLoginPreliminaries($probeindex, \core\common\EAP::EAPTYPE_ANY, $clientcert); |
|
1123
|
|
|
if ($preliminaries !== TRUE) { |
|
1124
|
|
|
return $preliminaries; |
|
1125
|
|
|
} |
|
1126
|
|
|
// we will need a config blob for wpa_supplicant, in a temporary directory |
|
1127
|
|
|
$temporary = \core\common\Entity::createTemporaryDirectory('test'); |
|
1128
|
|
|
$tmpDir = $temporary['dir']; |
|
1129
|
|
|
chdir($tmpDir); |
|
1130
|
|
|
$this->loggerInstance->debug(4, "temp dir: $tmpDir\n"); |
|
1131
|
|
|
file_put_contents($tmpDir . "/client.p12", $clientcert); |
|
1132
|
|
|
$testresults = ['cert_oddities' => []]; |
|
1133
|
|
|
$runtime_results = $this->executeEapolTest($tmpDir, $probeindex, \core\common\EAP::EAPTYPE_ANY, $outerId, $outerId, "eaplab", FALSE, FALSE); |
|
1134
|
|
|
$packetflow_orig = $runtime_results['output']; |
|
1135
|
|
|
$radiusResult = $this->checkRadiusPacketFlow($testresults, $packetflow_orig); |
|
1136
|
|
|
$negotiatedEapType = FALSE; |
|
1137
|
|
|
if ($radiusResult != RADIUSTests::RETVAL_IMMEDIATE_REJECT) { |
|
1138
|
|
|
$negotiatedEapType = $this->wasEapTypeNegotiated($testresults, $packetflow_orig); |
|
1139
|
|
|
$testresults['negotiated_eaptype'] = $negotiatedEapType; |
|
1140
|
|
|
$negotiatedTlsVersion = $this->wasModernTlsNegotiated($testresults, $packetflow_orig); |
|
1141
|
|
|
$testresults['tls_version_eap'] = $negotiatedTlsVersion; |
|
1142
|
|
|
} |
|
1143
|
|
|
// now let's look at the server cert+chain, if we got a cert at all |
|
1144
|
|
|
// that's not the case if we do EAP-pwd or could not negotiate an EAP method at |
|
1145
|
|
|
// all |
|
1146
|
|
|
// in that case: no server CA guess possible |
|
1147
|
|
|
if (! |
|
1148
|
|
|
($radiusResult == RADIUSTests::RETVAL_CONVERSATION_REJECT && $negotiatedEapType) || $radiusResult == RADIUSTests::RETVAL_OK |
|
1149
|
|
|
) { |
|
1150
|
|
|
return RADIUSTests::RETVAL_INVALID; |
|
1151
|
|
|
} |
|
1152
|
|
|
$bundle = $this->extractIncomingCertsfromEAP($testresults, $tmpDir); |
|
1153
|
|
|
// we need to check if we know the issuer of the server cert |
|
1154
|
|
|
// assume we have only one server cert - anything else is a |
|
1155
|
|
|
// misconfiguration on the EAP server side |
|
1156
|
|
|
$previousHighestKnownIssuer = []; |
|
1157
|
|
|
$currentHighestKnownIssuer = $bundle['SERVERCERT'][0]['full_details']['issuer']; |
|
1158
|
|
|
$serverName = $bundle['SERVERCERT'][0]['CN'][0]; |
|
1159
|
|
|
// maybe there is an intermediate and the EAP server sent it. If so, |
|
1160
|
|
|
// go and look at that, going one level higher |
|
1161
|
|
|
$x509 = new \core\common\X509(); |
|
1162
|
|
|
$allCACerts = array_merge($bundle['INTERMEDIATE_CA'], $bundle['UNTRUSTED_ROOT_INCLUDED']); |
|
1163
|
|
|
while ($previousHighestKnownIssuer != $currentHighestKnownIssuer) { |
|
1164
|
|
|
$previousHighestKnownIssuer = $currentHighestKnownIssuer; |
|
1165
|
|
|
foreach ($allCACerts as $oneCACert) { |
|
1166
|
|
|
$certDetails = $x509->processCertificate($oneCACert); |
|
1167
|
|
|
if ($certDetails['full_details']['subject'] == $previousHighestKnownIssuer) { |
|
1168
|
|
|
$currentHighestKnownIssuer = $certDetails['full_details']['issuer']; |
|
1169
|
|
|
} |
|
1170
|
|
|
if ($certDetails['full_details']['subject'] == $certDetails['full_details']['issuer']) { |
|
1171
|
|
|
// if we see a subject == issuer, then the EAP server even |
|
1172
|
|
|
// sent a root certificate. We'll propose that then. |
|
1173
|
|
|
return [ |
|
1174
|
|
|
"NAME" => $serverName, |
|
1175
|
|
|
"INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'], |
|
1176
|
|
|
"HIGHEST_ISSUER" => $currentHighestKnownIssuer, |
|
1177
|
|
|
"ROOT_CA" => $certDetails['pem'], |
|
1178
|
|
|
]; |
|
1179
|
|
|
} |
|
1180
|
|
|
} |
|
1181
|
|
|
} |
|
1182
|
|
|
// we now know the "highest" issuer name we got from the EAP |
|
1183
|
|
|
// conversation - ideally the name of a root CA we know. Let's look at |
|
1184
|
|
|
// our own system store to get a list of all commercial CAs with browser |
|
1185
|
|
|
// trust, and custom ones we may have configured |
|
1186
|
|
|
$ourRoots = file_get_contents(\config\ConfAssistant::PATHS['trust-store-custom']); |
|
1187
|
|
|
$mozillaRoots = file_get_contents(\config\ConfAssistant::PATHS['trust-store-mozilla']); |
|
1188
|
|
|
$allRoots = $x509->splitCertificate($ourRoots . "\n" . $mozillaRoots); |
|
1189
|
|
|
foreach ($allRoots as $oneRoot) { |
|
1190
|
|
|
$processedRoot = $x509->processCertificate($oneRoot); |
|
1191
|
|
|
if ($processedRoot['full_details']['subject'] == $currentHighestKnownIssuer) { |
|
1192
|
|
|
return [ |
|
1193
|
|
|
"NAME" => $serverName, |
|
1194
|
|
|
"INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'], |
|
1195
|
|
|
"HIGHEST_ISSUER" => $currentHighestKnownIssuer, |
|
1196
|
|
|
"ROOT_CA" => $oneRoot, |
|
1197
|
|
|
]; |
|
1198
|
|
|
} |
|
1199
|
|
|
} |
|
1200
|
|
|
return [ |
|
1201
|
|
|
"NAME" => $serverName, |
|
1202
|
|
|
"INTERMEDIATE_CA" => $bundle['INTERMEDIATE_CA'], |
|
1203
|
|
|
"HIGHEST_ISSUER" => $currentHighestKnownIssuer, |
|
1204
|
|
|
"ROOT_CA" => NULL, |
|
1205
|
|
|
]; |
|
1206
|
|
|
} |
|
1207
|
|
|
|
|
1208
|
|
|
/** |
|
1209
|
|
|
* The big Guy. This performs an actual login with EAP and records how far |
|
1210
|
|
|
* it got and what oddities were observed along the way |
|
1211
|
|
|
* @param int $probeindex the probe we are connecting to (as set in product config) |
|
1212
|
|
|
* @param array $eaptype EAP type to use for connection |
|
1213
|
|
|
* @param string $innerUser inner username to try |
|
1214
|
|
|
* @param string $password password to try |
|
1215
|
|
|
* @param boolean $opnameCheck whether or not we check with Operator-Name set |
|
1216
|
|
|
* @param boolean $frag whether or not we check with an oversized packet forcing fragmentation |
|
1217
|
|
|
* @param string $clientcertdata client certificate credential to try |
|
1218
|
|
|
* @return int overall return code of the login test |
|
1219
|
|
|
* @throws Exception |
|
1220
|
|
|
*/ |
|
1221
|
|
|
public function udpLogin($probeindex, $eaptype, $innerUser, $password, $opnameCheck = TRUE, $frag = TRUE, $clientcertdata = NULL) { |
|
1222
|
|
|
$preliminaries = $this->udpLoginPreliminaries($probeindex, $eaptype, $clientcertdata); |
|
1223
|
|
|
if ($preliminaries !== TRUE) { |
|
1224
|
|
|
return $preliminaries; |
|
1225
|
|
|
} |
|
1226
|
|
|
// we will need a config blob for wpa_supplicant, in a temporary directory |
|
1227
|
|
|
$temporary = \core\common\Entity::createTemporaryDirectory('test'); |
|
1228
|
|
|
$tmpDir = $temporary['dir']; |
|
1229
|
|
|
chdir($tmpDir); |
|
1230
|
|
|
$this->loggerInstance->debug(4, "temp dir: $tmpDir\n"); |
|
1231
|
|
|
if ($clientcertdata !== NULL) { |
|
1232
|
|
|
file_put_contents($tmpDir . "/client.p12", $clientcertdata); |
|
1233
|
|
|
} |
|
1234
|
|
|
$testresults = []; |
|
1235
|
|
|
// initialise the sub-array for cleaner parsing |
|
1236
|
|
|
$testresults['cert_oddities'] = []; |
|
1237
|
|
|
// execute RADIUS/EAP conversation |
|
1238
|
|
|
$runtime_results = $this->executeEapolTest($tmpDir, $probeindex, $eaptype, $this->outerUsernameForChecks, $innerUser, $password, $opnameCheck, $frag); |
|
1239
|
|
|
$testresults['time_millisec'] = $runtime_results['time']; |
|
1240
|
|
|
$packetflow_orig = $runtime_results['output']; |
|
1241
|
|
|
$radiusResult = $this->checkRadiusPacketFlow($testresults, $packetflow_orig); |
|
1242
|
|
|
// if the RADIUS conversation was immediately rejected, it is trivially |
|
1243
|
|
|
// true that no EAP type was negotiated, and that TLS didn't negotiate |
|
1244
|
|
|
// a version. Don't get excited about that then. |
|
1245
|
|
|
$negotiatedEapType = FALSE; |
|
1246
|
|
|
if ($radiusResult != RADIUSTests::RETVAL_IMMEDIATE_REJECT) { |
|
1247
|
|
|
$negotiatedEapType = $this->wasEapTypeNegotiated($testresults, $packetflow_orig); |
|
1248
|
|
|
$testresults['negotiated_eaptype'] = $negotiatedEapType; |
|
1249
|
|
|
$negotiatedTlsVersion = $this->wasModernTlsNegotiated($testresults, $packetflow_orig); |
|
1250
|
|
|
$testresults['tls_version_eap'] = $negotiatedTlsVersion; |
|
1251
|
|
|
} |
|
1252
|
|
|
// now let's look at the server cert+chain, if we got a cert at all |
|
1253
|
|
|
// that's not the case if we do EAP-pwd or could not negotiate an EAP method at |
|
1254
|
|
|
// all |
|
1255
|
|
|
if ( |
|
1256
|
|
|
$eaptype != \core\common\EAP::EAPTYPE_PWD && |
|
1257
|
|
|
(($radiusResult == RADIUSTests::RETVAL_CONVERSATION_REJECT && $negotiatedEapType) || $radiusResult == RADIUSTests::RETVAL_OK) |
|
1258
|
|
|
) { |
|
1259
|
|
|
$bundle = $this->extractIncomingCertsfromEAP($testresults, $tmpDir); |
|
1260
|
|
|
// FOR OWN REALMS check: |
|
1261
|
|
|
// 1) does the incoming chain have a root in one of the configured roots |
|
1262
|
|
|
// if not, this is a significant configuration error |
|
1263
|
|
|
// return this with one or more of the CERTPROB_ constants (see defs) |
|
1264
|
|
|
// TRUST_ROOT_NOT_REACHED |
|
1265
|
|
|
// TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES |
|
1266
|
|
|
// then check the presented names |
|
1267
|
|
|
// check intermediate ca cert properties |
|
1268
|
|
|
// check trust chain for completeness |
|
1269
|
|
|
// works only for thorough checks, not shallow, so: |
|
1270
|
|
|
$intermOdditiesCAT = []; |
|
1271
|
|
|
$verifyResult = 0; |
|
1272
|
|
|
|
|
1273
|
|
|
if ($this->opMode == self::RADIUS_TEST_OPERATION_MODE_THOROUGH && $bundle !== FALSE && !in_array(RADIUSTests::CERTPROB_NO_SERVER_CERT, $testresults['cert_oddities'])) { |
|
1274
|
|
|
$verifyResult = $this->thoroughChainChecks($testresults, $intermOdditiesCAT, $tmpDir, $bundle["SERVERCERT"], $bundle["INTERMEDIATE_CA"], $bundle["INTERMEDIATE_CRL"]); |
|
1275
|
|
|
$this->thoroughNameChecks($bundle["SERVERCERT"][0], $testresults); |
|
1276
|
|
|
} |
|
1277
|
|
|
|
|
1278
|
|
|
$testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $bundle["INTERMEDIATE_OBSERVED_ODDITIES"] ?? []); |
|
1279
|
|
|
if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT) && $verifyResult == 3) { |
|
1280
|
|
|
$key = array_search(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $intermOdditiesCAT); |
|
1281
|
|
|
$intermOdditiesCAT[$key] = RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD_WARN; |
|
1282
|
|
|
} |
|
1283
|
|
|
|
|
1284
|
|
|
$testresults['cert_oddities'] = array_merge($testresults['cert_oddities'], $intermOdditiesCAT); |
|
1285
|
|
|
|
|
1286
|
|
|
// mention trust chain failure only if no expired cert was in the chain; otherwise path validation will trivially fail |
|
1287
|
|
|
if (in_array(RADIUSTests::CERTPROB_OUTSIDE_VALIDITY_PERIOD, $testresults['cert_oddities'])) { |
|
1288
|
|
|
$this->loggerInstance->debug(4, "Deleting trust chain problem report, if present."); |
|
1289
|
|
|
if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_NOT_REACHED, $testresults['cert_oddities'])) !== false) { |
|
1290
|
|
|
unset($testresults['cert_oddities'][$key]); |
|
1291
|
|
|
} |
|
1292
|
|
|
if (($key = array_search(RADIUSTests::CERTPROB_TRUST_ROOT_REACHED_ONLY_WITH_OOB_INTERMEDIATES, $testresults['cert_oddities'])) !== false) { |
|
1293
|
|
|
unset($testresults['cert_oddities'][$key]); |
|
1294
|
|
|
} |
|
1295
|
|
|
} |
|
1296
|
|
|
} |
|
1297
|
|
|
$this->UDP_reachability_result[$probeindex] = $testresults; |
|
1298
|
|
|
$this->UDP_reachability_executed = $radiusResult; |
|
1299
|
|
|
return $radiusResult; |
|
1300
|
|
|
} |
|
1301
|
|
|
|
|
1302
|
|
|
/** |
|
1303
|
|
|
* sets the outer identity to use in the checks |
|
1304
|
|
|
* |
|
1305
|
|
|
* @param string $id the outer ID to use |
|
1306
|
|
|
* @return void |
|
1307
|
|
|
*/ |
|
1308
|
|
|
public function setOuterIdentity($id) { |
|
1309
|
|
|
$this->outerUsernameForChecks = $id; |
|
1310
|
|
|
} |
|
1311
|
|
|
|
|
1312
|
|
|
/** |
|
1313
|
|
|
* pull together all sub tests into a cohesive test result |
|
1314
|
|
|
* @param int $host index of the probe for which the results are collated |
|
1315
|
|
|
* @return array |
|
1316
|
|
|
*/ |
|
1317
|
|
|
public function consolidateUdpResult($host) { |
|
1318
|
|
|
\core\common\Entity::intoThePotatoes(); |
|
1319
|
|
|
$ret = []; |
|
1320
|
|
|
$serverCert = []; |
|
1321
|
|
|
$udpResult = $this->UDP_reachability_result[$host]; |
|
1322
|
|
|
if (isset($udpResult['certdata']) && count($udpResult['certdata'])) { |
|
1323
|
|
|
foreach ($udpResult['certdata'] as $certdata) { |
|
1324
|
|
|
if ($certdata['type'] != 'server' && $certdata['type'] != 'totally_selfsigned') { |
|
1325
|
|
|
continue; |
|
1326
|
|
|
} |
|
1327
|
|
|
if (isset($certdata['extensions'])) { |
|
1328
|
|
|
foreach ($certdata['extensions'] as $k => $v) { |
|
1329
|
|
|
$certdata['extensions'][$k] = iconv('UTF-8', 'UTF-8//IGNORE', $certdata['extensions'][$k]); |
|
1330
|
|
|
} |
|
1331
|
|
|
} |
|
1332
|
|
|
$serverCert = [ |
|
1333
|
|
|
'subject' => $this->printDN($certdata['subject']), |
|
1334
|
|
|
'issuer' => $this->printDN($certdata['issuer']), |
|
1335
|
|
|
'validFrom' => $this->printTm($certdata['validFrom_time_t']), |
|
1336
|
|
|
'validTo' => $this->printTm($certdata['validTo_time_t']), |
|
1337
|
|
|
'serialNumber' => $certdata['serialNumber'] . sprintf(" (0x%X)", $certdata['serialNumber']), |
|
1338
|
|
|
'sha1' => $certdata['sha1'], |
|
1339
|
|
|
'public_key_length' => $certdata['public_key_length'], |
|
1340
|
|
|
'extensions' => $certdata['extensions'] |
|
1341
|
|
|
]; |
|
1342
|
|
|
} |
|
1343
|
|
|
} |
|
1344
|
|
|
$ret['server_cert'] = $serverCert; |
|
1345
|
|
|
$ret['server'] = 0; |
|
1346
|
|
|
if (isset($udpResult['incoming_server_names'][0])) { |
|
1347
|
|
|
$ret['server'] = sprintf(_("Connected to %s."), $udpResult['incoming_server_names'][0]); |
|
1348
|
|
|
} |
|
1349
|
|
|
$ret['level'] = \core\common\Entity::L_OK; |
|
1350
|
|
|
$ret['time_millisec'] = sprintf("%d", $udpResult['time_millisec']); |
|
1351
|
|
|
if (empty($udpResult['cert_oddities'])) { |
|
1352
|
|
|
$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."); |
|
1353
|
|
|
\core\common\Entity::outOfThePotatoes(); |
|
1354
|
|
|
return $ret; |
|
1355
|
|
|
} |
|
1356
|
|
|
|
|
1357
|
|
|
$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."); |
|
1358
|
|
|
$ret['cert_oddities'] = []; |
|
1359
|
|
|
foreach ($udpResult['cert_oddities'] as $oddity) { |
|
1360
|
|
|
$o = []; |
|
1361
|
|
|
$o['code'] = $oddity; |
|
1362
|
|
|
$o['message'] = isset($this->returnCodes[$oddity]["message"]) && $this->returnCodes[$oddity]["message"] ? $this->returnCodes[$oddity]["message"] : $oddity; |
|
1363
|
|
|
if (isset($this->returnCodes[$oddity]['severity'])) { |
|
1364
|
|
|
$o['level'] = $this->returnCodes[$oddity]["severity"]; |
|
1365
|
|
|
$ret['level'] = max($ret['level'], $this->returnCodes[$oddity]["severity"]); |
|
1366
|
|
|
} |
|
1367
|
|
|
$ret['cert_oddities'][] = $o; |
|
1368
|
|
|
} |
|
1369
|
|
|
\core\common\Entity::outOfThePotatoes(); |
|
1370
|
|
|
return $ret; |
|
1371
|
|
|
} |
|
1372
|
|
|
|
|
1373
|
|
|
} |
|
1374
|
|
|
|
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:For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths