Firmar correos electrónicos con DKIM para autenticación y entregabilidad

DKIM (DomainKeys Identified Mail, RFC 6376) permite a un propietario de dominio adjuntar una firma criptográfica al correo saliente. Los servidores receptores recuperan la clave pública correspondiente desde DNS, recalculan el hash y confirman dos cosas a la vez: el cuerpo del mensaje no se modificó en tránsito y el mensaje realmente proviene de un remitente autorizado por el dominio. Sin DKIM, los proveedores modernos (Gmail, Microsoft 365, Yahoo) marcan el correo como spam, lo descartan silenciosamente o rechazan la entrega — DKIM ya no es opcional para el correo de producción.

Esta guía es un recorrido completo de Aspose.Emaildel espacio de nombres DKIM de .NET. Cada bloque de código en este artículo proviene de un ejemplo ejecutable en el DKIMExamples proyecto y ha sido verificado de extremo a extremo contra la biblioteca.

Requisitos previos

Los tipos DKIM viven en el Aspose.Email.DKIM espacio de nombres y solo se incluye con las compilaciones .NET Framework 4.0/4.5 de la biblioteca. Se excluyen intencionalmente de los paquetes .NET Standard 2.0 y .NET 6/8. Para seguir los ejemplos a continuación, su proyecto debe referenciar un ensamblado Aspose.Email construido para net45 (objetivo net48 en el consumo .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>

También necesita un par de claves RSA. La clave privada se mantiene en su aplicación y se usa para firmar; la clave pública se publica como un registro DNS TXT para que los receptores puedan verificar. Hoy en día, una clave de 2048 bits es el estándar:

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

de Aspose PemReader acepta tanto PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) y PKCS#8 (-----BEGIN PRIVATE KEY-----) archivos formateados.

| Clase | Rol | |——-|——| | DKIMSignatureInfo | Describe la firma: selector, dominio, algoritmo de hash, canonización y qué encabezados cubrir. | | PemReader | Carga una clave privada RSA desde un archivo o flujo con formato PEM en un RSACryptoServiceProvider. | | MailMessage.DKIMSign(rsa, info) | Extensión/método de instancia que devuelve una copia firmada del mensaje con el DKIM-Signature encabezado poblado. | | DkimVerifier | Realiza verificación basada en DNS de un mensaje firmado — archivo, flujo, sincrónico o asincrónico. |

El flujo completo siempre es: cargar clave → describir firma → construir mensaje → firmar → (opcionalmente) verificar.

El ejemplo mínimo de firma

Este es la menor cantidad de código que produce un mensaje firmado con DKIM válido:

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

Los dos argumentos para DKIMSignatureInfo"dkim" y "example.com" — son el selector y el dominio. Juntos le indican a los receptores dónde buscar su clave pública: en el registro DNS TXT dkim._domainkey.example.com. Elija un nombre de selector corto (a menudo default, mail, o una fecha como 2026jan); el selector le permite rotar claves sin descartar la anterior.

Un detalle crítico: DKIMSign devuelve un nuevo MailMessage instancia con el DKIM-Signature encabezado añadido. El mensaje original no se modifica. Guarde o envíe el objeto devuelto, no la entrada.

Elegir qué encabezados firmar

Los encabezados listados en signInfo.Headers son los únicos cuyos valores están vinculados a la firma. Cualquier otro que no esté listado puede ser modificado por relés sin romper la firma, lo que a veces es deseable (p. ej. Received: encabezados añadidos aguas abajo) y a veces peligrosos (p. ej., dejar From sin firmar hace que la firma sea sin sentido).

Regla práctica: firme siempre From, To, Subject, Date. From en particular es obligatorio para la alineación 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);

Si enumera Date pero nunca establezca mailMessage.Date, DKIMSign lanza The following headers to be signed do not exist: Date. Siempre rellene primero los encabezados, luego firme.

Algoritmos de hash — RSA‑SHA1 vs RSA‑SHA256

DKIM soporta dos algoritmos de firma. El predeterminado en Aspose.Email es RSASha1 para compatibilidad retroactiva, pero cada nuevo despliegue debería usar RSASha256. Muchos proveedores ahora marcan firmas SHA‑1 como inválidas incluso cuando verifican criptográficamente.

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

El resultante DKIM-Signature el encabezado anuncia el algoritmo vía el a= etiqueta (a=rsa-sha1 vs a=rsa-sha256). Los receptores consultan esa etiqueta para decidir qué hash recomputar.

Canonización — Simple vs Relajada

La canonización es cómo DKIM acuerda una forma estable, byte‑exacta del mensaje antes de hash. Se definen dos algoritmos para encabezados y cuerpos de forma independiente:

  • Simple — casi sin normalización. Las líneas vacías finales en el cuerpo se eliminan, pero no se toca otro espacio en blanco. Un solo espacio añadido por un relé invalidará la firma.
  • Relaxed — pliega los espacios en blanco dentro de los valores de encabezado, convierte a minúsculas los nombres de encabezado y colapsa secuencias de espacios en el cuerpo. Tolerante al reformateo trivial que tienden a hacer los agentes de transporte de correo.

