/ src / library / mail / DkimKey.php
<?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;
    }
}
X