Issues (752)

plugins/smime/php/class.certificate.php (12 issues)

1
<?php
2
3
use WAYF\OCSP;
4
use WAYF\X509;
5
6
include_once 'lib/X509.php';
7
include_once 'lib/Ocsp.php';
8
9
define('OCSP_CERT_EXPIRED', 1);
10
define('OCSP_NO_ISSUER', 2);
11
define('OCSP_NO_RESPONSE', 3);
12
define('OCSP_RESPONSE_STATUS', 4);
13
define('OCSP_CERT_STATUS', 5);
14
define('OCSP_CERT_MISMATCH', 6);
15
define('OCSP_RESPONSE_TIME_EARLY', 7);
16
define('OCSP_RESPONSE_TIME_INVALID', 8);
17
18
define('OCSP_CERT_STATUS_GOOD', 1);
19
define('OCSP_CERT_STATUS_REVOKED', 2);
20
define('OCSP_CERT_STATUS_UNKOWN', 3);
21
22
class OCSPException extends Exception {
23
	private $status;
24
25
	public function setCertStatus($status) {
26
		$this->status = $status;
27
	}
28
29
	public function getCertStatus() {
30
		if (!$this->status) {
31
			return;
32
		}
33
34
		if ($this->code !== OCSP_CERT_STATUS) {
35
			return;
36
		}
37
38
		return match ($this->status) {
39
			'good' => OCSP_CERT_STATUS_GOOD,
40
			'revoked' => OCSP_CERT_STATUS_REVOKED,
41
			default => OCSP_CERT_STATUS_UNKOWN,
42
		};
43
	}
44
}
45
46
function tempErrorHandler($errno, $errstr, $errfile, $errline) {
0 ignored issues
show
The parameter $errline is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

46
function tempErrorHandler($errno, $errstr, $errfile, /** @scrutinizer ignore-unused */ $errline) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
The parameter $errfile is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

46
function tempErrorHandler($errno, $errstr, /** @scrutinizer ignore-unused */ $errfile, $errline) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
The parameter $errstr is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

46
function tempErrorHandler($errno, /** @scrutinizer ignore-unused */ $errstr, $errfile, $errline) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
The parameter $errno is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

46
function tempErrorHandler(/** @scrutinizer ignore-unused */ $errno, $errstr, $errfile, $errline) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
47
	return true;
48
}
49
50
class Certificate {
51
	private $cert;
52
	private $data;
53
54
	public function __construct($cert, $issuer = '') {
55
		// XXX: error handling
56
		$this->data = openssl_x509_parse($cert);
57
		$this->cert = $cert;
58
		$this->issuer = $issuer;
0 ignored issues
show
Bug Best Practice introduced by
The property issuer does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
59
	}
60
61
	/**
62
	 * The name of the certificate in DN notation.
63
	 *
64
	 * @return string the name of the certificate
65
	 */
66
	public function getName() {
67
		return $this->data['name'];
68
	}
69
70
	/**
71
	 * Issuer of the certificate.
72
	 *
73
	 * @return string The issuer of the certificate in DN notation
74
	 */
75
	public function getIssuerName() {
76
		$issuer = '';
77
		foreach ($this->data['issuer'] as $key => $value) {
78
			$issuer .= "/{$key}={$value}";
79
		}
80
81
		return $issuer;
82
	}
83
84
	/**
85
	 * Converts X509 DER format string to PEM format.
86
	 *
87
	 * @param string X509 Certificate in DER format
88
	 * @param mixed $cert
89
	 *
90
	 * @return string X509 Certificate in PEM format
91
	 */
92
	protected function der2pem($cert) {
93
		return "-----BEGIN CERTIFICATE-----\n" . chunk_split(base64_encode((string) $cert), 64, "\n") . "-----END CERTIFICATE-----\n";
94
	}
95
96
	/**
97
	 * Converts X509 PEM format string to DER format.
98
	 *
99
	 * @param string X509 Certificate in PEM format
100
	 * @param mixed $pem_data
101
	 *
102
	 * @return string X509 Certificate in DER format
103
	 */
104
	protected function pem2der($pem_data) {
105
		$begin = "CERTIFICATE-----";
106
		$end = "-----END";
107
		$pem_data = substr((string) $pem_data, strpos((string) $pem_data, $begin) + strlen($begin));
108
		$pem_data = substr($pem_data, 0, strpos($pem_data, $end));
109
110
		return base64_decode($pem_data);
111
	}
112
113
	/**
114
	 * The subject/emailAddress or subjectAltName.
115
	 *
116
	 * @return string The email address belonging to the certificate
117
	 */
118
	public function emailAddress() {
119
		$certEmailAddress = "";
120
		// If subject/emailAddress is not set, try subjectAltName
121
		if (isset($this->data['subject']['emailAddress'])) {
122
			$certEmailAddress = $this->data['subject']['emailAddress'];
123
		}
124
		elseif (isset($this->data['extensions'], $this->data['extensions']['subjectAltName'])
125
		) {
126
			// Example [subjectAltName] => email:[email protected]
127
			$tmp = explode('email:', $this->data['extensions']['subjectAltName']);
128
			// Only get the first match
129
			if (isset($tmp[1]) && !empty($tmp[1])) {
130
				$certEmailAddress = $tmp[1];
131
			}
132
		}
133
134
		return $certEmailAddress;
135
	}
136
137
	/**
138
	 * Return the certificate in DER format.
139
	 *
140
	 * @return string certificate in DER format
141
	 */
142
	public function der() {
143
		return $this->pem2der($this->cert);
144
	}
145
146
	/**
147
	 * Return the certificate in PEM format.
148
	 *
149
	 * @return string certificate in PEM format
150
	 */
151
	public function pem() {
152
		return $this->cert;
153
	}
154
155
	/**
156
	 * The beginning of the valid period of the certificate.
157
	 *
158
	 * @return int timestamp from which the certificate is valid
159
	 */
160
	public function validFrom() {
161
		return $this->data['validFrom_time_t'];
162
	}
163
164
	/**
165
	 * The end of the valid period of the certificate.
166
	 *
167
	 * @return int timestamp from which the certificate is invalid
168
	 */
169
	public function validTo() {
170
		return $this->data['validTo_time_t'];
171
	}
172
173
	/**
174
	 * Determines if the certificate is valid.
175
	 *
176
	 * @return bool the valid status
177
	 */
178
	public function valid() {
179
		$time = time();
180
181
		return $time > $this->validFrom() && $time < $this->validTo();
182
	}
183
184
	/**
185
	 * The caURL of the certififcate.
186
	 *
187
	 * @return string return an empty string or the CA URL
188
	 */
189
	public function caURL() {
190
		$authorityInfoAccess = $this->authorityInfoAccess();
191
		if (preg_match("/CA Issuers - URI:(.*)/", $authorityInfoAccess, $matches)) {
192
			return array_pop($matches);
193
		}
194
195
		return '';
196
	}
197
198
	/**
199
	 * The OCSP URL of the certificate.
200
	 *
201
	 * @return string return an empty string or the OCSP URL
202
	 */
203
	public function ocspURL() {
204
		$authorityInfoAccess = $this->authorityInfoAccess();
205
		if (preg_match("/OCSP - URI:(.*)/", $authorityInfoAccess, $matches)) {
206
			return array_pop($matches);
207
		}
208
209
		return '';
210
	}
211
212
	/**
213
	 * Internal helper to obtain the authorityInfoAccess information.
214
	 *
215
	 * @return string authorityInfoAccess if set
216
	 */
217
	protected function authorityInfoAccess() {
218
		if (!isset($this->data['extensions'])) {
219
			return '';
220
		}
221
222
		if (!isset($this->data['extensions']['authorityInfoAccess'])) {
223
			return '';
224
		}
225
226
		return $this->data['extensions']['authorityInfoAccess'];
227
	}
228
229
	/**
230
	 * The fingerprint (hash) of the certificate body.
231
	 *
232
	 * @param string hash_algorithm either sha1 or md5
0 ignored issues
show
The type hash_algorithm was not found. Maybe you did not declare it correctly or list all dependencies?

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

filter:
    dependency_paths: ["lib/*"]

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

Loading history...
233
	 * @param mixed $hash_algorithm
234
	 *
235
	 * @return string the hash of the certificate's body
236
	 */
237
	public function fingerprint($hash_algorithm = "md5") {
238
		$body = str_replace('-----BEGIN CERTIFICATE-----', '', $this->cert);
239
		$body = str_replace('-----END CERTIFICATE-----', '', $body);
240
		$body = base64_decode($body);
241
		if ($hash_algorithm === 'sha1') {
242
			$fingerprint = sha1($body);
243
		}
244
		else {
245
			$fingerprint = md5($body);
246
		}
247
248
		// Format 1000AB as 10:00:AB
249
		return strtoupper(implode(':', str_split($fingerprint, 2)));
0 ignored issues
show
It seems like str_split($fingerprint, 2) can also be of type true; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

249
		return strtoupper(implode(':', /** @scrutinizer ignore-type */ str_split($fingerprint, 2)));
Loading history...
250
	}
251
252
	/**
253
	 * The issuer of this certificate.
254
	 *
255
	 * @return Certificate the issuer certificate
256
	 */
257
	public function issuer() {
258
		if (!empty($this->issuer)) {
259
			return $this->issuer;
260
		}
261
		$cert = '';
262
		$ch = curl_init();
263
		curl_setopt($ch, CURLOPT_URL, $this->caURL());
264
		curl_setopt($ch, CURLOPT_FAILONERROR, true);
265
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
266
267
		// HTTP Proxy settings
268
		if (defined('PLUGIN_SMIME_PROXY') && PLUGIN_SMIME_PROXY != '') {
0 ignored issues
show
The condition PLUGIN_SMIME_PROXY != '' is always false.
Loading history...
269
			curl_setopt($ch, CURLOPT_PROXY, PLUGIN_SMIME_PROXY);
270
		}
271
		if (defined('PLUGIN_SMIME_PROXY_PORT') && PLUGIN_SMIME_PROXY_PORT != '') {
0 ignored issues
show
The condition PLUGIN_SMIME_PROXY_PORT != '' is always false.
Loading history...
272
			curl_setopt($ch, CURLOPT_PROXYPORT, PLUGIN_SMIME_PROXY_PORT);
273
		}
274
		if (defined('PLUGIN_SMIME_PROXY_USERPWD') && PLUGIN_SMIME_PROXY_USERPWD != '') {
0 ignored issues
show
The condition PLUGIN_SMIME_PROXY_USERPWD != '' is always false.
Loading history...
275
			curl_setopt($ch, CURLOPT_PROXYUSERPWD, PLUGIN_SMIME_PROXY_USERPWD);
276
		}
277
278
		$output = curl_exec($ch);
279
		$http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
280
		$curl_error = curl_error($ch);
281
		if (!$curl_error && $http_status === 200) {
282
			$cert = $this->der2pem($output);
283
		}
284
		else {
285
			Log::Write(LOGLEVEL_ERROR, sprintf("[smime] Error when downloading internmediate certificate '%s', http status: '%s'", $curl_error, $http_status));
286
		}
287
		curl_close($ch);
288
289
		return new Certificate($cert);
290
	}
291
292
	/**
293
	 * Set the issuer of a certificate.
294
	 *
295
	 * @param string the issuer certificate
296
	 * @param mixed $issuer
297
	 */
298
	public function setIssuer($issuer) {
299
		if (is_object($issuer)) {
300
			$this->issuer = $issuer;
0 ignored issues
show
Bug Best Practice introduced by
The property issuer does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
301
		}
302
	}
303
304
	/**
305
	 * Verify the certificate status using OCSP.
306
	 *
307
	 * @return bool verification succeeded or failed
308
	 */
309
	public function verify() {
310
		$message = [];
0 ignored issues
show
The assignment to $message is dead and can be removed.
Loading history...
311
312
		if (!$this->valid()) {
313
			throw new OCSPException('Certificate expired', OCSP_CERT_EXPIRED);
314
		}
315
316
		$issuer = $this->issuer();
317
		if (!is_object($issuer)) {
318
			throw new OCSPException('No issuer', OCSP_NO_ISSUER);
319
		}
320
321
		/* Set custom error handler since the nemid ocsp library uses
322
		 * trigger_error() to throw errors when it cannot parse certain
323
		 * x509 fields which are not required for the OCSP Request.
324
		 * Also when receiving the OCSP request, the OCSP library
325
		 * triggers errors when the request does not adhere to the
326
		 * standard.
327
		 */
328
		set_error_handler("tempErrorHandler");
329
330
		$x509 = new X509();
331
		$issuer = $x509->certificate($issuer->der());
332
		$certificate = $x509->certificate($this->der());
333
334
		$ocspclient = new OCSP();
335
		$certID = $ocspclient->certOcspID(
336
			[
337
				'issuerName' => $issuer['tbsCertificate']['subject_der'],
338
				// remember to skip the first byte it is the number of
339
				// unused bits and it is always 0 for keys and certificates
340
				'issuerKey' => substr((string) $issuer['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey'], 1),
341
				'serialNumber_der' => $certificate['tbsCertificate']['serialNumber_der'],
342
			],
343
			'sha1'
344
		);
345
346
		$ocspreq = $ocspclient->request([$certID]);
347
348
		$stream_options = [
349
			'http' => [
350
				'ignore_errors' => false,
351
				'method' => 'POST',
352
				'header' => 'Content-type: application/ocsp-request' . "\r\n",
353
				'content' => $ocspreq,
354
				'timeout' => 1,
355
			],
356
		];
357
358
		$ocspUrl = $this->ocspURL();
359
		// The OCSP URL is empty, import certificate, but show a warning.
360
		if (strlen($ocspUrl) == 0) {
361
			throw new OCSPException('The OCSP URL is empty', OCSP_NO_RESPONSE);
362
		}
363
		// Do the OCSP request
364
		$context = stream_context_create($stream_options);
365
		$derresponse = file_get_contents($ocspUrl, false, $context);
366
		// OCSP service not available, import certificate, but show a warning.
367
		if ($derresponse === false) {
368
			throw new OCSPException('No response', OCSP_NO_RESPONSE);
369
		}
370
		$ocspresponse = $ocspclient->response($derresponse);
371
372
		// Restore the previous error handler
373
		restore_error_handler();
374
375
		// responseStatuses: successful, malformedRequest,
376
		// internalError, tryLater, sigRequired, unauthorized.
377
		if (isset($ocspresponse['responseStatus']) &&
378
			$ocspresponse['responseStatus'] !== 'successful') {
379
			throw new OCSPException('Response status' . $ocspresponse['responseStatus'], OCSP_RESPONSE_STATUS);
380
		}
381
382
		$resp = $ocspresponse['responseBytes']['BasicOCSPResponse']['tbsResponseData']['responses'][0];
383
		/*
384
		 * OCSP response status, possible values are: good, revoked,
385
		 * unknown according to the RFC
386
		 * https://www.ietf.org/rfc/rfc2560.txt
387
		 */
388
		if ($resp['certStatus'] !== 'good') {
389
			// Certificate status is not good, revoked or unknown
390
			$exception = new OCSPException('Certificate status ' . $resp['certStatus'], OCSP_CERT_STATUS);
391
			$exception->setCertStatus($resp['certStatus']);
392
393
			throw $exception;
394
		}
395
396
		/* Check if:
397
		 * - hash algorithm is equal
398
		 * - check if issuerNamehash is the same from response
399
		 * - check if issuerKeyHash is the same from response
400
		 * - check if serialNumber is the same from response
401
		 */
402
		if ($resp['certID']['hashAlgorithm'] !== 'sha1' &&
403
			$resp['certID']['issuerNameHash'] !== $certID['issuerNameHash'] &&
404
			$resp['certID']['issuerKeyHash'] !== $certID['issuerKeyHash'] &&
405
			$resp['certID']['serialNumber'] !== $certID['serialNumber']) {
406
			// OCSP Revocation, mismatch between original and checked certificate
407
			throw new OCSPException('Certificate mismatch', OCSP_CERT_MISMATCH);
408
		}
409
410
		// check if OCSP revocation update is recent
411
		$now = new DateTime(gmdate('YmdHis\Z'));
412
		$thisUpdate = new DateTime($resp['thisUpdate']);
413
414
		// Check if update time is earlier then our own time
415
		if (!isset($resp['nextupdate']) && $thisUpdate > $now) {
416
			throw new OCSPException('Update time earlier then our own time', OCSP_RESPONSE_TIME_EARLY);
417
		}
418
419
		// Current time should be between thisUpdate and nextUpdate.
420
		if ($thisUpdate > $now && $now > new DateTime($resp['nextUpdate'])) {
421
			// OCSP Revocation status not current
422
			throw new OCSPException('Current time not between thisUpdate and nextUpdate', OCSP_RESPONSE_TIME_INVALID);
423
		}
424
	}
425
}
426