mail-tls-check.php 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. <?php
  2. declare(strict_types=1);
  3. const CONNECT_TIMEOUT = 12; // seconds
  4. const EHLO_NAME = 'tool.medowar.de';
  5. const CIPHER_PROBE_TIMEOUT = 6; // per-cipher probe, kept short so enumeration stays snappy
  6. const MAX_CIPHERS_PER_VERSION = 64; // safety cap on the exclusion loop
  7. /**
  8. * Connects to a mail server, negotiates STARTTLS (or implicit TLS), and reports
  9. * on the TLS handshake and the server certificate: trust chain, hostname match,
  10. * validity window, issuer/subject and SANs.
  11. */
  12. class MailTlsChecker
  13. {
  14. /**
  15. * @return array TLS/certificate report for the given host:port and protocol.
  16. */
  17. public function check(string $host, int $port, string $protocol): array
  18. {
  19. $report = [
  20. 'host' => $host,
  21. 'port' => $port,
  22. 'protocol' => $protocol,
  23. 'mode' => in_array($protocol, ['smtps', 'imaps', 'pop3s'], true) ? 'implicit' : 'starttls',
  24. 'connected' => false,
  25. 'starttls' => null, // negotiation transcript lines (starttls only)
  26. 'tls_ok' => false, // handshake succeeded *and* peer verified
  27. 'trusted' => false, // chain verified against CA bundle
  28. 'hostname_ok' => false, // certificate valid for the requested host
  29. 'errors' => [],
  30. 'cert' => null,
  31. 'tls_version' => null,
  32. 'cipher' => null,
  33. 'ciphers' => [], // version => list of accepted cipher suites
  34. ];
  35. // 1) Handshake with full verification to learn whether the chain is trusted.
  36. $verified = $this->handshake($host, $port, $protocol, true, $vErr, $transcript);
  37. $report['starttls'] = $transcript;
  38. if ($verified === null) {
  39. // Could not even reach the TLS stage — connection/protocol error.
  40. $report['errors'][] = $vErr;
  41. return $report;
  42. }
  43. $report['connected'] = true;
  44. $report['trusted'] = ($vErr === null);
  45. if ($vErr !== null) {
  46. $report['errors'][] = 'Certificate chain not trusted: ' . $vErr;
  47. }
  48. // 2) Handshake again without verification so we can always inspect the cert,
  49. // even when it is self-signed, expired, or for the wrong hostname.
  50. $stream = $this->handshake($host, $port, $protocol, false, $rErr, $t2);
  51. if (!is_resource($stream)) {
  52. if ($rErr !== null) {
  53. $report['errors'][] = $rErr;
  54. }
  55. return $report;
  56. }
  57. $params = stream_context_get_params($stream);
  58. $meta = stream_get_meta_data($stream);
  59. $certRes = $params['options']['ssl']['peer_certificate'] ?? null;
  60. // Negotiated protocol version / cipher (from the crypto meta, when exposed).
  61. foreach (($meta['crypto'] ?? []) as $k => $v) {
  62. if ($k === 'protocol') { $report['tls_version'] = $v; }
  63. if ($k === 'cipher_name') { $report['cipher'] = $v; }
  64. }
  65. if ($certRes) {
  66. $report['cert'] = $this->describeCert($certRes, $host, $report);
  67. $report['hostname_ok'] = $report['cert']['hostname_ok'];
  68. } else {
  69. $report['errors'][] = 'TLS handshake completed but no peer certificate was presented.';
  70. }
  71. fclose($stream);
  72. $report['tls_ok'] = $report['trusted']
  73. && $report['hostname_ok']
  74. && ($report['cert']['time_ok'] ?? false);
  75. // 3) Enumerate every cipher suite the server is willing to negotiate on
  76. // this port. Best-effort — never let it break the main report.
  77. try {
  78. $report['ciphers'] = $this->enumerateCiphers($host, $port, $protocol);
  79. } catch (\Throwable $e) {
  80. // ignore — the core TLS/cert report above is what matters.
  81. }
  82. return $report;
  83. }
  84. /**
  85. * Discovers which cipher suites the server accepts, per TLS version.
  86. *
  87. * For TLS 1.2 and below we offer the full set, note the suite the server
  88. * picks, exclude it, and repeat until no common cipher remains — this yields
  89. * the complete list of server-supported suites in one connection each. PHP
  90. * cannot restrict the TLS 1.3 ciphersuite list, so for 1.3 we can only report
  91. * the single suite that gets negotiated.
  92. *
  93. * @return array<string,string[]> e.g. ['TLS 1.2' => ['ECDHE-RSA-AES256-GCM-SHA384', ...]]
  94. */
  95. public function enumerateCiphers(string $host, int $port, string $protocol): array
  96. {
  97. $versions = [];
  98. if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) { $versions['TLS 1.3'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT; }
  99. if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { $versions['TLS 1.2'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; }
  100. if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { $versions['TLS 1.1'] = STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; }
  101. if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT')) { $versions['TLS 1.0'] = STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; }
  102. $tls13Method = defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') ? STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT : -1;
  103. $out = [];
  104. foreach ($versions as $label => $method) {
  105. $isTls13 = ($method === $tls13Method);
  106. $found = [];
  107. for ($i = 0; $i < MAX_CIPHERS_PER_VERSION; $i++) {
  108. // null exclude => don't touch the cipher list (TLS 1.3 case).
  109. $name = $this->probeCipher($host, $port, $protocol, $method, $isTls13 ? null : $found);
  110. if ($name === null || in_array($name, $found, true)) {
  111. break; // no (further) suite negotiated for this version.
  112. }
  113. $found[] = $name;
  114. if ($isTls13) {
  115. break; // cannot iterate the 1.3 suite list from PHP.
  116. }
  117. }
  118. if ($found) {
  119. $out[$label] = $found;
  120. }
  121. }
  122. return $out;
  123. }
  124. /**
  125. * Attempts a single handshake at a fixed TLS version, optionally offering
  126. * every cipher except those in $exclude, and returns the negotiated cipher
  127. * suite name (or null if the handshake did not complete).
  128. *
  129. * @param string[]|null $exclude Suites to withhold, or null to leave the
  130. * cipher list untouched (used for TLS 1.3).
  131. */
  132. private function probeCipher(string $host, int $port, string $protocol, int $method, ?array $exclude): ?string
  133. {
  134. $ssl = [
  135. 'verify_peer' => false,
  136. 'verify_peer_name' => false,
  137. 'allow_self_signed' => true,
  138. 'SNI_enabled' => true,
  139. 'peer_name' => $host,
  140. ];
  141. if ($exclude !== null) {
  142. // @SECLEVEL=0 lets us also see weak/legacy suites the server still offers.
  143. $parts = ['ALL', 'COMPLEMENTOFALL'];
  144. foreach ($exclude as $c) {
  145. $parts[] = '!' . $c;
  146. }
  147. $parts[] = '@SECLEVEL=0';
  148. $ssl['ciphers'] = implode(':', $parts);
  149. }
  150. $ctx = stream_context_create(['ssl' => $ssl]);
  151. $errno = 0; $errstr = '';
  152. $stream = @stream_socket_client(
  153. sprintf('tcp://%s:%d', $host, $port),
  154. $errno, $errstr, CIPHER_PROBE_TIMEOUT, STREAM_CLIENT_CONNECT, $ctx
  155. );
  156. if (!is_resource($stream)) {
  157. return null;
  158. }
  159. stream_set_timeout($stream, CIPHER_PROBE_TIMEOUT);
  160. if (!in_array($protocol, ['smtps', 'imaps', 'pop3s'], true)) {
  161. try {
  162. $t = [];
  163. $this->negotiateStartTls($stream, $protocol, $t);
  164. } catch (\Throwable $e) {
  165. fclose($stream);
  166. return null;
  167. }
  168. }
  169. openssl_error_string(); // clear stale queue
  170. $ok = @stream_socket_enable_crypto($stream, true, $method);
  171. if ($ok !== true) {
  172. fclose($stream);
  173. return null;
  174. }
  175. $name = null;
  176. foreach ((stream_get_meta_data($stream)['crypto'] ?? []) as $k => $v) {
  177. if ($k === 'cipher_name') { $name = $v; }
  178. }
  179. fclose($stream);
  180. return ($name !== null && $name !== '') ? $name : null;
  181. }
  182. /**
  183. * Opens a TCP connection, runs the STARTTLS dance if required, then enables
  184. * crypto. Returns the stream resource on success. On verification failure
  185. * with $verify=true, returns false-y stream but populates $err. On connection
  186. * failure returns null. $transcript receives the plaintext protocol lines.
  187. *
  188. * @return resource|false|null
  189. */
  190. private function handshake(string $host, int $port, string $protocol, bool $verify, ?string &$err, ?array &$transcript)
  191. {
  192. $err = null;
  193. $transcript = [];
  194. $ctx = stream_context_create(['ssl' => [
  195. 'verify_peer' => $verify,
  196. 'verify_peer_name' => $verify,
  197. 'allow_self_signed' => !$verify,
  198. 'capture_peer_cert' => true,
  199. 'SNI_enabled' => true,
  200. 'peer_name' => $host,
  201. ]]);
  202. // Always connect over plain TCP and drive the crypto switch ourselves.
  203. // For implicit-TLS ports that simply means enabling crypto immediately,
  204. // with no plaintext exchange — this keeps a single code path and gives us
  205. // reliable error reporting via the OpenSSL error queue.
  206. $errno = 0; $errstr = '';
  207. $stream = @stream_socket_client(
  208. sprintf('tcp://%s:%d', $host, $port),
  209. $errno,
  210. $errstr,
  211. CONNECT_TIMEOUT,
  212. STREAM_CLIENT_CONNECT,
  213. $ctx
  214. );
  215. if (!is_resource($stream)) {
  216. $err = $errstr !== '' ? "Connection failed: $errstr (errno $errno)" : 'Connection failed.';
  217. return null;
  218. }
  219. stream_set_timeout($stream, CONNECT_TIMEOUT);
  220. // STARTTLS protocols exchange a few plaintext commands before the switch.
  221. if (!in_array($protocol, ['smtps', 'imaps', 'pop3s'], true)) {
  222. try {
  223. $this->negotiateStartTls($stream, $protocol, $transcript);
  224. } catch (\RuntimeException $e) {
  225. fclose($stream);
  226. $err = $e->getMessage();
  227. return null;
  228. }
  229. }
  230. openssl_error_string(); // clear any stale queue entries
  231. error_clear_last();
  232. $ok = @stream_socket_enable_crypto(
  233. $stream,
  234. true,
  235. STREAM_CRYPTO_METHOD_TLS_CLIENT
  236. );
  237. if ($ok !== true) {
  238. $sslErr = $this->drainSslErrors();
  239. $last = error_get_last()['message'] ?? '';
  240. fclose($stream);
  241. // Trim PHP's noisy function prefix and collapse multi-line OpenSSL blurb.
  242. $detail = trim(preg_replace('/\s+/', ' ', str_replace('stream_socket_enable_crypto():', '', $sslErr ?: $last)));
  243. if ($verify) {
  244. // The handshake was reached; the certificate simply did not verify.
  245. $err = $detail ?: 'Certificate verification failed.';
  246. return false;
  247. }
  248. $err = $detail ?: 'TLS handshake failed.';
  249. return null;
  250. }
  251. return $stream;
  252. }
  253. /**
  254. * Speaks the protocol-specific STARTTLS handshake up to (not including) the
  255. * crypto switch. Throws RuntimeException if the server refuses.
  256. * @param resource $stream
  257. */
  258. private function negotiateStartTls($stream, string $protocol, array &$transcript): void
  259. {
  260. switch ($protocol) {
  261. case 'smtp': // submission, port 587
  262. case 'smtp25': // MX / server-to-server, port 25
  263. $this->expect($stream, '220', $transcript);
  264. $this->send($stream, 'EHLO ' . EHLO_NAME, $transcript);
  265. $ehlo = $this->readSmtp($stream, $transcript);
  266. if (stripos($ehlo, 'STARTTLS') === false) {
  267. throw new \RuntimeException('Server did not advertise STARTTLS in its EHLO response.');
  268. }
  269. $this->send($stream, 'STARTTLS', $transcript);
  270. $this->expect($stream, '220', $transcript);
  271. break;
  272. case 'imap':
  273. $this->expect($stream, '* OK', $transcript, true);
  274. $this->send($stream, 'a1 STARTTLS', $transcript);
  275. $resp = $this->readLine($stream, $transcript);
  276. if (stripos($resp, 'a1 OK') === false) {
  277. throw new \RuntimeException('IMAP server refused STARTTLS: ' . trim($resp));
  278. }
  279. break;
  280. case 'pop3':
  281. $this->expect($stream, '+OK', $transcript, true);
  282. $this->send($stream, 'STLS', $transcript);
  283. $resp = $this->readLine($stream, $transcript);
  284. if (stripos($resp, '+OK') !== 0) {
  285. throw new \RuntimeException('POP3 server refused STLS: ' . trim($resp));
  286. }
  287. break;
  288. default:
  289. throw new \RuntimeException('Unsupported protocol: ' . $protocol);
  290. }
  291. }
  292. /** @param resource $stream */
  293. private function send($stream, string $line, array &$transcript): void
  294. {
  295. $transcript[] = 'C: ' . $line;
  296. fwrite($stream, $line . "\r\n");
  297. }
  298. /**
  299. * Reads one line and asserts it starts with $code (or contains it, when $contains).
  300. * @param resource $stream
  301. */
  302. private function expect($stream, string $code, array &$transcript, bool $contains = false): void
  303. {
  304. $line = $this->readLine($stream, $transcript);
  305. $hit = $contains ? (stripos($line, $code) !== false) : (strpos(ltrim($line), $code) === 0);
  306. if (!$hit) {
  307. throw new \RuntimeException(sprintf('Expected "%s" but got: %s', $code, trim($line)));
  308. }
  309. }
  310. /**
  311. * Reads a full multiline SMTP reply (lines like "250-..." until "250 ...").
  312. * @param resource $stream
  313. */
  314. private function readSmtp($stream, array &$transcript): string
  315. {
  316. $all = '';
  317. while (($line = $this->readLine($stream, $transcript)) !== '') {
  318. $all .= $line;
  319. // Continuation lines have a hyphen as the 4th character.
  320. if (strlen($line) < 4 || $line[3] !== '-') {
  321. break;
  322. }
  323. }
  324. return $all;
  325. }
  326. /** @param resource $stream */
  327. private function readLine($stream, array &$transcript): string
  328. {
  329. $line = fgets($stream, 4096);
  330. if ($line === false) {
  331. $meta = stream_get_meta_data($stream);
  332. if (!empty($meta['timed_out'])) {
  333. throw new \RuntimeException('Timed out waiting for the server to respond.');
  334. }
  335. throw new \RuntimeException('Connection closed by server before a reply was received.');
  336. }
  337. $transcript[] = 'S: ' . rtrim($line, "\r\n");
  338. return $line;
  339. }
  340. /** Collects any pending messages from the OpenSSL error queue. */
  341. private function drainSslErrors(): string
  342. {
  343. $msgs = [];
  344. while ($e = openssl_error_string()) {
  345. $msgs[] = $e;
  346. }
  347. return implode('; ', $msgs);
  348. }
  349. /**
  350. * Parses the peer certificate and evaluates validity window + hostname match.
  351. * @param \OpenSSLCertificate|resource $certRes
  352. */
  353. private function describeCert($certRes, string $host, array $report): array
  354. {
  355. $info = openssl_x509_parse($certRes) ?: [];
  356. $now = time();
  357. $from = $info['validFrom_time_t'] ?? 0;
  358. $to = $info['validTo_time_t'] ?? 0;
  359. $sans = [];
  360. $altName = $info['extensions']['subjectAltName'] ?? '';
  361. foreach (array_filter(array_map('trim', explode(',', $altName))) as $entry) {
  362. if (stripos($entry, 'DNS:') === 0) {
  363. $sans[] = substr($entry, 4);
  364. } elseif (stripos($entry, 'IP Address:') === 0) {
  365. $sans[] = substr($entry, 11);
  366. } else {
  367. $sans[] = $entry;
  368. }
  369. }
  370. // Fall back to CN if there are no SANs (legacy certs).
  371. $cn = $info['subject']['CN'] ?? null;
  372. $names = $sans ?: ($cn !== null ? [$cn] : []);
  373. $pem = '';
  374. openssl_x509_export($certRes, $pem);
  375. return [
  376. 'subject' => $this->dn($info['subject'] ?? []),
  377. 'issuer' => $this->dn($info['issuer'] ?? []),
  378. 'cn' => $cn,
  379. 'sans' => $sans,
  380. 'valid_from' => $from,
  381. 'valid_to' => $to,
  382. 'time_ok' => $from && $to && $now >= $from && $now <= $to,
  383. 'expired' => $to && $now > $to,
  384. 'not_yet' => $from && $now < $from,
  385. 'days_left' => $to ? (int) floor(($to - $now) / 86400) : null,
  386. 'hostname_ok' => $this->matchesHost($host, $names),
  387. 'self_signed' => ($this->dn($info['subject'] ?? []) === $this->dn($info['issuer'] ?? [])),
  388. 'serial' => $info['serialNumberHex'] ?? ($info['serialNumber'] ?? ''),
  389. 'sig_type' => $info['signatureTypeSN'] ?? '',
  390. 'fingerprint' => openssl_x509_fingerprint($certRes, 'sha256') ?: '',
  391. ];
  392. }
  393. /** @param array<string,mixed> $dn */
  394. private function dn(array $dn): string
  395. {
  396. $parts = [];
  397. foreach ($dn as $k => $v) {
  398. $v = is_array($v) ? implode(' + ', $v) : $v;
  399. $parts[] = "$k=$v";
  400. }
  401. return implode(', ', $parts);
  402. }
  403. /**
  404. * RFC 6125 style match: exact or single leftmost wildcard.
  405. * @param string[] $names
  406. */
  407. private function matchesHost(string $host, array $names): bool
  408. {
  409. $host = strtolower(rtrim($host, '.'));
  410. foreach ($names as $name) {
  411. $name = strtolower(rtrim($name, '.'));
  412. if ($name === $host) {
  413. return true;
  414. }
  415. if (str_starts_with($name, '*.')) {
  416. $suffix = substr($name, 1); // ".example.com"
  417. // Wildcard matches exactly one leftmost label.
  418. if (str_ends_with($host, $suffix)
  419. && substr_count($host, '.') === substr_count($name, '.')) {
  420. return true;
  421. }
  422. }
  423. }
  424. return false;
  425. }
  426. }
  427. /** Default port for each protocol, used when the user leaves the port blank. */
  428. const DEFAULT_PORTS = [
  429. 'smtp25' => 25,
  430. 'smtp' => 587,
  431. 'smtps' => 465,
  432. 'imap' => 143,
  433. 'imaps' => 993,
  434. 'pop3' => 110,
  435. 'pop3s' => 995,
  436. ];
  437. const PROTOCOL_LABELS = [
  438. 'smtp25' => 'SMTP + STARTTLS — MX / server-to-server (25)',
  439. 'smtp' => 'SMTP + STARTTLS — submission (587)',
  440. 'smtps' => 'SMTPS — implicit TLS (465)',
  441. 'imap' => 'IMAP + STARTTLS (143)',
  442. 'imaps' => 'IMAPS — implicit TLS (993)',
  443. 'pop3' => 'POP3 + STLS (110)',
  444. 'pop3s' => 'POP3S — implicit TLS (995)',
  445. ];
  446. $host = trim((string) ($_GET['host'] ?? ''));
  447. $protocol = (string) ($_GET['protocol'] ?? 'smtp25');
  448. $portRaw = trim((string) ($_GET['port'] ?? ''));
  449. $report = null;
  450. $inputError = null;
  451. if (!isset(DEFAULT_PORTS[$protocol])) {
  452. $protocol = 'smtp25';
  453. }
  454. $port = $portRaw !== '' ? (int) $portRaw : DEFAULT_PORTS[$protocol];
  455. if ($host !== '') {
  456. // Accept a bare hostname; strip scheme/path/port if a URL was pasted.
  457. $host = preg_replace('#^\w+://#', '', $host);
  458. $host = explode('/', $host)[0];
  459. $host = strtolower(trim($host));
  460. if (str_contains($host, ':')) {
  461. $host = explode(':', $host)[0];
  462. }
  463. if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $host)) {
  464. $inputError = 'Please enter a valid mail server hostname (e.g. mail.example.com).';
  465. } elseif ($port < 1 || $port > 65535) {
  466. $inputError = 'Please enter a valid port between 1 and 65535.';
  467. } else {
  468. $report = (new MailTlsChecker())->check($host, $port, $protocol);
  469. }
  470. }
  471. function fmtDate(?int $ts): string
  472. {
  473. return $ts ? gmdate('Y-m-d H:i:s', $ts) . ' UTC' : '—';
  474. }
  475. /** Rough strength bucket for a cipher suite, used only for colour-coding. */
  476. function cipherStrength(string $name): string
  477. {
  478. $n = strtoupper($name);
  479. if (preg_match('/NULL|EXP|RC4|DES|MD5|ADH|AECDH|ANON|SEED|IDEA/', $n)) {
  480. return 'weak';
  481. }
  482. if (str_starts_with($n, 'TLS_')
  483. || str_contains($n, 'GCM') || str_contains($n, 'CHACHA')
  484. || str_contains($n, 'POLY1305') || str_contains($n, 'CCM')) {
  485. return 'strong';
  486. }
  487. return 'medium'; // typically CBC-mode AEAD-less suites
  488. }
  489. ?>
  490. <!DOCTYPE html>
  491. <html lang="en">
  492. <head>
  493. <meta charset="UTF-8">
  494. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  495. <title>Mail TLS Checker</title>
  496. <style>
  497. body {
  498. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  499. max-width: 1000px;
  500. margin: 40px auto;
  501. padding: 0 20px;
  502. background: #f5f5f5;
  503. color: #333;
  504. }
  505. h1 { color: #333; }
  506. .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
  507. form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: stretch; }
  508. input[type=text], select {
  509. padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white;
  510. }
  511. input[name=host] { flex: 1; min-width: 220px; }
  512. input[name=port] { width: 90px; }
  513. .btn {
  514. display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
  515. text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
  516. }
  517. .btn:hover { background: #1976D2; }
  518. .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
  519. .verdict {
  520. display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
  521. font-size: 18px; font-weight: 600; margin: 16px 0;
  522. }
  523. .verdict.ok { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
  524. .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
  525. .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin: 16px 0; }
  526. .check {
  527. display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-radius: 5px;
  528. background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); font-size: 14px;
  529. }
  530. .check .ic { font-size: 18px; }
  531. .check.pass { border-left: 4px solid #43a047; }
  532. .check.warn { border-left: 4px solid #fb8c00; }
  533. .check.crit { border-left: 4px solid #e53935; }
  534. .errlist { margin: 10px 0; }
  535. .errlist .error { margin: 6px 0; }
  536. h2 { color: #333; margin-top: 30px; }
  537. table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
  538. th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
  539. th { width: 180px; color: #555; background: #fafafa; font-weight: 600; }
  540. td.mono, .mono { font-family: monospace; word-break: break-all; }
  541. ul.sans { margin: 0; padding-left: 18px; }
  542. ul.sans li { font-family: monospace; font-size: 13px; }
  543. .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
  544. .pill.g { background: #e8f5e9; color: #2e7d32; }
  545. .pill.r { background: #ffebee; color: #c62828; }
  546. .pill.o { background: #fff3e0; color: #e65100; }
  547. details.transcript { margin-top: 20px; }
  548. details.transcript pre {
  549. background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px;
  550. font-size: 12px; overflow-x: auto; line-height: 1.5;
  551. }
  552. summary { cursor: pointer; font-weight: 600; color: #1976D2; }
  553. </style>
  554. </head>
  555. <body>
  556. <h1>🔐 Mail TLS Checker</h1>
  557. <div class="info">
  558. Connects to a mail server, negotiates <strong>STARTTLS</strong> (or implicit TLS) and reports on the
  559. <strong>TLS handshake</strong> and <strong>server certificate</strong>: trust chain, hostname match,
  560. validity window, issuer and SANs. Nothing is sent beyond the TLS negotiation — no login, no mail.
  561. </div>
  562. <form class="lookup" method="GET">
  563. <input type="text" name="host" placeholder="mail.example.com"
  564. value="<?= htmlspecialchars($host) ?>" autofocus>
  565. <select name="protocol">
  566. <?php foreach (PROTOCOL_LABELS as $key => $label): ?>
  567. <option value="<?= $key ?>" <?= $protocol === $key ? 'selected' : '' ?>>
  568. <?= htmlspecialchars($label) ?>
  569. </option>
  570. <?php endforeach; ?>
  571. </select>
  572. <input type="text" name="port" placeholder="port"
  573. value="<?= htmlspecialchars($portRaw) ?>">
  574. <button type="submit" class="btn">🔍 Test TLS</button>
  575. </form>
  576. <p style="color:#888;font-size:12px;margin-top:-10px;">
  577. Leave the port blank to use the protocol default
  578. (<?= implode(', ', array_map(fn($p, $n) => "$p=$n", array_keys(DEFAULT_PORTS), DEFAULT_PORTS)) ?>).
  579. </p>
  580. <?php if ($inputError): ?>
  581. <div class="error"><?= htmlspecialchars($inputError) ?></div>
  582. <?php endif; ?>
  583. <?php if ($report !== null): ?>
  584. <?php $cert = $report['cert']; ?>
  585. <?php if ($report['tls_ok']): ?>
  586. <div class="verdict ok">✅ TLS is valid — trusted chain, correct hostname and within its validity period.</div>
  587. <?php else: ?>
  588. <div class="verdict fail">❌ TLS check failed — see the details below.</div>
  589. <?php endif; ?>
  590. <?php if (!empty($report['errors'])): ?>
  591. <div class="errlist">
  592. <?php foreach ($report['errors'] as $e): ?>
  593. <div class="error">⚠️ <?= htmlspecialchars($e) ?></div>
  594. <?php endforeach; ?>
  595. </div>
  596. <?php endif; ?>
  597. <?php if ($report['connected']): ?>
  598. <div class="checks">
  599. <?php
  600. $timeOk = $cert['time_ok'] ?? false;
  601. $renderCheck = function (bool $pass, string $label, string $detail, bool $criticalIfFail = true) {
  602. $cls = $pass ? 'pass' : ($criticalIfFail ? 'crit' : 'warn');
  603. $ic = $pass ? '✔️' : ($criticalIfFail ? '❌' : '⚠️');
  604. printf(
  605. '<div class="check %s"><span class="ic">%s</span><div><strong>%s</strong><br>%s</div></div>',
  606. $cls, $ic, htmlspecialchars($label), htmlspecialchars($detail)
  607. );
  608. };
  609. $renderCheck($report['trusted'], 'Trusted chain',
  610. $report['trusted'] ? 'Verified against system CA bundle' : 'Not verifiable / self-signed');
  611. $renderCheck($report['hostname_ok'], 'Hostname match',
  612. $report['hostname_ok'] ? $report['host'] . ' is covered' : 'Certificate not valid for ' . $report['host']);
  613. $renderCheck((bool) $timeOk, 'Validity period',
  614. $timeOk
  615. ? ($cert['days_left'] !== null ? $cert['days_left'] . ' day(s) until expiry' : 'Currently valid')
  616. : (($cert['expired'] ?? false) ? 'Certificate has expired' : 'Certificate not yet valid'));
  617. if ($cert && ($cert['days_left'] ?? null) !== null && $cert['days_left'] >= 0 && $cert['days_left'] <= 21) {
  618. $renderCheck(false, 'Expiry warning', 'Expires in ' . $cert['days_left'] . ' day(s)', false);
  619. }
  620. ?>
  621. </div>
  622. <?php endif; ?>
  623. <h2>Connection</h2>
  624. <table>
  625. <tr><th>Server</th><td class="mono"><?= htmlspecialchars($report['host'] . ':' . $report['port']) ?></td></tr>
  626. <tr><th>Protocol</th><td><?= htmlspecialchars(PROTOCOL_LABELS[$report['protocol']] ?? $report['protocol']) ?> — <?= htmlspecialchars($report['mode']) ?></td></tr>
  627. <tr><th>TLS version</th><td class="mono"><?= htmlspecialchars($report['tls_version'] ?: '—') ?></td></tr>
  628. <tr><th>Cipher</th><td class="mono"><?= htmlspecialchars($report['cipher'] ?: '—') ?></td></tr>
  629. </table>
  630. <?php if (!empty($report['ciphers'])): ?>
  631. <h2>Supported ciphers</h2>
  632. <table>
  633. <?php foreach ($report['ciphers'] as $ver => $list): ?>
  634. <tr>
  635. <th><?= htmlspecialchars($ver) ?> <span style="font-weight:400;color:#999;">(<?= count($list) ?>)</span></th>
  636. <td>
  637. <?php foreach ($list as $c):
  638. $cls = ['strong' => 'g', 'weak' => 'r', 'medium' => 'o'][cipherStrength($c)]; ?>
  639. <span class="pill <?= $cls ?>" style="font-family:monospace;margin:2px 4px 2px 0;"><?= htmlspecialchars($c) ?></span>
  640. <?php endforeach; ?>
  641. <?php if ($ver === 'TLS 1.3'): ?>
  642. <div style="color:#888;font-size:11px;margin-top:6px;">
  643. PHP cannot iterate individual TLS 1.3 suites — only the negotiated suite is shown.
  644. </div>
  645. <?php endif; ?>
  646. </td>
  647. </tr>
  648. <?php endforeach; ?>
  649. </table>
  650. <p style="color:#888;font-size:12px;margin-top:8px;">
  651. Enumerated by repeatedly handshaking and excluding the negotiated suite.
  652. <span class="pill g">strong</span> AEAD / TLS 1.3 &nbsp;
  653. <span class="pill o">legacy</span> CBC-mode &nbsp;
  654. <span class="pill r">weak</span> RC4/DES/NULL/export/anon.
  655. </p>
  656. <?php endif; ?>
  657. <?php if ($cert): ?>
  658. <h2>Certificate</h2>
  659. <table>
  660. <tr><th>Subject</th><td class="mono"><?= htmlspecialchars($cert['subject']) ?></td></tr>
  661. <tr><th>Common name</th><td class="mono"><?= htmlspecialchars($cert['cn'] ?? '—') ?></td></tr>
  662. <tr><th>Issuer</th><td class="mono"><?= htmlspecialchars($cert['issuer']) ?></td></tr>
  663. <tr>
  664. <th>Self-signed</th>
  665. <td><?= $cert['self_signed']
  666. ? '<span class="pill o">yes</span>'
  667. : '<span class="pill g">no</span>' ?></td>
  668. </tr>
  669. <tr>
  670. <th>Valid from</th>
  671. <td class="mono"><?= htmlspecialchars(fmtDate($cert['valid_from'])) ?>
  672. <?= ($cert['not_yet'] ?? false) ? ' <span class="pill r">not yet valid</span>' : '' ?></td>
  673. </tr>
  674. <tr>
  675. <th>Valid to</th>
  676. <td class="mono"><?= htmlspecialchars(fmtDate($cert['valid_to'])) ?>
  677. <?php if ($cert['expired'] ?? false): ?>
  678. <span class="pill r">expired</span>
  679. <?php elseif (($cert['days_left'] ?? 99) <= 21): ?>
  680. <span class="pill o"><?= (int) $cert['days_left'] ?> day(s) left</span>
  681. <?php else: ?>
  682. <span class="pill g"><?= (int) $cert['days_left'] ?> day(s) left</span>
  683. <?php endif; ?>
  684. </td>
  685. </tr>
  686. <tr>
  687. <th>Subject Alt Names</th>
  688. <td>
  689. <?php if (!empty($cert['sans'])): ?>
  690. <ul class="sans">
  691. <?php foreach ($cert['sans'] as $s): ?>
  692. <li><?= htmlspecialchars($s) ?></li>
  693. <?php endforeach; ?>
  694. </ul>
  695. <?php else: ?>—<?php endif; ?>
  696. </td>
  697. </tr>
  698. <tr><th>Signature</th><td class="mono"><?= htmlspecialchars($cert['sig_type'] ?: '—') ?></td></tr>
  699. <tr><th>Serial</th><td class="mono"><?= htmlspecialchars((string) $cert['serial']) ?></td></tr>
  700. <tr><th>SHA-256 fingerprint</th><td class="mono"><?= htmlspecialchars(trim(chunk_split($cert['fingerprint'], 2, ':'), ':')) ?></td></tr>
  701. </table>
  702. <?php endif; ?>
  703. <?php if (!empty($report['starttls'])): ?>
  704. <details class="transcript">
  705. <summary>STARTTLS negotiation transcript</summary>
  706. <pre><?= htmlspecialchars(implode("\n", $report['starttls'])) ?></pre>
  707. </details>
  708. <?php endif; ?>
  709. <?php endif; ?>
  710. </body>
  711. </html>