Подписване на имейли с DKIM за удостоверяване и доставяемост

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

Този наръчник е пълно ръководство за Aspose.Email’s 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). Получателите консултират този таг, за да решат кой хеш да пресметнат отново.

Канонизация — Просто срещу Отпуснато

Канонизацията е начинът, по който 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 fetch, опашка, 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 валидатор и MXToolbox DKIM проверка ще ви каже дали реалното съобщение, което изпращате, е проверимо от край до край. Използвайте ги преди да прехвърлите продукционния трафик.