Files
kiri-mail-server/src/DkimSigner.php
T

256 lines
7.1 KiB
PHP
Raw Normal View History

2026-06-28 19:42:35 +08:00
<?php
declare(strict_types=1);
namespace Kiri\MailServer;
/**
* DKIM 签名器 — 为外发邮件添加 DKIM-Signature header
*
* 签署流程:
* 1. 读取私钥
* 2. 规范化 header (relaxed/simple)
* 3. 计算 header hash
* 4. 规范化 body (relaxed/simple)
* 5. 计算 body hash
* 6. RSA-SHA256 签名
* 7. 构建 DKIM-Signature header
*/
class DkimSigner
{
/** @var string DKIM 头部版本 */
private const DKIM_VERSION = '1';
/** @var string 签名算法 */
private const SIGN_ALGO = 'rsa-sha256';
/** @var string body hash 算法 */
private const HASH_ALGO = 'sha256';
/** @var int body 长度限制 (0=不限制) */
private const BODY_LENGTH_LIMIT = 0;
/**
* @param string $domain 签署域名
* @param string $selector DKIM 选择器
* @param string $privateKeyPem RSA 私钥 (PEM 格式)
* @param array<string> $headersToSign 要签名的 header 列表
* @param string $canonicalization 规范化方式 (default: 'relaxed/relaxed')
*/
public function __construct(
private string $domain,
private string $selector,
private string $privateKeyPem = '',
private array $headersToSign = ['from', 'to', 'subject', 'date', 'message-id', 'mime-version', 'content-type'],
private string $canonicalization = 'relaxed/relaxed',
) {
}
/**
* 对邮件添加 DKIM 签名
*
* @param string $rawContent 原始邮件内容 (header + body)
* @return string 添加了 DKIM-Signature header 的邮件内容
*/
public function sign(string $rawContent): string
{
if (empty($this->privateKeyPem)) {
return $rawContent;
}
// 分离 header 和 body
$parts = explode("\r\n\r\n", $rawContent, 2);
if (count($parts) < 2) {
return $rawContent;
}
$headerSection = $parts[0];
$bodySection = $parts[1];
// 解析 header 为数组
$headers = $this->parseHeaders($headerSection);
// 规范化 body
$bodyHash = $this->computeBodyHash($bodySection);
// 规范化 header
$signedHeaders = $this->getSignedHeaderList();
$headerHash = $this->computeHeaderHash($headers, $signedHeaders);
// 构建 DKIM-Signature header (不含 b=)
$dkimHeader = $this->buildDkimHeader($signedHeaders, $bodyHash, $headerHash);
// 添加 dkim-signature 到 headers 列表用于签名计算
$headers['dkim-signature'] = $this->stripSignatureValue($dkimHeader);
// 重新计算 header hash (包含 DKIM-Signature 本身)
$finalHeaderHash = $this->computeHeaderHash($headers, array_merge($signedHeaders, ['dkim-signature']));
// RSA 签名
$privateKey = openssl_pkey_get_private($this->privateKeyPem);
if ($privateKey === false) {
return $rawContent;
}
$signature = '';
openssl_sign($finalHeaderHash, $signature, $privateKey, OPENSSL_ALGO_SHA256);
openssl_free_key($privateKey);
$b64Signature = base64_encode($signature);
// 替换 b= 值
$dkimHeader = str_replace('b=;', "b={$b64Signature};", $dkimHeader);
// 重建邮件
return "DKIM-Signature: {$dkimHeader}\r\n" . $headerSection . "\r\n\r\n" . $bodySection;
}
/**
* 计算 body hash
*/
private function computeBodyHash(string $body): string
{
// relaxed body: 压缩空白
$body = preg_replace('/[ \t]+$/m', '', $body);
$body = preg_replace('/\r\n$/', '', $body) . "\r\n";
return base64_encode(hash(self::HASH_ALGO, $body, true));
}
/**
* 计算规范化后的 header hash
*/
private function computeHeaderHash(array $headers, array $signedHeaders): string
{
$canonicalized = '';
foreach ($signedHeaders as $headerName) {
$value = $headers[strtolower($headerName)] ?? '';
// relaxed header: 小写 header 名 + 折叠空白
$canonicalized .= strtolower($headerName) . ':' . rtrim($value) . "\r\n";
}
return hash(self::HASH_ALGO, rtrim($canonicalized, "\r\n"), true);
}
/**
* 构建 DKIM-Signature header 值
*/
private function buildDkimHeader(string $signedHeaders, string $bodyHash, string $headerHash): string
{
$now = time();
$expires = $now + 604800; // 7 天
$params = [
"v={$this->domain}",
"a={$this->domain}.{$this->selector}",
"c={$this->canonicalization}",
"d={$this->domain}",
"s={$this->selector}",
"t={$now}",
"x={$expires}",
"bh={$bodyHash}",
"h=" . implode(':', $signedHeaders),
"b=;",
];
// 改写:正确构建 DKIM-Signature
$params = [
"v=" . self::DKIM_VERSION,
"a=" . self::SIGN_ALGO,
"c={$this->canonicalization}",
"d={$this->domain}",
"s={$this->selector}",
"t={$now}",
"x={$expires}",
"bh={$bodyHash}",
"h=" . implode(':', $signedHeaders),
"b=;",
];
$params = [
'v' => self::DKIM_VERSION,
'a' => self::SIGN_ALGO,
'c' => $this->canonicalization,
'd' => $this->domain,
's' => $this->selector,
't' => (string)$now,
'x' => (string)$expires,
'bh' => $bodyHash,
'h' => implode(':', $signedHeaders),
'b' => '',
];
$parts = [];
foreach ($params as $key => $value) {
$parts[] = "{$key}={$value}";
}
return implode('; ', $parts);
}
/**
* 获取签名的 header 列表字符串
*/
private function getSignedHeaderList(): array
{
return $this->headersToSign;
}
/**
* 移除 b= 值 (签名计算时不包含)
*/
private function stripSignatureValue(string $dkimHeader): string
{
return preg_replace('/b=[^;]*;?/', 'b=;', $dkimHeader);
}
/**
* 解析 header 区域为数组
*/
private function parseHeaders(string $headerSection): array
{
$headers = [];
$lines = explode("\r\n", $headerSection);
$currentKey = '';
$currentValue = '';
foreach ($lines as $line) {
if ($currentKey !== '' && (str_starts_with($line, ' ') || str_starts_with($line, "\t"))) {
$currentValue .= ' ' . ltrim($line);
continue;
}
if ($currentKey !== '') {
$headers[strtolower($currentKey)] = trim($currentValue);
}
$colonPos = strpos($line, ':');
if ($colonPos === false) {
$currentKey = '';
$currentValue = '';
continue;
}
$currentKey = trim(substr($line, 0, $colonPos));
$currentValue = trim(substr($line, $colonPos + 1));
}
if ($currentKey !== '') {
$headers[strtolower($currentKey)] = trim($currentValue);
}
return $headers;
}
}