Firma delle email con DKIM per autenticazione e consegna

DKIM (DomainKeys Identified Mail, RFC 6376) consente a un proprietario di dominio di allegare una firma crittografica alle email in uscita. I server di ricezione recuperano la chiave pubblica corrispondente da DNS, ricalcolano l’hash e confermano due cose contemporaneamente: il corpo del messaggio non è stato modificato durante il transito, e il messaggio proviene davvero da un mittente autorizzato dal dominio. Senza DKIM, i provider moderni (Gmail, Microsoft 365, Yahoo) segnalano le email come spam, le eliminano silenziosamente o rifiutano la consegna — DKIM non è più opzionale per le email di produzione.

Questa guida è un walkthrough completo di Aspose.Emailnamespace DKIM di .NET. Ogni blocco di codice in questo articolo è tratto da un esempio eseguibile nel DKIMExamples progetto ed è stato verificato end-to-end contro la libreria.

Prerequisiti

I tipi DKIM vivono nel Aspose.Email.DKIM namespace e distribuisci solo con le build .NET Framework 4.0/4.5 della libreria. Sono intenzionalmente escluse dai pacchetti .NET Standard 2.0 e .NET 6/8. Per seguire gli esempi sotto, il tuo progetto deve fare riferimento a un’assembly Aspose.Email costruita per net45 (destinazione net48 nel consumatore .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>

Hai anche bisogno di una coppia di chiavi RSA. La chiave privata è conservata nella tua applicazione e usata per firmare; la chiave pubblica è pubblicata come record DNS TXT così i destinatari possono verificare. Una chiave a 2048 bit è lo standard oggi:

openssl genrsa -out sample-private-key.pem 2048

di Aspose PemReader accetta entrambi PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) e PKCS#8 (-----BEGIN PRIVATE KEY-----) file formattati.

| Classe | Ruolo | |——-|——| | DKIMSignatureInfo | Descrive la firma: selettore, dominio, algoritmo hash, canonicalizzazione e quali intestazioni coprire. | | PemReader | Carica una chiave privata RSA da un file o stream in formato PEM in un RSACryptoServiceProvider. | | MailMessage.DKIMSign(rsa, info) | Metodo di estensione/istanza che restituisce una copia firmata del messaggio con il DKIM-Signature intestazione popolata. | | DkimVerifier | Esegue la verifica basata su DNS di un messaggio firmato — file, stream, sync o async. |

Il flusso completo è sempre: carica chiave → descrivi firma → costruisci messaggio → firma → (opzionalmente) verifica.

L’esempio minimo di firma

Questo è il minimo codice che produce un messaggio firmato DKIM valido:

// 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)}...");

I due argomenti per DKIMSignatureInfo"dkim" e "example.com" — sono il selettore e il dominio. Insieme indicano ai destinatari dove cercare la tua chiave pubblica: nel record DNS TXT dkim._domainkey.example.com. Scegli un nome di selettore breve (spesso default, mail, o una data come 2026jan); il selettore ti consente di ruotare le chiavi senza scartare quella vecchia.

Un dettaglio critico: DKIMSign restituisce un nuovo MailMessage istanza con il DKIM-Signature intestazione aggiunta. Il messaggio originale rimane invariato. Salva o invia l’oggetto restituito, non l’input.

Scegliere quali intestazioni firmare

Le intestazioni elencate in signInfo.Headers sono gli unici i cui valori sono legati alla firma. Qualunque cosa non elencata può essere modificata dai relay senza rompere la firma, il che a volte è desiderabile (es. Received: header aggiunti a valle) e talvolta pericolosi (es. lasciando From senza firma rende la firma priva di senso).

Regola empirica: firma sempre From, To, Subject, Date. From in particolare è obbligatorio per l’allineamento 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);

Se elenchi Date ma non impostare mai mailMessage.Date, DKIMSign lancia The following headers to be signed do not exist: Date. Popola sempre prima gli header, poi firma.

Algoritmi hash — RSA‑SHA1 vs RSA‑SHA256

DKIM supporta due algoritmi di firma. Il valore predefinito in Aspose.Email è RSASha1 per compatibilità retroattiva, ma ogni nuova distribuzione dovrebbe usare RSASha256. Molti provider ora segnano le firme SHA‑1 come invalide anche se verificano crittograficamente.

// 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);

Il risultato è DKIM-Signature l’header pubblicizza l’algoritmo tramite il a= tag (a=rsa-sha1 vs a=rsa-sha256). I destinatari consultano quel tag per decidere quale hash ricalcolare.

Canonicalizzazione — Simple vs Relaxed

La canonicalizzazione è il modo in cui DKIM concorda su una forma stabile, byte‑identica del messaggio prima dell’hashing. Due algoritmi sono definiti per header e body in modo indipendente:

  • Simple — quasi nessuna normalizzazione. Le linee vuote finali nel corpo sono rimosse, ma nessun altro spazio è toccato. Un singolo spazio aggiunto da un relay invaliderebbe la firma.
  • Relaxed — piega gli spazi all’interno dei valori header, converte i nomi header in minuscolo e comprime sequenze di spazi nel corpo. Tollerante al riformattamento triviale che gli agenti di trasporto mail tendono a fare.

