256 lines
7.1 KiB
PHP
256 lines
7.1 KiB
PHP
|
|
<?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;
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|