<?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");
}
}