summaryrefslogtreecommitdiffstats
path: root/library/X509/CertificateUtils.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--library/X509/CertificateUtils.php538
1 files changed, 538 insertions, 0 deletions
diff --git a/library/X509/CertificateUtils.php b/library/X509/CertificateUtils.php
new file mode 100644
index 0000000..e524024
--- /dev/null
+++ b/library/X509/CertificateUtils.php
@@ -0,0 +1,538 @@
+<?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 Icinga\Module\X509\Model\X509Certificate;
+use Icinga\Module\X509\Model\X509CertificateSubjectAltName;
+use Icinga\Module\X509\Model\X509Dn;
+use Icinga\Module\X509\Model\X509Target;
+use ipl\Orm\Model;
+use ipl\Sql\Connection;
+use ipl\Sql\Expression;
+use ipl\Sql\Select;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Str;
+
+use function ipl\Stdlib\yield_groups;
+
+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): string
+ {
+ if (isset($dn['CN'])) {
+ return ((array) $dn['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 $sanStr
+ *
+ * @return array
+ */
+ public static function splitSANs(?string $sanStr): array
+ {
+ $sans = [];
+ foreach (Str::trimSplit($sanStr) as $altName) {
+ if (strpos($altName, ':') === false) {
+ [$k, $v] = Str::trimSplit($altName, '=', 2);
+ } else {
+ [$k, $v] = Str::trimSplit($altName, ':', 2);
+ }
+
+ $sans[$k][] = $v;
+ }
+
+ $order = array_flip(['DNS', 'URI', 'IP Address', 'email', 'DirName']);
+ uksort($sans, function ($a, $b) use ($order) {
+ return ($order[$a] ?? PHP_INT_MAX) <=> ($order[$b] ?? PHP_INT_MAX);
+ });
+
+ return $sans;
+ }
+
+ /**
+ * 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 array
+ */
+ public static function findOrInsertCert(Connection $db, $cert)
+ {
+ $dbTool = new DbTool($db);
+
+ $certInfo = openssl_x509_parse($cert);
+
+ $fingerprint = openssl_x509_fingerprint($cert, 'sha256', true);
+
+ $row = X509Certificate::on($db);
+ $row
+ ->columns(['id', 'issuer_hash'])
+ ->filter(Filter::equal('fingerprint', $fingerprint));
+
+ $row = $row->first();
+ if ($row) {
+ return [$row->id, $row->issuer_hash];
+ }
+
+ 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']);
+
+ $sans = static::splitSANs($certInfo['extensions']['subjectAltName'] ?? null);
+ if (! isset($certInfo['subject']['CN']) && ! empty($sans)) {
+ $subject = current($sans)[0];
+ } else {
+ $subject = self::shortNameFromDN($certInfo['subject']);
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate',
+ [
+ 'subject' => $subject,
+ 'subject_hash' => $dbTool->marshalBinary($subjectHash),
+ 'issuer' => CertificateUtils::shortNameFromDN($certInfo['issuer']),
+ 'issuer_hash' => $dbTool->marshalBinary($issuerHash),
+ 'version' => $certInfo['version'] + 1,
+ 'self_signed' => $subjectHash === $issuerHash ? 'y' : 'n',
+ 'ca' => $ca ? 'y' : 'n',
+ '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'] * 1000.0,
+ 'valid_to' => $certInfo['validTo_time_t'] * 1000.0,
+ 'fingerprint' => $dbTool->marshalBinary($fingerprint),
+ 'serial' => $dbTool->marshalBinary(gmp_export($certInfo['serialNumber'])),
+ 'certificate' => $dbTool->marshalBinary($der),
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+
+ $certId = $db->lastInsertId();
+
+ CertificateUtils::insertSANs($db, $certId, $sans);
+
+ return [$certId, $issuerHash];
+ }
+
+ private static function insertSANs($db, $certId, iterable $sans): void
+ {
+ $dbTool = new DbTool($db);
+ foreach ($sans as $type => $values) {
+ foreach ($values as $value) {
+ $hash = hash('sha256', sprintf('%s=%s', $type, $value), true);
+
+ $row = X509CertificateSubjectAltName::on($db);
+ $row->columns([new Expression('1')]);
+
+ $filter = Filter::all(
+ Filter::equal('certificate_id', $certId),
+ Filter::equal('hash', $hash)
+ );
+
+ $row->filter($filter);
+
+ // Ignore duplicate SANs
+ if ($row->execute()->hasResult()) {
+ continue;
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_certificate_subject_alt_name',
+ [
+ 'certificate_id' => $certId,
+ 'hash' => $dbTool->marshalBinary($hash),
+ 'type' => $type,
+ 'value' => $value,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ }
+ }
+ }
+
+ private static function findOrInsertDn($db, $certInfo, $type)
+ {
+ $dbTool = new DbTool($db);
+
+ $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 = X509Dn::on($db);
+ $row
+ ->columns(['hash'])
+ ->filter(Filter::all(
+ Filter::equal('hash', $hash),
+ Filter::equal('type', $type)
+ ))
+ ->limit(1);
+
+ $row = $row->first();
+ if ($row) {
+ return $row->hash;
+ }
+
+ $index = 0;
+ foreach ($dn as $key => $value) {
+ if (!is_array($value)) {
+ $values = [$value];
+ } else {
+ $values = $value;
+ }
+
+ foreach ($values as $value) {
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->insert(
+ 'x509_dn',
+ [
+ 'hash' => $dbTool->marshalBinary($hash),
+ $db->quoteIdentifier('key') => $key,
+ $db->quoteIdentifier('value') => $value,
+ $db->quoteIdentifier('order') => $index,
+ 'type' => $type,
+ 'ctime' => new Expression('UNIX_TIMESTAMP() * 1000')
+ ]
+ );
+ $index++;
+ }
+ }
+
+ return $hash;
+ }
+
+ /**
+ * Remove certificates that are no longer in use
+ *
+ * Remove chains that aren't used by any target, certificates that aren't part of any chain, and DNs
+ * that aren't used anywhere.
+ *
+ * @param Connection $conn
+ */
+ public static function cleanupNoLongerUsedCertificates(Connection $conn)
+ {
+ $chainQuery = $conn->delete(
+ 'x509_certificate_chain',
+ ['id NOT IN ?' => X509Target::on($conn)->columns('latest_certificate_chain_id')->assembleSelect()]
+ );
+
+ $rows = $chainQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificate chains that are not used by any targets', $rows);
+ }
+
+ $certsQuery = $conn->delete('x509_certificate', [
+ 'id NOT IN ?' => (new Select())
+ ->from('x509_certificate_chain_link ccl')
+ ->columns(['ccl.certificate_id'])
+ ->distinct(),
+ 'trusted = ?' => 'n',
+ ]);
+
+ $rows = $certsQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d certificates that are not part of any chains', $rows);
+ }
+
+ $dnQuery = $conn->delete('x509_dn', [
+ 'hash NOT IN ?' => X509Certificate::on($conn)->columns('subject_hash')->assembleSelect()
+ ]);
+
+ $rows = $dnQuery->rowCount();
+ if ($rows > 0) {
+ Logger::info('Removed %d DNs that are not used anywhere', $rows);
+ }
+ }
+
+ /**
+ * 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 = X509Certificate::on($db);
+ $cas
+ ->columns(['certificate'])
+ ->filter(Filter::all(
+ Filter::equal('ca', true),
+ Filter::equal('trusted', true)
+ ));
+
+ $contents = [];
+ /** @var Model $ca */
+ foreach ($cas as $ca) {
+ $contents[] = $ca->certificate;
+ }
+
+ if (empty($contents)) {
+ throw new \RuntimeException('Trust store is empty');
+ }
+
+ $files->create($caFile, implode("\n", $contents));
+
+ $count = 0;
+ $certs = X509Certificate::on($db)
+ ->with(['chain'])
+ ->utilize('chain.target')
+ ->columns(['chain.id', 'certificate'])
+ ->filter(Filter::equal('chain.valid', false))
+ ->orderBy('chain.id')
+ ->orderBy(new Expression('certificate_link.order'), SORT_DESC);
+
+ $db->beginTransaction();
+
+ try {
+ $caFile = escapeshellarg($files->resolvePath($caFile));
+ $verifyCertsFunc = function (int $chainId, array $collection) use ($db, $caFile) {
+ $certFiles = new TemporaryLocalFileStorage();
+ $certFile = uniqid('cert');
+ $certFiles->create($certFile, array_pop($collection));
+
+ $untrusted = '';
+ if (! empty($collection)) {
+ $intermediateFile = uniqid('intermediate');
+ $certFiles->create($intermediateFile, implode("\n", $collection));
+
+ $untrusted = sprintf(
+ ' -untrusted %s',
+ escapeshellarg($certFiles->resolvePath($intermediateFile))
+ );
+ }
+
+ $command = sprintf(
+ 'openssl verify -CAfile %s%s %s 2>&1',
+ $caFile,
+ $untrusted,
+ escapeshellarg($certFiles->resolvePath($certFile))
+ );
+
+ $output = null;
+
+ exec($command, $output, $exitcode);
+
+ $output = implode("\n", $output);
+
+ if ($exitcode !== 0) {
+ Logger::debug('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' => 'y', 'invalid_reason' => null];
+ }
+
+ // TODO: https://github.com/Icinga/ipl-orm/pull/78
+ $db->update('x509_certificate_chain', $set, ['id = ?' => $chainId]);
+ };
+
+ $groupBy = function (X509Certificate $cert): array {
+ // Group all the certificates by their chain id.
+ return [$cert->chain->id, $cert->certificate];
+ };
+
+ foreach (yield_groups($certs, $groupBy) as $chainId => $collection) {
+ ++$count;
+ $verifyCertsFunc($chainId, $collection);
+ }
+
+ $db->commitTransaction();
+ } catch (Exception $e) {
+ Logger::error($e);
+ $db->rollBackTransaction();
+ }
+
+ return $count;
+ }
+}