Scegli Relaxed per entrambi header e corpo a meno che tu non abbia una ragione specifica per non farlo. Quasi ogni distribuzione in produzione lo fa.

// 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);

La canonicalizzazione di header e corpo è indipendente — relaxed/simple è una combinazione perfettamente valida, scritta in quel modo nel c= tag dell’header DKIM.

Caricamento della chiave privata

PemReader gestisce entrambe le codifiche PEM in modo trasparente. Non esiste un separato Pkcs1Reader / Pkcs8Reader — basta passare il percorso o lo stream:

// 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 restituisce un RSACryptoServiceProvider pronto da consegnare a DKIMSign. C’è anche un GetPrivateKey(Stream) sovraccarico per i casi in cui la chiave proviene da un vault, blob criptato, o risorsa incorporata anziché dal file system.

Regole di gestione delle chiavi:

  1. Non commettere mai chiavi private nel controllo di versione. *.pem appartiene a .gitignore.
  2. In produzione, carica le chiavi da un gestore di segreti (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault), non dal disco.
  3. Ruota le chiavi ogni 6–12 mesi. Pubblica prima il nuovo selettore, passa alla firma, poi ritira il vecchio record DNS dopo un periodo di grazia.

Verifica di un messaggio firmato da un file

La verifica è l’operazione simmetrica: dato un messaggio firmato .eml, DkimVerifier estrae il DKIM-Signature header, esegue una ricerca DNS contro <selector>._domainkey.<domain> per la chiave pubblica, e ricalcola l’hash. Il risultato è un DkimResult con IsValid e 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");

Verifica di un messaggio firmato con d=example.com fallirà sempre con "chiave non nel dizionario" o un errore simile di lookup DNS, perché example.com non pubblica un record DKIM. Usa un dominio che controlli (o un messaggio di test noto) per test di verifica reali.

Verifica da uno stream

Quando il messaggio non è su disco — proviene da un fetch IMAP, da una coda, da un upload HTTP o dallo storage di blob — usa il Stream sovraccarico. Accetta qualsiasi stream di lettura seekable:

// 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) e Verify(string) sono funzionalmente identiche — entrambe bloccano fino al completamento della ricerca DNS. Per qualsiasi cosa rivolta all’utente, usa le varianti asincrone qui sotto.

Verifica asincrona

La verifica DKIM comporta un round‑trip DNS, che può richiedere decine di millisecondi anche su una rete veloce. All’interno di un gestore di richieste ASP.NET, di un ciclo di elaborazione dei messaggi, o di qualsiasi thread UI, ciò si accumula rapidamente. VerifyAsync restituisce un Task<DkimResult> che puoi 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");

C’è anche un VerifyAsync(Stream) sovraccarico. Come con l’API sincrona, il metodo dichiara async Task (non async void) — i siti di chiamata dovrebbero await esso, e i punti di ingresso console‑app possono fare VerifySignatureAsync().GetAwaiter().GetResult() quando un async Main non è disponibile.

End‑to‑end: carica, firma, salva, verifica

I pezzi sopra si combinano in un unico flusso di lavoro. Questo è il pipeline completo di firma e verifica da integrare in un servizio di email transazionali:

// 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"]);

Un completo DKIM-Signature l’header prodotto da questo codice appare così:

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...

I tag interessanti:

  • v=1 — versione DKIM.
  • a=rsa-sha256 — algoritmo; riflette HashAlgorithm.
  • c=relaxed/relaxed — canonicalizzazione (header/corpo).
  • d=example.com / s=dkim — selettore e dominio; insieme formano il nome di ricerca DNS.
  • h=from:to:subject:date:mime-version — header firmati, in ordine.
  • bh= — hash base64 del corpo canonicalizzato.
  • b= — la reale firma RSA sugli header canonicalizzati.

Se cambi un qualsiasi valore dell’header firmato, o modifichi il corpo, bh e b non corrispondono più e la verifica fallisce.

Pubblicazione della chiave pubblica nel DNS

Firmare localmente è solo metà del lavoro. Perché i destinatari possano verificare, la controparte pubblica della tua chiave RSA deve essere pubblicata come record DNS TXT a <selector>._domainkey.<domain>:

Record: dkim._domainkey.example.com
Type:   TXT
Value:  v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBC...

Estrai la chiave pubblica dal tuo PEM con OpenSSL:

openssl rsa -in sample-private-key.pem -pubout -out sample-public-key.pem

Rimuovi le righe header/footer e concatena il contenuto base64 per il p= valore. Quindi conferma che il record è attivo prima di fare affidamento su di esso:

dig dkim._domainkey.example.com TXT

Strumenti online come DKIM Validator e MXToolbox DKIM Checker ti dirà se un messaggio reale che invii è verificabile end‑to‑end. Usali prima di passare al traffico in produzione.