| 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: |