391 lines
13 KiB
PHP
391 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use App\Helpers\InputValidator;
|
||
|
|
|
||
|
|
class SslService
|
||
|
|
{
|
||
|
|
private const DEFAULT_PORT = 443;
|
||
|
|
private const CONNECT_TIMEOUT = 15;
|
||
|
|
private const EXPIRING_SOON_DAYS = 30;
|
||
|
|
|
||
|
|
private Logger $logger;
|
||
|
|
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->logger = new Logger('ssl');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Normalize a user-supplied SSL host into a monitored hostname for the domain.
|
||
|
|
*/
|
||
|
|
public function normalizeHostname(string $input, string $baseDomain): ?string
|
||
|
|
{
|
||
|
|
$target = $this->parseMonitorTarget($input, $baseDomain);
|
||
|
|
return $target['hostname'] ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse a user-supplied SSL monitoring target into hostname + port.
|
||
|
|
*
|
||
|
|
* @return array{hostname:string,port:int}|null
|
||
|
|
*/
|
||
|
|
public function parseMonitorTarget(string $input, string $baseDomain): ?array
|
||
|
|
{
|
||
|
|
$baseDomain = strtolower(trim($baseDomain));
|
||
|
|
$input = strtolower(trim($input));
|
||
|
|
$port = self::DEFAULT_PORT;
|
||
|
|
|
||
|
|
if ($input === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (str_contains($input, '://') || preg_match('/[\/\\\\\s?#]/', $input)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$colonPos = strrpos($input, ':');
|
||
|
|
if ($colonPos !== false) {
|
||
|
|
$portText = substr($input, $colonPos + 1);
|
||
|
|
if ($portText === '' || !ctype_digit($portText)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$port = (int)$portText;
|
||
|
|
if ($port < 1 || $port > 65535) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$input = substr($input, 0, $colonPos);
|
||
|
|
if ($input === '') {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$hostname = $this->normalizeMonitorHostname($input, $baseDomain);
|
||
|
|
if ($hostname === null) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fetch and parse certificate details for a hostname.
|
||
|
|
*
|
||
|
|
* @return array{status:string,is_trusted:bool,is_self_signed:bool,valid_from:?string,valid_to:?string,days_remaining:?int,issuer_name:?string,subject_name:?string,serial_number:?string,signature_algorithm:?string,key_bits:?int,key_type:?string,certificate_version:?string,san_list:array,last_checked:string,last_error:?string,raw_data:array}
|
||
|
|
*/
|
||
|
|
public function fetchCertificateSnapshot(string $hostname, int $port = self::DEFAULT_PORT): array
|
||
|
|
{
|
||
|
|
$hostname = strtolower(trim($hostname));
|
||
|
|
$now = date('Y-m-d H:i:s');
|
||
|
|
|
||
|
|
$primary = $this->connect($hostname, $port, true);
|
||
|
|
$verified = $primary['success'];
|
||
|
|
$connection = $primary;
|
||
|
|
|
||
|
|
if (!$primary['success']) {
|
||
|
|
$fallback = $this->connect($hostname, $port, false);
|
||
|
|
if ($fallback['success']) {
|
||
|
|
$connection = $fallback;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (empty($connection['certificate'])) {
|
||
|
|
$error = $primary['error'] ?: ($connection['error'] ?? 'Could not retrieve certificate');
|
||
|
|
|
||
|
|
$this->logger->warning('SSL certificate fetch failed', [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
'error' => $error,
|
||
|
|
]);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'status' => 'invalid',
|
||
|
|
'is_trusted' => false,
|
||
|
|
'is_self_signed' => false,
|
||
|
|
'valid_from' => null,
|
||
|
|
'valid_to' => null,
|
||
|
|
'days_remaining' => null,
|
||
|
|
'issuer_name' => null,
|
||
|
|
'subject_name' => null,
|
||
|
|
'serial_number' => null,
|
||
|
|
'signature_algorithm' => null,
|
||
|
|
'key_bits' => null,
|
||
|
|
'key_type' => null,
|
||
|
|
'certificate_version' => null,
|
||
|
|
'san_list' => [],
|
||
|
|
'last_checked' => $now,
|
||
|
|
'last_error' => $error,
|
||
|
|
'raw_data' => [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
'verified_attempt_error' => $primary['error'] ?? null,
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$parsed = @openssl_x509_parse($connection['certificate']);
|
||
|
|
if (!is_array($parsed)) {
|
||
|
|
return [
|
||
|
|
'status' => 'invalid',
|
||
|
|
'is_trusted' => false,
|
||
|
|
'is_self_signed' => false,
|
||
|
|
'valid_from' => null,
|
||
|
|
'valid_to' => null,
|
||
|
|
'days_remaining' => null,
|
||
|
|
'issuer_name' => null,
|
||
|
|
'subject_name' => null,
|
||
|
|
'serial_number' => null,
|
||
|
|
'signature_algorithm' => null,
|
||
|
|
'key_bits' => null,
|
||
|
|
'key_type' => null,
|
||
|
|
'certificate_version' => null,
|
||
|
|
'san_list' => [],
|
||
|
|
'last_checked' => $now,
|
||
|
|
'last_error' => 'Could not parse certificate',
|
||
|
|
'raw_data' => [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
'verified_attempt_error' => $primary['error'] ?? null,
|
||
|
|
],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$publicKeyDetails = $this->getPublicKeyDetails($connection['certificate']);
|
||
|
|
$validFromTs = isset($parsed['validFrom_time_t']) ? (int)$parsed['validFrom_time_t'] : null;
|
||
|
|
$validToTs = isset($parsed['validTo_time_t']) ? (int)$parsed['validTo_time_t'] : null;
|
||
|
|
$daysRemaining = $validToTs !== null ? (int)floor(($validToTs - time()) / 86400) : null;
|
||
|
|
$subjectName = $this->formatDistinguishedName($parsed['subject'] ?? []);
|
||
|
|
$issuerName = $this->formatDistinguishedName($parsed['issuer'] ?? []);
|
||
|
|
$isSelfSigned = $subjectName !== '' && $subjectName === $issuerName;
|
||
|
|
$sanList = $this->extractSanList($parsed);
|
||
|
|
$status = $this->determineStatus($verified, $daysRemaining);
|
||
|
|
$error = $primary['error'] ?? null;
|
||
|
|
|
||
|
|
$snapshot = [
|
||
|
|
'status' => $status,
|
||
|
|
'is_trusted' => $verified,
|
||
|
|
'is_self_signed' => $isSelfSigned,
|
||
|
|
'valid_from' => $validFromTs ? date('Y-m-d H:i:s', $validFromTs) : null,
|
||
|
|
'valid_to' => $validToTs ? date('Y-m-d H:i:s', $validToTs) : null,
|
||
|
|
'days_remaining' => $daysRemaining,
|
||
|
|
'issuer_name' => $issuerName ?: null,
|
||
|
|
'subject_name' => $subjectName ?: null,
|
||
|
|
'serial_number' => $parsed['serialNumberHex'] ?? ($parsed['serialNumber'] ?? null),
|
||
|
|
'signature_algorithm' => $parsed['signatureTypeLN'] ?? ($parsed['signatureTypeSN'] ?? null),
|
||
|
|
'key_bits' => $publicKeyDetails['bits'],
|
||
|
|
'key_type' => $publicKeyDetails['type'],
|
||
|
|
'certificate_version' => isset($parsed['version']) ? 'v' . ((int)$parsed['version'] + 1) : null,
|
||
|
|
'san_list' => $sanList,
|
||
|
|
'last_checked' => $now,
|
||
|
|
'last_error' => $status === 'valid' || $status === 'expiring' || $status === 'expired' ? null : $error,
|
||
|
|
'raw_data' => [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
'subject' => $parsed['subject'] ?? [],
|
||
|
|
'issuer' => $parsed['issuer'] ?? [],
|
||
|
|
'extensions' => $parsed['extensions'] ?? [],
|
||
|
|
'verified_attempt_error' => $primary['error'] ?? null,
|
||
|
|
'san_list' => $sanList,
|
||
|
|
],
|
||
|
|
];
|
||
|
|
|
||
|
|
$this->logger->info('SSL certificate fetched', [
|
||
|
|
'hostname' => $hostname,
|
||
|
|
'port' => $port,
|
||
|
|
'status' => $snapshot['status'],
|
||
|
|
'trusted' => $snapshot['is_trusted'],
|
||
|
|
'days_remaining' => $snapshot['days_remaining'],
|
||
|
|
]);
|
||
|
|
|
||
|
|
return $snapshot;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format a monitored target for display and notifications.
|
||
|
|
*/
|
||
|
|
public function formatTargetLabel(string $hostname, int $port = self::DEFAULT_PORT): string
|
||
|
|
{
|
||
|
|
$hostname = strtolower(trim($hostname));
|
||
|
|
return $port === self::DEFAULT_PORT ? $hostname : $hostname . ':' . $port;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function determineStatus(bool $verified, ?int $daysRemaining): string
|
||
|
|
{
|
||
|
|
if ($daysRemaining !== null && $daysRemaining < 0) {
|
||
|
|
return 'expired';
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$verified) {
|
||
|
|
return 'invalid';
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($daysRemaining !== null && $daysRemaining <= self::EXPIRING_SOON_DAYS) {
|
||
|
|
return 'expiring';
|
||
|
|
}
|
||
|
|
|
||
|
|
return 'valid';
|
||
|
|
}
|
||
|
|
|
||
|
|
private function connect(string $hostname, int $port, bool $verifyPeer): array
|
||
|
|
{
|
||
|
|
$context = stream_context_create([
|
||
|
|
'ssl' => [
|
||
|
|
'capture_peer_cert' => true,
|
||
|
|
'capture_peer_cert_chain' => true,
|
||
|
|
'SNI_enabled' => true,
|
||
|
|
'peer_name' => $hostname,
|
||
|
|
'verify_peer' => $verifyPeer,
|
||
|
|
'verify_peer_name' => $verifyPeer,
|
||
|
|
'allow_self_signed' => !$verifyPeer,
|
||
|
|
'disable_compression' => true,
|
||
|
|
],
|
||
|
|
]);
|
||
|
|
|
||
|
|
$errno = 0;
|
||
|
|
$errstr = '';
|
||
|
|
$warning = null;
|
||
|
|
|
||
|
|
set_error_handler(static function (int $severity, string $message) use (&$warning): bool {
|
||
|
|
$warning = $message;
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
$socket = @stream_socket_client(
|
||
|
|
"ssl://{$hostname}:{$port}",
|
||
|
|
$errno,
|
||
|
|
$errstr,
|
||
|
|
self::CONNECT_TIMEOUT,
|
||
|
|
STREAM_CLIENT_CONNECT,
|
||
|
|
$context
|
||
|
|
);
|
||
|
|
} finally {
|
||
|
|
restore_error_handler();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$socket) {
|
||
|
|
return [
|
||
|
|
'success' => false,
|
||
|
|
'error' => $warning ?: $errstr ?: ('Connection failed (' . $errno . ')'),
|
||
|
|
'certificate' => null,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
$params = stream_context_get_params($socket);
|
||
|
|
fclose($socket);
|
||
|
|
|
||
|
|
return [
|
||
|
|
'success' => true,
|
||
|
|
'error' => $warning,
|
||
|
|
'certificate' => $params['options']['ssl']['peer_certificate'] ?? null,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function getPublicKeyDetails($certificate): array
|
||
|
|
{
|
||
|
|
$publicKey = @openssl_pkey_get_public($certificate);
|
||
|
|
if ($publicKey === false) {
|
||
|
|
return ['bits' => null, 'type' => null];
|
||
|
|
}
|
||
|
|
|
||
|
|
$details = @openssl_pkey_get_details($publicKey) ?: [];
|
||
|
|
if (PHP_VERSION_ID < 80000) {
|
||
|
|
@openssl_free_key($publicKey);
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'bits' => isset($details['bits']) ? (int)$details['bits'] : null,
|
||
|
|
'type' => $this->mapKeyType($details['type'] ?? null),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function mapKeyType(?int $type): ?string
|
||
|
|
{
|
||
|
|
return match ($type) {
|
||
|
|
OPENSSL_KEYTYPE_RSA => 'RSA',
|
||
|
|
OPENSSL_KEYTYPE_DSA => 'DSA',
|
||
|
|
OPENSSL_KEYTYPE_DH => 'DH',
|
||
|
|
OPENSSL_KEYTYPE_EC => 'EC',
|
||
|
|
default => null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private function extractSanList(array $parsed): array
|
||
|
|
{
|
||
|
|
$sanText = $parsed['extensions']['subjectAltName'] ?? '';
|
||
|
|
if ($sanText === '') {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = [];
|
||
|
|
foreach (explode(',', $sanText) as $entry) {
|
||
|
|
$entry = trim($entry);
|
||
|
|
if (str_starts_with($entry, 'DNS:')) {
|
||
|
|
$result[] = substr($entry, 4);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return array_values(array_unique(array_filter($result)));
|
||
|
|
}
|
||
|
|
|
||
|
|
private function formatDistinguishedName(array $parts): string
|
||
|
|
{
|
||
|
|
if (!empty($parts['CN'])) {
|
||
|
|
return (string)$parts['CN'];
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (['O', 'OU', 'emailAddress'] as $field) {
|
||
|
|
if (!empty($parts[$field])) {
|
||
|
|
return (string)$parts[$field];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$values = [];
|
||
|
|
foreach ($parts as $key => $value) {
|
||
|
|
if (is_scalar($value) && $value !== '') {
|
||
|
|
$values[] = $key . '=' . $value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return implode(', ', $values);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function normalizeMonitorHostname(string $input, string $baseDomain): ?string
|
||
|
|
{
|
||
|
|
if ($input === '' || $input === '@') {
|
||
|
|
return $baseDomain;
|
||
|
|
}
|
||
|
|
|
||
|
|
$input = rtrim($input, '.');
|
||
|
|
|
||
|
|
if ($input === $baseDomain) {
|
||
|
|
return $baseDomain;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (InputValidator::validateDomain($input) && str_ends_with($input, '.' . $baseDomain)) {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$this->isValidRelativeHost($input)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
$candidate = $input . '.' . $baseDomain;
|
||
|
|
return InputValidator::validateDomain($candidate) ? $candidate : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function isValidRelativeHost(string $host): bool
|
||
|
|
{
|
||
|
|
return (bool)preg_match(
|
||
|
|
'/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/i',
|
||
|
|
$host
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|