$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 */ 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']; ?>
— valid DKIM key record.
— invalid or unusable DKIM record.
⚠️
⚠️
%s
%s
%s
', $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); ?> '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): ?>
TagMeaningValue
60 ? '…' : '')) : $h($value) ?>
DKIM Record Checker

✉️ DKIM Record Checker

Looks up the DKIM TXT record for a domain + selector (selector._domainkey.domain), parses its tags and validates the public key. Leave the selector blank to scan a list of common selectors.

Common selector scan

No DKIM record found at any of the common selectors tried.
If you know the selector, enter it above for a direct lookup.