C#實現.Net對郵件進行DKIM簽名和驗證,支援附件,傳送郵件簽名後直接投遞到對方伺服器(無需己方郵件伺服器)

xiangyuecn發表於2018-11-11

專案地址

github.com/xiangyuecn/…

主要支援

  • 對郵件進行DKIM簽名,支援帶附件
  • 對整個郵件內容(.eml檔案)的DKIM簽名進行驗證
  • MailMessageSmtpClient進行了一次封裝,傳送郵件簡單易用,進行DKIM簽名後直接投遞到對方伺服器(無需己方郵件伺服器)

DKIM簽名、驗證規則

對於DKIM的簽名和驗證規則,QQ郵箱的《DKIM指引》這個文章已經寫的足夠詳細,就不搬運了。

還不行還可以去參考DKIM.Net庫對簽名的實現。

舉個例子

//建立DKIM簽名物件
var dkim = new EMail_DKIM("domain.com", "dkimSelector", new RSA.RSA(/*"-----BEGIN RSA PRIVATE KEY-----....", true*/ 1024));

//通過EMail類來操作發郵件
using (var email = new EMail("mx1.qq.com", 25)) {
	//使用簽名
	email.TryUseDKIM(dkim);

	email.FromEmail = "test@test.test";
	email.ToEmail("11111111@qq.com");//改成有效的郵箱地址

	//傳送郵件出去,去垃圾箱找,如果私鑰是域名設定的話正常點
	var res = email.Send("標題", "內容");
	Console.WriteLine(res.IsError ? "傳送失敗:" + res.ErrorMessage : "傳送成功");
}

//直接給MailMessage簽名
var msg = new MailMessage("test@test.test", "11111111@qq.com");
msg.SubjectEncoding = msg.BodyEncoding = msg.HeadersEncoding = Encoding.UTF8;
msg.Subject = "標題";
msg.Body = "內容";
msg.Attachments.Add(new Attachment(new MemoryStream(Encoding.UTF8.GetBytes("abc文字內容123")), "文字.txt"));

//簽名
Console.WriteLine(dkim.Sign(msg).IsError ? "簽名失敗" : "簽名完成");

//獲取郵件內容
var eml = EMail_DKIM_MailMessageText.ToRAW(msg).Value;

//驗證eml檔案簽名
Console.WriteLine(dkim.Verify(eml) ? "驗證通過" : "驗證未通過");

//郵件整體內容
Console.WriteLine(eml.Raw);
複製程式碼

輸出:

傳送失敗:郵件傳送出錯:郵箱不可用。 伺服器響應為:Mailbox not found. http://servi
ce.mail.qq.com/cgi-bin/help?subtype=1&&id=20022&&no=1000728
簽名完成
驗證通過
MIME-Version: 1.0
From: test@test.test
To: 11111111@qq.com
Date: Sun, 11 Nov 2018 05:31:55 +000
Subject: =?utf-8?B?5qCH6aKY?=
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=domain.com;
 s=dkimSelector; q=dns/txt; t=1541914315; h=Date:From:Subject:To;
 bh=iKgtfjx6cvO8YCUPyjjnbHU9jziQ+q1c/Hrz0aRDb98=;
 b=CidpxecyNHkZGsIQGnUD8eQwrEGS+Nx09RUOff6hU/7H1DV50m/h0xqRLFlgskiqm1r0exDTPf/zS
CKui1WWNO5iKXSZt9/3s0YN9fhliP72c0GRIJ8DM3tQilVYgFnayK61jmvCW0gtrPd3biDdMp/s+Arq8
lWD6CbQfBMIPmQ=
Content-Type: multipart/mixed; boundary=--boundaryhRN0aXVHKzDLi76qUZTq


----boundaryhRN0aXVHKzDLi76qUZTq
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

5YaF5a65
----boundaryhRN0aXVHKzDLi76qUZTq
Content-Type: application/octet-stream; name="=?utf-8?B?5paH5pysLnR4dA==?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment

YWJj5paH5pys5YaF5a65MTIz
----boundaryhRN0aXVHKzDLi76qUZTq--
複製程式碼

跑起來

