なりすましメールを根絶する SPF (RFC 7208) / DKIM (RFC 6376) / DMARC (RFC 7489) / ARC
暗号検証パイプライン詳解
監修:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / ネットワークスペシャリスト / 登録情報処理安全確保支援士(第007617号))
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 身近なたとえで見る仕組み:「封筒の差出人(SPF)」「便箋の割印(DKIM)」「両方を突き合わせる受付検査(DMARC)」 | 「自社の社名やメールアドレスを騙った偽メールが顧客に届く」「取引先を装った振込先変更メール(BEC)が届く」被害を止めるには、SPF・DKIM・DMARC の3点セットが不可欠です。 |
| 2. 現場での進め方と対策 | 現場で陥りやすい落とし穴と回避策:企業のメール認証設定で頻発する3大トラブル | SPFは「指定された郵便局(IP)から出たか」、DKIMは「途中で本文が1文字も書き換えられていないか(電子封蝋)」、DMARCは「画面に表示される差出人名と一致しているか(Identifier Alignment)」を証明します。 |
| 3. 関連する制度とルール | なぜ電子メールは、何も対策しないと「差出人のメールアドレス」を簡単に偽装できてしまうのですか? | 当社のメール基盤(mx1〜mx3)では、RFC 8463準拠のEd25519 DKIM署名とDMARC p=reject(偽メールの全体拒否)を標準実装し、なりすましを構造的に遮断しています。 |
| 4. 詳しい技術仕様としくみ | 「SPFは設定してあるから、うちの会社のドメインは安全」ではないのですか? | インターネットのメールの仕組み(SMTP)は1980年代に設計されたため、紙のハガキと同じように「封筒の裏に書く差出人名」と「便箋の中に書く差出人名」を、送り主が好きな文字で自由に書き込める構造になっていたからです。この弱点を補うために後から作られたのが、SPF・DKIM・DMARCという3つの本人確認の仕組みです。 |
- 01 「自社の社名やメールアドレスを騙った偽メールが顧客に届く」「取引先を装った振込先変更メール(BEC)が届く」被害を止めるには、SPF・DKIM・DMARC の3点セットが不可欠です。
- 02 SPFは「指定された郵便局(IP)から出たか」、DKIMは「途中で本文が1文字も書き換えられていないか(電子封蝋)」、DMARCは「画面に表示される差出人名と一致しているか(Identifier Alignment)」を証明します。
-
03
当社のメール基盤(mx1〜mx3)では、RFC 8463準拠のEd25519 DKIM署名とDMARC
p=reject(偽メールの全体拒否)を標準実装し、なりすましを構造的に遮断しています。
なぜ「請求書メールが取引先のGmailに届かなくなった」というトラブルが起きるのか、そして自社のブランドを騙る偽メールをどう止めるのかが3分でわかります。
クラウドサービスを増やしすぎて起きる「SPF 10回DNSルックアップ制限エラー(PermError)」や、いきなり p=reject にして正規メールが止まる罠の回避手順を確認できます。
後半の「第3章 Monograph」にて、DKIM relaxed/relaxed 正規化アルゴリズム、RFC 8463 Ed25519署名、DMARCアライメント論理式、Rust検証器を詳解しています。
身近なたとえで見る仕組み:「封筒の差出人(SPF)」「便箋の割印(DKIM)」「両方を突き合わせる受付検査(DMARC)」
実は、私たちが毎日見ている電子メールには「2つの差出人欄」があります。ひとつは郵便局員(メールサーバー同士)だけがやり取りに使う「外封筒の差出人(Envelope-From / Return-Path)」、もうひとつは私たちがメールソフトを開いたときに画面に表示される「中の便箋に書かれた差出人(Header-From)」です。
悪質な詐欺師は、外封筒には自分のサーバー名を書いてSPF検査をパスしながら、中の便箋には「有名銀行や取引先の社長のアドレス」を書いて送りつけてきます。受取人は外封筒を見ることができず、中の便箋しか見えないため、本物だと信じ込んでしまいます。
そこで3つの仕組みが連携して働きます。01「SPF」は外封筒が公認の発送場所から出たかを確認し、02「DKIM」は便箋そのものに「誰にも真似できないデジタル実印(電子署名)」を押して途中の書き換えを防ぎます。そして最後の要である03「DMARC(ディーマーク)」が、「画面に表示される便箋の差出人ドメインと、SPF・DKIMで確認されたドメインが本当に一致しているか(アライメント検査)」を突き合わせ、一致しない偽メールを「受取拒否(reject)」として玄関でシャットアウトするのです。
SPFレコードの中で外部サービス(include:)を呼び出す回数が合計10回を超えると、即座に「PermError(恒久エラー)」となり認証が失敗します。
従来の長いRSA-2048鍵に加え、短く強固な楕円曲線暗号Ed25519(公開鍵わずか32バイト)を併用することで、DNS応答の肥大化と改ざんを防ぎます。
Gmail等の大手送信者ガイドラインおよび2026年SCS評価制度において、SPF・DKIM・DMARCによるドメイン保護は企業の必須インフラ要件です。
| 認証技術(RFC番号) | 何を検査するか(役割) | 単独での限界と組み合わせの必然性 |
|---|---|---|
| SPF (RFC 7208) | 外封筒(Return-Path)のドメインと送信元IPアドレスの照合 | 画面に表示される差出人(Header-From)の偽装を見抜けない/メール転送で壊れる |
| DKIM (RFC 6376 / 8463) | ヘッダと本文に対する公開鍵暗号署名(途中改ざん検知) | 転送には強いが、攻撃者が別ドメインで正当署名した偽メールを弾くにはDMARCが必要 |
| DMARC (RFC 7489) | 画面上の差出人(Header-From)とSPF/DKIMドメインの一致確認 | p=quarantine または p=reject まで引き上げて初めて偽メールを自動遮断できる |
| ARC (RFC 8617) | メーリングリストや転送中継時の「元の認証結果」の引き継ぎ署名 | 学会・業界団体・社内転送環境で正規メールが不達になるのを防ぐ |
現場で陥りやすい落とし穴と回避策:企業のメール認証設定で頻発する3大トラブル
include: で追加し、「SPF 10回検索制限」を超えて全メールが不達になる
回避策:部門・用途ごとのサブドメイン分離(例:billing.example.jp)と不要な旧サービス記述の棚卸し
RFC 7208 Section 4.6.4 には、DNS増幅攻撃を防ぐため「SPFの検証中に発生するDNS検索(include, a, mx, redirect)は合計10回まで」という厳格な上限があります。1つの外部クラウドサービスを include: すると、そのサービスの中でさらに3〜4回の検索が連鎖することが多く、3〜4社のサービスを繋いだだけであっという間に10回を超えて PermError(全員への送信失敗)になります。これを防ぐ最も確実な方法は、社内連絡用・請求書システム用・広報配信用でサブドメインを分けるか、自動フラット化監視を導入することです。
p=none(監視のみ)で登録しただけで安心し、何年も放置して偽メールが素通りし続ける
回避策:集計レポート(rua=)で社内の正規送信元をすべて把握した上で、p=quarantine → p=reject へ計画的に引き上げる
「DMARCを設定しました」という企業のうち、半数以上が v=DMARC1; p=none; のまま止まっています。p=none は「もし偽メールが見つかっても、何もしないでそのまま相手の受信箱へ届けてください」という意味であり、それ自体には防御力がありません。まずは rua=mailto:... で毎日のXMLレポートを受け取り、社内の複合機やWebフォームなどすべての正規メールにDKIM署名が付いていることを確認したら、速やかに p=quarantine(隔離)、そして最終目標である p=reject(拒否)へ移行することが大切です。
c=simple/simple(非許容正規化モード)を使ってしまい、中継サーバーが空白や改行を1文字整えただけで署名エラーになる
回避策:空白や大文字小文字の揺れを吸収する c=relaxed/relaxed 正規化と、必須ヘッダ(From, Date, Subject, Message-ID)の署名固定
インターネット上のメール中継サーバー(MTA)は、配送の途中でヘッダの大文字・小文字を変えたり、行末の余分なスペースを削除したりすることがあります。DKIMの設定で simple モードを選んでいると、この無害な整形だけで「本文が改ざんされた!」と誤判定されてメールが届かなくなります。実運用では必ず c=relaxed/relaxed を指定し、意味を変えない空白の揺れを正規化してからハッシュ値を計算する設計にします。
今日から確認できる!なりすましメール・ビジネスメール詐欺(BEC)対策 3項目チェック
達成度: 0 / 3クリックしてチェックを入れると、自社のメール信頼性と詐欺対策レベルに応じたアドバイスが表示されます。
SPF / DKIM / DMARC / BIMI DNSレコード検証コマンドリファレンス
外封筒(RFC5321.MailFrom)と便箋(RFC5322.From)のドメイン一致(Identifier Alignment)およびDNSルックアップ上限(10回以下)をターミナルから直接検証する手順です。
| プロトコル | DNS検証コマンド | 合格基準(RFC準拠条件) |
|---|---|---|
| SPF (RFC 7208) | dig +short TXT ok.st | grep 'v=spf1' | DNS参照回数が合計10回以内であり、末尾が -all(HardFail)で閉じられていること |
| DKIM (RFC 6376 / 8463) | dig +short TXT selector._domainkey.ok.st | k=ed25519 または 2048bit以上のRSA公開鍵が登録され、d= ドメインがFromと整合すること |
| DMARC (RFC 7489) | dig +short TXT _dmarc.ok.st | v=DMARC1; p=reject; adkim=s; aspf=s; により未承認メールを検疫・拒絶し、RUA集計先が有効なこと |
SPFレコードの末尾にある「~all(チルダ・オール)」と「-all(ハイフン・オール)」の1文字の違いとは?
会社のDNS設定(TXTレコード)を見ると、SPFの最後によく ~all(SoftFail:怪しいけれど一応通す)または -all(Fail:リスト以外のサーバーから来たら偽物と断定する)と書かれています。決して使ってはいけないのが +all(世界中の誰が送っても本物として許可する)や ?all(何もしない)です。これらを設定してしまうと、世界中のスパム業者に「うちの会社の名前を使って自由にメールを送っていいですよ」と宣言したのと同じになってしまいます。DKIMとDMARCを揃えた上で、~all または -all でしっかりと鍵を閉めることが大切です。
DKIM relaxed/relaxed 正規化(RFC 6376)・Ed25519 署名(RFC 8463)と DMARC 識別子アライメント検証器の Rust 2024 実装
RFC 7489 Section 3.1 における DMARC(Domain-based Message Authentication, Reporting, and Conformance)の通過条件は、単なる SPF または DKIM の単独成功ではなく、RFC 5322 From: ヘッダから抽出されたドメイン D_From と、検証を通過した認証識別子(SPF の RFC5321.MailFrom / HELO ドメイン D_SPF、または DKIM DKIM-Signature の d= タグドメイン D_DKIM)との間の識別子アライメント述語(Identifier Alignment Predicate)として定式化されます:
また RFC 6376 Section 3.4.2 および Section 3.4.4 で定義される DKIM c=relaxed/relaxed 正規化関数 C_hdr, C_body は、MTA 中継時の軽微な空白改変に対するべき等性(C(C(x)) = C(x))を保証します。ヘッダ正規化 C_hdr は、(1) フィールド名をすべて ASCII 小文字へ変換、(2) 折り返し改行(CRLF + WSP)を単一スペース(0x20)へ展開、(3) 1つ以上の連続する空白文字(SP / HTAB)を単一スペースへ圧縮、(4) コロン前後の空白と行末空白を除去します。続いて RFC 8463(a=ed25519-sha256)では、正規化された本文の SHA-256 ダイジェストを bh= タグに格納し、選択されたヘッダ群と末端の dkim-signature ヘッダ(b= 値を空にしたもの)の SHA-256 ダイジェスト H_hdr に対して PureEdDSA(Ed25519)署名を検証します。
| Protocol / RFC | DNS / Header Tag | Production Value (ok.st) | Cryptographic / Protocol Guarantee |
|---|---|---|---|
| SPF (RFC 7208) | v=spf1 ip4:... -all | ≤ 3 Lookups, HardFail (-all) | Eliminates PermError & unauthorized MTA spoofing |
| DKIM Key (RFC 8463) | v=DKIM1; k=ed25519; p=... | 32-Byte Ed25519 Public Key | 128-bit security level, fits in single UDP DNS packet |
| DKIM-Signature | c=relaxed/relaxed; h=from:to:subject:date:message-id:from | Oversigned From Header | Prevents duplicate From header injection attacks |
| DMARC (RFC 7489) | v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s | Strict Alignment + Reject | Drops all unaligned Header-From spoofing attempts |
| ARC (RFC 8617) | ARC-Authentication-Results / ARC-Message-Signature / ARC-Seal | Instance i=1..50 Chain | Preserves pre-forwarding authentication across ML hops |
#![no_std]
#![forbid(unsafe_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlignmentMode {
Relaxed,
Strict,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DmarcPolicy {
None,
Quarantine,
Reject,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DmarcDisposition {
AcceptAuthenticated { spf_aligned: bool, dkim_aligned: bool },
RejectOrQuarantine(DmarcPolicy),
SpfLookupLimitExceeded(u8),
}
/// Case-insensitive ASCII byte comparison for domain labels (RFC 4343).
#[inline]
pub const fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
let ca = if a[i] >= b'A' && a[i] <= b'Z' { a[i] + 32 } else { a[i] };
let cb = if b[i] >= b'A' && b[i] <= b'Z' { b[i] + 32 } else { b[i] };
if ca != cb {
return false;
}
i += 1;
}
true
}
/// Evaluates RFC 7489 Section 3.1 Identifier Alignment between `RFC5322.From` domain
/// and an authenticated SPF or DKIM (`d=`) domain.
pub const fn check_domain_alignment(
header_from_domain: &[u8],
auth_domain: &[u8],
mode: AlignmentMode,
) -> bool {
if ascii_eq_ignore_case(header_from_domain, auth_domain) {
return true;
}
match mode {
AlignmentMode::Strict => false,
AlignmentMode::Relaxed => {
// Relaxed alignment allows subdomain relationship if Organizational Domain matches
if auth_domain.len() > header_from_domain.len() {
let prefix_len = auth_domain.len() - header_from_domain.len();
if auth_domain[prefix_len - 1] == b'.' {
let (_, tail) = auth_domain.split_at(prefix_len);
return ascii_eq_ignore_case(header_from_domain, tail);
}
}
false
}
}
}
/// Evaluates full DMARC disposition given SPF lookup count (RFC 7208 max 10),
/// SPF verification result, DKIM verification result, and alignment modes.
pub const fn evaluate_dmarc_pipeline(
header_from_domain: &[u8],
spf_mailfrom_domain: &[u8],
spf_passed: bool,
spf_dns_lookups: u8,
dkim_d_domain: &[u8],
dkim_signature_valid: bool,
aspf: AlignmentMode,
adkim: AlignmentMode,
policy: DmarcPolicy,
) -> DmarcDisposition {
if spf_dns_lookups > 10 {
return DmarcDisposition::SpfLookupLimitExceeded(spf_dns_lookups);
}
let spf_aligned = spf_passed
&& check_domain_alignment(header_from_domain, spf_mailfrom_domain, aspf);
let dkim_aligned = dkim_signature_valid
&& check_domain_alignment(header_from_domain, dkim_d_domain, adkim);
if spf_aligned || dkim_aligned {
DmarcDisposition::AcceptAuthenticated {
spf_aligned,
dkim_aligned,
}
} else {
DmarcDisposition::RejectOrQuarantine(policy)
}
}
/// Implements RFC 6376 Section 3.4.2 DKIM `relaxed` header canonicalization into `out_buf`.
/// Converts header field name to lowercase, unfolds CRLF+WSP, and compresses WSP runs to single 0x20.
pub fn canonicalize_dkim_header_relaxed(raw_header: &[u8], out_buf: &mut [u8]) -> Option<usize> {
let mut colon_pos: Option<usize> = None;
for (i, &b) in raw_header.iter().enumerate() {
if b == b':' {
colon_pos = Some(i);
break;
}
}
let c_idx = colon_pos?;
let mut w = 0usize;
// 1. Lowercase field name, stripping any WSP before colon
for &b in &raw_header[..c_idx] {
if b == b' ' || b == b'\t' {
continue;
}
if w >= out_buf.len() {
return None;
}
out_buf[w] = if b >= b'A' && b <= b'Z' { b + 32 } else { b };
w += 1;
}
if w >= out_buf.len() {
return None;
}
out_buf[w] = b':';
w += 1;
// 2. Skip leading WSP after colon and collapse internal WSP sequences
let mut in_wsp = false;
let mut started = false;
for &b in &raw_header[(c_idx + 1)..] {
if b == b'\r' || b == b'\n' {
continue;
}
if b == b' ' || b == b'\t' {
if started {
in_wsp = true;
}
} else {
if in_wsp {
if w >= out_buf.len() {
return None;
}
out_buf[w] = b' ';
w += 1;
in_wsp = false;
}
if w >= out_buf.len() {
return None;
}
out_buf[w] = b;
w += 1;
started = true;
}
}
Some(w)
}