Подпись писем с помощью DKIM для аутентификации и доставки
DKIM (DomainKeys Identified Mail, RFC 6376) позволяет владельцу домена прикрепить криптографическую подпись к исходящей почте. Принимающие серверы получают соответствующий открытый ключ из DNS, пересчитывают хеш и одновременно подтверждают две вещи: тело сообщения не было изменено в пути, и сообщение действительно отправлено авторизованным отправителем домена. Без DKIM современные провайдеры (Gmail, Microsoft 365, Yahoo) помечают письма как спам, тихо отбрасывают их или отклоняют доставку — DKIM больше не является опциональным для производственной почты.
Это руководство — полное пошаговое описание Aspose.Emailпространство имён DKIM от Aspose для .NET. Каждый блок кода в этой статье взят из исполняемого примера в DKIMExamples проекта и был проверен полностью от начала до конца против библиотеки.
Требования
Типы DKIM находятся в Aspose.Email.DKIM пространство имён и поставляется только с сборками .NET Framework 4.0/4.5 библиотеки. Они намеренно исключены из пакетов .NET Standard 2.0 и .NET 6/8. Чтобы следовать примерам ниже, ваш проект должен ссылаться на сборку Aspose.Email, построенную для net45 (цель net48 в потребляющем .csproj).
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Reference Include="Aspose.Email">
<HintPath>..\path\to\net45\Aspose.Email.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
Вам также нужна пара RSA‑ключей. Закрытый ключ хранится в вашем приложении и используется для подписи; открытый ключ публикуется в виде DNS TXT записи, чтобы получатели могли проверять подпись. Стандартом сегодня считается 2048‑битный ключ:
openssl genrsa -out sample-private-key.pem 2048
Aspose’s PemReader принимает как PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) и PKCS#8 (-----BEGIN PRIVATE KEY-----) отформатированные файлы.
| Класс | Роль | |——-|——| | DKIMSignatureInfo | Описывает подпись: селектор, домен, алгоритм хеширования, каноникализацию и какие заголовки покрывать. | | PemReader | Загружает закрытый RSA‑ключ из PEM‑файла или потока в RSACryptoServiceProvider. |
| MailMessage.DKIMSign(rsa, info) | Метод‑расширение/экземпляр, возвращающий подписанную копию сообщения с DKIM-Signature заголовок заполнен. | | DkimVerifier | Выполняет проверку подписи сообщения на основе DNS — файл, поток, синхронно или асинхронно. |
Полный процесс всегда выглядит так: загрузить ключ → описать подпись → построить сообщение → подписать → (опционально) проверить.
Минимальный пример подписи
Это минимальный объём кода, который создаёт корректное сообщение, подписанное DKIM:
// Load private key from PEM file
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
// Create DKIM signature information
var signInfo = new DKIMSignatureInfo("dkim", "example.com")
{
// Default: Relaxed/Relaxed canonicalization
// Default: RSASha1 hash algorithm
};
// Add headers to sign
signInfo.Headers.Add("From");
signInfo.Headers.Add("To");
signInfo.Headers.Add("Subject");
// Create a test email
var mailMessage = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Test Subject",
"This is a test email body signed with DKIM.")
{
From = new MailAddress("sender@example.com", "Sender Name")
};
// Sign the message
var signedMsg = mailMessage.DKIMSign(rsa, signInfo);
// Save the signed message
string outputPath = Path.Combine(DataDir, "basic-signed.eml");
signedMsg.Save(outputPath);
Console.WriteLine($"DKIM-Signature header: {signedMsg.Headers["DKIM-Signature"]?.Substring(0, 50)}...");
Два аргумента для DKIMSignatureInfo — "dkim" и "example.com" — это селектор и домен. Вместе они указывают получателям, где искать ваш публичный ключ: в DNS TXT записи dkim._domainkey.example.com. Выберите короткое имя селектора (часто default, mail, или дата вроде 2026jan); селектор позволяет вращать ключи без удаления старого.
Критически важная деталь: DKIMSign возвращает новый MailMessage instance с DKIM-Signature заголовок добавлен. Исходное сообщение не изменено. Сохраните или отправьте возвращённый объект, а не входные данные.
Выбор, какие заголовки подписывать
Заголовки, перечисленные в signInfo.Headers являются единственными, значения которых привязаны к подписи. Всё, что не включено в список, может быть изменено ретрансляторами без нарушения подписи, что иногда желаемо (например Received: заголовки, добавленные ниже по цепочке) и иногда опасны (например, оставляя From неподписанные делают подпись бессмысленной).
Практический совет: всегда подписывайте From, To, Subject, Date. From в частности, обязателен для согласования DMARC.
var signInfo = new DKIMSignatureInfo("dkim2", "example.com")
{
Headers =
{
"From",
"To",
"Subject",
"Date",
"MIME-Version"
}
};
// Create email with additional headers
var mailMessage = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Custom Headers Test",
"This message includes custom headers in the DKIM signature.")
{
From = new MailAddress("sender@example.com", "Sender"),
Date = DateTime.UtcNow
};
// Ensure headers listed in DKIM signature are present on the message
mailMessage.Headers.Add("MIME-Version", "1.0");
mailMessage.Headers.Add("X-Custom-Header", "CustomValue");
// Sign with custom headers
var signedMsg = mailMessage.DKIMSign(rsa, signInfo);
string outputPath = Path.Combine(DataDir, "custom-headers-signed.eml");
signedMsg.Save(outputPath);
Если вы перечисляете Date но никогда не устанавливайте mailMessage.Date, DKIMSign вызывает The following headers to be signed do not exist: Date. Сначала заполняйте заголовки, затем подпишите.
Алгоритмы хеширования — RSA‑SHA1 против RSA‑SHA256
DKIM поддерживает два алгоритма подписи. По умолчанию в Aspose.Email используется RSASha1 для обратной совместимости, но каждое новое развертывание должно использовать RSASha256. Многие провайдеры теперь помечают подписи SHA‑1 как недействительные, даже если они криптографически проверяются.
// Example with RSASha1 (older, less secure but widely supported)
var signInfoSha1 = new DKIMSignatureInfo("dkim-sha1", "example.com")
{
HashAlgorithm = DKIMHashAlgorithm.RSASha1,
Headers = { "From", "To", "Subject" }
};
var mailMessage1 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"SHA1 Test",
"Signed with RSASha1 algorithm.");
mailMessage1.From = new MailAddress("sender@example.com");
var signedMsgSha1 = mailMessage1.DKIMSign(rsa, signInfoSha1);
string pathSha1 = Path.Combine(DataDir, "sha1-signed.eml");
signedMsgSha1.Save(pathSha1);
// Example with RSASha256 (newer, more secure)
var signInfoSha256 = new DKIMSignatureInfo("dkim-sha256", "example.com")
{
HashAlgorithm = DKIMHashAlgorithm.RSASha256,
Headers = { "From", "To", "Subject" }
};
var mailMessage2 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"SHA256 Test",
"Signed with RSASha256 algorithm.");
mailMessage2.From = new MailAddress("sender@example.com");
var signedMsgSha256 = mailMessage2.DKIMSign(rsa, signInfoSha256);
string pathSha256 = Path.Combine(DataDir, "sha256-signed.eml");
signedMsgSha256.Save(pathSha256);
Полученный DKIM-Signature заголовок объявляет алгоритм через a= тег (a=rsa-sha1 против a=rsa-sha256). Получатели используют этот тег, чтобы решить, какой хеш пересчитать.
Канонизация — Simple vs Relaxed
Канонизация — это способ, которым DKIM соглашается на стабильную, байтово‑точную форму сообщения перед хешированием. Для заголовков и тел определены два алгоритма независимо друг от друга:
Simple— почти без нормализации. Пустые завершающие строки в теле удаляются, но любые другие пробелы остаются нетронутыми. Единственный пробел, добавленный ретранслятором, аннулирует подпись.Relaxed— сворачивает пробелы внутри значений заголовков, приводит имена заголовков к нижнему регистру и убирает повторяющиеся пробелы в теле. Терпим к тривиальному переоформлению, которое часто делают транспортные агенты почты.
Выберите Relaxed для обоих заголовка и тела, если у вас нет специфической причины отказываться от этого. Практически все продакшен‑развертывания так делают.
// Simple canonicalization - almost no changes allowed
var signInfoSimple = new DKIMSignatureInfo("dkim-simple", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Simple,
BodyCanonicalization = CanonicalizationType.Simple,
Headers = { "From", "To", "Subject" }
};
var mailMessage1 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Simple Canonicalization",
"This message uses Simple canonicalization.");
mailMessage1.From = new MailAddress("sender@example.com");
var signedSimple = mailMessage1.DKIMSign(rsa, signInfoSimple);
string pathSimple = Path.Combine(DataDir, "simple-canonical.eml");
signedSimple.Save(pathSimple);
// Relaxed canonicalization - minor changes allowed (whitespace normalization)
var signInfoRelaxed = new DKIMSignatureInfo("dkim-relaxed", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Relaxed,
BodyCanonicalization = CanonicalizationType.Relaxed,
Headers = { "From", "To", "Subject" }
};
var mailMessage2 = new MailMessage(
"sender@example.com",
"recipient@example.com",
"Relaxed Canonicalization",
"This message uses Relaxed canonicalization.");
mailMessage2.From = new MailAddress("sender@example.com");
var signedRelaxed = mailMessage2.DKIMSign(rsa, signInfoRelaxed);
string pathRelaxed = Path.Combine(DataDir, "relaxed-canonical.eml");
signedRelaxed.Save(pathRelaxed);
Канонизация заголовка и тела независимы — relaxed/simple это полностью допустимая комбинация, написанная так в c= тег DKIM‑заголовка.
Загрузка приватного ключа
PemReader прозрачно обрабатывает обе кодировки PEM. Отдельного Pkcs1Reader / Pkcs8Reader — просто передайте путь или поток:
// Check if private key file exists
if (!File.Exists(PrivateKeyPath))
{
Console.WriteLine($"Private key file not found: {PrivateKeyPath}");
Console.WriteLine("Creating a sample private key for demonstration...");
// Create a sample RSA key (for testing only - in production, use real keys)
using var rsa = new System.Security.Cryptography.RSACryptoServiceProvider(2048);
var privateKeyParams = rsa.ExportParameters(true);
Console.WriteLine("Sample RSA key created (2048 bits).");
Console.WriteLine("In production, load your actual private key from a secure location.");
Console.WriteLine("PEM file format should contain either:");
Console.WriteLine(" - BEGIN RSA PRIVATE KEY (PKCS#1)");
Console.WriteLine(" - BEGIN PRIVATE KEY (PKCS#8)");
}
else
{
// Load the private key
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
Console.WriteLine($"Private key loaded successfully from: {PrivateKeyPath}");
Console.WriteLine("Key size: 2048 bits (typical for DKIM)");
}
Console.WriteLine("\nPEM File Format Examples:");
Console.WriteLine("--------------------------");
Console.WriteLine("PKCS#1 format:");
Console.WriteLine("-----BEGIN RSA PRIVATE KEY-----");
Console.WriteLine("MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy...");
Console.WriteLine("-----END RSA PRIVATE KEY-----");
Console.WriteLine();
Console.WriteLine("PKCS#8 format:");
Console.WriteLine("-----BEGIN PRIVATE KEY-----");
Console.WriteLine("MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...");
Console.WriteLine("-----END PRIVATE KEY-----");
PemReader.GetPrivateKey возвращает RSACryptoServiceProvider готовый к использованию DKIMSign. Существует также GetPrivateKey(Stream) перегрузка для случаев, когда ключ берётся из хранилища ключей, зашифрованного блоба или встроенного ресурса, а не из файловой системы.
Правила обращения с ключами:
- Никогда не коммитьте приватные ключи в систему контроля версий.
*.pemпринадлежит к.gitignore. - В продакшене загружайте ключи из менеджера секретов (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault), а не с диска.
- Меняйте ключи каждые 6–12 месяцев. Сначала публикуйте новый селектор, переключайте подпись, затем после периода грации удаляйте старую DNS‑запись.
Проверка подписанного сообщения из файла
Проверка — симметричная операция: дано подписанное .eml, DkimVerifier извлекает DKIM-Signature заголовок, выполняет DNS‑запрос к <selector>._domainkey.<domain> для открытого ключа и перерасчитывает хеш. Результат — DkimResult с IsValid и ErrorMessage.
// For this example, we need a signed message
if (!File.Exists(signedMessagePath))
{
Console.WriteLine($"Signed message file not found: {signedMessagePath}");
Console.WriteLine("Please provide a DKIM-signed .eml file for verification.");
return;
}
// Verify the signature
DkimResult result = DkimVerifier.Verify(signedMessagePath);
// Check the result
if (result.IsValid)
{
Console.WriteLine("[OK] DKIM signature verification successful!");
Console.WriteLine("The message was signed by the claimed domain.");
}
else
{
Console.WriteLine("[FAIL] DKIM signature verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
Console.WriteLine("\nVerification Process:");
Console.WriteLine("1. Extracts DKIM-Signature header from the message");
Console.WriteLine("2. Queries DNS for the public key (selector._domainkey.domain)");
Console.WriteLine("3. Verifies the signature using the public key");
Console.WriteLine("4. Compares computed body hash with the signed body hash");
Проверка сообщения, подписанного с помощью
d=example.comвсегда будет приводить к ошибке «ключ не найден в словаре» или аналогичной ошибке DNS‑запроса, потому чтоexample.comне публикует запись DKIM. Для реальных проверок используйте домен, которым вы владеете (или известное тестовое сообщение).
Проверка из потока
Когда сообщение не находится на диске — оно пришло из IMAP‑запроса, очереди, HTTP‑загрузки или blob‑хранилища — используйте Stream перегрузка. Принимает любой поток чтения с возможностью перемотки:
// Load the signed message into a stream
using (var stream = File.OpenRead(signedMessagePath))
{
DkimResult result = DkimVerifier.Verify(stream);
if (result.IsValid)
{
Console.WriteLine("[OK] DKIM signature verified successfully from stream!");
}
else
{
Console.WriteLine("[FAIL] Verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
}
Console.WriteLine("\nStream-based verification is useful when:");
Console.WriteLine(" - Working with email from IMAP/POP3 clients");
Console.WriteLine(" - Processing emails from databases or cloud storage");
Console.WriteLine(" - Building web applications that receive emails via HTTP");
Verify(Stream) и Verify(string) функционально идентичны — обе блокируют выполнение до завершения DNS‑запроса. Для любой пользовательской функции используйте асинхронные варианты ниже.
Асинхронная проверка
Проверка DKIM включает DNS‑запрос, который может занимать десятки миллисекунд даже в быстрой сети. Внутри обработчика запросов ASP.NET, цикла обработки сообщений или любого UI‑потока это быстро накапливается. VerifyAsync возвращает Task<DkimResult> что вы можете await:
if (!File.Exists(signedMessagePath))
{
Console.WriteLine("Signed message file not found.");
return;
}
// Async verification
DkimResult result = await DkimVerifier.VerifyAsync(signedMessagePath);
if (result.IsValid)
{
Console.WriteLine("[OK] Async verification successful!");
}
else
{
Console.WriteLine("[FAIL] Async verification failed!");
Console.WriteLine($"Error: {result.ErrorMessage}");
}
Console.WriteLine("\nAsync verification is recommended for:");
Console.WriteLine(" - Web applications (ASP.NET, etc.)");
Console.WriteLine(" - Services that process multiple emails");
Console.WriteLine(" - Any application where you want to avoid blocking the main thread");
Также существует VerifyAsync(Stream) перегрузка. Как и в синхронном API, метод объявляет async Task (не async void) — места вызова должны await оно, и точки входа консольных приложений могут VerifySignatureAsync().GetAwaiter().GetResult() когда async Main недоступен.
С конца в конец: загрузка, подпись, сохранение, проверка
Эти части комбинируются в единый рабочий процесс. Это полный конвейер подписи и проверки, который вы внедряете в сервис транзакционных писем:
// Step 1: Load private key
var rsa = PemReader.GetPrivateKey(PrivateKeyPath);
// Step 2: Create signature information
var signInfo = new DKIMSignatureInfo("dkim", "example.com")
{
HeaderCanonicalization = CanonicalizationType.Relaxed,
BodyCanonicalization = CanonicalizationType.Relaxed,
HashAlgorithm = DKIMHashAlgorithm.RSASha256,
Headers =
{
"From",
"To",
"Subject",
"Date",
"MIME-Version"
}
};
// Step 3: Create email message
var mailMessage = new MailMessage(
"john.doe@example.com",
"jane.smith@example.org",
"Important Document - DKIM Signed",
"Please find the attached document.\n\nBest regards,\nJohn Doe");
mailMessage.From = new MailAddress("john.doe@example.com", "John Doe");
mailMessage.To.Add(new MailAddress("jane.smith@example.org", "Jane Smith"));
mailMessage.Date = DateTime.UtcNow;
mailMessage.Headers.Add("MIME-Version", "1.0");
// Step 4: Sign the message
var signedMessage = mailMessage.DKIMSign(rsa, signInfo);
// Step 5: Save the signed message
string signedPath = Path.Combine(DataDir, "end-to-end-signed.eml");
signedMessage.Save(signedPath);
// Step 6: Verify the signature
var result = DkimVerifier.Verify(signedPath);
if (result.IsValid)
{
Console.WriteLine(" [OK] Signature verification successful!");
Console.WriteLine(" [OK] The message integrity is confirmed.");
Console.WriteLine(" [OK] The sender domain is authenticated.");
}
else
{
Console.WriteLine($" [FAIL] Verification failed: {result.ErrorMessage}");
}
// Step 7: Display DKIM-Signature header
Console.WriteLine("\nStep 7: DKIM-Signature header:");
Console.WriteLine(" " + signedMessage.Headers["DKIM-Signature"]);
Полный DKIM-Signature заголовок, сгенерированный этим кодом, выглядит так:
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
h=from:to:subject:date:mime-version; q=dns/txt; s=dkim; t=1780252880;
bh=e2RkwEm0k0yLcVmhi3KeXpt14Ppx3Xx49S33/+J8OGc=;
b=Zd3sfCGGD9YdOLw1o3YHLFs7DO6V9cc7anye5E3kQ/MEdkU14daTru0sXTZCtCryvJubCs...
Интересные теги:
v=1— версию DKIM.a=rsa-sha256— алгоритм; отражаетHashAlgorithm.c=relaxed/relaxed— канонизация (заголовок/тело).d=example.com/s=dkim— селектор и домен; вместе они формируют имя DNS‑запроса.h=from:to:subject:date:mime-version— подписанные заголовки в порядке их следования.bh=— base64‑хеш канонизированного тела.b=— фактическая RSA‑подпись над канонизированными заголовками.
Если вы измените любое подписанное значение заголовка или измените тело, bh и b больше не совпадают, и проверка завершается неудачей.
Публикация открытого ключа в DNS
Подпись локально — это лишь половина работы. Чтобы получатели могли проверить, открытая часть вашего RSA‑ключа должна быть опубликована как DNS‑запись TXT по адресу <selector>._domainkey.<domain>:
Record: dkim._domainkey.example.com
Type: TXT
Value: v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBC...
Извлеките открытый ключ из вашего PEM с помощью OpenSSL:
openssl rsa -in sample-private-key.pem -pubout -out sample-public-key.pem
Удалите строки заголовка/подвала и объедините содержимое base64 для p= значение. Затем подтвердите, что запись активна, прежде чем полагаться на неё:
dig dkim._domainkey.example.com TXT
Онлайн‑инструменты, такие как Валидатор DKIM и Проверка DKIM от MXToolbox сообщит вам, проверяется ли реальное отправленное сообщение от начала до конца. Используйте их перед переключением производственного трафика.