はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

弊社ではお客様のメール環境の管理をお任せいただく案件も多く、送信ドメイン認証として必ず DKIM (DomainKeys Identified Mail) を設定するようにしています。

Amazon SES や SendGrid などのモダンなメール送信サービスでは、その仕組みを知らなくてもガイダンスにしたがってセットアップするだけで簡単に DKIM を有効化できます。
このように DKIM を利用しやすい状況が整っているのはとてもよいことなのですが、詳細を知らずにただ使うのではインフラを扱う者の心構えとしては不充分です。

そこで本記事では、DKIM がどのような仕組みで動いているのか理解するために、手動での DKIM のセットアップと署名付与にチャレンジしてみました。

DKIM の大まかな仕様

一般財団法人インターネット協会・迷惑メール対策委員会のサイトが詳しいです。

https://salt.iajapan.org/wpmu/anti_spam/admin/tech/explanation/dkim/

大体の仕様把握はこのサイトを見れば OK なのですが、ポイントを 3行でまとめると次のとおりです。

  • 送信側は、メールのヘッダと本文に基づいて秘密鍵で署名する。
  • 署名は DKIM-Signatureヘッダ・フィールドに格納されて、送信するメールのヘッダに追加される。
  • 受信側は、送信側があらかじめ DNS に登録しておいた公開鍵を取得して、署名を検証する。

手動で署名する

普段はメール送信サービスにお任せの署名処理ですが、DKIM に対する理解を深めるために DKIM-Signatureヘッダ・フィールドを手動で作成してみます。

ここでは具体例として、以下のメッセージに対して、最新の RFC である RFC 6376 を参考にしながら 1ステップずつ処理結果を確認しながら進めていきます。

$ cat -t message.eml
From: Yumeko <gondawara_yumeko@example.com>^M
To: Joe <joe@example.net>^M
Subject:     Gon gon     gon   ^M
    dawara    dawa^I^Ira   ^M
^I  gon dawara  ^I^M
Date: Wed, 7 Apr 2021 10:43:47 +0900^M
Message-ID: <CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
Gondawara: yumeko^M
^M
gooooooooooo.  ^M
   ^Innnn^M
    da    wa    ra     !   ^I^M
^M
yumeko.   ^I  ^M
^M
^I^M

^I はタブ、 ^M はキャリッジ・リターンを表しています。
(署名の一手順として空白やタブを除去する正規化処理を行うため、敢えてグチャグチャしたメッセージにしてみました。)

前準備

署名の前準備として、DKIM-Signatureヘッダ・フィールドに含めるタグの選択と、DNS への公開鍵の登録を済ませておきます。

タグの選択

DKIM-Signatureヘッダ・フィールドは、k=v という形式の複数の タグ から構成されます。

本記事では当該フィールドの構成要素として以下のタグを選択しました。

ドメインとセレクタ

ドメイン (= dタグ) は tech.quickguard.jp、セレクタ (= sタグ) は gondawara-yumeko とします。

つまり、gondawara-yumeko._domainkey.tech.quickguard.jp. という DNSレコードで鍵を公開するということです。
(DNSレコードの具体的な中身は後続のセクションで論じます。)

署名アルゴリズム

署名アルゴリズム (= aタグ) は RSA-SHA1 または RSA-SHA256 から選べますが、もちろん RSA-SHA256 で行きます。

正規化アルゴリズム

メールが目的地まで配送されるまでに、中継MTA やセキュリティ装置などがメールの中身を書き換える (= ヘッダ・フィールドの付与、置換など) ことがありますが、中身が書き換わると署名とデータが一致しなくなり検証に失敗してしまいます。

これを防ぐため、ある程度ならば中身が書き換わっても耐えられる (= 署名の正当性が失われない) ように、署名の前段階で 正規化処理 を実施することを定めています。

ヘッダと本文それぞれに異なる正規化アルゴリズム (= cタグ) を選択できますが、本記事ではともに relaxedアリゴリズムを適用してみます。

署名に用いるヘッダ・フィールド

署名対象とするヘッダ・フィールド を選択的に指定できます。

Fromフィールドは必ず対象に含める必要がありますが、それ以外は署名者の任意です。

メッセージの核となるフィールド (= そのフィールドが書き換わると、メッセージとして別物になってしまうもの) を選びます。
配送中に変更されたり追加される可能性が高いフィールドは除外するのが無難でしょう。

