| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- <?php
- declare(strict_types=1);
- /** Selectors tried when the user leaves the selector field blank. */
- const COMMON_SELECTORS = [
- 'default', 'selector1', 'selector2', 'google', 'k1', 'k2', 's1', 's2',
- 'dkim', 'mail', 'smtp', 'mx', 'pm', 'zoho', 'mandrill', 'sendgrid',
- 'amazonses', 'mailjet', 'mailgun',
- ];
- /**
- * Looks up a DKIM selector TXT record, parses its tags and validates the
- * public key (RSA or Ed25519).
- */
- class DkimChecker
- {
- public function check(string $domain, string $selector): array
- {
- $fqdn = $selector . '._domainkey.' . $domain;
- $report = [
- 'fqdn' => $fqdn,
- 'selector' => $selector,
- 'record' => null,
- 'tags' => [],
- 'key' => null,
- 'errors' => [],
- 'warnings' => [],
- 'valid' => false,
- ];
- $record = self::getTxtRecord($fqdn);
- if ($record === null) {
- $report['errors'][] = 'No TXT record found at ' . $fqdn;
- return $report;
- }
- $report['record'] = $record;
- $tags = self::parseTags($record);
- $report['tags'] = $tags;
- $version = $tags['v'] ?? null;
- if ($version !== null && strtoupper($version) !== 'DKIM1') {
- $report['errors'][] = 'Unexpected v= tag: "' . $version . '" (expected "DKIM1").';
- }
- $keyType = strtolower($tags['k'] ?? 'rsa');
- if (!in_array($keyType, ['rsa', 'ed25519'], true)) {
- $report['warnings'][] = 'Unknown key type k=' . $keyType . ' — treating as opaque.';
- }
- if (!isset($tags['p'])) {
- $report['errors'][] = 'Missing p= tag — record is not a valid DKIM key record.';
- } elseif ($tags['p'] === '') {
- $report['errors'][] = 'Empty p= tag — this key has been revoked.';
- } else {
- $report['key'] = self::analyzeKey($tags['p'], $keyType);
- if (!$report['key']['valid']) {
- $report['errors'][] = 'Public key could not be parsed: ' . $report['key']['error'];
- } elseif ($keyType === 'rsa' && $report['key']['bits'] !== null && $report['key']['bits'] < 1024) {
- $report['warnings'][] = 'RSA key is only ' . $report['key']['bits'] . ' bits — below the recommended 1024-bit minimum.';
- } elseif ($keyType === 'rsa' && $report['key']['bits'] !== null && $report['key']['bits'] < 2048) {
- $report['warnings'][] = 'RSA key is ' . $report['key']['bits'] . ' bits — 2048 bits is recommended for new keys.';
- }
- }
- if (($tags['t'] ?? '') !== '' && str_contains($tags['t'], 'y')) {
- $report['warnings'][] = 'Testing mode is on (t=y) — receivers may not enforce this key\'s signatures.';
- }
- if (isset($tags['h'])) {
- $algos = array_map('trim', explode(':', $tags['h']));
- if (!array_intersect($algos, ['sha256'])) {
- $report['warnings'][] = 'h= tag does not list sha256 — allowed hash algorithms: ' . $tags['h'];
- }
- }
- $report['valid'] = empty($report['errors']);
- return $report;
- }
- /** @return array<string,string> */
- private static function parseTags(string $record): array
- {
- $tags = [];
- foreach (explode(';', $record) as $part) {
- $part = trim($part);
- if ($part === '' || !str_contains($part, '=')) {
- continue;
- }
- [$key, $value] = explode('=', $part, 2);
- $tags[strtolower(trim($key))] = trim($value);
- }
- return $tags;
- }
- private static function analyzeKey(string $p, string $keyType): array
- {
- $clean = preg_replace('/\s+/', '', $p) ?? '';
- $der = base64_decode($clean, true);
- if ($der === false) {
- return ['valid' => false, 'error' => 'p= is not valid base64.', 'bits' => null, 'type' => $keyType];
- }
- if ($keyType === 'ed25519') {
- if (strlen($der) !== 32) {
- return [
- 'valid' => false,
- 'error' => sprintf('Ed25519 keys must be 32 raw bytes, got %d.', strlen($der)),
- 'bits' => null,
- 'type' => $keyType,
- ];
- }
- return ['valid' => true, 'error' => null, 'bits' => 256, 'type' => $keyType];
- }
- // RSA (or unknown, treated as RSA-encoded SubjectPublicKeyInfo).
- $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
- $pubKey = @openssl_pkey_get_public($pem);
- if ($pubKey === false) {
- return ['valid' => false, 'error' => 'OpenSSL could not parse the public key.', 'bits' => null, 'type' => $keyType];
- }
- $details = openssl_pkey_get_details($pubKey);
- return [
- 'valid' => true,
- 'error' => null,
- 'bits' => $details['bits'] ?? null,
- 'type' => $keyType,
- ];
- }
- private static function getTxtRecord(string $fqdn): ?string
- {
- foreach ((@dns_get_record($fqdn, DNS_TXT) ?: []) as $r) {
- $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : '');
- if ($txt !== '') {
- return $txt;
- }
- }
- return null;
- }
- }
- $domain = trim((string) ($_GET['domain'] ?? ''));
- $selector = trim((string) ($_GET['selector'] ?? ''));
- $report = null;
- $scanResults = [];
- $inputError = null;
- if ($domain !== '') {
- // Accept a bare hostname; strip scheme/path if a URL was pasted.
- $domain = preg_replace('#^\w+://#', '', $domain);
- $domain = explode('/', $domain)[0];
- $domain = strtolower(trim($domain));
- if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
- $inputError = 'Please enter a valid domain name (e.g. example.com).';
- } elseif ($selector !== '' && !preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9._-]{0,127})?$/', $selector)) {
- $inputError = 'Please enter a valid selector (letters, digits, dot, dash, underscore).';
- } else {
- $checker = new DkimChecker();
- if ($selector !== '') {
- $report = $checker->check($domain, $selector);
- } else {
- foreach (COMMON_SELECTORS as $candidate) {
- $result = $checker->check($domain, $candidate);
- if ($result['record'] !== null) {
- $scanResults[] = $result;
- }
- }
- }
- }
- }
- /** Renders a single DKIM report (verdict, checks, tag table). */
- function renderReport(array $report): void
- {
- $h = 'htmlspecialchars';
- $key = $report['key'];
- ?>
- <?php if ($report['valid']): ?>
- <div class="verdict ok">✅ <?= $h($report['fqdn']) ?> — valid DKIM key record.</div>
- <?php else: ?>
- <div class="verdict fail">❌ <?= $h($report['fqdn']) ?> — invalid or unusable DKIM record.</div>
- <?php endif; ?>
- <?php foreach ($report['errors'] as $e): ?>
- <div class="error">⚠️ <?= $h($e) ?></div>
- <?php endforeach; ?>
- <?php foreach ($report['warnings'] as $w): ?>
- <div class="warning">⚠️ <?= $h($w) ?></div>
- <?php endforeach; ?>
- <?php if ($report['record'] !== null): ?>
- <div class="record"><?= $h($report['record']) ?></div>
- <div class="checks">
- <?php
- $renderCheck = function (bool $pass, string $label, string $detail, bool $criticalIfFail = true) use ($h) {
- $cls = $pass ? 'pass' : ($criticalIfFail ? 'crit' : 'warn');
- $ic = $pass ? '✔️' : ($criticalIfFail ? '❌' : '⚠️');
- printf(
- '<div class="check %s"><span class="ic">%s</span><div><strong>%s</strong><br>%s</div></div>',
- $cls, $ic, $h($label), $h($detail)
- );
- };
- $renderCheck(($report['tags']['p'] ?? '') !== '', 'Public key present', ($report['tags']['p'] ?? '') !== '' ? 'p= tag is populated' : 'Key missing or revoked');
- if ($key) {
- $renderCheck($key['valid'], 'Key parses', $key['valid'] ? strtoupper($key['type']) . ($key['bits'] ? ', ' . $key['bits'] . ' bits' : '') : ($key['error'] ?? 'Parse error'));
- }
- $renderCheck(!str_contains($report['tags']['t'] ?? '', 'y'), 'Not in testing mode', str_contains($report['tags']['t'] ?? '', 'y') ? 't=y is set' : 'No testing flag', false);
- ?>
- </div>
- <table>
- <thead><tr><th>Tag</th><th>Meaning</th><th>Value</th></tr></thead>
- <tbody>
- <?php
- $meanings = [
- 'v' => 'Version', 'k' => 'Key type', 'p' => 'Public key (base64)',
- 'h' => 'Hash algorithms', 't' => 'Flags', 's' => 'Service type',
- 'n' => 'Notes', 'g' => 'Granularity',
- ];
- foreach ($report['tags'] as $tag => $value):
- ?>
- <tr>
- <td class="mono"><?= $h($tag) ?></td>
- <td><?= $h($meanings[$tag] ?? '—') ?></td>
- <td class="mono" style="word-break: break-all;">
- <?= $tag === 'p' ? $h(substr($value, 0, 60) . (strlen($value) > 60 ? '…' : '')) : $h($value) ?>
- </td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <?php endif; ?>
- <?php
- }
- ?>
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>DKIM Record Checker</title>
- <style>
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
- max-width: 1000px;
- margin: 40px auto;
- padding: 0 20px;
- background: #f5f5f5;
- color: #333;
- }
- h1 { color: #333; }
- .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
- form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
- input[type=text] {
- flex: 1; min-width: 180px; padding: 10px; font-size: 15px;
- border: 1px solid #ccc; border-radius: 5px;
- }
- input[name=selector] { flex: 0 0 200px; min-width: 140px; }
- .btn {
- display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
- text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
- }
- .btn:hover { background: #1976D2; }
- .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
- .warning {
- background: #fff8e1; color: #e65100; padding: 10px; border-radius: 5px;
- margin: 10px 0; border-left: 5px solid #ff9800;
- }
- .verdict {
- display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
- font-size: 17px; font-weight: 600; margin: 20px 0 10px;
- }
- .verdict.ok { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
- .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
- .record {
- background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
- font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
- }
- .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin: 12px 0; }
- .check {
- display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-radius: 5px;
- background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); font-size: 14px;
- }
- .check .ic { font-size: 18px; }
- .check.pass { border-left: 4px solid #43a047; }
- .check.warn { border-left: 4px solid #fb8c00; }
- .check.crit { border-left: 4px solid #e53935; }
- table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
- th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
- th { background: #fafafa; color: #555; font-weight: 600; }
- td.mono, .mono { font-family: monospace; }
- h2 { color: #333; margin-top: 30px; }
- details.scan { margin: 14px 0; }
- summary { cursor: pointer; font-weight: 600; color: #1976D2; }
- .empty { text-align: center; color: #999; padding: 40px; }
- </style>
- </head>
- <body>
- <h1>✉️ DKIM Record Checker</h1>
- <div class="info">
- Looks up the <strong>DKIM</strong> TXT record for a domain + selector
- (<code>selector._domainkey.domain</code>), parses its tags and validates the
- public key. Leave the selector blank to scan a list of common selectors.
- </div>
- <form class="lookup" method="GET">
- <input type="text" name="domain" placeholder="example.com" value="<?= htmlspecialchars($domain) ?>" autofocus>
- <input type="text" name="selector" placeholder="selector (optional)" value="<?= htmlspecialchars($selector) ?>">
- <button type="submit" class="btn">🔍 Check DKIM</button>
- </form>
- <?php if ($inputError): ?>
- <div class="error"><?= htmlspecialchars($inputError) ?></div>
- <?php endif; ?>
- <?php if ($report !== null): ?>
- <?php renderReport($report); ?>
- <?php elseif ($domain !== '' && $selector === '' && !$inputError): ?>
- <h2>Common selector scan</h2>
- <?php if (empty($scanResults)): ?>
- <div class="empty">No DKIM record found at any of the <?= count(COMMON_SELECTORS) ?> common selectors tried.<br>
- If you know the selector, enter it above for a direct lookup.</div>
- <?php else: ?>
- <?php foreach ($scanResults as $result): ?>
- <details class="scan" open>
- <summary><?= htmlspecialchars($result['selector']) ?></summary>
- <?php renderReport($result); ?>
- </details>
- <?php endforeach; ?>
- <?php endif; ?>
- <?php endif; ?>
- </body>
- </html>
|