인증 및 전송 가능성을 위한 DKIM 이메일 서명

DKIM (DomainKeys Identified Mail, RFC 6376) 도메인 소유자가 발신 이메일에 암호 서명을 첨부하도록 합니다. 수신 서버는 DNS에서 해당 공개 키를 가져와 해시를 재계산하고 두 가지를 동시에 확인합니다: 전송 중에 메시지 본문이 수정되지 않았으며, 메시지가 도메인에 의해 승인된 발신자에서 실제로 발송되었는지. DKIM이 없으면 최신 제공업체(Gmail, Microsoft 365, Yahoo)는 메일을 스팸으로 표시하거나 조용히 삭제하거나 배달을 거부합니다 — DKIM은 이제 프로덕션 메일에 선택 사항이 아닙니다.

이 가이드는 전체 walkthrough입니다 Aspose.Email의 .NET용 DKIM 네임스페이스입니다. 이 기사에 있는 모든 코드 블록은 DKIMExamples 프로젝트에 포함되어 라이브러리와 전체 엔드투엔드 검증을 거쳤습니다.

전제 조건

DKIM 유형이 위치한 Aspose.Email.DKIM 네임스페이스이며 라이브러리의 .NET Framework 4.0/4.5 빌드와만 함께 배포됩니다. .NET Standard 2.0 및 .NET 6/8 패키지에서는 의도적으로 제외되었습니다. 아래 예제를 따르려면 프로젝트가 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의 PemReader PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) 및 PKCS#8 (-----BEGIN PRIVATE KEY-----) 형식 파일.

| 클래스 | 역할 | |——-|——| | DKIMSignatureInfo | 서명을 설명합니다: 선택자, 도메인, 해시 알고리즘, 정규화, 그리고 포함할 헤더. | | PemReader | PEM 형식 파일 또는 스트림에서 RSA 개인 키를 로드하여 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 are the only ones whose values are bound to the signature. Anything not listed can be modified by relays without breaking the signature, which is sometimes desirable (e.g. Received: headers added downstream) and sometimes dangerous (e.g. leaving From unsigned makes the signature meaningless).

경험법칙: 항상 서명하십시오 From, To, Subject, Date. From in particular is mandatory for DMARC alignment.

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

If you list Date but never set mailMessage.Date, DKIMSign throws The following headers to be signed do not exist: Date. 항상 헤더를 먼저 채운 다음 서명하십시오.

해시 알고리즘 — RSA-SHA1 vs 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 vs a=rsa-sha256). 수신자는 해당 태그를 참조하여 어떤 해시를 재계산할지 결정합니다.

정규화 — Simple vs Relaxed

정규화는 해시하기 전에 메시지의 안정적인 바이트 정확형을 정의하는 방법입니다. 헤더와 본문에 대해 각각 두 가지 알고리즘이 정의됩니다:

  • 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 hand to DKIMSign. 또한 GetPrivateKey(Stream) 키가 파일 시스템이 아닌 키 볼트, 암호화된 Blob 또는 임베디드 리소스에서 오는 경우에 대한 overload.

키 처리 규칙:

  1. 절대 개인 키를 버전 관리에 커밋하지 마십시오. *.pem belongs in .gitignore.
  2. 운영 환경에서는 디스크가 아닌 비밀 관리 서비스(Azure Key Vault, AWS Secrets Manager, HashiCorp Vault)에서 키를 로드하십시오.
  3. 키를 6–12개월마다 교체하십시오. 새 선택자를 먼저 게시하고, 서명을 전환한 뒤, 유예 기간 후에 오래된 DNS 레코드를 제거합니다.

파일에서 서명된 메시지 검증

검증은 대칭 연산입니다: 서명된 .eml, DkimVerifier 추출합니다 DKIM-Signature 헤더이며, DNS 조회를 수행합니다 <selector>._domainkey.<domain> 공개 키를 가져와 해시를 재계산합니다. 결과는 DkimResult 와 함께 IsValidErrorMessage.

// 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 "키가 사전(dict)에 없습니다" 또는 유사한 DNS 조회 오류와 함께 항상 실패합니다, 왜냐하면 example.com DKIM 레코드를 게시하지 않습니다. 실제 검증 테스트를 위해서는 제어 가능한 도메인(또는 알려진 테스트 메시지)을 사용하십시오.

스트림에서 검증

메시지가 디스크에 없을 때 — IMAP 가져오기, 큐, HTTP 업로드 또는 Blob 스토리지에서 온 경우 — Stream overload. It accepts any seekable read 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 returns a Task<DkimResult> that you can 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) overload. As with the sync API, the method declares async Task (not async void) — call sites should await it, and console-app entrypoints can do VerifySignatureAsync().GetAwaiter().GetResult() when an 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 서명.

서명된 헤더 값을 변경하거나 본문을 수정하면 bhb 더 이상 일치하지 않아 검증이 실패합니다.

DNS에 공개 키 게시

로컬 서명은 절반에 불과합니다. 수신자가 검증하려면 RSA 키의 공개 부분을 DNS TXT 레코드에 게시해야 합니다: <selector>._domainkey.<domain>:

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

OpenSSL을 사용하여 PEM에서 공개 키를 추출:

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

헤더/푸터 라인을 제거하고 base64 콘텐츠를 연결하여 p= 값을 입력하십시오. 그런 다음 레코드가 실시간인지 확인한 후 사용하십시오:

dig dkim._domainkey.example.com TXT

온라인 도구 예: DKIM 검증기MXToolbox DKIM 검사기 실제 전송한 메시지가 끝까지 검증 가능한지 알려줍니다. 프로덕션 트래픽을 전환하기 전에 이를 사용하십시오.