今回は RFC で推奨されているフィールド を加味しつつ、独断と偏見で以下のフィールドを選択しました。

  • Cc
  • Content-Transfer-Encoding
  • Content-Type
  • Date
  • From
  • In-Reply-To
  • List-Archive
  • List-Help
  • List-Id
  • List-Owner
  • List-Post
  • List-Subscribe
  • List-Unsubscribe
  • Message-ID
  • MIME-Version
  • References
  • Reply-To
  • Resent-Cc
  • Resent-Date
  • Resent-From
  • Resent-To
  • Subject
  • To

この中から、メッセージ内に出現したフィールドをコロン (= :) 区切りで hタグに列挙します。

なお、RFC いわく、特に以下のフィールドは配送中に変更されたり同名のフィールドが追加されたりするので、hタグへの列挙は避けたほうがよさそうです。

  • Return-Path
  • Received
  • Comments
  • Keywords

また、メッセージ内に同名のフィールドが複数出現した場合の扱い が定義されていますが、実装がやや面倒なので今回はそのようなケースを考えないことにします。

タイムスタンプ

署名のタイムスタンプ (= tタグ) には署名処理時点の現在時刻を用います。

生成されるヘッダ

これらのタグに、以下の必須タグを追加して DKIM-Signatureヘッダ・フィールドを組み立てます。

タグ 説明
v バージョン指定
bh 本文のハッシュ
b メール全体の署名

フィールド全体:

DKIM-Signature: v=1; a=rsa-sha256;
  bh=...;
  c=relaxed/relaxed; d=tech.quickguard.jp;
  h=from:to:...;
  s=gondawara-yumeko; t=1617760375;
  b=...

DNSレコード

続いて DNSレコードを登録していきます。

まずは鍵ペアを作ります。
(RFC の Appendix に手順が例示されています。)

秘密鍵の長さは 2,048 bit がよいでしょう。
それ以上のサイズだと DNS の UDPパケット (512 byte) に 収まりません

$ openssl genrsa -out ./rsa.private 2048

秘密鍵から公開鍵を生成します。

$ openssl rsa -in ./rsa.private -out ./rsa.public -pubout -outform PEM

$ cat ./rsa.public
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsQj1FgHhVuE9nEjmvWdU
iyU5JChq8WdN/dVAyS/41mGLWVrWUKkrZuEOe/JTZOp0Qdf83tvJsVuQM2dlWHVw
AUpoyT7H7k3v12k/OTOE19FyXweBSRgSyiCT3H8vmxCM0etDOhUGYQ6LzUakYaW1
yirYJp7Bj2bycMuNPObSQ2GMHQdQVZKY+P7QV3Zg39352iyiDaFN1Ka7V4gX0/dV
Pjp1qXWylIGrxK7hjycETFXW/F3c/5HDMG53mSohH/f74tS3506hzskyY3gfOpz3
DRGBNVsx4dCKwToBdVDMLOnRLLLyiw0div0CZJ8up1Uu/b1ktEKjXDeOV9yeQzPu
KwIDAQAB
-----END PUBLIC KEY-----

この公開鍵を DNSレコード gondawara-yumeko._domainkey.tech.quickguard.jp. に登録します。

値に含めるタグは、リスト の中からとりあえず最低限のものだけ選びました。

タグ 説明
v=DKIM1 DKIM のバージョン。
記述する場合は、必ず先頭に配置する必要がある。
t=y 試験運用中であることを示す。
(= 受信者は、検証に失敗した場合には未署名のメールと同列に扱う。)
p=MIIBIj... 公開鍵。

レコード:

gondawara-yumeko._domainkey.tech.quickguard.jp.  IN  TXT  ( "v=DKIM1; t=y; "
                                                            "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsQj1FgHhVuE9nEjmvWdU"
                                                            "iyU5JChq8WdN/dVAyS/41mGLWVrWUKkrZuEOe/JTZOp0Qdf83tvJsVuQM2dlWHVw"
                                                            "AUpoyT7H7k3v12k/OTOE19FyXweBSRgSyiCT3H8vmxCM0etDOhUGYQ6LzUakYaW1"
                                                            "yirYJp7Bj2bycMuNPObSQ2GMHQdQVZKY+P7QV3Zg39352iyiDaFN1Ka7V4gX0/dV"
                                                            "Pjp1qXWylIGrxK7hjycETFXW/F3c/5HDMG53mSohH/f74tS3506hzskyY3gfOpz3"
                                                            "DRGBNVsx4dCKwToBdVDMLOnRLLLyiw0div0CZJ8up1Uu/b1ktEKjXDeOV9yeQzPu"
                                                            "KwIDAQAB" )