clone下來用vs應該能夠直接開啟,經目測看起來沒什麼卵用的檔案都svn:ignore掉了(svn滑稽。

前言、自述、還有啥

在實現郵件傳送時發現就算不把郵件投遞到自己的郵件伺服器(由郵件服務伺服器進行傳送給對方),有些郵箱(QQ郵箱)不會拒絕,但有郵箱直接就拒絕了(網易郵箱)。對比由郵件伺服器傳送的和直接傳送的郵件內容的區別,發現直接傳送缺少了DKIM-Signature郵件頭。

好了,缺少那就加上。但.Net的MailMessageSmtpClient簡陋到一份郵件傳送到一個Stream的介面都不捨得暴露(任性寫入到資料夾不給檔名卻支援),直接就沒有支援簽名的頭緒。那自行實現。

研究了一下RFC 6376長篇大論看不懂(主要沒給一個簡單的實現步驟),然後QQ給的簡單易懂多了(流式.清晰)。簽名和驗證演算法就清楚了。

發現DKIM.Net

要簽名先搞定bodyhash計算body怎麼獲取?一堆附件、一堆轉碼...... MailMessageSmtpClient沒給獲取body的支援。然後找到一個庫 DKIM.Net,他裡面實現了獲取整個郵件內容的方法,簡單呼叫一下MailMessage的私有方法搞定。

然後遇到了DKIM.Net也沒有搞定的問題,對於帶附件AttachmentsAlternateViews的郵件,由於每次獲取的郵件內容因為boundary分隔符(邊界)不一致導致簽名無效,DKIM.Net是直接粗暴的拒絕multipart格式郵件的簽名的。然後翻閱.NET Framework MimeMultiPart原始碼找到了以下程式碼:

internal string GetNextBoundary() {
	int b = Interlocked.Increment(ref boundary) - 1;
	string boundaryString = "--boundary_" + b.ToString(CultureInfo.InvariantCulture)+"_"+Guid.NewGuid().ToString(null, CultureInfo.InvariantCulture);

	return boundaryString;
}
複製程式碼

這個方法只會在MimeMultiPart初始化時呼叫一次,MimeMultiPart的初始化時機在MailMessage.Send呼叫時,通過MailMessage.SetContent來初始化。而私有方法MailMessage.Send是傳送郵件時才會呼叫到的,我們獲取郵件內容也是通過這個方法。如果我們通過手段使MimeMultiPart.GetNextBoundary返回的boundary相同,那麼每次獲取的郵件內容也會相同了(Date相同的情況下)。

發現DotNetDetour

然後就是尋找控制MimeMultiPart.GetNextBoundary函式的方法。研究了半天反射,沒有找到頭緒,反射能替換一個類例項的方法為另一個方法?然後順著查詢C# hook,找到多篇一樣的內容,還是看原創吧《自己寫的一個可以hook .net方法的庫》,內容本身並不感冒(沒看懂),但結尾一句話但hook一般都需要dll注入來配合,因為hook自身程式沒什麼意義,hook別人的程式才有意義,咦,搞自己,有意思,然後仔細看了一下程式碼,沒錯!這就是我要的功能,修改類的一個方法為另外一個方法!DotNetDetour庫。

Date隱患

因為簽名和傳送是在不同時間內,就有可能導致簽名時是8:05.999,而傳送時是8:06.001,從而導致帶Date header的簽名失敗,但簽名時建議攜帶Date header一起簽名。

so 這個問題hook System.Net.Mail.Message.PrepareHeaders 可以解決,每次原始函式處理完成後我們獲取System.Net.Mail.Message.Headers,然後把Date header刪掉,然後寫入我們可以控制的值。

對DotNetDetour的修改

測試DotNetDetour過程中發現他不能 hook 非public的方法,然後魔改了一下Monitor.cs,主要在反射獲取類的方法的時候新增了flags引數,用來獲取類的所有方法。

使用中給IMethodMonitor介面加了一個void SetMethod(MethodInfo method)方法,用來把原始方法資訊傳遞給我們自己的方法,簡化我們自己函式內的反射操作(獲取.Net框架內的型別敲的字串比較複雜,有了MethodInfo就是一個屬性呼叫的事)。

準備好了

有了DKIM.Net提供的思路來獲取郵件內容,搬來DotNetDetour hook修改 .Net系統類的方法,郵件DKIM簽名唾手可得~

參考文章:

C#傳送DKIM簽名的郵件》:發現DKIM.Net

RFC 6376》:DKIM簽名規則

DKIM指引》:QQ提供的DKIM簽名、驗證規則文件

自己寫的一個可以hook .net方法的庫》:發現DotNetDetour

DKIM 測試》:測試簽名,測試前提:需要有一個自己的域名,並配置郵箱域名的DKIM公鑰

方法文件

EMail_DKIM.cs

郵件進行DKIM簽名和驗證的所有程式碼都在裡面。

EMail_DKIM類:提供簽名Sign和驗證Verify

EMail_DKIM_RAW_EML類:提供ParseOrNull用來解析一封.eml檔案內容。

EMail_DKIM_MailMessageText類:提供ToRAW用來獲取MailMessage的全部內容,並轉成EMail_DKIM_RAW_EML格式。

EMail.cs

封裝的一個傳送郵件的功能。

主要提供TimeoutMillisecond,ClientName設定,一堆新增附件的方法AddAttachment(x,x,x),最後Send傳送郵件。

EMail_Unit.cs

封裝的一些通用方法,如:base64。都是比較周邊的功能。

/Lib/RSA-csharp目錄

這個目錄裡面是我的RSA-csharp倉庫程式碼,用來解析PEM祕鑰對的。

/Lib/DotNetDetour目錄

這個目錄裡面是DotNetDetour庫,使用的這個版本程式碼。已經修改過,用來支援私有方法的hook。

任意郵箱收件地址查詢

比如qq郵箱,smtp.qq.com這種是發件用的地址,不是收件地址,接收郵件的地址需要進行mx查詢。有了收件地址就可以傳送任意郵件給他,他收不收是另外一回事,比如偽造發件人。

mx查詢方法:

比如查詢qq郵箱的收件地址

> nslookup
> set type=mx
> qq.com

非權威應答:
qq.com  MX preference = 30, mail exchanger = mx1.qq.com
qq.com  MX preference = 20, mail exchanger = mx2.qq.com
qq.com  MX preference = 10, mail exchanger = mx3.qq.com
複製程式碼

然後響應的mail exchanger就是收件地址,隨便挑一個給他發垃圾郵件。

郵箱域名DKIM公鑰查詢

要驗證一份郵件的簽名,需要先獲取公鑰(有私鑰用私鑰驗證也可以)。給個郵箱然後查詢公鑰的方法(比如QQ郵箱):

步驟1:開啟郵件原始碼獲取到DKIM-Signature中的s引數(selector),QQ為s201512 步驟2:和QQ郵箱拼接出ns txt記錄名稱:s201512._domainkey.qq.com

> nslookup
> set type=txt
> s201512._domainkey.qq.com

非權威應答:
s201512._domainkey.qq.com       text =

        "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDPsFIOSteMStsN6
15gUWK2RpNJ/B/ekmm4jVlu2fNzXADFkjF8mCMgh0uYe8w46FVqxUS97habZq6P5jmCj/WvtPGZAX49j
mdaB38hzZ5cUmwYZkdue6dM17sWocPZO8e7HVdq7bQwfGuUjVuMKfeTB3iNeo6/hFhb9TmUgnwjpQIDA
QAB"
複製程式碼

然後響應的text內的p引數就是公鑰了,copy出來拼成PEM格式就可以拿來進行DKIM驗證。

線上DKIM簽名測試

測試需要有一個域名並且配置好相應ns DKIM的 txt記錄。

本次測試例項程式碼:

var rsa = new RSA.RSA(@"-----BEGIN RSA PRIVATE KEY-----
私鑰內容
-----END RSA PRIVATE KEY-----
", true);

var mail = new EMail("mail.appmaildev.com", 25);
mail.TryUseDKIM(new EMail_DKIM("email.jiebian.life", "email", rsa));
mail.FromEmail = "test-7ea72484@email.jiebian.life";
mail.ToEmail("test-7ea72484@appmaildev.com");
var res=mail.Send("測試", "測試內容");
Console.WriteLine(res.IsError?"傳送失敗:"+res.ErrorMessage:"傳送成功");
複製程式碼

本次測試報告:見images/report-7ea72484.txt

相關截圖

測試線上結果:

測試結果

開始線上測試:

開始測試

控制檯執行:

控制檯執行

相關文章