PHP: Signing outbound emails with DKIM
By now you should have already generated a DKIM key pair and chosen a selector to use for DKIM signing. In this article we'll be presenting code for generating DKIM signature headers and adding them to website-generated emails.
DKIM Signature Explained
When you sign an email with a DKIM signature all you are effectively doing is adding an extra header to the email as it is sent, as shown here:
Date: ...
Message-Id: ...
To: ...
Subject: ...
MIME-Version: 1.0
Content-type: text/plain; charset=UTF-8
Sender: ...
From: ...
Reply-To: ...
DKIM-Signature: ...
This extra header includes various values taken from the email itself (recipient, subject, body), plus an encrypted hash based on some of the same values, which has been signed using the previously generated private key.
When the email arrives at the receiving end, their MTA downloads your public key via a DNS lookup and uses that to verify the signature. That is outside the scope of this article.
If anything has been tampered with in the email during transit, verification will fail and the email can be either quarantined or rejected according to the applicable DMARC policy.
The available DKIM signature tags are defined as follows:
- v=
- version (*)
- a=
- signing algorithm (*)
- d=
- Signing Domain Identifier (*)
- s=
- selector (*)
- c=
- canonicalization algorithm(s) for header and body
- q=
- default query method
- i=
- Agent or User Identifier
- t=
- signature timestamp
- x=
- expire time
- l=
- body length
- h=
- header fields - list of those that have been signed (*)
- z=
- header fields - copy of selected header fields and values
- bh=
- body hash (*)
- b=
- signature of headers and body (*)
(*) required tags
For more details on these tags and how they might be used follow the links under References below.
DKIM Signature PHP Class
Our \Chirp\DKIM class is based on an earlier PHP Implementation of DKIM which is available as a CVS repository on Sourceforge. We have re-written the original implementation into a PHP class which should be compatible with PHP7+.
DKIM.php:
<?PHP
namespace Chirp;
class DKIM
{
const HASHING_ALGORITHM = "rsa-sha1"; // rsa-sha256
const SIGNING_ALGORITHM = OPENSSL_ALGO_SHA1; // OPENSSL_ALGO_SHA256
const DIGEST_ALGORITHM = "sha1"; // sha256
const CANONICALIZATION = "relaxed/simple";
const QUERY_METHOD = "dns/txt";
private $domain;
private $private_key;
private $selector;
public function __construct(string $domain, string $key, string $selector)
{
$this->domain = $domain;
$key_file = stream_resolve_include_path("dkim-keys/{$key}") or die(__METHOD__ . ": failed to locate key file dkim-keys/{$key}");
$this->private_key = file_get_contents($key_file) or die(__METHOD__ . ": could not read private key {$key_file}");
$this->selector = $selector;
}
private function quote_string(string $input) : string
{
$retval = "";
foreach(str_split($input) as $char) {
$ord = ord($char);
switch(TRUE)
{
case (0x21 <= $ord) && ($ord <= 0x3A):
case (0x3E <= $ord) && ($ord <= 0x7E):
case 0x3C == $ord:
$retval .= $char;
break;
default:
$retval .= "=" . dechex($ord);
}
}
return $retval;
}
private function compact_header(array $input) : string
{
return preg_replace_callback(
"/(\S+):\s*([^\r\n]+)/",
fn($m) => strtolower($m[1]) . ":" . trim($m[2]),
implode("\r\n", $input)
);
}
private function generate_signature(array $input) : string|false
{
$input = $this->compact_header($input);
if(openssl_sign($input, $signature, $this->private_key, self::SIGNING_ALGORITHM)) {
return base64_encode($signature);
}
return FALSE;
}
public function sign(array $headers, string $to, string $subject, string $body) : string
{
$encoded = $tosign = [];
$body = preg_replace("/\r?\n/", "\r\n", rtrim($body));
$signing_values = [
'Subject' => $subject,
'To' => $to,
];
foreach($headers as $header) {
if(preg_match("/^(To|From): (.+)/", $header, $regs)) {
$signing_values[$regs[1]] = $regs[2];
}
}
foreach($signing_values as $key => $val) {
$tosign[] = "{$key}: {$val}";
$encoded[$key] = $this->quote_string("{$key}: {$val}");
}
$dkim_header = "DKIM-Signature: " . implode("; ", [
"v=1",
"a=" . self::HASHING_ALGORITHM,
"q=" . self::QUERY_METHOD,
"l=" . strlen($body),
"s=" . $this->selector,
"t=" . time(),
"c=" . self::CANONICALIZATION,
"h=" . implode(":", array_keys($signing_values)),
"d=" . $this->domain,
"i=" . preg_replace("/(.+\<|\>$)/", "", $signing_values['From']),
"z=" . implode("|", $encoded),
"bh=" . base64_encode(pack("H*", openssl_digest($body, self::DIGEST_ALGORITHM))),
"b=",
]);
$tosign[] = $dkim_header;
return $dkim_header . $this->generate_signature($tosign) . "\r\n";
}
}
The public methods of the \Chirp\DKIM class are:
- the constructor which accepts a domain name, the name of the file storing your private key, and the chosen selector; and
- the sign method which accepts the existing email headers (array) followed by the email recipient, subject and body (all strings).
Note that your private key file needs to be accessible and readable by the web server under the PHP include_path at ~/dkim-keys/$key or you will see an error message.
If your private key is encrypted with a passphrase then an extra step will be required. See openssl_pkey_get_private for more information on this.
Once instantiated, the class object can be used to sign any number of emails using the same key and selector.
The sign method returns a string containing the DKIM Signature header line that needs to be added to the email headers before sending. Read on below for an example.
Example Usage
If previously you were invoking the PHP mail function as:
<?PHP
mail(
$target,
$subject,
$body,
implode("\r\n", $headers),
$params
);
You now just require an extra step to append the DKIM signature header:
<?PHP
$dkim = new \Chirp\DKIM("example.net", "mydomain-website.key", "website");
$headers[] = $dkim->sign($headers, $target, $subject, $body);
mail(
$target,
$subject,
$body,
implode("\r\n", $headers),
$params
);
And if everything is in place you will now be sending DKIM-signed emails.
Troubleshooting Checklist
- Your private key is readable by the webserver at the specified filepath;
- Your public key was extracted from the same private key you are using;
- You have added the DKIM TXT record to your DNS and reloaded the DNS;
- You have checked your DKIM record using an online validator;
Now just send an email to yourself to confirm that it has a DKIM-Signature included in the headers. And if you are using GMail or a similar service, the validity of your DKIM signature should also be visible in the received email headers.
References
Related Articles - Sendmail
- PHP Signing outbound emails with DKIM
- PHP Generating a Key Pair for DKIM
- System DKIM Key Pair Generator
- System Analysing mailq and the mqueue directory
- System Using qtool.pl to manage sendmail queues
- System Analysing the mail.log
- System Expanding IPv6 Addresses for DNSBL Checks
Ingrid 25 May, 2024
for later OpenSSL versions (>= 3) use following lines in class DKIM as SHA1 is no longer working:
const SIGNING_ALGORITHM = "rsa-sha256";
...
openssl_sign($input, $signature, $this->private_key, OPENSSL_ALGO_SHA256)
...
"bh=" . base64_encode(pack("H*", openssl_digest($body, "sha256"))),
Thank you. We haven't noticed a problem yet (OpenSSL 3.0.11), but it's good not know what changes will be needed to upgrade to SHA256.
I've modified the code now to make these all constants