署名

以上で署名の準備が整いました。

RFC を読み解きながら、1ステップずつ進めていきます。

ヘッダの正規化

まず、ヘッダを正規化します。

今回選択した relaxedアルゴリズムの詳細は セクション 3.4.2. に記載されています。

  1. 署名に用いるヘッダ・フィールド に該当しないフィールドを除去する。
  2. フィールド名を小文字に変換する。
    例: SUBJect: AbC » subject: AbC
  3. 値が複数行にまたがっているフィールドを 1行に畳む。
    単純に CRLF を削除すればよい。(RFC 5322)
  4. 連続する WSP を単一の SP に畳む。
    「WSP」とは、セクション 2.8. で定義されているとおり SP (= 空白 \x20) と HTAB (= タブ \x09) のこと。
  5. 行末の WSP を除去する。
  6. フィールド名と値を区切る : の両端の WSP を除去する。

Before:

From: Yumeko <gondawara_yumeko@example.com>^M
To: Joe <joe@example.net>^M
Subject:     Gon gon     gon   ^M
    dawara    dawa^I^Ira   ^M
^I  gon dawara  ^I^M
Date: Wed, 7 Apr 2021 10:43:47 +0900^M
Message-ID: <CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
Gondawara: yumeko^M

After:

from:Yumeko <gondawara_yumeko@example.com>^M
to:Joe <joe@example.net>^M
subject:Gon gon gon dawara dawa ra gon dawara^M
date:Wed, 7 Apr 2021 10:43:47 +0900^M
message-id:<CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M

本文の正規化

続いて、本文も セクション 3.4.4. の指示どおりに正規化します。

  1. 各行の末尾の whitespace (原文ママ) を削除する。
    (whitespace は WSP のことなのか、それとも限定的に SP のみを指しているのかよくわからないが、他の箇所に合わせて WSP と解釈した。)
  2. 連続する WSP を単一の SP に畳む。
  3. 本文末に並ぶ空行を除去する。
    「空行」の定義は セクション 3.4.3. に記載されており、CRLF を除去したあとの文字列の長さが 0 の行のこと。
  4. 本文が空ではなく、かつ、CRLF で終わっていない場合は、CRLF を付与する。
    なお、原文からは読み取りづらいが、本文が空の場合は CRLF を付与せず空文字列のままとする。

Before:

gooooooooooo.  ^M
   ^Innnn^M
    da    wa    ra     !   ^I^M
^M
yumeko.   ^I  ^M
^M
^I^M

After:

gooooooooooo.^M
 nnnn^M
 da wa ra !^M
^M
yumeko.^M

bhタグの計算

本文をハッシュして DKIM-Signature: bh=... にセットします。

ハッシュ方法は セクション 3.7. の Step 1 に記載されています。

  1. 正規化した本文を lタグで指定された長さに切り詰める。
    ただし、今回は lタグを使わないので、本文全体をハッシュする。
  2. aタグで指定したアルゴリズムを用いてハッシュする。
  3. Base64エンコードする。
$ cat -t canonicalized-body.txt
gooooooooooo.^M
 nnnn^M
 da wa ra !^M
^M
yumeko.^M

$ cat canonicalized-body.txt | openssl dgst -sha256 -binary | base64
ZGyhDqAkwAxoSrjjkuIlRjYPeZhasQzT3eoel+0+FsA=

hタグの列挙

署名に用いるヘッダ・フィールドをカンマ (= :) 区切りで列挙し、DKIM-Signature: h=... にセットします。

セクション 3.5.hタグの欄に記載してあるとおり、以下の点に注意します。

  • 署名処理に渡す順序で列挙する。
  • メッセージに含まれないフィールドを含めてもよい。
  • 大文字、小文字を区別しない。
    なので、今回は小文字で列挙する。
$ cat -t canonicalized-header.txt
from:Yumeko <gondawara_yumeko@example.com>^M
to:Joe <joe@example.net>^M
subject:Gon gon gon dawara dawa ra gon dawara^M
date:Wed, 7 Apr 2021 10:43:47 +0900^M
message-id:<CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M

