DKIM "invalid signature": body hash uyuşmazlığını ve canonicalization tuzağını teşhis etmek
DKIM "invalid signature" hatalarının çoğu anahtar sorunu değil, body hash uyuşmazlığıdır: imzalanan gövde ile alıcının gördüğü gövde artık aynı değildir. Authentication-Results satırını doğru okumaktan bh='yi openssl ile elle yeniden hesaplamaya kadar deterministik bir teşhis akışı.
EvilMail Team21 Temmuz 202611 dk okuma
"DKIM anahtarını yeniledim, DNS'i üç kez kontrol ettim, hâlâ dkim=fail." Bu cümleyi kuran herkes neredeyse her zaman yanlış yeri kazıyor. Çünkü DKIM tek bir doğrulama değil, iki bağımsız doğrulamadır ve bunların hangisinin koptuğunu bilmeden anahtar döndürmek, motoru çalışmayan arabanın lastiklerini değiştirmeye benzer.
Vakaların büyük çoğunluğunda anahtar gayet iyidir. Kopan şey body hash'tir: imzayı atan sunucunun gördüğü gövde ile alıcının aldığı gövde artık byte-byte aynı değildir. Aradaki bir sistem mesajı değiştirmiştir. Bu yazıda tahmini bırakıp deterministik teşhise geçiyoruz: hatayı doğru okumak, ham .eml üzerinden kanıt çıkarmak, bh='yi openssl ile elle yeniden hesaplamak ve suçluyu Received zincirinde yakalamak.
Önce hatayı doğru oku: bh= mi, b= mi koptu?
RFC 6376'ya göre DKIM doğrulaması iki kapıdan geçer. Birinci kapı gövdeyi canonicalize edip SHA-256 ile hash'ler ve sonucu imzadaki bh=
DKIM Invalid Signature: Body Hash ve Canonicalization Teşhisi — EvilMail Blog
değeriyle karşılaştırır. İkinci kapı
h=
listesindeki başlıkları artı
DKIM-Signature
başlığının kendisini canonicalize edip, DNS'ten çekilen public key ile
b=
imzasını doğrular. İkisi de geçerse
dkim=pass
. Kritik nokta: bu iki kapı
bağımsız
fail eder ve ikisinin fail nedeni tamamen farklı yerlere işaret eder.
Bu yüzden Authentication-Results başlığında sadece dkim=fail görmek yetmez. Asıl bilgi reason alanındadır:
Authentication-Results: mx.example.com;
dkim=fail reason="body hash did not verify"
header.d=gonderen.com header.s=s1 header.b=Ab3dEf==
reason="body hash did not verify" → Kapı 1 koptu, gövde değişti. Anahtara dokunma. reason="signature did not verify" → Kapı 2 koptu, sorun anahtar, selector ya da imzalanan başlıklardadır. Gmail'de "Show original", Outlook'ta ham başlıklar, ya da kendi MTA'nızda rspamd/OpenDKIM logları bu satırı verir. Reason'ı okumadan atılan her adım kördür.
Kanıtı ham mesajdan çıkar: .eml'i alıcıdan iste
En sık yapılan hata: imzayı atan sunucunun outbound kopyasıyla debug etmek. O kopya her zaman geçerli görünür, çünkü bozulma transit'te olur. Alıcının aldığı ham mesajı isteyin — Gmail'de "Show original → Download original", Outlook'ta mesajı .eml olarak kaydet.
Elinizde ham mesaj varken DKIM-Signature başlığının her alanını okuyun:
d= ve s= imzalayan domain ve selector — DNS sorgusunda bunları kullanacağız.
h= imzalanan başlıklar. Buradaki bir başlık transit'te değişirse Kapı 2 kırılır. from burada zorunludur (RFC 6376 §5.4).
bh= gövdenin base64 SHA-256'sı. Teşhisin merkezi bu.
b= asıl imza.
l=, t=, x= varsa: gövde uzunluğu, zaman damgası, son kullanma.
Vakayı burada donduruyoruz: bh= imzalayanın iddiasıdır. Şimdi gerçeği hesaplayacağız.
Canonicalization: simple mi relaxed mi, ve neden her şeyi değiştirir
Canonicalization, hash'lemeden önce mesajı normalize etme kuralıdır. c=simple/simple ile c=relaxed/relaxed arasındaki fark, imzanın transit'te hayatta kalıp kalmayacağını belirler.
Simple body neredeyse sıfır tolerans tanır. Sadece gövdenin sonundaki fazla boş satırları kırpar. Satır içinde tek bir boşluğun sekmeye dönmesi, iki boşluğun bire inmesi — hepsi bh='yi anında kırar. Gerçek dünyada hiçbir mail geçidi bu kadar temiz davranmaz.
Relaxed body çok daha dayanıklıdır: satır içindeki ardışık boşlukları (WSP) tek boşluğa indirir, satır sonundaki boşlukları siler, gövdenin sonundaki boş satırları atar. Yani boşluk düzeyinde oynayan ama satır yapısını koruyan sistemlere karşı biraz nefes payı bırakır.
Relaxed header ise başlık adını küçük harfe çevirir, katlanmış (folded) başlıkları tek satıra düzleştirir ve iki nokta etrafındaki boşlukları normalize eder.
Pratik tavsiye tek cümle: her zaman `relaxed/relaxed` imzalayın.simple, footer ekleyen, encoding değiştiren, başlık katlayan gerçek dünya transit'inin hiçbirinde hayatta kalmaz. OpenDKIM'de Canonicalization relaxed/relaxed, rspamd'de varsayılan zaten budur.
bh='yi elle yeniden hesapla — asıl teşhis adımı
İşin özü burada. bh='yi kendiniz üretip imzadakiyle karşılaştırdığınızda teşhis tahminden çıkıp deterministik olur.
Ham .eml'den gövdeyi ayıklayın — gövde, ilk boş satırdan sonraki her şeydir. Kritik detay: gövde CRLF (`\r\n`) satır sonlarıyla olmalı, aksi halde hash asla tutmaz. relaxed gövdeyi hazırladıktan sonra:
bash
# 1) Gövdeyi çıkar (ilk boş satırdan sonrası), satır sonlarını CRLF'e zorla
sed '1,/^\r\{0,1\}$/d' alici.eml | sed 's/\r*$/\r/' > body.raw
# 2) relaxed kuralı: satır-içi ardışık WSP -> tek boşluk, satır sonu WSP sil
# (basit hâli; tam kural için opendkim-testmsg / rspamadm tercih edin)
# 3) SHA-256 + base64 = hesaplanan bh
openssl dgst -sha256 -binary body.raw | openssl base64
Elle canonicalization hataya açık olduğu için üretimde opendkim-testmsg < alici.eml ya da rspamd'nin rspamadm aracını kullanmak daha güvenli. Ama mantık aynı:
Hesaplanan değer imzadaki bh= ile aynıysa → gövde el değmemiş. Sorun Kapı 2'de: anahtar, selector ya da h= başlıklarından biri değişmiş.
Farklıysa → gövde transit'te değişti. Suçlu aradaki bir sunucu. Received zincirine geçin.
Suçlu genelde aradaki sunucu — "the man in the middle"
Gövde değiştiyse, mesajın gönderen MTA ile alıcı arasında dokunduğu her hop şüphelidir. En sık görülen senaryolar:
1.Mailing list yöneticileri (Mailman, Google Groups): Subject'e [liste-adı] etiketi ve gövdeye unsubscribe footer'ı ekler. Hem h='deki Subject (Kapı 2) hem gövde (Kapı 1) kırılır. Çözüm liste tarafında From rewrite + ARC.
2.Kurumsal güvenlik geçitleri (Proofpoint, Mimecast, Barracuda): "EXTERNAL SENDER" banner'ı ya da tarama footer'ı enjekte eder. Gövde büyür, bh= gider.
3.Exchange transport rule / disclaimer: yasal footer ekler — klasik bh= katili.
4.Encoding yeniden kodlaması: Content-Transfer-Encoding: 8bit → quoted-printable dönüşümü gövdenin her byte'ını değiştirir. Görünüşte aynı metin, tamamen farklı hash.
5.Başlık recode: =?utf-8?Q? / =?utf-8?B? yeniden kodlaması Subject gibi imzalı başlıkları değiştirip Kapı 2'yi kırar.
Suçluyu bulma yöntemi: Received başlıklarını en üstten (en son hop) aşağı okuyun. Boyutun ya da Content-Transfer-Encoding'in hangi hop'tan sonra değiştiğini izleyin. O hop sizin adamınızdır.
l= tuzağı ve klasik başlık hataları
l= etiketi gövdenin yalnızca ilk N byte'ını imzalar. Footer ekleyen sistemlere karşı bir kaçış yolu gibi görünür — "footer imzasız kalır, bh= tutar" mantığı. Yapmayın. l= bir güvenlik açığıdır: saldırgan imzasız bölgeye içerik ekleyebilir ve modern doğrulayıcıların birçoğu l= içeren imzalara giderek daha az güvenir. Footer sorununun doğru çözümü l= değil, ya imzayı footer eklendikten sonra atmak ya da ARC'dir.
Diğer sık hatalar: From'un h= listesinde olmaması (RFC ihlali, çoğu doğrulayıcı otomatik fail verir); saat kayması yüzünden t= gelecekte ya da x= geçmişte kalması. Oversigning tekniği doğrudur — bir başlığı h='de iki kez listelemek (subject:subject), sonradan eklenen sahte kopya başlığa karşı korur — ama yanlış uygulanınca meşru mesajlarda fail üretebilir.
Kendi tarafında doğrula: selector, DNS ve anahtar
Kapı 2 koptuysa (yani bh= tutuyor ama imza tutmuyor) sıra kendi altyapınızda. Yayınlanan public key'i çekin:
`p=` boşsa anahtar iptal edilmiş (revoked) demektir — doğrulama başarısız olur.
Selector uyuşmazlığı: DKIM-Signature'daki s= ile DNS'te yayınlanan selector aynı mı? Rotasyon sonrası eski selector'la imzalayıp yeniyi yayınlamak klasik hatadır.
255-byte tuzağı: 2048-bit anahtar tek TXT string'ine sığmaz, birden fazla parçaya bölünür ("...abc" "def..."). DNS bunları birleştirir; parçaları yanlış tırnaklamak ya da araya boşluk koymak "signature did not verify" üretir.
OpenDKIM tarafında kontrol edilecek anahtarlar: Canonicalization relaxed/relaxed, SignHeaders (oversigning listesi), Selector, KeyFile. Postfix'e milter olarak bağlanır. Uçtan uca test için mesajı [email protected] benzeri bir yansıtıcıya, dkimvalidator.com'a ya da mail-tester.com'a gönderin; learndmarc.com ise akışı interaktif gösterir.
DMARC bağı: pass yetmez, alignment şart
DKIM'in pass olması DMARC için tek başına yeterli değildir. d= domaini From: başlığındaki domain ile alignment içinde olmalı. Relaxed alignment organizational domain eşleşmesi ister (mail.gonderen.com ≈ gonderen.com), strict tam eşleşme. Bu yüzden liste ya da geçit From'u yeniden yazdığında DKIM teknik olarak pass olsa bile DMARC fail olabilir — ve tam bu noktada ARC (RFC 8617) devreye girer: meşru değiştirici, orijinal doğrulama sonucunu zincirleyerek taşır, böylece alıcı DKIM kırılmış olsa da geçmişteki "pass"i güvenilir biçimde görebilir.
Teşhis checklist'i
1.Authentication-Results reason'ını oku: body hash did not verify (Kapı 1) mı, signature did not verify (Kapı 2) mi.
2.Debug'ı alıcının ham .eml'i üzerinden yap, kendi outbound kopyanla değil.
3.bh='yi openssl dgst -sha256 -binary | openssl base64 ile elle hesapla; gövdeyi CRLF'e zorla.
4.Farklıysa Received zincirini takip et; Content-Transfer-Encoding/boyut hangi hop'ta değişti, suçlu o hop.
5.c=relaxed/relaxed'e geç, l='yi tamamen kaldır, From'un h='de olduğunu doğrula.
6.dig +short s=._domainkey.d= TXT ile selector'ı ve p='nin boş olmadığını doğrula; 255-byte bölünmesini kontrol et.
7.Kaynak liste/geçit ise ARC'yi devreye al ve DMARC alignment'ı d=/From üzerinden doğrula.
Bu sırayı takip ederseniz, "anahtarı yeniledim hâlâ fail" döngüsünden çıkar ve her vakada tam olarak hangi kapının, hangi hop'ta koptuğunu byte düzeyinde kanıtlarsınız.