Elija Relaxed para ambos encabezado y cuerpo a menos que tenga una razón específica para no hacerlo. Casi todas las implementaciones de producción lo hacen.

// 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 canonización del encabezado y cuerpo son independientes — relaxed/simple es una combinación perfectamente válida, escrita de esa manera en el c= etiqueta del encabezado DKIM.

Cargando la clave privada

PemReader maneja ambas codificaciones PEM de forma transparente. No hay un Pkcs1Reader / Pkcs8Reader — simplemente pase la ruta o el flujo:

// 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 devuelve un RSACryptoServiceProvider lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista lista DKIMSign. También hay una GetPrivateKey(Stream) sobrecarga para casos en que la clave proviene de una bóveda de claves, blob cifrado o recurso incrustado en lugar del sistema de archivos.

Reglas de manejo de claves:

  1. Nunca comprometa claves privadas al control de versiones. *.pem pertenece a .gitignore.
  2. En producción, cargue claves desde un gestor de secretos (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault), no desde el disco.
  3. Gire las claves cada 6–12 meses. Publique primero el nuevo selector, cambie la firma, luego retire el registro DNS antiguo después de un período de gracia.

Verificando un mensaje firmado desde un archivo

La verificación es la operación simétrica: dado un firmado .eml, DkimVerifier extrae el DKIM-Signature encabezado, realiza una búsqueda DNS contra <selector>._domainkey.<domain> para la clave pública, y recalcula el hash. El resultado es un DkimResult con IsValid y 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");

Verificando un mensaje firmado con d=example.com siempre fallará con "clave no en diccionario" o un error similar de búsqueda DNS, porque example.com no publica un registro DKIM. Use un dominio que controle (o un mensaje de prueba conocido) para pruebas de verificación reales.

Verificando desde un flujo

Cuando el mensaje no está en disco — proviene de una extracción IMAP, una cola, una carga HTTP o almacenamiento de blobs — use el Stream sobrecarga. Acepta cualquier flujo de lectura buscable:

// 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) y Verify(string) son funcionalmente idénticas — ambas bloquean hasta que la búsqueda DNS se completa. Para cualquier cosa orientada al usuario, use las variantes asíncronas a continuación.

Verificación asíncrona

La verificación DKIM implica una ida y vuelta DNS, lo que puede tomar decenas de milisegundos incluso en una red rápida. Dentro de un controlador de solicitud ASP.NET, bucle de procesamiento de mensajes, o cualquier hilo UI, eso se suma rápidamente. VerifyAsync devuelve un Task<DkimResult> que puede 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");

También hay un VerifyAsync(Stream) sobrecarga. Al igual que con la API sincrónica, el método declara async Task (no async void) — los sitios de llamada deberían await ello, y los puntos de entrada de aplicaciones de consola pueden hacer VerifySignatureAsync().GetAwaiter().GetResult() cuando un async Main no está disponible.

De extremo a extremo: cargar, firmar, guardar, verificar

Las piezas anteriores se combinan en un flujo de trabajo único. Este es el pipeline completo de firmar‑y‑verificar que incorporaría dentro de un servicio de correo transaccional:

// 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 el encabezado producido por este código se ve así:

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

Las etiquetas interesantes:

  • v=1 — versión DKIM.
  • a=rsa-sha256 — algoritmo; refleja HashAlgorithm.
  • c=relaxed/relaxed — canonización (encabezado/cuerpo).
  • d=example.com / s=dkim — selector y dominio; juntos forman el nombre de búsqueda DNS.
  • h=from:to:subject:date:mime-version — encabezados firmados, en orden.
  • bh= — hash base64 del cuerpo canonizado.
  • b= — la firma RSA real sobre los encabezados canonizados.

Si cambia cualquier valor de encabezado firmado, o modifica el cuerpo, bh y b ya no coinciden y la verificación falla.

Publicar la clave pública en DNS

Firmar localmente es solo la mitad del trabajo. Para que los receptores verifiquen, la contraparte pública de su clave RSA debe publicarse como un registro TXT DNS en <selector>._domainkey.<domain>:

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

Extraiga la clave pública de su PEM con OpenSSL:

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

Elimine las líneas de encabezado/pie de página y concatene el contenido base64 para el p= valor. Luego confirme que el registro está activo antes de confiar en él:

dig dkim._domainkey.example.com TXT

Herramientas en línea como Validador DKIM y Verificador DKIM de MXToolbox le dirá si un mensaje real que envía es verificable de extremo a extremo. Úselos antes de cambiar el tráfico de producción.