Подпись писем с помощью 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) перегрузка для случаев, когда ключ берётся из хранилища ключей, зашифрованного блоба или встроенного ресурса, а не из файловой системы.

Правила обращения с ключами:

  1. Никогда не коммитьте приватные ключи в систему контроля версий. *.pem принадлежит к .gitignore.
  2. В продакшене загружайте ключи из менеджера секретов (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault), а не с диска.
  3. Меняйте ключи каждые 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 сообщит вам, проверяется ли реальное отправленное сообщение от начала до конца. Используйте их перед переключением производственного трафика.