專案地址
主要支援
- 對郵件進行DKIM簽名,支援帶附件
- 對整個郵件內容(.eml檔案)的DKIM簽名進行驗證
- 對
MailMessage
、SmtpClient
進行了一次封裝,傳送郵件簡單易用,進行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的MailMessage
、SmtpClient
簡陋到一份郵件傳送到一個Stream
的介面都不捨得暴露(任性寫入到資料夾不給檔名卻支援),直接就沒有支援簽名的頭緒。那自行實現。
研究了一下RFC 6376長篇大論看不懂(主要沒給一個簡單的實現步驟),然後QQ給的簡單易懂多了(流式.清晰)。簽名和驗證演算法就清楚了。
發現DKIM.Net
要簽名先搞定body
的hash計算
,body
怎麼獲取?一堆附件、一堆轉碼...... MailMessage
、SmtpClient
沒給獲取body
的支援。然後找到一個庫 DKIM.Net,他裡面實現了獲取整個郵件內容的方法,簡單呼叫一下MailMessage
的私有方法搞定。
然後遇到了DKIM.Net
也沒有搞定的問題,對於帶附件Attachments
或AlternateViews
的郵件,由於每次獲取的郵件內容因為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簽名唾手可得~
參考文章:
《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
相關截圖
測試線上結果:
開始線上測試:
控制檯執行: