diff options
Diffstat (limited to '')
-rw-r--r-- | library/X509/CertificateUtils.php | 460 |
1 files changed, 460 insertions, 0 deletions
diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php new file mode 100644 index 0000000..c538444 --- /dev/null +++ b/library/X509/CertificateUtils.php @@ -0,0 +1,460 @@ +<?php +// Icinga Web 2 X.509 Module | (c) 2018 Icinga GmbH | GPLv2 + +namespace Icinga\Module\X509; + +use Exception; +use Icinga\Application\Logger; +use Icinga\File\Storage\TemporaryLocalFileStorage; +use ipl\Sql\Connection; +use ipl\Sql\Select; + +class CertificateUtils +{ + /** + * Possible public key types + * + * @var string[] + */ + protected static $pubkeyTypes = [ + -1 => 'unknown', + OPENSSL_KEYTYPE_RSA => 'RSA', + OPENSSL_KEYTYPE_DSA => 'DSA', + OPENSSL_KEYTYPE_DH => 'DH', + OPENSSL_KEYTYPE_EC => 'EC' + ]; + + /** + * Convert the given chunk from PEM to DER + * + * @param string $pem + * + * @return string + */ + public static function pem2der($pem) + { + $lines = explode("\n", $pem); + + $der = ''; + + foreach ($lines as $line) { + if (strpos($line, '-----') === 0) { + continue; + } + + $der .= base64_decode($line); + } + + return $der; + } + + /** + * Convert the given chunk from DER to PEM + * + * @param string $der + * + * @return string + */ + public static function der2pem($der) + { + $block = chunk_split(base64_encode($der), 64, "\n"); + + return "-----BEGIN CERTIFICATE-----\n{$block}-----END CERTIFICATE-----"; + } + + /** + * Format seconds to human-readable duration + * + * @param int $seconds + * + * @return string + */ + public static function duration($seconds) + { + if ($seconds < 60) { + return "$seconds Seconds"; + } + + if ($seconds < 3600) { + $minutes = round($seconds / 60); + + return "$minutes Minutes"; + } + + if ($seconds < 86400) { + $hours = round($seconds / 3600); + + return "$hours Hours"; + } + + if ($seconds < 604800) { + $days = round($seconds / 86400); + + return "$days Days"; + } + + if ($seconds < 2592000) { + $weeks = round($seconds / 604800); + + return "$weeks Weeks"; + } + + if ($seconds < 31536000) { + $months = round($seconds / 2592000); + + return "$months Months"; + } + + $years = round($seconds / 31536000); + + return "$years Years"; + } + + /** + * Get the short name from the given DN + * + * If the given DN contains a CN, the CN is returned. Else, the DN is returned as string. + * + * @param array $dn + * + * @return string The CN if it exists or the full DN as string + */ + private static function shortNameFromDN(array $dn) + { + if (isset($dn['CN'])) { + $cn = (array) $dn['CN']; + return $cn[0]; + } else { + $result = []; + foreach ($dn as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $result[] = "{$key}={$item}"; + } + } else { + $result[] = "{$key}={$value}"; + } + } + + return implode(', ', $result); + } + } + + /** + * Split the given Subject Alternative Names into key-value pairs + * + * @param string $sans + * + * @return \Generator + */ + private static function splitSANs($sans) + { + preg_match_all('/(?:^|, )([^:]+):/', $sans, $keys); + $values = preg_split('/(^|, )[^:]+:\s*/', $sans); + for ($i = 0; $i < count($keys[1]); $i++) { + yield [$keys[1][$i], $values[$i + 1]]; + } + } + + /** + * Yield certificates in the given bundle + * + * @param string $file Path to the bundle + * + * @return \Generator + */ + public static function parseBundle($file) + { + $content = file_get_contents($file); + + $blocks = explode('-----BEGIN CERTIFICATE-----', $content); + + foreach ($blocks as $block) { + $end = strrpos($block, '-----END CERTIFICATE-----'); + + if ($end !== false) { + yield '-----BEGIN CERTIFICATE-----' . substr($block, 0, $end) . '-----END CERTIFICATE-----'; + } + } + } + + /** + * Find or insert the given certificate and return its ID + * + * @param Connection $db + * @param mixed $cert + * + * @return int + */ + public static function findOrInsertCert(Connection $db, $cert) + { + $certInfo = openssl_x509_parse($cert); + + $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true); + + $row = $db->select( + (new Select()) + ->columns(['id']) + ->from('x509_certificate') + ->where(['fingerprint = ?' => $fingerprint]) + )->fetch(); + + if ($row !== false) { + return (int) $row['id']; + } + + Logger::debug("Importing certificate: %s", $certInfo['name']); + + $pem = null; + if (! openssl_x509_export($cert, $pem)) { + die('Failed to encode X.509 certificate.'); + } + $der = CertificateUtils::pem2der($pem); + + $ca = false; + if (isset($certInfo['extensions']['basicConstraints'])) { + if (strpos($certInfo['extensions']['basicConstraints'], 'CA:TRUE') !== false) { + $ca = true; + } + } + + $subjectHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'subject'); + $issuerHash = CertificateUtils::findOrInsertDn($db, $certInfo, 'issuer'); + $pubkey = openssl_pkey_get_details(openssl_pkey_get_public($cert)); + $signature = explode('-', $certInfo['signatureTypeSN']); + + $db->insert( + 'x509_certificate', + [ + 'subject' => CertificateUtils::shortNameFromDN($certInfo['subject']), + 'subject_hash' => $subjectHash, + 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']), + 'issuer_hash' => $issuerHash, + 'version' => $certInfo['version'] + 1, + 'self_signed' => $subjectHash === $issuerHash ? 'yes' : 'no', + 'ca' => $ca ? 'yes' : 'no', + 'pubkey_algo' => CertificateUtils::$pubkeyTypes[$pubkey['type']], + 'pubkey_bits' => $pubkey['bits'], + 'signature_algo' => array_shift($signature), // Support formats like RSA-SHA1 and + 'signature_hash_algo' => array_pop($signature), // ecdsa-with-SHA384 + 'valid_from' => $certInfo['validFrom_time_t'], + 'valid_to' => $certInfo['validTo_time_t'], + 'fingerprint' => $fingerprint, + 'serial' => gmp_export($certInfo['serialNumber']), + 'certificate' => $der + ] + ); + + $certId = (int) $db->lastInsertId(); + + CertificateUtils::insertSANs($db, $certId, $certInfo); + + return $certId; + } + + private static function insertSANs($db, $certId, array $certInfo) + { + if (isset($certInfo['extensions']['subjectAltName'])) { + foreach (CertificateUtils::splitSANs($certInfo['extensions']['subjectAltName']) as $san) { + list($type, $value) = $san; + + $hash = hash('sha256', sprintf('%s=%s', $type, $value), true); + + $row = $db->select( + (new Select()) + ->from('x509_certificate_subject_alt_name') + ->columns('certificate_id') + ->where([ + 'certificate_id = ?' => $certId, + 'hash = ?' => $hash + ]) + )->fetch(); + + // Ignore duplicate SANs + if ($row !== false) { + continue; + } + + $db->insert( + 'x509_certificate_subject_alt_name', + [ + 'certificate_id' => $certId, + 'hash' => $hash, + 'type' => $type, + 'value' => $value + ] + ); + } + } + } + + private static function findOrInsertDn($db, $certInfo, $type) + { + $dn = $certInfo[$type]; + + $data = ''; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $data .= "{$key}=${value}, "; + } + } + $hash = hash('sha256', $data, true); + + $row = $db->select( + (new Select()) + ->from('x509_dn') + ->columns('hash') + ->where([ 'hash = ?' => $hash, 'type = ?' => $type ]) + ->limit(1) + )->fetch(); + + if ($row !== false) { + return $row['hash']; + } + + $index = 0; + foreach ($dn as $key => $value) { + if (!is_array($value)) { + $values = [$value]; + } else { + $values = $value; + } + + foreach ($values as $value) { + $db->insert( + 'x509_dn', + [ + 'hash' => $hash, + '`key`' => $key, + '`value`' => $value, + '`order`' => $index, + 'type' => $type + ] + ); + $index++; + } + } + + return $hash; + } + + /** + * Verify certificates + * + * @param Connection $db Connection to the X.509 database + * + * @return int + */ + public static function verifyCertificates(Connection $db) + { + $files = new TemporaryLocalFileStorage(); + + $caFile = uniqid('ca'); + + $cas = $db->select( + (new Select) + ->from('x509_certificate') + ->columns(['certificate']) + ->where(['ca = ?' => 'yes', 'trusted = ?' => 'yes']) + ); + + $contents = []; + + foreach ($cas as $ca) { + $contents[] = static::der2pem($ca['certificate']); + } + + if (empty($contents)) { + throw new \RuntimeException('Trust store is empty'); + } + + $files->create($caFile, implode("\n", $contents)); + + $count = 0; + + $db->beginTransaction(); + + try { + $chains = $db->select( + (new Select) + ->from('x509_certificate_chain c') + ->join('x509_target t', ['t.latest_certificate_chain_id = c.id', 'c.valid = ?' => 'no']) + ->columns('c.id') + ); + + foreach ($chains as $chain) { + ++$count; + + $certs = $db->select( + (new Select) + ->from('x509_certificate c') + ->columns('c.certificate') + ->join('x509_certificate_chain_link ccl', 'ccl.certificate_id = c.id') + ->where(['ccl.certificate_chain_id = ?' => $chain['id']]) + ->orderBy(['ccl.order' => 'DESC']) + ); + + $collection = []; + + foreach ($certs as $cert) { + $collection[] = CertificateUtils::der2pem($cert['certificate']); + } + + $certFile = uniqid('cert'); + + $files->create($certFile, array_pop($collection)); + + $untrusted = ''; + foreach ($collection as $intermediate) { + $intermediateFile = uniqid('intermediate'); + $files->create($intermediateFile, $intermediate); + $untrusted .= ' -untrusted ' . escapeshellarg($files->resolvePath($intermediateFile)); + } + + $command = sprintf( + 'openssl verify -CAfile %s%s %s 2>&1', + escapeshellarg($files->resolvePath($caFile)), + $untrusted, + escapeshellarg($files->resolvePath($certFile)) + ); + + $output = null; + + exec($command, $output, $exitcode); + + $output = implode("\n", $output); + + if ($exitcode !== 0) { + Logger::warning('openssl verify failed for command %s: %s', $command, $output); + } + + preg_match('/^error \d+ at \d+ depth lookup:(.+)$/m', $output, $match); + + if (!empty($match)) { + $set = ['invalid_reason' => trim($match[1])]; + } else { + $set = ['valid' => 'yes', 'invalid_reason' => null]; + } + + $db->update( + 'x509_certificate_chain', + $set, + ['id = ?' => $chain['id']] + ); + } + + $db->commitTransaction(); + } catch (Exception $e) { + Logger::error($e); + $db->rollBackTransaction(); + } + + return $count; + } +} |