$ cat canonicalized-header.txt | cut -d ':' -f 1 | xargs | sed 's/ /:/g'
from:to:subject:date​:message-id

bタグの計算

対象のヘッダに署名して DKIM-Signature: b=... にセットします。

セクション 3.7. の Step 2 に記載のとおりに以下 2種類のヘッダ・フィールドを CRLF で連結し、aタグで指定したアルゴリズムにより署名します。

  1. 正規化したフィールド群。
    hタグに列挙した順序で並べる。
  2. 組み立て途中の DKIM-Signatureフィールドを正規化したもの。
    bタグの値は空 (= DKIM-Signature: ...; b=; ...) とする。

最後の行 (= dkim-signature:...) の末尾には CRLF を付与しないので注意しましょう。

この署名データを Base64エンコードして bタグの値とします。

$ cat -t data
from:Yumeko <gondawara_yumeko@example.com>^M
to:Joe <joe@example.net>^M
subject:Gon gon gon dawara dawa ra gon dawara^M
date:Wed, 7 Apr 2021 10:43:47 +0900^M
message-id:<CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
dkim-signature:v=1; a=rsa-sha256; bh=ZGyhDqAkwAxoSrjjkuIlRjYPeZhasQzT3eoel+0+FsA=; c=relaxed/relaxed; d=tech.quickguard.jp; h=from:to:subject:date​:message-id; s=gondawara-yumeko; t=1617760375; b=

$ cat data | openssl dgst -sha256 -sign ./rsa.private | base64 -b 60
pfxzhEKtBLJZmOPdj8xFv+iB8I74CYftivYIf1vf5zU9DeRV3z+GIXWBMlyp
l6qj34NzwQjEAVEQC861WnpsdYhmnyZyymZiHC20YsU5gAE6FAaASgVnJVNR
mX3bVZNQ4SYiXftIRTgVyNG++BQlaAeswgfbeyaqL1/62v98SkhznBUwOalT
LrjHpBx5f8y+CPlkoAiHcKlxmBjT3xq8/ICxIqGAVSpqT4PyaqTwvwwJhv4S
2gAPrP2ZWZDe5M1Dco3qhD3ysOp2BvjI+PViGLIUcXvln2azxhpOtgKM6Fwo
UcWkx3tHJ2vH5/3217DccYnMzUIeI4zneQU738tSSg==

以上で DKIM-Signatureフィールドの組み立ては完了です。

DKIM-Signatureヘッダ・フィールドの付与

最後に、出来上がった DKIM-Signatureフィールドを元のメッセージに追加します。

セクション 5.6. で述べられているように、他の DKIM-Signatureフィールドが存在する場合、新たに付与するフィールドはそれらよりも前に位置しないといけません。
推奨どおり、メッセージの先頭に機械的に挿入するのがよさそうです。

DKIM-Signature: v=1; a=rsa-sha256;^M
 bh=ZGyhDqAkwAxoSrjjkuIlRjYPeZhasQzT3eoel+0+FsA=;^M
 c=relaxed/relaxed; d=tech.quickguard.jp;^M
 h=from:to:subject:date​:message-id;^M
 s=gondawara-yumeko; t=1617760375;^M
 b=pfxzhEKtBLJZmOPdj8xFv+iB8I74CYftivYIf1vf5zU9DeRV3z+GIXWBMlyp^M
   l6qj34NzwQjEAVEQC861WnpsdYhmnyZyymZiHC20YsU5gAE6FAaASgVnJVNR^M
   mX3bVZNQ4SYiXftIRTgVyNG++BQlaAeswgfbeyaqL1/62v98SkhznBUwOalT^M
   LrjHpBx5f8y+CPlkoAiHcKlxmBjT3xq8/ICxIqGAVSpqT4PyaqTwvwwJhv4S^M
   2gAPrP2ZWZDe5M1Dco3qhD3ysOp2BvjI+PViGLIUcXvln2azxhpOtgKM6Fwo^M
   UcWkx3tHJ2vH5/3217DccYnMzUIeI4zneQU738tSSg==^M
From: Yumeko <gondawara_yumeko@example.com>^M
To: Joe <joe@example.net>^M
Subject:     Gon gon     gon   ^M
    dawara    dawa^I^Ira   ^M
