1: <?php
2: /**
3: * X509Cert.php
4: *
5: * Copyright © 2017-2026 - Thierry Thiers <webcoder31@gmail.com>
6: *
7: * This software is governed by the CeCILL-C license under French law and
8: * abiding by the rules of distribution of free software. You can use, modify
9: * and/or redistribute the software under the terms of the CeCILL-C license as
10: * circulated by CEA, CNRS and INRIA at the following URL:
11: *
12: * http://www.cecill.info
13: *
14: * As a counterpart to the access to the source code and rights to copy, modify
15: * and redistribute granted by the license, users are provided only with a
16: * limited warranty and the software's author, the holder of the economic
17: * rights, and the successive licensors have only limited liability.
18: *
19: * In this respect, the user's attention is drawn to the risks associated with
20: * loading, using, modifying and/or developing or reproducing the software by
21: * the user in light of its specific status of free software, that may mean that
22: * it is complicated to manipulate, and that also therefore means that it is
23: * reserved for developers and experienced professionals having in-depth
24: * computer knowledge. Users are therefore encouraged to load and test the
25: * software's suitability as regards their requirements in conditions enabling
26: * the security of their systems and/or data to be ensured and, more generally,
27: * to use and operate it in the same conditions as regards security.
28: *
29: * The fact that you are presently reading this means that you have had
30: * knowledge of the CeCILL-C license and that you accept its terms.
31: *
32: * @author Thierry Thiers <webcoder31@gmail.com>
33: * @copyright 2017-2026 - Thierry Thiers <webcoder31@gmail.com>
34: * @license http://www.cecill.info CeCILL-C License
35: * @version 1.1.0
36: */
37:
38:
39: // Namespace.
40: namespace webcoder31\ezxmldsig;
41:
42: // Use.
43: use DateTime;
44: use DateTimeZone;
45: use Exception;
46:
47: /**
48: * Analyze an X.509 certificate.
49: *
50: * The `X509Cert` class provides a set of convenient methods for extracting
51: * essential information contained in an X.509 certificate and for performing
52: * some useful checks. Every cryptographic check (signature of the
53: * certificate, chain of trust, validity dates) is performed by OpenSSL. This
54: * class is used internally by the `XMLDSigToken` class.
55: */
56: class X509Cert
57: {
58: /** @var string The X.509 certificate in PEM format. */
59: private $cert;
60:
61: /** @var array The X.509 information of the certificate. */
62: private $x509Info;
63:
64:
65: /**
66: * Create a new X509Cert object.
67: *
68: * @param string $cert An X.509 certificate (public key) in PEM format.
69: *
70: * @throws Exception
71: */
72: public function __construct($cert)
73: {
74: // Check that OpenSSL can read the certificate.
75: if (!is_string($cert) || false === @openssl_x509_read($cert))
76: {
77: throw new Exception("Invalid certificate!");
78: }
79:
80: // Get X.509 information.
81: $x509Info = openssl_x509_parse($cert);
82: if ($x509Info === false)
83: {
84: $msg = "Failed to extract X.509 information from certificate!";
85: throw new Exception($msg);
86: }
87:
88: // Store the PEM certificate.
89: $this->cert = $cert;
90:
91: // Store X.509 information.
92: $this->x509Info = $x509Info;
93: }
94:
95:
96: /**
97: * Get the certificate (public key) in PEM format.
98: *
99: * @return String The certificate (public key) in PEM format.
100: */
101: public function getPem()
102: {
103: return $this->cert;
104: }
105:
106:
107: /**
108: * Get the fingerprint of the certificate: the hash of its DER encoding.
109: *
110: * Two certificates are the same certificate if, and only if, their
111: * fingerprints are equal.
112: *
113: * @param string $algorithm The hash algorithm. Default: 'sha256'.
114: *
115: * @return string The fingerprint, as a lowercase hexadecimal string.
116: *
117: * @throws Exception
118: */
119: public function getFingerprint($algorithm = 'sha256')
120: {
121: $fingerprint = openssl_x509_fingerprint($this->cert, $algorithm);
122: if (false === $fingerprint)
123: {
124: throw new Exception("Failed to compute the fingerprint of the certificate!");
125: }
126:
127: return $fingerprint;
128: }
129:
130:
131: /**
132: * Get the issuer information of the certificate.
133: *
134: * @return array The issuer information of the certificate.
135: */
136: public function getIssuer()
137: {
138: return $this->x509Info['issuer'];
139: }
140:
141:
142: /**
143: * Get the subject information of the certificate.
144: *
145: * @return array The subject information of the certificate.
146: */
147: public function getSubject()
148: {
149: return $this->x509Info['subject'];
150: }
151:
152:
153: /**
154: * Get the Distinguished Name of the certificate.
155: *
156: * @return string The Distinguished Name of the certificate.
157: */
158: public function getDN()
159: {
160: return $this->x509Info['name'];
161: }
162:
163:
164: /**
165: * Get the UTC date from which the certificate is valid.
166: *
167: * @param string $dateFormat The format of the returned date.
168: * Default: 'ymdHise'
169: *
170: * @return string|false The date from which the certificate is valid,
171: * FALSE if $dateFormat is invalid.
172: */
173: public function getValidFrom($dateFormat = 'ymdHise')
174: {
175: $tz = new DateTimeZone('UTC');
176: $validFrom = $this->x509Info['validFrom'];
177: $dt = DateTime::createFromFormat('ymdHise', $validFrom, $tz);
178: return $dt->format($dateFormat);
179: }
180:
181:
182: /**
183: * Get the UTC date to which the certificate is valid.
184: *
185: * @param string $dateFormat The format of the returned date.
186: * Default: 'ymdHise'
187: *
188: * @return string|false The date to which the certificate is valid,
189: * FALSE if $dateFormat is invalid.
190: */
191: public function getValidTo($dateFormat = 'ymdHise')
192: {
193: $tz = new DateTimeZone('UTC');
194: $validTo = $this->x509Info['validTo'];
195: $dt = DateTime::createFromFormat('ymdHise', $validTo, $tz);
196: return $dt->format($dateFormat);
197: }
198:
199:
200: /**
201: * Indicates whether the certificate is out of date or not.
202: *
203: * @return boolean TRUE if embedded certificate is out of date,
204: * FALSE otherwise.
205: */
206: public function isOutOfDate()
207: {
208: // We will compare date in UTC.
209: $tz = new DateTimeZone('UTC');
210:
211: // Compute the UTC UNIX timestamp for the current date/time.
212: $curDate = new DateTime('now', $tz);
213: $curTimestamp = $curDate->getTimestamp();
214:
215: // Compute the UTC UNIX timestamp certificate 'validFrom' date.
216: $validFrom = $this->x509Info['validFrom'];
217: $fromDate = DateTime::createFromFormat('ymdHise', $validFrom, $tz);
218: $fromTimestamp = $fromDate->getTimestamp();
219:
220: // Compute the UTC UNIX timestamp certificate 'validTo' date.
221: $validTo = $this->x509Info['validTo'];
222: $toDate = DateTime::createFromFormat('ymdHise', $validTo, $tz);
223: $toTimestamp = $toDate->getTimestamp();
224:
225: // Return timestamps comparison result.
226: return $curTimestamp < $fromTimestamp || $curTimestamp > $toTimestamp;
227: }
228:
229:
230: /**
231: * Indicates whether the key usage of the certificate allows it to sign
232: * data.
233: *
234: * The "keyUsage" extension, when the certificate has one, must allow
235: * "digitalSignature" or "nonRepudiation". A certificate without this
236: * extension is not restricted.
237: *
238: * @return boolean TRUE if the certificate may sign data, FALSE otherwise.
239: */
240: public function canSign()
241: {
242: if (!isset($this->x509Info['extensions']['keyUsage']))
243: {
244: return true;
245: }
246:
247: $keyUsage = array_map('trim', explode(',', $this->x509Info['extensions']['keyUsage']));
248:
249: return in_array('Digital Signature', $keyUsage, true)
250: || in_array('Non Repudiation', $keyUsage, true);
251: }
252:
253:
254: /**
255: * Check that the certificate is trusted: OpenSSL builds and verifies its
256: * chain up to a certificate of the given CA files.
257: *
258: * Every certificate of the given files is trusted, and the chain is built
259: * from these certificates only. OpenSSL verifies the signature of each
260: * link, the validity dates of every certificate of the chain at the
261: * current date, that each issuer is a CA (basic constraints, key usage,
262: * path length) and that the chain ends at a self-signed certificate of
263: * the given files. A chain that stops at an intermediate CA is rejected:
264: * to trust the certificates issued by an intermediate CA, give its
265: * certificate and the certificates up to its root (in one file or in
266: * several). A certificate issued by another intermediate CA of the same
267: * root is then rejected, since the chain cannot be built without that
268: * other intermediate certificate.
269: *
270: * @param string|array $caCertPaths The path, or the list of paths, of PEM
271: * files that hold the trusted CA certificates.
272: *
273: * @return boolean TRUE if the chain of the certificate has been verified,
274: * FALSE otherwise.
275: *
276: * @throws Exception
277: */
278: public function isTrustedBy($caCertPaths)
279: {
280: $caCertPaths = (array) $caCertPaths;
281: if (empty($caCertPaths))
282: {
283: throw new Exception("No CA certificate file to verify the certificate chain!");
284: }
285: foreach ($caCertPaths as $caCertPath)
286: {
287: if (!is_string($caCertPath) || !is_file($caCertPath) || !is_readable($caCertPath))
288: {
289: throw new Exception("Cannot read CA certificate file! File: " . (is_string($caCertPath) ? $caCertPath : gettype($caCertPath)));
290: }
291: }
292:
293: // Only the given CA certificates may be trusted, not those of the
294: // system. When the list given to openssl_x509_checkpurpose() holds no
295: // directory, PHP adds the default CA directory of OpenSSL (that of
296: // the system, or the one named by the environment variable
297: // SSL_CERT_DIR) to the trusted certificates, and the default CA file
298: // when the list holds no file: any public CA would then be a trust
299: // anchor. An empty directory, created for this call, is therefore
300: // always added to the list. This guard must not be removed.
301: $emptyDir = self::createEmptyDirectory();
302: try
303: {
304: // X509_PURPOSE_ANY: the chain is verified, but no purpose is
305: // required from the certificate (see canSign() for its key
306: // usage). openssl_x509_checkpurpose() returns -1 on error: only
307: // TRUE is a success.
308: $caInfo = array_merge(array_values($caCertPaths), [$emptyDir]);
309: return true === openssl_x509_checkpurpose($this->cert, X509_PURPOSE_ANY, $caInfo);
310: }
311: finally
312: {
313: @rmdir($emptyDir);
314: }
315: }
316:
317:
318: /**
319: * Create an empty directory, readable by its owner only, in the
320: * temporary directory of the system.
321: *
322: * @return string The path of the directory.
323: *
324: * @throws Exception If the directory cannot be created.
325: */
326: static private function createEmptyDirectory()
327: {
328: // mkdir() fails if the path exists (even as a link): the directory is
329: // the one created here, hence empty.
330: $path = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'ezxmldsig-ca-' . bin2hex(random_bytes(16));
331: if (!@mkdir($path, 0700))
332: {
333: throw new Exception("Cannot create an empty directory to verify the certificate chain in " . sys_get_temp_dir() . "!");
334: }
335:
336: return $path;
337: }
338:
339:
340: /**
341: * Check the origin of the certificate.
342: *
343: * Verify that the X.509 certificate has been issued by the given CA
344: * certificate: OpenSSL verifies the signature of the certificate with the
345: * public key of the CA certificate, the issuer name of the certificate
346: * must be the subject name of the CA certificate, and the CA certificate
347: * must be a CA ("CA:TRUE" basic constraint and, if it has a "keyUsage"
348: * extension, "keyCertSign").
349: *
350: * Only this link is checked: neither the validity dates nor the chain of
351: * the CA certificate. Use `isTrustedBy()` to verify the whole chain.
352: *
353: * NOTE: More than one CA certificate can give a positive result,
354: * some CAs re-issue signing certificates after having only
355: * changed the expiration dates.
356: * Note that it also works with self-signed CA certificates. In this
357: * case, pass the X.509 certificate itself to the function.
358: *
359: * @param string $caCert The certificate (in PEM format) corresponding to
360: * the private key that is supposed to be used by the CA to sign the X.509
361: * certificate.
362: *
363: * @return boolean TRUE if origin of the X.509 certificate has been
364: * validated, FALSE otherwise.
365: *
366: * @throws Exception
367: */
368: public function isValidCA($caCert)
369: {
370: if (!is_string($caCert) || false === @openssl_x509_read($caCert))
371: {
372: throw new Exception("Invalid CA certificate!");
373: }
374:
375: $caInfo = openssl_x509_parse($caCert);
376: if ($caInfo === false)
377: {
378: throw new Exception("Failed to extract X.509 information from CA certificate!");
379: }
380:
381: // The CA certificate must be allowed to issue certificates.
382: $basicConstraints = isset($caInfo['extensions']['basicConstraints']) ? $caInfo['extensions']['basicConstraints'] : '';
383: if (!in_array('CA:TRUE', array_map('trim', explode(',', $basicConstraints)), true))
384: {
385: return false;
386: }
387: if (isset($caInfo['extensions']['keyUsage']))
388: {
389: $keyUsage = array_map('trim', explode(',', $caInfo['extensions']['keyUsage']));
390: if (!in_array('Certificate Sign', $keyUsage, true))
391: {
392: return false;
393: }
394: }
395:
396: // The certificate must name the CA as its issuer.
397: if ($this->x509Info['issuer'] !== $caInfo['subject'])
398: {
399: return false;
400: }
401:
402: // The signature of the certificate must verify with the public key of
403: // the CA. openssl_x509_verify() returns 1 (valid), 0 (invalid) or -1
404: // (error): only 1 is a success.
405: return 1 === openssl_x509_verify($this->cert, $caCert);
406: }
407:
408:
409: /**
410: * Convert PEM encoded X.509 certificate to DER encoding.
411: *
412: * @param string $pemCertificate PEM encoded X.509 certificate.
413: *
414: * @return string|false DER encoded X.509 certificate on success,
415: * FALSE on failure.
416: */
417: static public function pemToDer($pemCertificate)
418: {
419: if (!is_string($pemCertificate))
420: {
421: // Invalid parameter.
422: return false;
423: }
424:
425: $pattern = '/(-----((BEGIN)|(END)) CERTIFICATE-----)/';
426: $base64Content = preg_split($pattern, $pemCertificate);
427: if (!isset($base64Content[1]))
428: {
429: // Invalid PEM certificate.
430: return false;
431: }
432:
433: // Returns the DER encoded certificate.
434: return base64_decode($base64Content[1]);
435: }
436:
437:
438: /**
439: * Convert DER encoded X.509 certificate to PEM encoding.
440: *
441: * @param string $derCertificate DER encoded X.509 certificate.
442: *
443: * @return string|false PEM encoded X.509 certificate on success,
444: * FALSE on failure.
445: */
446: static public function derToPem($derCertificate)
447: {
448: if (!is_string($derCertificate))
449: {
450: // Invalid parameter.
451: return false;
452: }
453:
454: $b64DerCert = base64_encode($derCertificate);
455: $pem = "-----BEGIN CERTIFICATE-----\n";
456: $pem .= implode("\n", str_split($b64DerCert, 65)) . "\n";
457: $pem .= "-----END CERTIFICATE-----\n";
458:
459: return $pem;
460: }
461: }
462: