Josef Straßl 4 тижнів тому
батько
коміт
91abe25de1
1 змінених файлів з 164 додано та 2 видалено
  1. 164 2
      mail-tls-check.php

+ 164 - 2
mail-tls-check.php

@@ -3,6 +3,8 @@ declare(strict_types=1);
 
 const CONNECT_TIMEOUT = 12; // seconds
 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
@@ -30,6 +32,7 @@ class MailTlsChecker
             'cert'        => null,
             'tls_version' => null,
             'cipher'      => null,
+            'ciphers'     => [],     // version => list of accepted cipher suites
         ];
 
         // 1) Handshake with full verification to learn whether the chain is trusted.
@@ -79,9 +82,125 @@ class MailTlsChecker
             && $report['hostname_ok']
             && ($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;
     }
 
+    /**
+     * 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
      * crypto. Returns the stream resource on success. On verification failure
@@ -373,13 +492,13 @@ const PROTOCOL_LABELS = [
 ];
 
 $host     = trim((string) ($_GET['host'] ?? ''));
-$protocol = (string) ($_GET['protocol'] ?? 'smtp');
+$protocol = (string) ($_GET['protocol'] ?? 'smtp25');
 $portRaw  = trim((string) ($_GET['port'] ?? ''));
 $report   = null;
 $inputError = null;
 
 if (!isset(DEFAULT_PORTS[$protocol])) {
-    $protocol = 'smtp';
+    $protocol = 'smtp25';
 }
 $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' : '—';
 }
+
+/** 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>
 <html lang="en">
@@ -555,6 +689,34 @@ function fmtDate(?int $ts): string
             <tr><th>Cipher</th><td class="mono"><?= htmlspecialchars($report['cipher'] ?: '—') ?></td></tr>
         </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 &nbsp;
+            <span class="pill o">legacy</span> CBC-mode &nbsp;
+            <span class="pill r">weak</span> RC4/DES/NULL/export/anon.
+        </p>
+        <?php endif; ?>
+
         <?php if ($cert): ?>
         <h2>Certificate</h2>
         <table>