はじめに
メールの送信元認証技術であるSPF (Sender Policy Framework) や DKIM (DomainKeys Identified Mail) は、なりすましメール対策として非常に重要です。
しかし、実際に受信したメール(.eml ファイルなど)が正しく認証されているかを手動で確認するのは、ヘッダを読み解く必要があり少々面倒です。
古いレンタルサーバーで受信したあメールは、SPFやDKIMの検証を実施していない場合があります。
そこで今回は、Go言語を使って EMLファイルを読み込み、SPFとDKIMの検証結果を自動で判定・出力するCLIツール を作成しました。
この記事では、そのツールの紹介と実装のポイントを解説します。
作ったもの
指定された .eml ファイルを入力として受け取り、以下の処理を行うコマンドラインツール mailcheck です。
-
SPF検証: メールの
Receivedヘッダから送信元IPアドレスを抽出し、送信元ドメインのSPFレコードに基づいて認証結果(Pass/Fail/SoftFailなど)を判定します - DKIM検証: メールに含まれるDKIM署名を検証し、改ざんが行われていないか、署名が有効かを確認します
ソースコード(展開すると見えます)
package main
import (
"fmt"
"log"
"net"
"net/mail"
"os"
"regexp"
"strings"
"blitiri.com.ar/go/spf"
"github.com/emersion/go-msgauth/dkim"
)
// main はアプリケーションのエントリーポイントです。
// コマンドライン引数で指定されたEMLファイルを読み込み、SPFとDKIMの検証結果を出力します。
func main() {
if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" {
printUsage()
return
}
// 1. EMLファイルを開く
file, err := os.Open(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening file: %v\n", err)
os.Exit(1)
}
defer file.Close()
// 2. ヘッダのみを読み込む (mail.ReadMessageはヘッダを先にパースします)
msg, err := mail.ReadMessage(file)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading email header: %v\n", err)
os.Exit(1)
}
// Return-Path または From からドメインを抽出
returnPath := msg.Header.Get("Return-Path")
returnPath = strings.Trim(returnPath, "<> \t\n")
if returnPath == "" {
returnPath = msg.Header.Get("From")
}
domain := extractDomain(returnPath)
// 3. SPFチェックに必要な情報の取得
for _, v := range msg.Header["Received"] {
senderIP := extractIP(v)
if senderIP == nil {
continue
}
helo := extractHelo(v)
if helo == "" {
helo = domain
}
// 4. SPF検証の実行
// CheckHostはDNSに問い合わせてSPFレコードを検証します
result, err := spf.CheckHostWithSender(senderIP, helo, returnPath)
if err != nil {
fmt.Printf("SPF Error: %v\n", err)
log.Printf("%s %s %s", senderIP, helo, returnPath)
}
fmt.Printf("SPF IP: %s Result: %s\n", senderIP, result) // pass, fail, softfail, neutral 等
}
// 5. DKIM検証
if _, err := file.Seek(0, 0); err != nil {
fmt.Fprintf(os.Stderr, "Error seeking file: %v\n", err)
os.Exit(1)
}
verifications, err := dkim.Verify(file)
if err != nil {
// Verify自体がエラーを返す場合は致命的なフォーマットエラー等の可能性が高いが、
// 署名が見つからない場合などはここには来ず verifications が空になるかエラーが含まれる
fmt.Printf("DKIM Verify Error: %v\n", err)
}
if len(verifications) == 0 {
fmt.Println("DKIM Result: none (no signatures found)")
}
for _, v := range verifications {
if v.Err == nil {
fmt.Printf("DKIM Result: pass (domain: %s)\n", v.Domain)
} else {
fmt.Printf("DKIM Result: fail (domain: %s, error: %v)\n", v.Domain, v.Err)
}
}
}
// printUsage はツールの使用方法を標準出力に表示します。
func printUsage() {
fmt.Println("Usage: mailcheck <path/to/email.eml>")
fmt.Println("Checks SPF and DKIM authentication results for the given EML file.")
}
// extractDomain はメールアドレス文字列からドメイン部分を抽出して返します。
// 例: "user@example.com" -> "example.com"
func extractDomain(addr string) string {
if strings.Contains(addr, "@") {
parts := strings.Split(addr, "@")
domain := strings.Trim(parts[len(parts)-1], "> ")
return domain
}
return addr
}
// extractIP はReceivedヘッダの文字列から送信元のIPアドレスを抽出します。
// [] や () で囲まれたIPアドレス、あるいは単独のIPアドレスを探します。
func extractIP(received string) net.IP {
// IPv4 pattern
// Simple pattern to find IP in [] or () or just plain, prefer brackets
// Typical formats: "from ... [1.2.3.4]", "from ... (1.2.3.4)"
// Try to find IP enclosed in brackets first (most reliable for Received headers)
reBrackets := regexp.MustCompile(`\[(::ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]|\[([a-fA-F0-9:]+)\]`)
matches := reBrackets.FindStringSubmatch(received)
if len(matches) > 0 {
if matches[2] != "" {
return net.ParseIP(matches[2])
}
if matches[3] != "" {
return net.ParseIP(matches[3])
}
}
// Try parenthesis
reParens := regexp.MustCompile(`\((::ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\)|\(([a-fA-F0-9:]+)\)`)
matches = reParens.FindStringSubmatch(received)
if len(matches) > 0 {
if matches[2] != "" {
return net.ParseIP(matches[2])
}
if matches[3] != "" {
return net.ParseIP(matches[3])
}
}
// Fallback to simple IPv4 search
reV4 := regexp.MustCompile(`\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b`)
matchesV4 := reV4.FindStringSubmatch(received)
if len(matchesV4) > 1 {
return net.ParseIP(matchesV4[1])
}
return nil
}
// extractHelo はReceivedヘッダの文字列からHELO/EHLOホスト名を抽出します。
// "from" の直後にある文字列を取得します。
func extractHelo(recived string) string {
re := regexp.MustCompile(`(?i)from\s+([^\s(]+)`)
matches := re.FindStringSubmatch(recived)
if len(matches) > 1 {
return matches[1]
}
return ""
}
使い方
1. ビルド
まずはソースコードをビルドします。
go build
2. 実行
解析したいEMLファイルのパスを引数に渡して実行します。
./mailcheck sample.eml
実行結果の例
SPF IP: 209.85.220.41 Result: pass
DKIM Result: pass (domain: google.com)
このように、SPFとDKIMの検証結果がシンプルに出力されます。
実装のポイント
実装には主に以下のライブラリと標準パッケージを使用しています。
-
SPF検証:
blitiri.com.ar/go/spf -
DKIM検証:
github.com/emersion/go-msgauth/dkim -
メール解析: 標準パッケージ
net/mail
SPFチェックの仕組み
SPF検証では、「どのIPアドレスから送られてきたか」という情報が不可欠です。このツールでは、メールヘッダの Received フィールドを解析して、送信元のIPアドレスを抽出しています。
// ReceivedヘッダからIPアドレスを正規表現で抽出する部分(抜粋)
func extractIP(received string) net.IP {
// [1.2.3.4] のような形式を優先して探す
reBrackets := regexp.MustCompile(`\[(::ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]`)
// ...
}
抽出したIPアドレスと、Return-Path (または From) から得られるドメインを使って、SPFライブラリの CheckHostWithSender 関数を呼び出します。
DKIMチェックの仕組み
DKIM検証は go-msgauth/dkim ライブラリを使うことで非常にシンプルに実装できます。ファイルの読み込み位置を先頭に戻してから dkim.Verify に渡すだけです。
// ファイルポインタを先頭に戻す
file.Seek(0, 0)
// 検証実行
verifications, err := dkim.Verify(file)
for _, v := range verifications {
if v.Err == nil {
fmt.Printf("DKIM Result: pass (domain: %s)\n", v.Domain)
} else {
fmt.Printf("DKIM Result: fail (domain: %s, error: %v)\n", v.Domain, v.Err)
}
}
まとめ
Go言語のエコシステム(強力な標準ライブラリとサードパーティ製の認証ライブラリ)を活用することで、比較的少ないコード量で実用的なメール認証チェッカーを作ることができました。
メールサーバーのログ調査や、怪しいメールの解析などに役立てば幸いです。