項(xiàng)目地址
https://github.com/xiangyuecn/DKIM-Smtp-csharp
主要支持
- 對(duì)郵件進(jìn)行DKIM簽名,支持帶附件
- 對(duì)整個(gè)郵件內(nèi)容(.eml文件)的DKIM簽名進(jìn)行驗(yàn)證
- 對(duì)
MailMessage、SmtpClient進(jìn)行了一次封裝,發(fā)送郵件簡單易用,進(jìn)行DKIM簽名后直接投遞到對(duì)方服務(wù)器(無需己方郵件服務(wù)器)
DKIM簽名、驗(yàn)證規(guī)則
對(duì)于DKIM的簽名和驗(yàn)證規(guī)則,QQ郵箱的《DKIM指引》這個(gè)文章已經(jīng)寫的足夠詳細(xì),就不搬運(yùn)了。
還不行還可以去參考DKIM.Net庫對(duì)簽名的實(shí)現(xiàn)。
舉個(gè)例子
//創(chuàng)建DKIM簽名對(duì)象
var dkim = new EMail_DKIM("domain.com", "dkimSelector", new RSA.RSA(/*"-----BEGIN RSA PRIVATE KEY-----....", true*/ 1024));
//通過EMail類來操作發(fā)郵件
using (var email = new EMail("mx1.qq.com", 25)) {
//使用簽名
email.TryUseDKIM(dkim);
email.FromEmail = "test@test.test";
email.ToEmail("11111111@qq.com");//改成有效的郵箱地址
//發(fā)送郵件出去,去垃圾箱找,如果私鑰是域名設(shè)置的話正常點(diǎn)
var res = email.Send("標(biāo)題", "內(nèi)容");
Console.WriteLine(res.IsError ? "發(fā)送失敗:" + res.ErrorMessage : "發(fā)送成功");
}
//直接給MailMessage簽名
var msg = new MailMessage("test@test.test", "11111111@qq.com");
msg.SubjectEncoding = msg.BodyEncoding = msg.HeadersEncoding = Encoding.UTF8;
msg.Subject = "標(biāo)題";
msg.Body = "內(nèi)容";
msg.Attachments.Add(new Attachment(new MemoryStream(Encoding.UTF8.GetBytes("abc文本內(nèi)容123")), "文本.txt"));
//簽名
Console.WriteLine(dkim.Sign(msg).IsError ? "簽名失敗" : "簽名完成");
//獲取郵件內(nèi)容
var eml = EMail_DKIM_MailMessageText.ToRAW(msg).Value;
//驗(yàn)證eml文件簽名
Console.WriteLine(dkim.Verify(eml) ? "驗(yàn)證通過" : "驗(yàn)證未通過");
//郵件整體內(nèi)容
Console.WriteLine(eml.Raw);
輸出:
發(fā)送失敗:郵件發(fā)送出錯(cuò):郵箱不可用。 服務(wù)器響應(yīng)為:Mailbox not found. http://servi
ce.mail.qq.com/cgi-bin/help?subtype=1&&id=20022&&no=1000728
簽名完成
驗(yàn)證通過
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應(yīng)該能夠直接打開,經(jīng)目測看起來沒什么卵用的文件都svn:ignore掉了(svn滑稽。
前言、自述、還有啥
在實(shí)現(xiàn)郵件發(fā)送時(shí)發(fā)現(xiàn)就算不把郵件投遞到自己的郵件服務(wù)器(由郵件服務(wù)服務(wù)器進(jìn)行發(fā)送給對(duì)方),有些郵箱(QQ郵箱)不會(huì)拒絕,但有郵箱直接就拒絕了(網(wǎng)易郵箱)。對(duì)比由郵件服務(wù)器發(fā)送的和直接發(fā)送的郵件內(nèi)容的區(qū)別,發(fā)現(xiàn)直接發(fā)送缺少了DKIM-Signature郵件頭。
好了,缺少那就加上。但.Net的MailMessage、SmtpClient簡陋到一份郵件發(fā)送到一個(gè)Stream的接口都不舍得暴露(任性寫入到文件夾不給文件名卻支持),直接就沒有支持簽名的頭緒。那自行實(shí)現(xiàn)。
研究了一下RFC 6376長篇大論看不懂(主要沒給一個(gè)簡單的實(shí)現(xiàn)步驟),然后QQ給的簡單易懂多了(流式.清晰)。簽名和驗(yàn)證算法就清楚了。
發(fā)現(xiàn)DKIM.Net
要簽名先搞定body的hash計(jì)算,body怎么獲取?一堆附件、一堆轉(zhuǎn)碼...... MailMessage、SmtpClient沒給獲取body的支持。然后找到一個(gè)庫 DKIM.Net,他里面實(shí)現(xiàn)了獲取整個(gè)郵件內(nèi)容的方法,簡單調(diào)用一下MailMessage的私有方法搞定。
然后遇到了DKIM.Net也沒有搞定的問題,對(duì)于帶附件Attachments或AlternateViews的郵件,由于每次獲取的郵件內(nèi)容因?yàn)?code>boundary分隔符(邊界)不一致導(dǎo)致簽名無效,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;
}
這個(gè)方法只會(huì)在MimeMultiPart初始化時(shí)調(diào)用一次,MimeMultiPart的初始化時(shí)機(jī)在MailMessage.Send調(diào)用時(shí),通過MailMessage.SetContent來初始化。而私有方法MailMessage.Send是發(fā)送郵件時(shí)才會(huì)調(diào)用到的,我們獲取郵件內(nèi)容也是通過這個(gè)方法。如果我們通過手段使MimeMultiPart.GetNextBoundary返回的boundary相同,那么每次獲取的郵件內(nèi)容也會(huì)相同了(Date相同的情況下)。
發(fā)現(xiàn)DotNetDetour
然后就是尋找控制MimeMultiPart.GetNextBoundary函數(shù)的方法。研究了半天反射,沒有找到頭緒,反射能替換一個(gè)類實(shí)例的方法為另一個(gè)方法?然后順著查找C# hook,找到多篇一樣的內(nèi)容,還是看原創(chuàng)吧《自己寫的一個(gè)可以hook .net方法的庫》,內(nèi)容本身并不感冒(沒看懂),但結(jié)尾一句話但hook一般都需要dll注入來配合,因?yàn)閔ook自身進(jìn)程沒什么意義,hook別人的進(jìn)程才有意義,咦,搞自己,有意思,然后仔細(xì)看了一下代碼,沒錯(cuò)!這就是我要的功能,修改類的一個(gè)方法為另外一個(gè)方法!DotNetDetour庫。
Date隱患
因?yàn)楹灻桶l(fā)送是在不同時(shí)間內(nèi),就有可能導(dǎo)致簽名時(shí)是8:05.999,而發(fā)送時(shí)是8:06.001,從而導(dǎo)致帶Date header的簽名失敗,但簽名時(shí)建議攜帶Date header一起簽名。
so 這個(gè)問題hook System.Net.Mail.Message.PrepareHeaders 可以解決,每次原始函數(shù)處理完成后我們獲取System.Net.Mail.Message.Headers,然后把Date header刪掉,然后寫入我們可以控制的值。
對(duì)DotNetDetour的修改
測試DotNetDetour過程中發(fā)現(xiàn)他不能 hook 非public的方法,然后魔改了一下Monitor.cs,主要在反射獲取類的方法的時(shí)候添加了flags參數(shù),用來獲取類的所有方法。
使用中給IMethodMonitor接口加了一個(gè)void SetMethod(MethodInfo method)方法,用來把原始方法信息傳遞給我們自己的方法,簡化我們自己函數(shù)內(nèi)的反射操作(獲取.Net框架內(nèi)的類型敲的字符串比較復(fù)雜,有了MethodInfo就是一個(gè)屬性調(diào)用的事)。
準(zhǔn)備好了
有了DKIM.Net提供的思路來獲取郵件內(nèi)容,搬來DotNetDetour hook修改 .Net系統(tǒng)類的方法,郵件DKIM簽名唾手可得~
參考文章:
《C#發(fā)送DKIM簽名的郵件》:發(fā)現(xiàn)DKIM.Net
《RFC 6376》:DKIM簽名規(guī)則
《DKIM指引》:QQ提供的DKIM簽名、驗(yàn)證規(guī)則文檔
《自己寫的一個(gè)可以hook .net方法的庫》:發(fā)現(xiàn)DotNetDetour
《DKIM 測試》:測試簽名,測試前提:需要有一個(gè)自己的域名,并配置郵箱域名的DKIM公鑰
方法文檔
EMail_DKIM.cs
郵件進(jìn)行DKIM簽名和驗(yàn)證的所有代碼都在里面。
EMail_DKIM類:提供簽名Sign和驗(yàn)證Verify。
EMail_DKIM_RAW_EML類:提供ParseOrNull用來解析一封.eml文件內(nèi)容。
EMail_DKIM_MailMessageText類:提供ToRAW用來獲取MailMessage的全部內(nèi)容,并轉(zhuǎn)成EMail_DKIM_RAW_EML格式。
EMail.cs
封裝的一個(gè)發(fā)送郵件的功能。
主要提供TimeoutMillisecond,ClientName設(shè)置,一堆添加附件的方法AddAttachment(x,x,x),最后Send發(fā)送郵件。
EMail_Unit.cs
封裝的一些通用方法,如:base64。都是比較周邊的功能。
/Lib/RSA-csharp目錄
這個(gè)目錄里面是我的RSA-csharp倉庫代碼,用來解析PEM秘鑰對(duì)的。
/Lib/DotNetDetour目錄
這個(gè)目錄里面是DotNetDetour庫,使用的這個(gè)版本代碼。已經(jīng)修改過,用來支持私有方法的hook。
附
任意郵箱收件地址查詢
比如qq郵箱,smtp.qq.com這種是發(fā)件用的地址,不是收件地址,接收郵件的地址需要進(jìn)行mx查詢。有了收件地址就可以發(fā)送任意郵件給他,他收不收是另外一回事,比如偽造發(fā)件人。
mx查詢方法:
比如查詢qq郵箱的收件地址
> nslookup
> set type=mx
> qq.com
非權(quán)威應(yīng)答:
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
然后響應(yīng)的mail exchanger就是收件地址,隨便挑一個(gè)給他發(fā)垃圾郵件。
郵箱域名DKIM公鑰查詢
要驗(yàn)證一份郵件的簽名,需要先獲取公鑰(有私鑰用私鑰驗(yàn)證也可以)。給個(gè)郵箱然后查詢公鑰的方法(比如QQ郵箱):
步驟1:打開郵件源碼獲取到DKIM-Signature中的s參數(shù)(selector),QQ為s201512
步驟2:和QQ郵箱拼接出ns txt記錄名稱:s201512._domainkey.qq.com
> nslookup
> set type=txt
> s201512._domainkey.qq.com
非權(quán)威應(yīng)答:
s201512._domainkey.qq.com text =
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDPsFIOSteMStsN6
15gUWK2RpNJ/B/ekmm4jVlu2fNzXADFkjF8mCMgh0uYe8w46FVqxUS97habZq6P5jmCj/WvtPGZAX49j
mdaB38hzZ5cUmwYZkdue6dM17sWocPZO8e7HVdq7bQwfGuUjVuMKfeTB3iNeo6/hFhb9TmUgnwjpQIDA
QAB"
然后響應(yīng)的text內(nèi)的p參數(shù)就是公鑰了,copy出來拼成PEM格式就可以拿來進(jìn)行DKIM驗(yàn)證。
線上DKIM簽名測試
測試需要有一個(gè)域名并且配置好相應(yīng)ns DKIM的 txt記錄。
本次測試實(shí)例代碼:
var rsa = new RSA.RSA(@"-----BEGIN RSA PRIVATE KEY-----
私鑰內(nèi)容
-----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("測試", "測試內(nèi)容");
Console.WriteLine(res.IsError?"發(fā)送失敗:"+res.ErrorMessage:"發(fā)送成功");
本次測試報(bào)告:見images/report-7ea72484.txt
相關(guān)截圖
測試線上結(jié)果:

開始線上測試:

控制臺(tái)運(yùn)行:
