<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library\mail;
use seekquarry\yioop\configs as C;
/**
* Manages the server's DKIM signing key pair: a single 2048-bit
* RSA key used to sign outbound mail for every local domain. The
* private key signs messages (see the outbound send path), and the
* public key is published in DNS as a TXT record at
* <selector>._domainkey.<domain> so receivers can verify the
* signature.
*
* RSA is used (via the openssl extension) rather than the sodium
* Ed25519 primitives because, as of 2026, the largest mailbox
* providers do not yet verify Ed25519 DKIM signatures, so RSA is
* the only choice that validates universally. The key pair lives
* in SECURITY_DIR alongside the TLS material, with the private key
* kept mode 0600 and the directory 0700 so it is never web served
* or world readable.
*/
class DkimKey
{
/**
* File name of the DKIM private signing key within SECURITY_DIR.
*/
const PRIVATE_KEY_FILE = 'dkim.key';
/**
* File name of the DKIM public key within SECURITY_DIR.
*/
const PUBLIC_KEY_FILE = 'dkim.pub';
/**
* Number of bits in the generated RSA key. 2048 is the size the
* major providers expect; 1024 is widely treated as too weak
* and 4096 produces a TXT record long enough to cause DNS
* publishing trouble.
*/
const KEY_BITS = 2048;
/**
* Directory the key files live in. Null means use the
* configured SECURITY_DIR, which is the production behavior. A
* test can point this at a throwaway directory so it never
* reads, writes, or deletes the real key files; production code
* never sets it.
* @var string|null
*/
private static $security_dir = null;
/**
* Returns the directory the key files live in: the override
* when one has been set, otherwise the configured SECURITY_DIR.
* @return string the security directory path
*/
public static function securityDir()
{
if (self::$security_dir !== null) {
return self::$security_dir;
}
return C\SECURITY_DIR;
}
/**
* Overrides the security directory, for tests that must not
* touch the real key files. Passing null restores the default
* of using the configured SECURITY_DIR.
*
* @param string|null $directory throwaway directory to use, or
* null to restore the configured default
*/
public static function setSecurityDir($directory)
{
self::$security_dir = $directory;
}
/**
* Absolute path of the private key file.
* @return string path within the security directory
*/
public static function privateKeyPath()
{
return self::securityDir() . "/" . self::PRIVATE_KEY_FILE;
}
/**
* Absolute path of the public key file.
* @return string path within the security directory
*/
public static function publicKeyPath()
{
return self::securityDir() . "/" . self::PUBLIC_KEY_FILE;
}
/**
* Generates the DKIM key pair if it does not already exist,
* writing the private key mode 0600 and the public key mode
* 0644 into SECURITY_DIR (created mode 0700 when absent). A
* no-op when both files are already present, so it is safe to
* call at profile creation and from the upgrade migration. The
* openssl extension is required; when it is missing this
* returns false rather than throwing, leaving the operator able
* to run mail without DKIM.
*
* @return bool true when the pair exists or was created, false
* when generation was not possible
*/
public static function ensureKeyPair()
{
$private_path = self::privateKeyPath();
$public_path = self::publicKeyPath();
if (file_exists($private_path) && file_exists($public_path)) {
return true;
}
if (!function_exists('openssl_pkey_new')) {
return false;
}
$directory = self::securityDir();
if (!is_dir($directory)) {
mkdir($directory, 0700, true);
}
$resource = openssl_pkey_new([
'private_key_bits' => self::KEY_BITS,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($resource === false) {
return false;
}
$private_pem = '';
if (!openssl_pkey_export($resource, $private_pem)) {
return false;
}
$details = openssl_pkey_get_details($resource);
if (empty($details['key'])) {
return false;
}
file_put_contents($private_path, $private_pem);
chmod($private_path, 0600);
file_put_contents($public_path, $details['key']);
chmod($public_path, 0644);
return true;
}
/**
* Reads the stored public key and returns just its base64 body
* with the PEM armor lines and whitespace removed, which is the
* form a DKIM TXT record's p= tag carries. Returns the empty
* string when no public key has been generated yet.
*
* @return string base64 public key body, or '' when absent
*/
public static function publicKeyBase64()
{
$public_path = self::publicKeyPath();
if (!file_exists($public_path)) {
return '';
}
$pem = (string) file_get_contents($public_path);
return preg_replace('/-----[^-]+-----|\s+/', '', $pem);
}
/**
* Builds the DKIM public-key DNS TXT record value advertising
* the RSA key. Published at <selector>._domainkey.<domain>.
* Returns the empty string when no key has been generated, so
* callers can omit the record rather than publish an empty one.
*
* @return string the TXT record value, or '' when no key exists
*/
public static function publicKeyRecord()
{
$base64 = self::publicKeyBase64();
if ($base64 === '') {
return '';
}
return 'v=DKIM1; k=rsa; p=' . $base64;
}
/**
* Headers that are signed when present, in the order they are
* listed in the signature's h= tag. From is required by RFC
* 6376; the others are the commonly signed message headers.
* @var array
*/
const SIGNED_HEADERS = ['From', 'To', 'Cc', 'Subject', 'Date',
'Message-ID', 'MIME-Version', 'Content-Type'];
/**
* Canonicalizes a header field with the relaxed algorithm (RFC
* 6376 3.4.2): the name is lowercased, the value is unfolded,
* internal whitespace runs are collapsed to a single space, and
* leading and trailing whitespace is removed. The result has no
* trailing CRLF.
*
* @param string $name header field name
* @param string $value header field value, possibly folded
* @return string the canonicalized "name:value" form
*/
public static function canonicalizeHeader($name, $value)
{
$name = strtolower(trim($name));
$value = str_replace("\r\n", '', $value);
$value = preg_replace('/[ \t]+/', ' ', $value);
$value = trim($value);
return $name . ':' . $value;
}
/**
* Canonicalizes a message body with the relaxed algorithm (RFC
* 6376 3.4.4): line endings become CRLF, trailing whitespace on
* each line is removed, internal whitespace runs collapse to a
* single space, and trailing empty lines are removed so the
* body ends in exactly one CRLF.
*
* @param string $body the raw message body
* @return string the canonicalized body
*/
public static function canonicalizeBody($body)
{
$body = str_replace("\r\n", "\n", $body);
$body = str_replace("\n", "\r\n", $body);
$body = preg_replace('/[ \t]+\r\n/', "\r\n", $body);
$body = preg_replace('/[ \t]+/', ' ', $body);
$body = preg_replace('/(\r\n)+$/', '', $body);
return $body . "\r\n";
}
/**
* Splits a complete RFC 5322 message into its header block and
* body at the first blank line, normalizing to LF internally so
* the boundary is found regardless of incoming line endings.
*
* @param string $message the complete message
* @return array a pair: the header block and the body
*/
public static function splitMessage($message)
{
$message = str_replace("\r\n", "\n", $message);
$boundary = strpos($message, "\n\n");
if ($boundary === false) {
return [$message, ''];
}
return [substr($message, 0, $boundary),
substr($message, $boundary + 2)];
}
/**
* Parses a header block into a map of lowercased field name to
* the list of that field's values in order, keeping folded
* continuation lines joined with CRLF. Used to pick the last
* instance of each signed field.
*
* @param string $header_block the message header block
* @return array map of lowercase name to list of raw values
*/
public static function parseHeaders($header_block)
{
$header_block = str_replace("\r\n", "\n", $header_block);
$lines = explode("\n", $header_block);
$headers = [];
$current = null;
foreach ($lines as $line) {
if ($current !== null &&
preg_match('/^[ \t]/', $line)) {
$last = count($headers[$current]) - 1;
$headers[$current][$last] .= "\r\n" . $line;
} else if (strpos($line, ':') !== false) {
$parts = explode(':', $line, 2);
$current = strtolower(trim($parts[0]));
if (!isset($headers[$current])) {
$headers[$current] = [];
}
$headers[$current][] = ltrim($parts[1]);
}
}
return $headers;
}
/**
* Extracts the domain from the message's From address, used as
* the DKIM signing domain (d= tag). Returns the empty string
* when no From domain can be found.
*
* @param array $headers parsed headers from parseHeaders
* @return string the From domain, or '' when not determinable
*/
public static function fromDomain($headers)
{
if (empty($headers['from'])) {
return '';
}
$from = end($headers['from']);
$at = strrpos($from, '@');
if ($at === false) {
return '';
}
$domain = substr($from, $at + 1);
$domain = trim($domain, " \t<>");
if (preg_match('/^([^ \t>]+)/', $domain, $match)) {
return strtolower($match[1]);
}
return '';
}
/**
* Signs a complete RFC 5322 message and returns it with a
* DKIM-Signature header prepended, using relaxed/relaxed
* canonicalization and rsa-sha256 (RFC 6376). The signing
* domain is taken from the From header. When no key exists,
* the From domain cannot be determined, the selector is empty,
* or signing otherwise fails, the original message is returned
* unchanged so a delivery is never blocked by a signing
* problem.
*
* @param string $message the complete message to sign
* @param string $selector the DKIM selector to name in the
* signature
* @param int $timestamp signature creation time for the t= tag
* @return string the message with a DKIM-Signature prepended,
* or the original message when signing is not possible
*/
public static function sign($message, $selector, $timestamp)
{
$private_path = self::privateKeyPath();
if ($selector === '' || !file_exists($private_path) ||
!function_exists('openssl_sign')) {
return $message;
}
list($header_block, $body) = self::splitMessage($message);
$headers = self::parseHeaders($header_block);
$domain = self::fromDomain($headers);
if ($domain === '') {
return $message;
}
$body_hash = base64_encode(
hash('sha256', self::canonicalizeBody($body), true));
/* Build the h= list with oversigning: each header we sign is
listed once more than it occurs in the message. The extra
occurrence binds the null string (see
canonicalizeSignedHeaders), so if a relay later inserts a
second instance of that header -- a forged From or Subject
-- the recomputed hash no longer matches and the forged
message fails verification. A header that is absent is
listed once, which likewise binds null and prevents one
from being added. This matches what major senders do. */
$signed_names = [];
foreach (self::SIGNED_HEADERS as $name) {
$occurrences = empty($headers[strtolower($name)]) ? 0 :
count($headers[strtolower($name)]);
for ($repeat = 0; $repeat <= $occurrences; $repeat++) {
$signed_names[] = $name;
}
}
$canonical = self::canonicalizeSignedHeaders($headers,
$signed_names);
$tags = 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=' . $domain .
'; s=' . $selector . '; t=' . ((int) $timestamp) .
'; bh=' . $body_hash . '; h=' .
implode(':', $signed_names) . '; b=';
$canonical .= self::canonicalizeHeader('DKIM-Signature',
$tags);
$private_pem = (string) file_get_contents($private_path);
$signature = '';
if (!openssl_sign($canonical, $signature, $private_pem,
OPENSSL_ALGO_SHA256)) {
return $message;
}
$dkim_header = 'DKIM-Signature: ' . $tags .
base64_encode($signature);
return $dkim_header . "\r\n" . $message;
}
/**
* Verification result: the message carried a DKIM-Signature and
* the signature checked out against the signer's published key.
* The badge shows green for this.
*/
const VERIFY_PASS = 'pass';
/**
* Verification result: a DKIM-Signature was present but did not
* validate -- a bad signature, an altered body or signed
* header, or a malformed signature. The badge shows red.
*/
const VERIFY_FAIL = 'fail';
/**
* Verification result: a DKIM-Signature was present but the
* signer's public key could not be retrieved (no record in DNS,
* an empty or revoked key, or DNS lookup unavailable), so the
* signature can be neither confirmed nor refuted. The badge
* shows grey.
*/
const VERIFY_NO_KEY = 'no_key';
/**
* Verification result: the message carried no DKIM-Signature at
* all, so there is nothing to verify and no badge is shown.
*/
const VERIFY_NONE = 'none';
/**
* Parses a DKIM-Signature header value into its tag map (the
* v=, a=, d=, s=, bh=, h=, b= tags and so on). Whitespace
* inside tag values, which folding and the base64 of bh=/b=
* can introduce, is removed so the values are usable directly.
*
* @param string $signature_value the DKIM-Signature field value
* (without the "DKIM-Signature:" name)
* @return array map of tag name to tag value
*/
public static function parseSignatureTags($signature_value)
{
$tags = [];
foreach (explode(';', $signature_value) as $pair) {
if (strpos($pair, '=') === false) {
continue;
}
$kv = explode('=', $pair, 2);
$name = trim($kv[0]);
$value = preg_replace('/\s+/', '', $kv[1]);
if ($name !== '') {
$tags[$name] = $value;
}
}
return $tags;
}
/**
* Fetches a DKIM public key from DNS, given a selector and
* domain, by reading the TXT record at
* <selector>._domainkey.<domain> and returning the base64 key
* body from its p= tag. Returns the empty string when no record
* is found, the record has no key, or DNS is unavailable.
*
* @param string $selector the DKIM selector (s= tag)
* @param string $domain the signing domain (d= tag)
* @return string the base64 public key body, or '' when none
*/
public static function fetchPublicKey($selector, $domain)
{
$selector = trim((string) $selector);
$domain = trim((string) $domain);
if ($selector === '' || $domain === '') {
return '';
}
$name = $selector . '._domainkey.' . $domain;
$cache = MailRecordCache::getInstance();
$cached = $cache->get(MailRecordCache::TYPE_DKIM, $name);
if ($cached !== null) {
return $cached;
}
$records = AsyncDnsResolver::query($name,
AsyncDnsResolver::TYPE_TXT);
$key = '';
$time_to_live = 0;
if (!empty($records)) {
foreach ($records as $record) {
$text = $record['txt'] ?? '';
$tags = self::parseSignatureTags($text);
if (!empty($tags['p'])) {
$key = $tags['p'];
$time_to_live = (int) ($record['ttl'] ?? 0);
break;
}
}
}
$cache->put(MailRecordCache::TYPE_DKIM, $name, $key,
$time_to_live);
return $key;
}
/**
* Wraps a base64 DKIM public key body (the p= value) in PEM
* armor so openssl can load it as a public key.
*
* @param string $base64 the base64 key body
* @return string the key in PEM form
*/
public static function publicKeyPem($base64)
{
$wrapped = chunk_split($base64, 64, "\n");
return "-----BEGIN PUBLIC KEY-----\n" . $wrapped .
"-----END PUBLIC KEY-----\n";
}
/**
* Verifies a message's DKIM signature against a supplied public
* key (PEM form), performing the receiver-side checks of RFC
* 6376: recompute the relaxed body hash and compare to bh=,
* rebuild the signed-header block in h= order with the
* DKIM-Signature's own b= emptied, and check the RSA signature.
* This is the pure core with no DNS lookup, so it is directly
* unit-testable.
*
* @param string $message the complete received message
* @param string $public_key_pem the signer's public key in PEM
* @return string one of the VERIFY_ status constants
*/
public static function verifyWithPublicKey($message,
$public_key_pem)
{
$detail = self::verifyDetailed($message, $public_key_pem);
return $detail['status'];
}
/**
* Builds the canonicalized signed-header block for the h= name
* list, following RFC 6376 5.4.2: header fields are consumed
* bottom to top, and a name with no matching instance left
* contributes the null string -- nothing is added to the block.
* So when a name appears in h= more times than the message
* carries it (oversigning), the first occurrences bind the
* existing instances most recent first, and each further
* occurrence adds nothing. That, not an empty header line, is
* what makes oversigning prevent an attacker from adding another
* instance: a later-injected header has no signed counterpart,
* so the hash no longer matches. Names absent from the message
* are likewise skipped. parseHeaders stores each header's
* instances in arrival (top-to-bottom) order, so consuming from
* the end walks bottom to top.
*
* @param array $headers parsed headers from parseHeaders, a map
* of lowercase name to its list of values in arrival order
* @param array $names the h= header names in order, possibly
* with repeats
* @return string the canonicalized header block, each present
* header on its own line ending in CRLF
*/
public static function canonicalizeSignedHeaders($headers,
$names)
{
$remaining = [];
foreach ($headers as $key => $values) {
$remaining[$key] = count($values);
}
$canonical = '';
foreach ($names as $name) {
$key = strtolower(trim($name));
/* A name with no matching header instance left -- absent
from the message, or listed in h= more times than it
occurs (oversigning) -- contributes the null string
per RFC 6376 5.4.2: nothing is added, not even an
empty header line. Emitting an empty line here would
corrupt the hash for the common oversigned case. */
if (!isset($headers[$key]) || $remaining[$key] <= 0) {
continue;
}
$remaining[$key]--;
$value = $headers[$key][$remaining[$key]];
$canonical .= self::canonicalizeHeader($name, $value) .
"\r\n";
}
return $canonical;
}
/**
* Like verifyWithPublicKey but returns the evidence behind the
* verdict, not just the status, so the message view can show
* what was actually checked: the signature's algorithm and
* signed-header list, the body-hash comparison (the bh= value
* carried in the header versus the hash recomputed from the
* received body), and whether the RSA signature over the signed
* headers validated against the public key. The returned map
* always has 'status'; when a signature is present it also has
* 'algorithm', 'signed_headers', 'body_hash_header',
* 'body_hash_computed', 'body_hash_match', and 'signature_ok'.
*
* @param string $message the complete received message
* @param string $public_key_pem the signer's public key in PEM,
* or '' when none could be retrieved
* @return array the verdict and its supporting evidence
*/
public static function verifyDetailed($message, $public_key_pem)
{
list($header_block, $body) = self::splitMessage($message);
$headers = self::parseHeaders($header_block);
if (empty($headers['dkim-signature'])) {
return ['status' => self::VERIFY_NONE];
}
$signature_value = end($headers['dkim-signature']);
$tags = self::parseSignatureTags($signature_value);
$detail = [
'algorithm' => $tags['a'] ?? '',
'signed_headers' => $tags['h'] ?? '',
'body_hash_header' => $tags['bh'] ?? '',
'body_hash_computed' => '',
'body_hash_match' => false,
'signature_ok' => false,
];
if ($public_key_pem === '' ||
!function_exists('openssl_verify')) {
$detail['status'] = self::VERIFY_NO_KEY;
return $detail;
}
if (empty($tags['b']) || empty($tags['bh']) ||
empty($tags['h'])) {
$detail['status'] = self::VERIFY_FAIL;
return $detail;
}
$body_hash = base64_encode(
hash('sha256', self::canonicalizeBody($body), true));
$detail['body_hash_computed'] = $body_hash;
$detail['body_hash_match'] = ($body_hash === $tags['bh']);
if (!$detail['body_hash_match']) {
$detail['status'] = self::VERIFY_FAIL;
return $detail;
}
$canonical = self::canonicalizeSignedHeaders($headers,
explode(':', $tags['h']));
$unsigned = preg_replace('/b=[^;]*/', 'b=',
$signature_value, 1);
$canonical .= self::canonicalizeHeader('DKIM-Signature',
$unsigned);
$signature = base64_decode($tags['b']);
$result = openssl_verify($canonical, $signature,
$public_key_pem, OPENSSL_ALGO_SHA256);
$detail['signature_ok'] = ($result === 1);
$detail['status'] = ($result === 1) ? self::VERIFY_PASS :
self::VERIFY_FAIL;
return $detail;
}
/**
* Verifies a received message's DKIM signature end to end:
* reads the selector and domain from the DKIM-Signature,
* fetches the signer's public key from DNS, and checks the
* signature. Returns a result map carrying the status (one of
* the VERIFY_ constants, suitable for the view badge color),
* and, when a signature is present, the signing domain and
* selector for display. When no key can be fetched the status
* is VERIFY_NO_KEY (grey badge) rather than a failure, since the
* signature cannot be judged either way.
*
* @param string $message the complete received message
* @return array a map with 'status' and, when a signature is
* present, the signing 'domain' and 'selector', the
* 'dns_name' the public key was looked up at, whether a
* key was 'key_found', and the verifyDetailed evidence
* ('algorithm', 'signed_headers', 'body_hash_header',
* 'body_hash_computed', 'body_hash_match', 'signature_ok')
*/
public static function verify($message)
{
list($header_block, ) = self::splitMessage($message);
$headers = self::parseHeaders($header_block);
if (empty($headers['dkim-signature'])) {
return ['status' => self::VERIFY_NONE];
}
$tags = self::parseSignatureTags(
end($headers['dkim-signature']));
$domain = $tags['d'] ?? '';
$selector = $tags['s'] ?? '';
$dns_name = ($selector !== '' && $domain !== '') ?
$selector . '._domainkey.' . $domain : '';
$base64 = self::fetchPublicKey($selector, $domain);
$public_key_pem = ($base64 === '') ? '' :
self::publicKeyPem($base64);
$detail = self::verifyDetailed($message, $public_key_pem);
$detail['domain'] = $domain;
$detail['selector'] = $selector;
$detail['dns_name'] = $dns_name;
$detail['key_found'] = ($base64 !== '');
return $detail;
}
}