^I  gon dawara  ^I^M
Date: Wed, 7 Apr 2021 10:43:47 +0900^M
Message-ID: <CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
Gondawara: yumeko^M
^M
gooooooooooo.  ^M
   ^Innnn^M
    da    wa    ra     !   ^I^M
^M
yumeko.   ^I  ^M
^M
^I^M

送信してみる

このメッセージを適当なアドレスに送信して、正しく署名できているか確かめてみます。

試しに Gmail へ送信してみたところ、無事に署名の検証にパスできていることが確認できました。

Authentication-Resultsヘッダ・フィールドからも同様の結果 (= dkim=pass) が読み取れます。
((test mode) と付記されているのは、DNSレコードに t=yタグを含めているためでしょうか。)

Authentication-Results: mx.google.com;
       dkim=pass (test mode) header.i=@tech.quickguard.jp header.s=gondawara-yumeko header.b=pfxzhEKt;
       spf=neutral (google.com: 203.137.183.115 is neither permitted nor denied by best guess record for domain of root@e2vm-0e4f2281-0dad-45d4-8c9e-60529b2d116b.localdomain) smtp.mailfrom=root@e2vm-0e4f2281-0dad-45d4-8c9e-60529b2d116b.localdomain

ちなみに検証結果の掲示について、RFC では Authentication-Resultsフィールドへの格納を ゆるく推奨している ようですが、絶対的な規定ではないようです。

Appendix

参考までに、Ruby による実装といくつかのメッセージに対する署名例をご紹介します。

Ruby による実装

Ruby 3.0.0p0 で実装しました。

sign.rb
#
# Usage: ruby ./sign.rb {{ emlファイル }} {{ 秘密鍵 }}
#

require 'base64'
require 'openssl'

DKIM_SIGNATURE_HEADER = 'DKIM-Signature'

CRLF= "\r\n"

#
# `WSP` の定義
# 
# https://tools.ietf.org/html/rfc6376#section-2.8
# 
# > WSP represents simple whitespace, i.e., a space or a tab character
# > (formal definition in [RFC5234]).
#
WSP = '[ \t]'  

#
# 署名対象のヘッダ・フィールド
# 
# RFC の推奨を参考に選定した。
# 
# https://tools.ietf.org/html/rfc6376#section-5.4.1
#
SIGNED_FIELDS = [
  'Cc',
  'Content-Transfer-Encoding',
  'Content-Type',
  'Date',
  'From',
  'In-Reply-To',
  'List-Archive',
  'List-Help',
  'List-Id',
  'List-Owner',
  'List-Post',
  'List-Subscribe',
  'List-Unsubscribe',
  'Message-ID',
  'MIME-Version',
  'References',
  'Reply-To',
  'Resent-Cc',
  'Resent-Date',
  'Resent-From',
  'Resent-To',
  'Subject',
  'To'
].map(&:downcase)

#
# 署名対象の emlファイルを読み込む。
#
message = File.read(ARGV[0])

#
# 秘密鍵を読み込む。
#
rsa_key = OpenSSL::PKey::RSA.new(
  File.read(ARGV[1])
)

