$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; } }