Підписання листів за допомогою DKIM для автентифікації та доставки

DKIM (DomainKeys Identified Mail, RFC 6376) дозволяє власнику домену прикріпити криптографічний підпис до вихідної пошти. Сервери‑отримувачі отримують відповідний публічний ключ із DNS, обчислюють хеш і одночасно підтверджують два факти: тіло повідомлення не було змінено під час транспорту, і повідомлення дійсно походить від відправника, уповноваженого доменом. Без DKIM сучасні провайдери (Gmail, Microsoft 365, Yahoo) позначають листи як спам, мовчки відкидають їх або відмовляються доставляти — DKIM більше не є опціональним для продуктивної пошти.

Цей посібник — повний покроковий огляд Aspose.Emailпростір імен DKIM для .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 екземпляр з 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 проти 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 завжди завершуватиметься помилкою «key not in dictionary» або подібною помилкою 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 його, і точки входу console‑app можуть виконувати 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 Validator і MXToolbox DKIM Checker повідомить вас, чи реальне повідомлення, яке ви надсилаєте, можна перевірити end-to-end. Використовуйте їх перед переходом на продуктивний трафік.