|
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
const CONNECT_TIMEOUT = 12; // seconds
|
|
const CONNECT_TIMEOUT = 12; // seconds
|
|
|
const EHLO_NAME = 'tool.medowar.de';
|
|
const EHLO_NAME = 'tool.medowar.de';
|
|
|
|
|
+const CIPHER_PROBE_TIMEOUT = 6; // per-cipher probe, kept short so enumeration stays snappy
|
|
|
|
|
+const MAX_CIPHERS_PER_VERSION = 64; // safety cap on the exclusion loop
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Connects to a mail server, negotiates STARTTLS (or implicit TLS), and reports
|
|
* Connects to a mail server, negotiates STARTTLS (or implicit TLS), and reports
|
|
@@ -30,6 +32,7 @@ class MailTlsChecker
|
|
|
'cert' => null,
|
|
'cert' => null,
|
|
|
'tls_version' => null,
|
|
'tls_version' => null,
|
|
|
'cipher' => null,
|
|
'cipher' => null,
|
|
|
|
|
+ 'ciphers' => [], // version => list of accepted cipher suites
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
// 1) Handshake with full verification to learn whether the chain is trusted.
|
|
// 1) Handshake with full verification to learn whether the chain is trusted.
|
|
@@ -79,9 +82,125 @@ class MailTlsChecker
|
|
|
&& $report['hostname_ok']
|
|
&& $report['hostname_ok']
|
|
|
&& ($report['cert']['time_ok'] ?? false);
|
|
&& ($report['cert']['time_ok'] ?? false);
|
|
|
|
|
|
|
|
|
|
+ // 3) Enumerate every cipher suite the server is willing to negotiate on
|
|
|
|
|
+ // this port. Best-effort — never let it break the main report.
|
|
|
|
|
+ try {
|
|
|
|
|
+ $report['ciphers'] = $this->enumerateCiphers($host, $port, $protocol);
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ // ignore — the core TLS/cert report above is what matters.
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return $report;
|
|
return $report;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Discovers which cipher suites the server accepts, per TLS version.
|
|
|
|
|
+ *
|
|
|
|
|
+ * For TLS 1.2 and below we offer the full set, note the suite the server
|
|
|
|
|
+ * picks, exclude it, and repeat until no common cipher remains — this yields
|
|
|
|
|
+ * the complete list of server-supported suites in one connection each. PHP
|
|
|
|
|
+ * cannot restrict the TLS 1.3 ciphersuite list, so for 1.3 we can only report
|
|
|
|
|
+ * the single suite that gets negotiated.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array<string,string[]> e.g. ['TLS 1.2' => ['ECDHE-RSA-AES256-GCM-SHA384', ...]]
|
|
|
|
|
+ */
|
|
|
|
|
+ public function enumerateCiphers(string $host, int $port, string $protocol): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $versions = [];
|
|
|
|
|
+ if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) { $versions['TLS 1.3'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; }
|
|
|
|
|
+ if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { $versions['TLS 1.2'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; }
|
|
|
|
|
+ if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { $versions['TLS 1.1'] = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; }
|
|
|
|
|
+ if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT')) { $versions['TLS 1.0'] = STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; }
|
|
|
|
|
+
|
|
|
|
|
+ $tls13Method = defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') ? STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT : -1;
|
|
|
|
|
+ $out = [];
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($versions as $label => $method) {
|
|
|
|
|
+ $isTls13 = ($method === $tls13Method);
|
|
|
|
|
+ $found = [];
|
|
|
|
|
+ for ($i = 0; $i < MAX_CIPHERS_PER_VERSION; $i++) {
|
|
|
|
|
+ // null exclude => don't touch the cipher list (TLS 1.3 case).
|
|
|
|
|
+ $name = $this->probeCipher($host, $port, $protocol, $method, $isTls13 ? null : $found);
|
|
|
|
|
+ if ($name === null || in_array($name, $found, true)) {
|
|
|
|
|
+ break; // no (further) suite negotiated for this version.
|
|
|
|
|
+ }
|
|
|
|
|
+ $found[] = $name;
|
|
|
|
|
+ if ($isTls13) {
|
|
|
|
|
+ break; // cannot iterate the 1.3 suite list from PHP.
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($found) {
|
|
|
|
|
+ $out[$label] = $found;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Attempts a single handshake at a fixed TLS version, optionally offering
|
|
|
|
|
+ * every cipher except those in $exclude, and returns the negotiated cipher
|
|
|
|
|
+ * suite name (or null if the handshake did not complete).
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param string[]|null $exclude Suites to withhold, or null to leave the
|
|
|
|
|
+ * cipher list untouched (used for TLS 1.3).
|
|
|
|
|
+ */
|
|
|
|
|
+ private function probeCipher(string $host, int $port, string $protocol, int $method, ?array $exclude): ?string
|
|
|
|
|
+ {
|
|
|
|
|
+ $ssl = [
|
|
|
|
|
+ 'verify_peer' => false,
|
|
|
|
|
+ 'verify_peer_name' => false,
|
|
|
|
|
+ 'allow_self_signed' => true,
|
|
|
|
|
+ 'SNI_enabled' => true,
|
|
|
|
|
+ 'peer_name' => $host,
|
|
|
|
|
+ ];
|
|
|
|
|
+ if ($exclude !== null) {
|
|
|
|
|
+ // @SECLEVEL=0 lets us also see weak/legacy suites the server still offers.
|
|
|
|
|
+ $parts = ['ALL', 'COMPLEMENTOFALL'];
|
|
|
|
|
+ foreach ($exclude as $c) {
|
|
|
|
|
+ $parts[] = '!' . $c;
|
|
|
|
|
+ }
|
|
|
|
|
+ $parts[] = '@SECLEVEL=0';
|
|
|
|
|
+ $ssl['ciphers'] = implode(':', $parts);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $ctx = stream_context_create(['ssl' => $ssl]);
|
|
|
|
|
+ $errno = 0; $errstr = '';
|
|
|
|
|
+ $stream = @stream_socket_client(
|
|
|
|
|
+ sprintf('tcp://%s:%d', $host, $port),
|
|
|
|
|
+ $errno, $errstr, CIPHER_PROBE_TIMEOUT, STREAM_CLIENT_CONNECT, $ctx
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!is_resource($stream)) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ stream_set_timeout($stream, CIPHER_PROBE_TIMEOUT);
|
|
|
|
|
+
|
|
|
|
|
+ if (!in_array($protocol, ['smtps', 'imaps', 'pop3s'], true)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ $t = [];
|
|
|
|
|
+ $this->negotiateStartTls($stream, $protocol, $t);
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ fclose($stream);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ openssl_error_string(); // clear stale queue
|
|
|
|
|
+ $ok = @stream_socket_enable_crypto($stream, true, $method);
|
|
|
|
|
+ if ($ok !== true) {
|
|
|
|
|
+ fclose($stream);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $name = null;
|
|
|
|
|
+ foreach ((stream_get_meta_data($stream)['crypto'] ?? []) as $k => $v) {
|
|
|
|
|
+ if ($k === 'cipher_name') { $name = $v; }
|
|
|
|
|
+ }
|
|
|
|
|
+ fclose($stream);
|
|
|
|
|
+
|
|
|
|
|
+ return ($name !== null && $name !== '') ? $name : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Opens a TCP connection, runs the STARTTLS dance if required, then enables
|
|
* Opens a TCP connection, runs the STARTTLS dance if required, then enables
|
|
|
* crypto. Returns the stream resource on success. On verification failure
|
|
* crypto. Returns the stream resource on success. On verification failure
|
|
@@ -373,13 +492,13 @@ const PROTOCOL_LABELS = [
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
$host = trim((string) ($_GET['host'] ?? ''));
|
|
$host = trim((string) ($_GET['host'] ?? ''));
|
|
|
-$protocol = (string) ($_GET['protocol'] ?? 'smtp');
|
|
|
|
|
|
|
+$protocol = (string) ($_GET['protocol'] ?? 'smtp25');
|
|
|
$portRaw = trim((string) ($_GET['port'] ?? ''));
|
|
$portRaw = trim((string) ($_GET['port'] ?? ''));
|
|
|
$report = null;
|
|
$report = null;
|
|
|
$inputError = null;
|
|
$inputError = null;
|
|
|
|
|
|
|
|
if (!isset(DEFAULT_PORTS[$protocol])) {
|
|
if (!isset(DEFAULT_PORTS[$protocol])) {
|
|
|
- $protocol = 'smtp';
|
|
|
|
|
|
|
+ $protocol = 'smtp25';
|
|
|
}
|
|
}
|
|
|
$port = $portRaw !== '' ? (int) $portRaw : DEFAULT_PORTS[$protocol];
|
|
$port = $portRaw !== '' ? (int) $portRaw : DEFAULT_PORTS[$protocol];
|
|
|
|
|
|
|
@@ -405,6 +524,21 @@ function fmtDate(?int $ts): string
|
|
|
{
|
|
{
|
|
|
return $ts ? gmdate('Y-m-d H:i:s', $ts) . ' UTC' : '—';
|
|
return $ts ? gmdate('Y-m-d H:i:s', $ts) . ' UTC' : '—';
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+/** Rough strength bucket for a cipher suite, used only for colour-coding. */
|
|
|
|
|
+function cipherStrength(string $name): string
|
|
|
|
|
+{
|
|
|
|
|
+ $n = strtoupper($name);
|
|
|
|
|
+ if (preg_match('/NULL|EXP|RC4|DES|MD5|ADH|AECDH|ANON|SEED|IDEA/', $n)) {
|
|
|
|
|
+ return 'weak';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (str_starts_with($n, 'TLS_')
|
|
|
|
|
+ || str_contains($n, 'GCM') || str_contains($n, 'CHACHA')
|
|
|
|
|
+ || str_contains($n, 'POLY1305') || str_contains($n, 'CCM')) {
|
|
|
|
|
+ return 'strong';
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'medium'; // typically CBC-mode AEAD-less suites
|
|
|
|
|
+}
|
|
|
?>
|
|
?>
|
|
|
<!DOCTYPE html>
|
|
<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
<html lang="en">
|
|
@@ -555,6 +689,34 @@ function fmtDate(?int $ts): string
|
|
|
<tr><th>Cipher</th><td class="mono"><?= htmlspecialchars($report['cipher'] ?: '—') ?></td></tr>
|
|
<tr><th>Cipher</th><td class="mono"><?= htmlspecialchars($report['cipher'] ?: '—') ?></td></tr>
|
|
|
</table>
|
|
</table>
|
|
|
|
|
|
|
|
|
|
+ <?php if (!empty($report['ciphers'])): ?>
|
|
|
|
|
+ <h2>Supported ciphers</h2>
|
|
|
|
|
+ <table>
|
|
|
|
|
+ <?php foreach ($report['ciphers'] as $ver => $list): ?>
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <th><?= htmlspecialchars($ver) ?> <span style="font-weight:400;color:#999;">(<?= count($list) ?>)</span></th>
|
|
|
|
|
+ <td>
|
|
|
|
|
+ <?php foreach ($list as $c):
|
|
|
|
|
+ $cls = ['strong' => 'g', 'weak' => 'r', 'medium' => 'o'][cipherStrength($c)]; ?>
|
|
|
|
|
+ <span class="pill <?= $cls ?>" style="font-family:monospace;margin:2px 4px 2px 0;"><?= htmlspecialchars($c) ?></span>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ <?php if ($ver === 'TLS 1.3'): ?>
|
|
|
|
|
+ <div style="color:#888;font-size:11px;margin-top:6px;">
|
|
|
|
|
+ PHP cannot iterate individual TLS 1.3 suites — only the negotiated suite is shown.
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ <?php endforeach; ?>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ <p style="color:#888;font-size:12px;margin-top:8px;">
|
|
|
|
|
+ Enumerated by repeatedly handshaking and excluding the negotiated suite.
|
|
|
|
|
+ <span class="pill g">strong</span> AEAD / TLS 1.3
|
|
|
|
|
+ <span class="pill o">legacy</span> CBC-mode
|
|
|
|
|
+ <span class="pill r">weak</span> RC4/DES/NULL/export/anon.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <?php endif; ?>
|
|
|
|
|
+
|
|
|
<?php if ($cert): ?>
|
|
<?php if ($cert): ?>
|
|
|
<h2>Certificate</h2>
|
|
<h2>Certificate</h2>
|
|
|
<table>
|
|
<table>
|