/ tests / DkimKeyTest.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\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\mail\DkimKey;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests for DkimKey, which manages the RSA DKIM signing key pair.
 * Each case runs against a throwaway temporary security directory
 * set in setUp and deleted in tearDown, so the test never reads,
 * writes, or deletes the real DKIM key files in the configured
 * SECURITY_DIR -- running the suite must not disturb a live
 * server's signing key. Generation uses real openssl, so a case is
 * fast but not instantaneous; if a case ever runs noticeably
 * slowly it is the key generation, not unintended I/O.
 *
 * @author Chris Pollett
 */
class DkimKeyTest extends UnitTest
{
    /**
     * Throwaway directory the key files live in for the duration of
     * a case; created in setUp, removed in tearDown.
     * @var string
     */
    public $temp_dir;
    /**
     * Points DkimKey at a fresh, unique temporary directory so the
     * case never touches the real SECURITY_DIR key files.
     */
    public function setUp()
    {
        $this->temp_dir = sys_get_temp_dir() . "/dkim_test_" .
            getmypid() . "_" . uniqid();
        DkimKey::setSecurityDir($this->temp_dir);
    }
    /**
     * Removes the throwaway key files and directory, then restores
     * DkimKey to the configured SECURITY_DIR so nothing leaks into
     * later tests or the real environment.
     */
    public function tearDown()
    {
        if (file_exists(DkimKey::privateKeyPath())) {
            unlink(DkimKey::privateKeyPath());
        }
        if (file_exists(DkimKey::publicKeyPath())) {
            unlink(DkimKey::publicKeyPath());
        }
        if (is_dir($this->temp_dir)) {
            rmdir($this->temp_dir);
        }
        DkimKey::setSecurityDir(null);
    }
    /**
     * Before any key is generated there is no public-key record to
     * publish, so the record value is empty.
     */
    public function noKeyYieldsEmptyRecordTestCase()
    {
        $this->assertEqual('', DkimKey::publicKeyRecord(),
            "no record before a key is generated");
    }
    /**
     * Generating the pair writes both key files and yields a DKIM
     * TXT record whose p= body is base64 with the PEM armor and
     * whitespace stripped.
     */
    public function generatesKeyAndRecordTestCase()
    {
        $this->assertTrue(DkimKey::ensureKeyPair(),
            "ensureKeyPair succeeds");
        $this->assertTrue(file_exists(DkimKey::privateKeyPath()),
            "private key file written");
        $this->assertTrue(file_exists(DkimKey::publicKeyPath()),
            "public key file written");
        $record = DkimKey::publicKeyRecord();
        $this->assertTrue(strpos($record, 'v=DKIM1; k=rsa; p=') === 0,
            "record carries the DKIM version and key type");
        $body = DkimKey::publicKeyBase64();
        $this->assertTrue(strpos($body, 'BEGIN') === false &&
            strpos($body, "\n") === false,
            "record body has no PEM armor or newlines");
        $this->assertTrue(base64_decode($body, true) !== false,
            "record body decodes as base64");
    }
    /**
     * The private key file is written with owner-only permissions
     * so it is not world readable.
     */
    public function privateKeyModeTestCase()
    {
        DkimKey::ensureKeyPair();
        $mode = substr(sprintf('%o',
            fileperms(DkimKey::privateKeyPath())), -4);
        $this->assertEqual('0600', $mode,
            "private key is mode 0600");
    }
    /**
     * Calling ensureKeyPair again when a key already exists is a
     * no-op: it succeeds without regenerating, so a re-provision
     * (a second CreateDB run, or the upgrade migration after a
     * fresh install already made the key) neither errors nor
     * replaces the key, which would invalidate the published DNS
     * record.
     */
    public function regenerationIsIdempotentTestCase()
    {
        $this->assertTrue(DkimKey::ensureKeyPair(),
            "first generation succeeds");
        $first_private = file_get_contents(
            DkimKey::privateKeyPath());
        $first_record = DkimKey::publicKeyRecord();
        $this->assertTrue(DkimKey::ensureKeyPair(),
            "second call still succeeds");
        $second_private = file_get_contents(
            DkimKey::privateKeyPath());
        $this->assertEqual($first_private, $second_private,
            "private key is not regenerated on a re-run");
        $this->assertEqual($first_record, DkimKey::publicKeyRecord(),
            "published record is unchanged on a re-run");
    }
    /**
     * A signed message gets a DKIM-Signature header prepended that
     * names the From domain, uses relaxed/relaxed rsa-sha256, and
     * leaves the original message intact below it; and the RSA
     * signature verifies against the public key the way a receiver
     * would reconstruct and check it.
     */
    public function signAndVerifyTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: Test <tester@example.com>\r\n" .
            "To: <root2@127.0.0.1>\r\n" .
            "Subject: hello   world\r\n" .
            "Date: Mon, 02 Jun 2026 10:00:00 -0700\r\n" .
            "\r\n" .
            "This is a test body.\r\nLine two.  \r\n\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602',
            1780447857);
        $this->assertTrue(
            strpos($signed, 'DKIM-Signature: ') === 0,
            "signature header is prepended");
        $this->assertTrue(strpos($signed, $message) !== false,
            "original message is preserved below the signature");
        $this->assertTrue(strpos($signed, 'd=example.com;') !== false,
            "signing domain is taken from the From header");
        $this->assertTrue(
            strpos($signed, 'a=rsa-sha256; c=relaxed/relaxed')
            !== false, "uses relaxed/relaxed rsa-sha256");
        $this->assertTrue(
            $this->verifySignature($signed, $message),
            "signature verifies against the public key");
    }
    /**
     * Verifies a signed message the way a receiver would: recompute
     * the body hash, rebuild the signed-header block with the
     * DKIM-Signature's b= emptied, and check the RSA signature
     * against the stored public key. Returns true on a good
     * signature.
     *
     * @param string $signed the message with its DKIM-Signature
     * @param string $message the original unsigned message
     * @return bool whether the signature verifies
     */
    public function verifySignature($signed, $message)
    {
        $break = strpos($signed, "\r\n");
        $dkim_line = substr($signed, 0, $break);
        $tag_string = substr($dkim_line, strlen("DKIM-Signature: "));
        $tags = [];
        foreach (explode(';', $tag_string) as $pair) {
            if (strpos($pair, '=') === false) {
                continue;
            }
            $kv = explode('=', $pair, 2);
            $tags[trim($kv[0])] = trim($kv[1]);
        }
        list($header_block, $body) = DkimKey::splitMessage($message);
        $headers = DkimKey::parseHeaders($header_block);
        $body_hash = base64_encode(hash('sha256',
            DkimKey::canonicalizeBody($body), true));
        if ($body_hash !== $tags['bh']) {
            return false;
        }
        $canonical = DkimKey::canonicalizeSignedHeaders($headers,
            explode(':', $tags['h']));
        $tags_no_b = preg_replace('/b=[^;]*$/', 'b=', $tag_string);
        $canonical .= DkimKey::canonicalizeHeader('DKIM-Signature',
            $tags_no_b);
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        return openssl_verify($canonical, base64_decode($tags['b']),
            $public_pem, OPENSSL_ALGO_SHA256) === 1;
    }
    /**
     * Tampering with the body after signing makes verification
     * fail, confirming the body hash actually covers the body.
     */
    public function tamperedBodyFailsTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: a@example.com\r\nSubject: hi\r\n\r\n" .
            "original body\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1);
        $tampered = "From: a@example.com\r\nSubject: hi\r\n\r\n" .
            "tampered body\r\n";
        $this->assertFalse(
            $this->verifySignature($signed, $tampered),
            "altered body no longer verifies");
    }
    /**
     * With no key present, signing returns the message unchanged so
     * a missing key never blocks delivery.
     */
    public function noKeySignReturnsOriginalTestCase()
    {
        $message = "From: a@example.com\r\nSubject: hi\r\n\r\nbody\r\n";
        $this->assertEqual($message,
            DkimKey::sign($message, 'yioop20260602', 1),
            "signing without a key returns the original message");
    }
    /**
     * verifyWithPublicKey returns PASS for a message signed by the
     * matching key, exercising the receiver-side body-hash and
     * signature checks against the stored public key (in PEM form).
     */
    public function verifyPassTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: Test <tester@example.com>\r\n" .
            "To: <root2@127.0.0.1>\r\n" .
            "Subject: hello   world\r\n" .
            "Date: Mon, 02 Jun 2026 10:00:00 -0700\r\n" .
            "\r\n" .
            "This is a test body.\r\nLine two.  \r\n\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1);
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        $this->assertEqual(DkimKey::VERIFY_PASS,
            DkimKey::verifyWithPublicKey($signed, $public_pem),
            "a signature verifies against the matching key");
        $rewrapped = DkimKey::publicKeyPem(
            DkimKey::publicKeyBase64());
        $this->assertEqual(DkimKey::VERIFY_PASS,
            DkimKey::verifyWithPublicKey($signed, $rewrapped),
            "verifies against a key rebuilt from its base64 body");
    }
    /**
     * verifyWithPublicKey returns FAIL when the body is altered
     * after signing, when a signed header is altered, and when a
     * different key is used, confirming the checks actually bind
     * the body, the signed headers, and the key.
     */
    public function verifyFailTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: Test <tester@example.com>\r\n" .
            "Subject: hello world\r\n\r\n" .
            "This is a test body.\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1);
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        $body_tampered = str_replace('test body', 'evil body',
            $signed);
        $this->assertEqual(DkimKey::VERIFY_FAIL,
            DkimKey::verifyWithPublicKey($body_tampered,
            $public_pem), "altered body fails");
        $header_tampered = str_replace('Subject: hello world',
            'Subject: HELLO world', $signed);
        $this->assertEqual(DkimKey::VERIFY_FAIL,
            DkimKey::verifyWithPublicKey($header_tampered,
            $public_pem), "altered signed header fails");
        $other = openssl_pkey_new(['private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA]);
        $other_pem = openssl_pkey_get_details($other)['key'];
        $this->assertEqual(DkimKey::VERIFY_FAIL,
            DkimKey::verifyWithPublicKey($signed, $other_pem),
            "a different key fails");
    }
    /**
     * verifyWithPublicKey returns NO_KEY when a signature is present
     * but no public key is available (grey badge), and NONE when the
     * message carries no DKIM-Signature at all.
     */
    public function verifyNoKeyAndNoneTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: a@example.com\r\nSubject: hi\r\n\r\nbody\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1);
        $this->assertEqual(DkimKey::VERIFY_NO_KEY,
            DkimKey::verifyWithPublicKey($signed, ''),
            "present signature with no key is NO_KEY");
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        $this->assertEqual(DkimKey::VERIFY_NONE,
            DkimKey::verifyWithPublicKey($message, $public_pem),
            "an unsigned message is NONE");
    }
    /**
     * verifyDetailed returns the evidence behind the verdict: the
     * algorithm and signed-header list from the signature, the
     * body-hash carried in the header alongside the hash recomputed
     * here (which match for an untampered message), and that the
     * signature validated. A tampered body shows the hashes no
     * longer matching.
     */
    public function verifyDetailedEvidenceTestCase()
    {
        DkimKey::ensureKeyPair();
        $message = "From: root@127.0.0.1\r\nSubject: test\r\n" .
            "Date: x\r\n\r\nhello body\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1,
            ['127.0.0.1']);
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        $detail = DkimKey::verifyDetailed($signed, $public_pem);
        $this->assertEqual('pass', $detail['status'],
            "status is pass for a good signature");
        $this->assertEqual('rsa-sha256', $detail['algorithm'],
            "algorithm is reported");
        $this->assertTrue(
            strpos($detail['signed_headers'], 'From') !== false,
            "signed headers are reported");
        $this->assertEqual($detail['body_hash_header'],
            $detail['body_hash_computed'],
            "header and recomputed body hashes match");
        $this->assertTrue($detail['body_hash_match'],
            "body hash match flag is set");
        $this->assertTrue($detail['signature_ok'],
            "signature check flag is set");
        $tampered = str_replace('hello body', 'evil body', $signed);
        $bad = DkimKey::verifyDetailed($tampered, $public_pem);
        $this->assertFalse($bad['body_hash_match'],
            "tampered body shows hashes not matching");
        $this->assertEqual('fail', $bad['status'],
            "tampered body fails");
    }
    /**
     * Header fields listed more than once in h= are consumed bottom
     * to top (RFC 6376 5.4.2), and a name with no matching instance
     * left contributes the null string -- nothing is added, not an
     * empty header line. This covers the common ESP shape (as sent
     * by HubSpot for Princeton University Press) where h= oversigns
     * names the message carries only once or not at all, such as
     * sender:from:from:to:to:cc:cc. A message signed that way must
     * verify, and an attacker prepending a second instance of an
     * oversigned header after signing must break verification.
     */
    public function oversignedHeadersTestCase()
    {
        DkimKey::ensureKeyPair();
        $private_pem = file_get_contents(DkimKey::privateKeyPath());
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        /* One From, one To, no Cc, no Sender -- yet h= oversigns
           all of them, exactly like the real-world failing case. */
        $message = "From: sender@example.com\r\n" .
            "To: rcpt@example.com\r\nSubject: hello\r\n" .
            "Date: Mon, 02 Jun 2026 10:00:00 -0700\r\n\r\n" .
            "body here\r\n";
        list($header_block, $body) =
            DkimKey::splitMessage($message);
        $headers = DkimKey::parseHeaders($header_block);
        $body_hash = base64_encode(hash('sha256',
            DkimKey::canonicalizeBody($body), true));
        $names = ['sender', 'from', 'from', 'to', 'to', 'cc', 'cc',
            'subject', 'subject', 'date'];
        $canonical = DkimKey::canonicalizeSignedHeaders($headers,
            $names);
        $tags = 'v=1; a=rsa-sha256; c=relaxed/relaxed; ' .
            'd=example.com; s=yioop20260602; t=1; bh=' .
            $body_hash . '; h=' . implode(':', $names) .
            '; b=';
        $canonical .= DkimKey::canonicalizeHeader('DKIM-Signature',
            $tags);
        $raw_signature = '';
        openssl_sign($canonical, $raw_signature, $private_pem,
            OPENSSL_ALGO_SHA256);
        $signed = 'DKIM-Signature: ' . $tags .
            base64_encode($raw_signature) . "\r\n" . $message;
        $detail = DkimKey::verifyDetailed($signed, $public_pem);
        $this->assertTrue($detail['body_hash_match'],
            "the oversigned message body hash matches");
        $this->assertEqual('pass', $detail['status'],
            "an oversigned message with absent names verifies");
        $this->assertTrue($detail['signature_ok'],
            "the oversigned signature validates");
        $attacked = "From: attacker@evil.com\r\n" . $signed;
        $attacked_detail = DkimKey::verifyDetailed($attacked,
            $public_pem);
        $this->assertFalse($attacked_detail['signature_ok'],
            "injecting an extra From breaks verification");
    }
    /**
     * Outgoing mail is oversigned: sign() lists each signed header
     * once more than it occurs, so every present header appears
     * twice in h= and an absent one appears once. The message still
     * verifies, but a relay that injects a second instance of a
     * signed header after signing -- a forged Subject or From --
     * breaks verification, which is the protection oversigning
     * provides.
     */
    public function outgoingOversignTestCase()
    {
        DkimKey::ensureKeyPair();
        $public_pem = file_get_contents(DkimKey::publicKeyPath());
        $message = "From: root@127.0.0.1\r\n" .
            "To: chris@pollett.org\r\nSubject: test\r\n" .
            "Date: Wed, 03 Jun 2026 00:00:00 -0700\r\n" .
            "Message-ID: <abc@127.0.0.1>\r\nMIME-Version: 1.0\r\n" .
            "Content-Type: text/plain; charset=utf-8\r\n\r\n" .
            "hello\r\n";
        $signed = DkimKey::sign($message, 'yioop20260602', 1,
            ['127.0.0.1']);
        list($header_block, ) = DkimKey::splitMessage($signed);
        $headers = DkimKey::parseHeaders($header_block);
        $tags = DkimKey::parseSignatureTags(
            end($headers['dkim-signature']));
        $names_text = strtolower($tags['h']);
        $this->assertEqual(2,
            substr_count($names_text, 'from'),
            "a present header is oversigned (listed twice)");
        $this->assertEqual(1, substr_count($names_text, 'cc'),
            "an absent header is listed once");
        $detail = DkimKey::verifyDetailed($signed, $public_pem);
        $this->assertTrue($detail['signature_ok'],
            "oversigned outgoing mail still verifies");
        $forged = str_replace("From: root@127.0.0.1\r\n",
            "From: root@127.0.0.1\r\nSubject: HIJACKED\r\n",
            $signed);
        $forged_detail = DkimKey::verifyDetailed($forged,
            $public_pem);
        $this->assertFalse($forged_detail['signature_ok'],
            "a second injected Subject breaks verification");
    }
    /**
     * parseSignatureTags reads the tag map from a DKIM-Signature
     * value, stripping whitespace that folding and base64 wrapping
     * introduce; publicKeyPem wraps a base64 body in PEM armor.
     */
    public function tagParsingAndPemTestCase()
    {
        $tags = DkimKey::parseSignatureTags(
            "v=1; a=rsa-sha256; d=example.com; s=sel; " .
            "bh=AAA; h=from:to; b=BBB");
        $this->assertEqual('example.com', $tags['d'] ?? '',
            "reads the signing domain");
        $this->assertEqual('sel', $tags['s'] ?? '',
            "reads the selector");
        $pem = DkimKey::publicKeyPem('QUJD');
        $this->assertTrue(
            strpos($pem, '-----BEGIN PUBLIC KEY-----') === 0,
            "wraps the body in PEM armor");
    }
}
X