#
# メッセージをヘッダと本文に分割する。
#
headers, body = message.partition(/#{CRLF}#{CRLF}/).then do |header_block, _separator, body_block|
  [
    header_block.split(/#{CRLF}(?!#{WSP})/).map do |h|
      h.partition(':').values_at(0, 2)
    end,
    body_block
  ]
end

#
# ヘッダを relaxedアルゴリズムで正規化する。
# 
# https://tools.ietf.org/html/rfc6376#section-3.4.2
#
relaxed_headers = headers.map do |k, v|
  #
  # フィールド名を正規化する。
  #
  canonicalized_key = k.downcase  # > Convert all header field names (not the header field values) to
                                  # > lowercase.  For example, convert "SUBJect: AbC" to "subject: AbC".
                       .gsub(/#{WSP}+\z/, '')  # > Delete any WSP characters remaining before and after the colon
                                               # > separating the header field name from the header field value.

  #
  # 署名対象のフィールドでなければスキップする。
  #
  next nil unless SIGNED_FIELDS.include?(canonicalized_key)

  #
  # 値を正規化する。
  #
  canonicalized_value = v.gsub(/#{CRLF}(?=#{WSP})/, '')  # > Unfold all header field continuation lines as described in
                                                         # > [RFC5322];
                         .gsub(/#{WSP}+/, ' ')  # > Convert all sequences of one or more WSP characters to a single SP
                                                # > character.
                         .gsub(/#{WSP}+\z/, '')  # > Delete all WSP characters at the end of each unfolded header field
                                                 # > value.
                         .gsub(/\A#{WSP}+/, '')  # > Delete any WSP characters remaining before and after the colon
                                                 # > separating the header field name from the header field value.

  [
    canonicalized_key,
    canonicalized_value + CRLF  # > Implementations MUST
                                # > NOT remove the CRLF at the end of the header field value.
  ]
end.compact

#
# 本文を relaxedアルゴリズムで正規化する。
# 
# https://tools.ietf.org/html/rfc6376#section-3.4.4
#
relaxed_body = body.gsub(/#{WSP}+(?=#{CRLF})/, '')  # > Ignore all whitespace at the end of lines.
                   .gsub(/#{WSP}+/, ' ')  # > Reduce all sequences of WSP within a line to a single SP
                                          # > character.
                   .gsub(/(#{CRLF})+\z/, '')  # > Ignore all empty lines at the end of the message body.
                   .then do |b|
                     if b.empty?
                       ''
                     else
                       b + CRLF  # > If the body is non-empty but
                                 # > does not end with a CRLF, a CRLF is added.
                     end
                   end

#
# 署名前の `DKIM-Signature`ヘッダ・フィールドを用意する。
# 
# https://tools.ietf.org/html/rfc6376#section-3.5
#
dkim_header_tags = [
  [:v,  '1'],
  [:a,  'rsa-sha256'],
  [:bh, Base64.strict_encode64(                           # 本文をハッシュして Base64エンコードする。
          OpenSSL::Digest.digest('sha256', relaxed_body)  # https://tools.ietf.org/html/rfc6376#section-3.7
        )],                                               # > ... the Signer/Verifier MUST hash the message body,
                                                          # > canonicalized using the body canonicalization algorithm ...
                                                          # > That hash value is then converted to base64 form and inserted
                                                          # > into (Signers) or compared to (Verifiers) the "bh=" tag of the DKIM-
                                                          # > Signature header field.
  [:c,  'relaxed/relaxed'],
  [:d,  'tech.quickguard.jp'],
  [:h,  relaxed_headers.map(&:first).join(':')],  # 署名対象のヘッダ・フィールド名を `:` 区切りで列挙する。
  [:s,  'gondawara-yumeko'],
  [:t,  Time.now.to_i],
  [:b,  '']  # 署名時の bタグの値は空文字とする。
             # https://tools.ietf.org/html/rfc6376#section-3.7
             # > All tags and their values in the DKIM-Signature header field are
             # > included in the cryptographic hash with the sole exception of the
             # > value portion of the "b=" (signature) tag, which MUST be treated as
             # > the null string.
]

#
# 対象ヘッダに署名して Base64エンコードし、bタグにセットする。
# 
# https://tools.ietf.org/html/rfc6376#section-3.7
#
dkim_header_tags[-1][1] = Base64.strict_encode64(
  rsa_key.sign(
    'sha256',
    relaxed_headers.map{|x| x.join(':')}.join + (         # > ... the Signer/Verifier MUST pass the following to the
      '%s:%s' % [                                         # > hash algorithm in the indicated order.
        DKIM_SIGNATURE_HEADER.downcase,                   # > 1.  The header fields specified by the "h=" tag, ...
        dkim_header_tags.map{|x| x.join('=')}.join('; ')  # > 2.  The DKIM-Signature header field that exists (verifying) or will
      ]                                                   # >     be inserted (signing) in the message, ...
    )                                                     # >     ... and without
                                                          # >     a trailing CRLF.
  )
)

# 
# 元のメッセージの先頭に挿入する。
# 
# https://tools.ietf.org/html/rfc6376#section-5.6
# 
# > The DKIM-Signature header field MUST be inserted before any other
# > DKIM-Signature fields in the header block.
#
dkimed_message = (
  '%s: %s' % [
    DKIM_SIGNATURE_HEADER,
    dkim_header_tags.map{|x| x.join('=')}.join('; ')
  ]
) + CRLF + message

#
# 署名済みメッセージの完成!
#
puts(dkimed_message)

他の署名例

本文なし

本文が空のメッセージ。

DKIM-Signature: v=1; a=rsa-sha256;^M
 bh=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=;^M
 c=relaxed/relaxed; d=tech.quickguard.jp;^M
 h=from:to:subject:date​:message-id;^M
 s=gondawara-yumeko; t=1617760375;^M
 b=LYT6yQz1cvtft8AYiunFQk0OlwOvvVusCFqlJnSas0FDoqCqv...^M
From: Yumeko <gondawara_yumeko@example.com>^M
To: Joe <joe@example.net>^M
Subject: Empty Body^M
Date: Wed, 7 Apr 2021 10:43:47 +0900^M
Message-ID: <CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
Gondawara: yumeko^M
^M

relaxedアルゴリズムの場合、本文に CRLF は付与せず、そのまま空文字としてハッシュするのがポイントです。

マルチパート + UTF-8

UTF-8 でエンコードされた text + HTML のマルチパート・メッセージ。

DKIM-Signature: v=1; a=rsa-sha256;^M
 bh=AYvpX3oi+o0t7bJxSSFUTdngA5ux6GetPWUiDt1n6sw=;^M
 c=relaxed/relaxed; d=tech.quickguard.jp;^M
 h=from:to:subject:date​:message-id:content-type:mime-version;^M
 s=gondawara-yumeko; t=1617760375;^M
 b=kha80jUUcseq5kZEqM4NTvpiFFMyNViHiVy/OYXP3IHFe8aIC...^M
From: Yumeko <gondawara_yumeko@example.com>^M
To: Joe <joe@example.net>^M
Subject: =?UTF-8?B?44GC44GE44GG44GI44GK44KE44GV44GE44KG44KB44GT44G+44GE44Gj44Gh44KT44GQ?=^M
 =?UTF-8?B?44GU44KT44Gg44KP44KJ44GQ44KP?=^M
Date: Wed, 7 Apr 2021 10:43:47 +0900^M
Message-ID: <CAD+6YXKciJqQ=J18_gF09hYCWPE3sUVpsDU9CKC6dpKrVWAj1A@example.com>^M
Gondawara: yumeko^M
Content-Type: multipart/alternative; boundary="00000000000088162305bf5810fd"^M
MIME-Version: 1.0^M
^M
--00000000000088162305bf5810fd^M
Content-Type: text/plain; charset="UTF-8"^M
Content-Transfer-Encoding: base64^M
^M
44G844KT44Gw44KL44Gn44GD44GCDQogICAg44G244GK44O844KTDQrjgbvjgaPjgZEgIOOBvuOB^M
vOOBo+OBkSAgICAg44Gt44G844Gj44GRDQo=^M
--00000000000088162305bf5810fd^M
Content-Type: text/html; charset="UTF-8"^M
Content-Transfer-Encoding: base64^M
^M
PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJz^M
ZXQ9dXRmLTgiPjxkaXYgZGlyPSJsdHIiPjxkaXYgZGlyPSJsdHIiPjxkaXYgZGlyPSJsdHIiPjxk^M
aXYgZGlyPSJsdHIiPuOBvOOCk+OBsOOCi+OBp+OBg+OBgiZuYnNwOyAmbmJzcDsmbmJzcDs8YnI+^M
PGRpdj4mbmJzcDsgJm5ic3A7IOOBtuOBiuODvOOCkzxicj48L2Rpdj48ZGl2PuOBu+OBo+OBkSZu^M
YnNwOyDjgb7jgbzjgaPjgZEmbmJzcDsgJm5ic3A7ICZuYnNwO+OBreOBvOOBo+OBkTwvZGl2Pjwv^M
ZGl2PjwvZGl2PjwvZGl2PjwvZGl2Pg0K^M
--00000000000088162305bf5810fd--^M

署名の手順に変わりはなく、MIME-Version: 1.0 までがヘッダ、それ以降は boundary を気にせず丸ごと本文として扱えば OK です。

エンコードされているヘッダ (= =?UTF-8?B?...) も特別な加工は不要で、そのまま署名処理に突っ込みます。

まとめ

以上、DKIM の中身を理解するため、RFC を読み解きながら手動での署名を試みました。

1ステップずつ処理内容を確認しながら進めることで、DKIM に対する理解が深まったように思います。

これで仕組みも知らずに漫然と DKIM を設定していた状態から脱却できたので、今後は胸を張って DKIM と付き合っていけそうです。:)

今後ともよろしくお願い申し上げます。


当記事の図表には Font Awesome のアイコンを使用しています。