このシリーズについて
この記事は「OpenSSLでプライベートCAを作る(全15章)」の一部である。章番号はシリーズ全体で通し番号になっている。はじめに・開発環境・まとめ・参考文献は目次記事にある。
2. 基本概念
この章は別記事にある。→ 2. 基本概念
3. 作成するCAの構成
3.1. 全体像
本番想定に合わせて、CAを2段構成にする。
中間CAを挟む理由は以下の2つ。
-
ルートCAの秘密鍵をオフラインに置ける
- 日常の発行は中間CAがおこなう。ルートCAの鍵は普段取り出さない
- ルートCAの鍵が漏れると、信頼ストアに入れた全端末をやり直すことになる
-
端末側で「どの証明書を出すか」の候補を絞れる
- 「2.2.3. mTLSのハンドシェイク」のCertificateRequestの解説で触れたとおり
- mTLS専用の中間CAを1つ用意しておくと、ユーザーに無関係な証明書を選ばせずに済む
用語解説(別記事)
3.2. 生成する証明書の一覧
クライアント証明書は、検証したいパターンごとに5枚作る。
| ファイル | CN |
用途 |
|---|---|---|
device-001.p12 |
device-001 |
正常系 |
device-001-renewed.p12 |
device-001 |
更新後を模したもの。CNの値は同じで連番だけ変わる
|
device-002.p12 |
device-002 |
許可リストから外して遮断する確認用 |
device-003.p12 |
device-003 |
CA側で失効させた証明書。CRLに載っている |
device-999.p12 |
device-999 |
有効期限切れ |
2枚目のdevice-001-renewed の証明書を更新しても同じ端末として扱えるかを確認するために使う。
最終的な出力は次の構成になる。
certs/
├── ca-chain.cert.pem ← 中間CA + ルートCA。nginxのssl_client_certificateに指定する(6.3)
├── root/ ← ルートCAの一式(4.2でディレクトリ、5章で鍵と証明書)
│ ├── ca.cert.pem ← ルートCAの証明書。端末の信頼ストアに入れるのはこれ
│ ├── openssl.cnf ← このCAの設定ファイル(4.3)
│ ├── index.txt ← 発行済み証明書の台帳
│ ├── serial ← 次に採番するシリアル番号
│ ├── crlnumber ← 次に発行するCRLの番号
│ ├── newcerts/ ← 発行した証明書のコピー置き場。シリアル番号がファイル名になる
│ └── private/ ← 秘密鍵の置き場。所有者だけがアクセスできる(chmod 700)
│ └── ca.key.pem ← ルートCAの秘密鍵。これが漏れると全体が破られる
├── intermediate/ ← 中間CAの一式(4.2でディレクトリ、6章で鍵と証明書)
│ ├── ca.cert.pem ← 中間CAの証明書。ルートCAが署名したもの
│ ├── ca.csr.pem ← 署名を申請するためのCSR。署名後は使わない
│ ├── openssl.cnf ← このCAの設定ファイル。rootとの違いはdirの1行だけ(4.3.2)
│ ├── index.txt ← 台帳。サーバ証明書とクライアント証明書がここに記録される
│ ├── serial ← 次に採番するシリアル番号
│ ├── crlnumber ← 次に発行するCRLの番号
│ ├── newcerts/ ← 発行した証明書のコピー置き場
│ └── private/ ← 秘密鍵の置き場
│ └── ca.key.pem ← 中間CAの秘密鍵。日常の発行にはこちらを使う
├── server/ ← サーバ証明書の一式(7章)
│ ├── san.cnf ← ホスト名とIPを書いた拡張ファイル。発行時にだけ使う
│ ├── server.key.pem ← サーバの秘密鍵
│ ├── server.csr.pem ← 署名を申請するためのCSR。署名後は使わない
│ ├── server.cert.pem ← サーバ証明書。単体ではnginxに渡さない
│ └── server-fullchain.pem ← サーバ証明書 + 中間CA。nginxのssl_certificateに指定する
├── clients/ ← クライアント証明書の一式(8章)
│ ├── device-001.key.pem ← 端末の秘密鍵
│ ├── device-001.csr.pem ← 署名を申請するためのCSR。署名後は使わない
│ ├── device-001.cert.pem ← クライアント証明書
│ ├── device-001.p12 ← 秘密鍵・証明書・CAチェーンを1つにまとめたもの。端末に配る
│ └── ... ← 同じ4ファイルが残り4枚(renewed / 002 / 003 / 999)ぶん並ぶ
└── crl/ ← 失効リスト(9章)
├── intermediate.crl.pem ← 中間CAが発行したCRL。device-003の失効が記録される
├── root.crl.pem ← ルートCAが発行したCRL。中間CAの段を確認するために要る
└── ca-chain.crl.pem ← 上の2つを連結したもの。nginxのssl_crlに指定する
このほかに、openssl caがindex.txt.attrや*.old(更新前の台帳やシリアル)を自動で作る。
3.3. 有効期間の決め方
有効期間の設定(-daysに何を入れるか)は、CA/Browser Forumという業界団体が定めるBaseline Requirementsの値を参考とする。
用語解説(別記事)
実際にはプライベートCAでは特に制限はないが、それでもこの記事では参考にして次のように定めることとする。
| 対象 | 日数 | 理由 |
|---|---|---|
| ルートCA | 3650(10年) | 再作成のたびに全端末の信頼設定をやり直すことになるため長め |
| 中間CA | 1825(5年) | ルートより短く。差し替え時にルートCAまで触らなくて済む |
| サーバ / クライアント | 825(約2年) | 検証期間を十分に超え、かつ「かつての公的CAの上限」に収まる値 |
用語解説(別記事)
4. 事前準備
証明書を作り始める前に、次の3つを用意する。
| 用意するもの | 節 | |
|---|---|---|
| 0 | 作業ディレクトリ | 4.0 |
| 1 | OpenSSL 3.x | 4.1 |
| 2 | CAごとのディレクトリと台帳ファイル | 4.2 |
| 3 | CAごとの設定ファイル | 4.3 |
4.0. 作業ディレクトリとコードの扱い
作業用のディレクトリを作り、以降はその中で作業する。
mkdir -p ~/mtls-demo
cd ~/mtls-demo
以降のコマンドはすべてこのディレクトリで実行する。 生成物はcerts/の下にできる。
この記事のコードには2通りの使い方がある。
| 方法 | 向いている場面 | |
|---|---|---|
| A | ターミナルに1つずつ貼って実行する | 手順を1つずつ確認しながら進めたいとき |
| B | 12章のスクリプトを保存して一度に実行する | 結果だけ先に見たいとき |
この記事はAの順序で説明する。関数の定義も、そのままターミナルに貼れば使える。
用語解説(別記事)
4.1. OpenSSL 3.xを用意する
まず、手元のopensslが何かを確認する。
openssl version
OpenSSL 3.6.3 9 Jun 2026 (Library: OpenSSL 3.6.3 9 Jun 2026)
出力がOpenSSL 3.で始まっていれば、そのまま使える。LibreSSLと表示された場合は、macOS標準のものを見ているので以下のコマンドでopenssl@3をインストールする。
brew install openssl@3
以降、この記事ではopensslと打たずに、変数$OPENSSLを通してコマンドを呼ぶ。以下のコマンドをターミナルに貼り付けて実行。
export OPENSSL="$(brew --prefix openssl@3)/bin/openssl"
確認する。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL version
OpenSSL 3.6.3 9 Jun 2026 (Library: OpenSSL 3.6.3 9 Jun 2026)
OpenSSL 3.で始まる版が表示されていればよい。LibreSSLのままなら、変数の設定が効いていない。
4.1.1. なぜ変数を使うのか
opensslと打ったときにどれが実行されるかは、環境によって変わる。macOSには最初から/usr/bin/opensslがあり、その実体はOpenSSLではなくLibreSSLである(G30. なぜmacOS標準のopensslでは動かないのか)。Homebrewで入れたものとmacOS標準のものが同居した状態で、どちらが呼ばれるかはPATHの順序で決まる。
この記事の手順はOpenSSL 3.xでしか動かない。LibreSSLには存在しないオプションを使うためである。opensslと書いていると、環境次第で途中まで動いて突然失敗することになり、原因も分かりにくい。
変数に実体のパスを入れておけば、PATHの状態に関係なく同じバイナリが呼ばれる。この記事のコマンドがすべて$OPENSSLで始まっているのは、どのopensslを使っているかを毎回はっきりさせるためである。
4.1.2. コマンドの読み方
| 記述 | 意味 |
|---|---|
export |
この変数を、以降に起動するコマンドやスクリプトにも渡す |
OPENSSL=... |
変数に値を入れる。=の前後に空白を入れてはいけない |
$( ... ) |
中のコマンドを実行し、その出力に置き換える(コマンド置換) |
brew --prefix openssl@3 |
openssl@3が入っている場所を返す |
/bin/openssl |
その下にある実行ファイル |
" " |
パスに空白が含まれても1つの値として扱う |
brew --prefixで求めているのは、Homebrewの導入先がCPUによって違うためである。Apple Siliconは/opt/homebrew、Intelは/usr/localに入る。パスを直書きすると片方の環境で動かない(G29. Homebrewの導入先を確認する)。
4.1.3. 変数が消えたとき
exportで設定した変数は、そのターミナルを閉じると消える。新しいタブやウィンドウを開いた場合も引き継がれない。
変数が無い状態を再現すると、次のようになる。
env -u OPENSSL zsh -c '$OPENSSL version'
zsh:1: command not found: version
$OPENSSLが空のまま展開され、残ったversionがコマンドとして実行されるためである。作業中にcommand not found: versionが出たら、変数が消えている。上のexportをもう一度実行する。
用語解説(別記事)
4.2. CAディレクトリと台帳を用意する
6章から、証明書の発行にはopenssl caというサブコマンドを使う。このコマンドは、CAが今までに何を発行したかの記録(台帳)を、ファイルとして持っていることを前提に動く。
台帳はopenssl caが自分で作ってくれるわけではない。公式ドキュメントでもMandatory(必須)かつmust be present(存在していなければならない)とされており、無ければエラーで止まる。
そのため、証明書を1枚も発行していないこの段階で、空の台帳と置き場を先に用意しておく。CAごとに次の一式が必要。
| ファイル / ディレクトリ | 役割 |
|---|---|
index.txt |
発行済み証明書の台帳。空ファイルとして作る |
serial |
次に採番するシリアル番号 |
crlnumber |
次に発行するCRLの番号 |
newcerts/ |
発行した証明書のコピー置き場 |
private/ |
秘密鍵の置き場 |
この記事ではルートCAと中間CAの2つを作るので、同じ一式を2組作ることになる。手で2回書くかわりに、関数にまとめて2回呼ぶ。
init_ca_dir() {
local dir="$1"
mkdir -p "$dir/newcerts" "$dir/private"
chmod 700 "$dir/private"
: > "$dir/index.txt"
echo 1000 > "$dir/serial"
echo 1000 > "$dir/crlnumber"
}
4.2.1. 関数の中身
1行ずつ見る。
| 行 | 意味 |
|---|---|
init_ca_dir() { … }
|
init_ca_dirという名前の関数を定義する。{から}までが中身 |
local dir="$1" |
呼び出すときに渡した1つ目の引数を、変数dirに入れる。localは関数の外に値を持ち出さない指定 |
mkdir -p "$dir/newcerts" "$dir/private" |
2つのディレクトリをまとめて作る |
chmod 700 "$dir/private" |
秘密鍵の置き場を、所有者だけが開けるようにする |
: > "$dir/index.txt" |
空の台帳を作る |
echo 1000 > "$dir/serial" |
シリアル番号の初期値を書き込む |
echo 1000 > "$dir/crlnumber" |
CRL番号の初期値を書き込む |
mkdirの-pは、途中の階層もまとめて作り、既にあってもエラーにしないオプションである。この関数は2回実行しても壊れない。
"$dir"をクォートで囲んでいるのは、パスに空白が含まれても1つの引数として扱うためである。
: > fileとchmod 700の詳しい説明はG35. : > fileとchmod 700の意味を参照。: >は空ファイルを作るシェルのイディオムで、touchと違って既存の中身を消す。まっさらな台帳を作るのが目的なのでこちらを使う。
4.2.2. 初期値の1000について
serialとcrlnumberに書く1000は、16進数として読まれる。10進では4096である。
証明書のシリアル番号も16進で表示されるため、発行していくと1000、1001、1002と並んで見える。10進の千番台ではない。
1000から始めるのは慣例で、技術的な意味はない。詳しくはG34. シリアル番号(serial)とはを参照。
4.2.3. 関数を呼び出す
ルートCAと中間CAの2つに対して実行し、一式をそれぞれ作成する。以下のコマンドをターミナルに貼り付けて実行。
init_ca_dir certs/root
init_ca_dir certs/intermediate
定義のときはinit_ca_dir()と括弧を書いたが、呼び出すときに括弧は使わない。シェルの関数はlsやmkdirと同じ書き方で呼び、引数は括弧やカンマではなく空白で区切って並べる。
| 書き方 | |
|---|---|
| 定義 | init_ca_dir() { ... } |
| 呼び出し | init_ca_dir certs/root |
init_ca_dir(certs/root)のようには書けない。シェルでは括弧が別の意味を持つためである。
init_ca_dir(certs/root)
zsh: number expected
エラーの文面はシェルによって変わる。上はmacOSの既定であるzshのものである。
1行目のcerts/rootが関数の中で$1として受け取られ、local dir="$1"によってdirに入る。2行目も同じで、certs/intermediateが渡される。
できたものを確認する。まずディレクトリの構成を見る。以下のコマンドをターミナルに貼り付けて実行。
find certs -type d
certs
certs/root
certs/root/newcerts
certs/root/private
certs/intermediate
certs/intermediate/newcerts
certs/intermediate/private
certs/rootとcerts/intermediateのそれぞれにnewcertsとprivateが並び、7行表示されていればよい。
| 記述 | 意味 |
|---|---|
find certs |
certsの下を再帰的にたどって、見つかったものを並べる |
-type d |
そのうちディレクトリだけに絞る。-type fならファイルだけになる |
ファイルも含めて見たい場合は-type dを外す。index.txtやserialも一覧に出る。
次に、秘密鍵の置き場のアクセス権を確認する。以下のコマンドをターミナルに貼り付けて実行。
ls -ld certs/root/private
drwx------ 2 user staff 64 Sep 16 10:00 certs/root/private
先頭がdrwx------になっていれば、chmod 700が効いている。所有者以外は中を見られない。
| 記述 | 意味 |
|---|---|
ls |
ファイルやディレクトリを一覧する |
-l |
1行1件の詳細表示にする。アクセス権・所有者・サイズ・更新日時が付く |
-d |
指定したディレクトリ自身の情報を出す。付けないと中身の一覧になる |
-dが要点である。ls -l certs/root/privateと書くと中身が一覧されるが、いま知りたいのはprivateディレクトリそのもののアクセス権なので、-dで自分自身に絞る。中身はまだ空なので、-dを付けないと何も表示されない。
出力の先頭drwx------は10文字で、次のように読む。
d rwx --- ---
│ │ │ └─ その他のユーザー:権限なし
│ │ └───── グループ:権限なし
│ └───────── 所有者:読み(r)・書き(w)・入る(x)
└──────────── ディレクトリであることを示す(ファイルなら -)
数字の700がこの表示に対応している。詳しくはG35. : > fileとchmod 700の意味を参照。
用語解説(別記事)
4.3. CA設定ファイルを用意する
openssl caは、台帳の場所も、発行する証明書の中身も、すべて設定ファイルから読む。コマンドラインで毎回指定するのではなく、CAごとに設定ファイルを1つ持たせる形になっている。
そのため、4.2で作った2つのCAディレクトリに、設定ファイルを1つずつ置く。
certs/
├── root/
│ └── openssl.cnf ← ルートCA 用(このステップで作る)
└── intermediate/
└── openssl.cnf ← 中間CA 用(このステップで作る)
注意
このあと出てくる設定ファイルの中身は、ターミナルにそのまま打ち込むものではない。ファイルとして保存するものである。
下のcat > ... <<'CNF_EOF'からCNF_EOFまでをまとめてコピーしてターミナルに貼ると、ファイルが作られる。
4.3.1. ルートCA用の設定ファイルを作る
次をまとめてコピーし、ターミナルに貼って実行する。
cat > certs/root/openssl.cnf <<'CNF_EOF'
# ======================================================================
# CAの動作設定
# ======================================================================
[ ca ]
default_ca = CA_default # 既定で使う設定の節。下の [ CA_default ] を指す
[ CA_default ]
# --- 台帳と鍵の置き場所(4.2で作ったもの)-----------------------------
dir = certs/root # このCAの作業ディレクトリ(★CAごとに変える)
new_certs_dir = $dir/newcerts # 発行した証明書のコピー置き場
database = $dir/index.txt # 発行済み証明書の台帳
serial = $dir/serial # 次に採番するシリアル番号
crlnumber = $dir/crlnumber # 次に発行するCRLの番号
private_key = $dir/private/ca.key.pem # このCAの秘密鍵。署名に使う
certificate = $dir/ca.cert.pem # このCAの証明書
# --- 発行時の既定値 ---------------------------------------------------
default_md = sha256 # 署名に使うハッシュアルゴリズム
default_days = 825 # -daysを省略したときの有効日数
default_crl_days = 30 # CRLの有効日数
# --- 発行時のふるまい -------------------------------------------------
preserve = no # CSRのDNの並び順をそのまま使わない
email_in_dn = no # メールアドレスをDNに含めない
unique_subject = no # 同じCNの証明書を複数発行できるようにする(8.2)
policy = policy_loose # DNの必須項目のルール。下の節を指す
copy_extensions = none # CSRに書かれた拡張をコピーしない(安全のため)
# ======================================================================
# DNのどの項目を必須にするか
# ======================================================================
[ policy_loose ]
commonName = supplied # CNだけは必須。無ければ発行を拒否する
countryName = optional # 以下はすべて任意
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
# ======================================================================
# CSRを作るときの既定値
# ※ この記事ではopenssl reqに -configを渡さないため実際には使われない。
# 12章のスクリプトに合わせて残している
# ======================================================================
[ req ]
default_bits = 2048
default_md = sha256
string_mask = utf8only
prompt = no # 対話入力をせず -subjの値を使う
distinguished_name = req_dn
[ req_dn ]
# ======================================================================
# 発行する証明書に付けるX.509拡張
# 発行時に -extensions <節名>で、どれを使うか選ぶ
# ======================================================================
[ v3_intermediate_ca ] # 中間CAを発行するときに使う(6章)
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0 # CAである。配下にCAは作れない
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ server_cert ] # サーバ証明書を発行するときに使う(7章)
basicConstraints = CA:FALSE # CAではない
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth # サーバ用途に限定する
[ client_cert ] # クライアント証明書を発行するときに使う(8章)
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth # クライアント用途に限定する
[ crl_ext ] # CRLを発行するときに使う(9章)
authorityKeyIdentifier = keyid:always
CNF_EOF
用語解説(別記事)
4.3.2. 中間CA用の設定ファイルを作る
次に中間CA用の設定ファイルを作成するが、中間CAとルートCAの違いは、dirの行だけである。よってルートCA用の設定ファイルをコピーして、その1行を差し替える方法で作成する。以下のコマンドを用いる。
sed 's|^dir .*|dir = certs/intermediate|' \
certs/root/openssl.cnf > certs/intermediate/openssl.cnf
-
sed 's|置換前|置換後|'— 行の中身を置き換えるコマンド -
^dir .*— 「dirで始まる行の全体」という意味 -
>で別ファイルに書き出しているので、元のファイルは変わらない
4.3.3. できたか確認する
4.3.1と4.3.2で作った2つの設定ファイル(certs/root/openssl.cnfとcerts/intermediate/openssl.cnf)ができていることと、それぞれのdirの値を順に確認する。
ls certs/*/openssl.cnf
certs/intermediate/openssl.cnf
certs/root/openssl.cnf
certs/root/openssl.cnfとcerts/intermediate/openssl.cnfの2つが表示されていればよい。
grep '^dir' certs/*/openssl.cnf
certs/intermediate/openssl.cnf:dir = certs/intermediate
certs/root/openssl.cnf:dir = certs/root # このCAの作業ディレクトリ(★CAごとに変える)
dirの値が、それぞれcerts/intermediateとcerts/rootになっていればよい。この1行だけが2つのファイルの違いである。
用語解説(別記事)
4.3.4. 設定ファイルの読まれ方
1つのファイルに複数の節が入っているが、すべてが同時に使われるわけではない。発行するものに応じて、使う節が切り替わる。
| 節 | いつ使われるか | 指定方法 |
|---|---|---|
[ CA_default ] |
openssl caを実行するたび |
-configで自動的に読まれる |
[ policy_loose ] |
同上 |
[ CA_default ]のpolicyから参照される |
[ v3_intermediate_ca ] |
中間CAを発行するとき(6章) | -extensions v3_intermediate_ca |
[ server_cert ] |
サーバ証明書を発行するとき(7章) | -extensions server_cert |
[ client_cert ] |
クライアント証明書を発行するとき(8章) | -extensions client_cert |
[ crl_ext ] |
CRLを発行するとき(9章) | -crlexts crl_ext |
つまり後の章で出てくる-extensions ○○は、このファイルのどの節を使うかの指定である。
とくに効いてくる設定は次の3つである。
| 設定 | 効果 | 詳しくは |
|---|---|---|
unique_subject = no |
同じCNの値の証明書を2枚発行できるようにする |
8.2 |
policy = policy_loose |
CN以外のDN項目を任意にする |
下の用語解説 |
copy_extensions = none |
CSRに書かれた拡張をコピーしない | 下の用語解説 |
用語解説(別記事)
5. ルートCAを作成する
信頼の起点(トラストアンカー)になる証明書を作る。上位が存在しないため、自分の秘密鍵で自分に署名する(自己署名)。
4.2で作ったcerts/root/の中に、次の2ファイルができる。
certs/root/
├── private/
│ └── ca.key.pem ← ルートCAの秘密鍵(このステップで作る)
└── ca.cert.pem ← ルートCAの証明書(このステップで作る)
このうち、後で端末の信頼ストアに登録するファイルがca.cert.pemである。
5.1. 鍵と証明書を作る
次をターミナルに貼って実行する。
$OPENSSL req -x509 -new -nodes -newkey rsa:4096 -sha256 -days 3650 \
-keyout certs/root/private/ca.key.pem \
-out certs/root/ca.cert.pem \
-subj "/C=JP/O=Example Corp/CN=Example Corp Dev Root CA" \
-addext "basicConstraints=critical,CA:true,pathlen:1" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
続けて、生成された秘密鍵のアクセス権を所有者だけに絞る。
chmod 600 certs/root/private/ca.key.pem
5.1.1. コマンドの読み方
5.1で実行したコマンドを分解すると、次の構造になっている。
$OPENSSL req -x509 -new -nodes ...
↑ ↑ ↑
│ │ └─ オプション
│ └─ サブコマンド。reqはCSRと証明書を扱う
└─ 4.1で定義した変数。実体は /usr/local/opt/openssl@3/bin/openssl
reqは本来CSR(証明書署名要求)を作るサブコマンドだが、-x509を付けると証明書そのものを出力する。
行末の\はコマンドが次の行に続くという印である。1行にまとめて書いても同じ動作になるが、長くなるため改行している。貼り付けるときは\も含めて全行をまとめてコピーする。
5.1.2. オプション
| オプション | 意味 |
|---|---|
-x509 |
CSRではなく証明書を直接出力する(=自己署名) |
-new |
新しく作る。既存のCSRを読まない |
-nodes |
秘密鍵にパスワードをかけない("no DES"の意味) |
-newkey rsa:4096 |
秘密鍵も同時に生成する。RSA4096ビット |
-sha256 |
署名に使うハッシュアルゴリズム |
-days 3650 |
有効期間。3.3で決めた10年 |
-keyout |
秘密鍵の出力先 |
-out |
証明書の出力先 |
-subj |
Subjectをコマンドラインで指定する。省くと対話入力になる |
-addext |
X.509拡張を追加する。複数指定できる |
-keyoutと-outで出力先が2つに分かれている点に注意する。このコマンドは秘密鍵と証明書を同時に作るため、それぞれの保存先を別に指定する。
5.1.3. -subjに書いた値
-subjに渡した文字列を分解すると、次のようになる。
/C=JP/O=Example Corp/CN=Example Corp Dev Root CA
↑ ↑ ↑
│ │ └─ CN(Common Name):この証明書の名前
│ └─ O(Organization):組織名
└─ C(Country):国コード(2文字)
スラッシュ区切りで、先頭にもスラッシュが要る。値に空白を含むため、全体をダブルクォートで囲んでいる。
4.3でpolicy_looseを設定しているため、必須なのはCNだけである。CとOは任意だが、後で見分けがつくよう入れている。
CNは証明書の「名前」にあたる項目で、DN(識別名)を構成する要素の1つである。DNの各項目についてはG14. SubjectとDN(識別名)を参照。
なお、CNの使い道は証明書の種類によって違う。サーバ証明書ではホスト名の照合に使ってはならず(7.3.2)、クライアント証明書では端末の識別子として使える(8.1.3)。
5.1.4. -addextに書いた値
-addextは2回指定している。1つずつ見る。
basicConstraints=critical,CA:true,pathlen:1
-
basicConstraints— この証明書がCAかどうかを示す拡張 -
critical— 「この拡張を理解できない検証側は、この証明書を拒否しなければならない」という印 -
CA:true— CAである -
pathlen:1— 配下にあと1段だけCAを作れる(=次章の中間CAまで)
keyUsage=critical,keyCertSign,cRLSign
-
keyUsage— この鍵の用途を制限する拡張 -
keyCertSign— 証明書への署名に使える -
cRLSign— CRLへの署名に使える
ルートCAの鍵は、この2つ以外の用途では使わない。用途を書いておくことで、誤用を検証側が弾けるようになる。
5.1.5. chmod 600
生成直後の秘密鍵は、他のユーザーからも読める権限になっていることがある。秘密鍵が読めるということは、その鍵の持ち主になりすませるということである。 そのため所有者だけが読み書きできる状態に絞る。
用語解説(別記事)
5.1.6. なぜルートCAだけopenssl caを使わないのか
4.2で用意した台帳はopenssl caが使うものだが、ルートCAの作成では使わない。
openssl caは「上位のCAが、下位のCSRに署名する」コマンドである。ルートCAには署名してくれる上位が存在しないため、このコマンドは使えない。そこでopenssl req -x509を使い、生成した秘密鍵でそのまま自分に署名する。
なおopenssl req -x509は台帳を使わないため、ルートCA自身のシリアル番号はランダムな値になる。certs/root/serialに書いた1000が使われるのは、次章で中間CAに署名するときからである。
⚠️ 注意:-nodesについて
本番のルートCAでは必ずパスワードをかける(-nodesを外す)。この記事は手順を止めずに通しで実行できるようにするため外しているが、パスワード無しの秘密鍵は、ファイルが読めれば誰でも使える。
5.2. できたか確認する
5.2.1. ファイルができているか
秘密鍵と証明書が作られたか、アクセス権とあわせて確認する。
ls -l certs/root/ca.cert.pem certs/root/private/ca.key.pem
-rw-r--r-- 1 user staff 2094 Sep 16 01:05 certs/root/ca.cert.pem
-rw------- 1 user staff 3272 Sep 16 01:05 certs/root/private/ca.key.pem
秘密鍵が-rw-------(所有者のみ読み書き可)になっていればchmodが効いている。
5.2.2. 自己署名になっているか
証明書からsubjectとissuerを取り出して表示する。
$OPENSSL x509 -in certs/root/ca.cert.pem -noout -subject -issuer
subject=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
issuer=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
subjectとissuerが同じであれば自己署名である。これがトラストアンカーの証拠になる。
確認に使ったコマンドの内訳は次のとおり。
| 要素 | 意味 |
|---|---|
x509 |
証明書を読んで表示・変換するサブコマンド |
-in |
読み込む証明書 |
-noout |
証明書そのものを出力しない。付けないとPEM全文が流れる |
-subject |
Subject(この証明書は誰のものか)を表示する |
-issuer |
Issuer(誰が署名したか)を表示する |
5.2.3. CAとして使える設定になっているか
証明書の中からbasicConstraints拡張だけを抜き出して表示する。
$OPENSSL x509 -in certs/root/ca.cert.pem -noout -text | grep -A2 'Basic Constraints'
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:1
X509v3 Key Usage: critical
CA:TRUEと表示され、pathlen:1が付いていればよい。
3行目のX509v3 Key Usageは、grep -A2が後ろ2行まで拾うために出てくる次の拡張である。ここでは見なくてよい。5.1.4で-addextに指定したものである。
-textは証明書の中身を人が読める形で全部出すオプションである。そのままでは長いため、grepで必要な行だけ抜き出している。
| 要素 | 意味 |
|---|---|
-text |
証明書の内容を人間可読の形式で表示する |
| |
前のコマンドの出力を次のコマンドに渡す(パイプ) |
grep -A2 '...' |
一致した行と、その後ろ2行を表示する |
ここがCA:FALSEだったり、表示自体が無い場合は-addextが効いていない。
5.2.4. 有効期間が10年になっているか
有効期間の開始日時と終了日時を表示する。
$OPENSSL x509 -in certs/root/ca.cert.pem -noout -dates
notBefore=Sep 24 12:24:47 2026 GMT
notAfter=Sep 21 12:24:47 2036 GMT
notBeforeからnotAfterまでが、-daysに指定した3650日になっていればよい。
-daysは日数での指定なので、うるう年を含むぶん暦の10年より3日短くなる。上の例では2026年9月24日の3650日後にあたる2036年9月21日が終了日である。
-datesは有効期間の開始と終了だけを表示するオプションである。
日時はUTCで表示される(末尾のGMT)。日本時間で見るときは9時間足して読む。
5.2.5. 署名に使える設定になっているか
証明書の中からkeyUsage拡張だけを抜き出して表示する。
$OPENSSL x509 -in certs/root/ca.cert.pem -noout -text | grep -A1 'Key Usage'
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
Certificate SignとCRL Signが表示されていればよい。5.1.4で指定したkeyCertSignとcRLSignが、表示用の名前に置き換わったものである。
| 設定での指定 | 証明書での表示 | 意味 |
|---|---|---|
keyCertSign |
Certificate Sign |
証明書に署名できる |
cRLSign |
CRL Sign |
CRLに署名できる |
7.6.2でserverAuthがTLS Web Server Authenticationと表示されるのと同じで、指定した名前と表示名は一致しない。
criticalが付いているのは、この拡張を理解できない実装に証明書を拒否させるためである。CAの権限にかかわる指定なので、黙って無視されては困る。
6. 中間CAを作成する
実際に証明書を発行するCAを作る。ルートCAと違い、上位(ルートCA)に署名してもらうため、手順が2段階になる。
- 鍵とCSR(申請書)を作る
- ルートCAに署名してもらう
できるファイルは次のとおり。
certs/
├── intermediate/
│ ├── private/ca.key.pem ← 中間CAの秘密鍵(このステップで作る)
│ ├── ca.csr.pem ← 署名の申請書(このステップで作る)
│ └── ca.cert.pem ← 中間CAの証明書(このステップで作る)
└── ca-chain.cert.pem ← 中間CA + ルートCA(このステップで作る)
ディレクトリは4.2で作成済みのため、ここでのmkdirは不要である。
6.1. 鍵とCSR(証明書署名要求)を作る
最初に、中間CAの秘密鍵と、署名を申請するためのCSRを作る。
$OPENSSL req -new -nodes -newkey rsa:4096 -sha256 \
-keyout certs/intermediate/private/ca.key.pem \
-out certs/intermediate/ca.csr.pem \
-subj "/C=JP/O=Example Corp/CN=Example Corp mTLS Intermediate CA"
chmod 600 certs/intermediate/private/ca.key.pem
6.1.1. 5章との違い
コマンドの形は5章とほぼ同じである。違いは3点だけ。
| 5章(ルートCA) | 6章(中間CA) | |
|---|---|---|
-x509 |
あり → 証明書を出力 | なし → CSRを出力 |
-days |
あり | なし(署名する側が決める) |
-addext |
あり | なし(署名する側が付ける) |
-x509を外すと、出力が証明書ではなくCSRになる。これが「上位に署名してもらうための申請書」である。
有効期間と拡張が無いのは、それらを決めるのは申請者ではなく署名するCA側だからである。申請者の言い値をそのまま通すと、CAを名乗る証明書を勝手に作られてしまう(4.3のcopy_extensionsの話と同じ理由)。
各オプションの意味は5.1.2. オプションを参照。-subjの書き方は5.1.3. -subjに書いた値、chmod 600の理由は5.1.5. chmod 600を参照。
用語解説(別記事)
6.2. ルートCAに署名してもらう
6.1で作ったCSRを、ルートCAに署名してもらう。
$OPENSSL ca -batch -config certs/root/openssl.cnf \
-extensions v3_intermediate_ca -days 1825 -notext -md sha256 \
-in certs/intermediate/ca.csr.pem \
-out certs/intermediate/ca.cert.pem
実行すると、署名の内容が表示される。
Using configuration from certs/root/openssl.cnf
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
countryName :PRINTABLE:'JP'
organizationName :ASN.1 12:'Example Corp'
commonName :ASN.1 12:'Example Corp mTLS Intermediate CA'
Certificate is to be certified until Sep 14 16:08:50 2031 GMT (1825 days)
Write out database with 1 new entries
Database updated
Signature okと表示され、最後がDatabase updatedで終わっていればよい。最後の2行が、ルートCAの台帳に記録されたことを示している。
6.2.1. openssl caサブコマンド
5章ではreqとx509を使ったが、ここで初めてcaが出てくる。
$OPENSSL ca -batch -config ... -in ... -out ...
↑ ↑
│ └─ サブコマンド。CAとしてCSRに署名する
└─ 4.1で定義した変数
reqとの役割の違いは次のとおり。
| サブコマンド | 役割 | 台帳 |
|---|---|---|
req |
CSRを作る。-x509付きで自己署名証明書も作れる |
使わない |
ca |
CSRに署名して証明書を発行する | 使う(4.2で作ったもの) |
caは台帳を更新するため、4.2のindex.txtとserialが無いと動かない。
6.2.2. オプション
| オプション | 意味 |
|---|---|
-batch |
確認プロンプトを出さずに実行する。付けないと「署名しますか?」と2回聞かれる |
-config certs/root/openssl.cnf |
どのCAとして署名するかを決める。ルートCAの設定を指すのでルートCAが署名者になる |
-extensions v3_intermediate_ca |
設定ファイルの[ v3_intermediate_ca ]節の拡張を付ける(4.3.4参照) |
-days 1825 |
有効期間。3.3で決めた5年 |
-notext |
出力する証明書ファイルに人間可読のテキストを含めない |
-md sha256 |
署名に使うハッシュアルゴリズム |
-in |
入力するCSR |
-out |
出力する証明書 |
5章の-in/-outとは対象が違う点に注意する。
5章(req -x509) |
6章(ca) |
|
|---|---|---|
-in |
使わない | CSRを読む |
-out |
証明書を書く | 証明書を書く |
-keyout |
秘密鍵を書く | 使わない(鍵は6.1で作成済み) |
-notextを付けない場合、証明書ファイルの先頭に内容の一覧が平文で付く。読む分には便利だが、他のツールが読み込むときに邪魔になることがあるため外している。
-extensions v3_intermediate_caを忘れると、CAではない普通の証明書ができる。
生成自体は成功するため気づきにくい。6.4の確認で必ずCA:TRUEを見ること。
6.3. ca-chain.cert.pemを作る
nginxがクライアント証明書を検証するときに使うファイルである。中間CAとルートCAを連結する。
cat certs/intermediate/ca.cert.pem certs/root/ca.cert.pem > certs/ca-chain.cert.pem
6.3.1. catによる連結
catはファイルの中身をそのまま出力するコマンドである。ファイルを複数並べると、順に連結して出力する。
cat ファイルA ファイルB > ファイルC
↑ ↑
│ └─ 出力先のファイルに書き込む(リダイレクト)
└─ 中身を順に出力する
PEMはテキスト形式なので、単純に連結するだけで複数の証明書を1ファイルにまとめられる。順序がそのまま残るため、並べる順に意味がある。
用語解説(別記事)
このあと7章で作る
server-fullchain.pemとは別物である。中身も役割も違う。詳細は7章で説明する。
6.4. できたか確認する
6.4.1. 誰が誰に署名したか
証明書からsubjectとissuerを取り出して表示する。
$OPENSSL x509 -in certs/intermediate/ca.cert.pem -noout -subject -issuer
subject=CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
issuer=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
subjectが中間CA、issuerがルートCAになっていれば正しい。5章の自己署名(両者が同じ)との違いがここに出る。
コマンドの各要素は5.2.2. 自己署名になっているかを参照。
6.4.2. CAとして使える設定か
証明書の中からbasicConstraints拡張だけを抜き出して表示する。
$OPENSSL x509 -in certs/intermediate/ca.cert.pem -noout -text | grep -A2 'Basic Constraints'
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Key Usage: critical
CA:TRUEと表示され、pathlen:0が付いていればよい。
3行目のX509v3 Key Usageは、grep -A2が後ろ2行まで拾うために出てくる次の拡張である。ここでは見なくてよい。
pathlen:0は「この下にもうCAを作れない」という意味である(3.1参照)。ルートCAのpathlen:1と見比べると、段数が1つ減っていることが分かる。
6.4.3. チェーンファイルに2枚入っているか
連結したファイルに証明書が何枚入っているかを数える。
grep -c 'BEGIN CERTIFICATE' certs/ca-chain.cert.pem
2
2と表示されていればよい。1しか出ない場合は6.3のcatで連結できていない。
grep -cは、一致した行を表示する代わりに一致した行数を数えるオプションである。PEMは証明書1枚につき-----BEGIN CERTIFICATE-----を1行持つため、この数がそのまま枚数になる。
中身の順序も確認できる。
$OPENSSL crl2pkcs7 -nocrl -certfile certs/ca-chain.cert.pem | $OPENSSL pkcs7 -print_certs -noout
subject=CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
issuer=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
subject=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
issuer=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
1枚目が中間CA、2枚目がルートCAの順で並んでいればよい。
出力は証明書1枚につきsubjectとissuerの2行が並び、空行で区切られる。並ぶ順序はファイル内の順序と同じである。
どちらがどの証明書かは、subjectを見て判断する。issuerは「誰が署名したか」であって、その証明書自身の名前ではない。
1枚目 subject=CN=Example Corp mTLS Intermediate CA, ... ← この証明書の名前
issuer =C=JP, O=Example Corp, CN=Example Corp Dev Root CA
└ 署名したのはルートCA
→ subjectとissuerが違う = 中間CA
2枚目 subject=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
issuer =C=JP, O=Example Corp, CN=Example Corp Dev Root CA
→ subjectとissuerが同じ = 自己署名 = ルートCA
見分ける手がかりは2つある。
-
CN=の値 — 中間CAにはIntermediate、ルートCAにはRootが入るよう名前を付けてある -
subjectとissuerが一致するか — 一致していれば自己署名、つまりルートCAである(5.2.2. 自己署名になっているか参照)
この順序は、6.3でcatに指定した順序がそのまま入った結果である。
CN=の位置が2枚で違う点について
1枚目はCNが先頭、2枚目は末尾にある。これは作り方の違いによるもので、中身は同じである。
- ルートCA(
req -x509で作成)は-subjに書いた順序をそのまま保つ- 中間CA(
openssl caで発行)は設定ファイルの[ policy_loose ]に並べた順序で書き直す。4.3でcommonNameを先頭に置いているためCNが先頭に来る
CN=は位置ではなく文字列で探す。
openssl x509は1ファイルに1枚だけの前提で動くため、連結ファイルの2枚目以降を読めない。そこで一度PKCS#7という「複数の証明書を束ねる形式」に変換してから、まとめて表示させている。
| 要素 | 意味 |
|---|---|
crl2pkcs7 |
証明書やCRLをPKCS#7形式に変換するサブコマンド |
-nocrl |
CRLは含めない(証明書だけを束ねる) |
-certfile |
束ねる対象のPEMファイル |
pkcs7 |
PKCS#7を読むサブコマンド |
-print_certs |
中の証明書を一覧表示する |
-noout |
PKCS#7そのものは出力しない |
|(パイプ)で前のコマンドの出力を次に渡している点は5.2.3. CAとして使える設定になっているかと同じである。
6.4.4. ルートCAの台帳に記録されたか
ルートCAの台帳を表示する。
cat certs/root/index.txt
V 310914160850Z 1000 unknown /CN=Example Corp mTLS Intermediate CA/C=JP/O=Example Corp
1行だけ表示され、先頭がV、シリアル番号が1000、Subjectが中間CAになっていればよい。
index.txtはタブ区切りで、左から次の意味を持つ。
| 位置 | 値 | 意味 |
|---|---|---|
| 1 | V |
状態。V=有効、R=失効済み、E=期限切れ |
| 2 | 310914160850Z |
有効期限(YYMMDDHHMMSSZ形式、UTC) |
| 3 | (空) | 失効日時。失効させると入る(9章) |
| 4 | 1000 |
シリアル番号 |
| 5 | unknown |
証明書ファイルの場所。openssl caは使わないため常にunknown
|
| 6 | /CN=.../C=JP/... |
Subject |
シリアル番号1000は、4.2でserialファイルに書いた初期値である。これが1枚目の発行にあたる。
用語解説(別記事)
7. サーバ証明書を作成する
nginxが「自分はgw.example.internalである」と名乗るための証明書を作る。中間CAに署名してもらうので、手順は6章と同じ2段階である。
ただし6章と違い、発行時に付ける拡張を別ファイルに書く必要がある。サーバ証明書にはホスト名を書き込むためで、ここが7章のつまずきどころになる。
できるファイルは次のとおり。
certs/server/ ← このステップで作る
├── san.cnf ← ホスト名を書いた拡張ファイル
├── server.key.pem ← サーバの秘密鍵
├── server.csr.pem ← 署名の申請書
├── server.cert.pem ← サーバ証明書
└── server-fullchain.pem ← サーバ証明書 + 中間CA
7.1. なぜサーバ証明書も自分で作るのか
公開Webサイトのサーバ証明書を自作することはまずない。Let's EncryptやDigiCertのような公的CAから取るのが当たり前だからである。では、なぜこの記事では自分で作るのか。
理由は2つある。
1つ目は、TLSにサーバ証明書が必須だからである。
mTLSは「TLSにクライアント認証を足したもの」であり、TLSそのものが成立しなければ始まらない。クライアント証明書だけを用意しても動かない。
2つ目は、公的CAが内部向けの名前に証明書を発行できないからである。
CA/Browser Forumの規定により、公的CAは次のような名前に証明書を出せない。ドメインの所有を確認できないためである。
-
gw.example.internalのように公開DNSに存在しない名前 -
*.localなどの内部向けTLD -
192.168.0.1のようなプライベートIPアドレス
2015年11月に発行が禁止され、2016年10月には既に発行済みのものも失効の対象となった(CA/Browser Forum — Guidance on Internal Names)。
この記事で使う.internalは、2024年7月にICANNが私用向けに正式に予約したTLDである。公的CAが扱えないことが前提の名前空間であり、証明書が必要なら自前のCAで出すしかない。
7.1.1. 自作が当たり前の場面
内部向けのサーバ証明書を自前のCAで出す構成は、珍しいどころか広く使われている。
| 場面 | 内容 |
|---|---|
| Kubernetes | クラスタごとに専用CAを持ち、各コンポーネントのサーバ証明書を自動発行する |
| サービスメッシュ | Istioなどが内部CAでワークロードの証明書を発行する |
| ミドルウェアのTLS | PostgreSQL、MySQL、Kafka、Elasticsearchの通信暗号化 |
| 閉域網・オンプレ | インターネットに出ない社内システム |
| 開発環境 |
mkcertのように、ローカルCAとサーバ証明書を作る専用ツールがある |
| IoT機器 | 公開DNS名を持たない機器 |
7.1.2. 発行元を分ける構成もある
現実の構成では、サーバ証明書とクライアント証明書で発行元を分けることがある。
| 発行元 | 理由 | |
|---|---|---|
| サーバ証明書 | 公的CA | ホストに公開DNS名があるなら使える。端末側にルートCAを入れる必要がない |
| クライアント証明書 | プライベートCA | 公的CAは自社端末向けのクライアント証明書を発行してくれない |
判断の分かれ目は、ホスト名が公開DNSに存在するかである。存在するなら公的CAを使えるし、そのほうが端末側の手間が減る。存在しないなら、この記事のように両方を自前のCAで出すことになる。
7.2. 準備する
ディレクトリを作る。
mkdir -p certs/server
続けて、証明書に書き込むホスト名とIPを変数に入れる。以降のコマンドで使う。
export SERVER_HOSTNAME=gw.example.internal
export SERVER_IP=192.0.2.10
この2つは、本番環境を想定した名前とアドレスである。gw.example.internalは社内向けのホスト名を、192.0.2.10はそのサーバのIPを模している(192.0.2.0/24はRFC 5737が文書用に予約した範囲で、実在のホストには割り当てられない)。
実際に11章で接続するのはlocalhostなので、この2つは手元では使わない。あえて設定しているのは、サーバ証明書に何を書くことになるかを本番の形で示すためである。理由は7.3で説明する。
手元の環境に合わせて変えてもよい。
7.3. ホスト名を書いた拡張ファイルを作る
発行時に渡す拡張ファイルを作る。ホスト名とIPは、7.2で設定した変数から埋め込まれる。
cat > certs/server/san.cnf <<SAN_EOF
[ server_cert ]
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:${SERVER_HOSTNAME}, DNS:localhost, IP:${SERVER_IP}, IP:127.0.0.1
SAN_EOF
変数が展開されたか確認する。
tail -1 certs/server/san.cnf
subjectAltName = DNS:gw.example.internal, DNS:localhost, IP:192.0.2.10, IP:127.0.0.1
${SERVER_HOSTNAME}が実際の値に置き換わっていれば正しい。
subjectAltNameに4つ書いているが、このうち手元の手順で使うのは1つだけである。
| 項目 | 目的 | この記事の手順で使うか |
|---|---|---|
DNS:gw.example.internal |
本番想定のホスト名 | 使わない |
IP:192.0.2.10 |
本番想定のIP | 使わない |
DNS:localhost |
手元での動作確認 | 使う(11章のcurl) |
IP:127.0.0.1 |
手元でIPを指定して接続する場合の予備 | 使わない |
11章で接続するのはlocalhostだけなので、動かすだけならDNS:localhostの1つで足りる。
それでも本番想定の2つを残しているのは、SANに何を書くかがこの章でいちばん判断を要する部分だからである。SANには接続時に使われる名前をすべて書く必要があり、書き漏らすとその名前では接続できない。本番のホスト名とIPを並べておくことで、実際の運用でどう書くことになるかが形として残る。
DNS:とIP:を書き分ける必要があることも、両方あるほうが分かりやすい。IPでアクセスするならIP:で書かなければならず、DNS:に書いても照合されない。
なお、使わない名前を入れること自体に動作上の害はないが、本番では不要な名前を入れないのが原則である。証明書に書かれた名前はすべて「この証明書で名乗ってよい名前」になるためである。
7.3.1. 4.3との書き方の違い
同じヒアドキュメントでも、4.3とは区切り文字の書き方が違う。
| 章 | 書き方 |
$の扱い |
|---|---|---|
| 4.3 |
<<'CNF_EOF'(クォートあり) |
展開されない。文字として残る |
| 7.1 |
<<SAN_EOF(クォートなし) |
シェルが展開する |
4.3では設定ファイル内の$dirをOpenSSLに解釈させたかったので展開を止めた。ここでは逆に、ホスト名を埋め込みたいので展開させている。
用語解説(別記事)
7.3.2. なぜホスト名を別ファイルに書くのか
サーバ証明書には、その証明書が有効なホスト名を書き込む必要がある。これをSAN(Subject Alternative Name)と呼ぶ。
ホスト名は環境ごとに変わるため、4.3のCA設定ファイルに固定では書けない。そこで発行時に別ファイルを作って渡す。
CNにホスト名を書いても照合には使われない。現在は規格上も禁止されている。
The Common Name RDN MUST NOT be used to identify a service because it is not strongly typed (it is essentially free-form text) and therefore suffers from ambiguities in interpretation.
(RFC 9525)
DNS:とIP:は別物である。IPでアクセスするならIP:の行が要る。DNS:192.0.2.10と書いても、IPでのアクセスには使われない。開発中は名前解決が怪しいことが多いので、両方入れておく。
用語解説(別記事)
7.3.3. 拡張を全部この1ファイルに書く理由
san.cnfにはsubjectAltNameだけでなく、basicConstraintsやkeyUsageも書いている。4.3のCA設定ファイルにある[ server_cert ]節と、ほぼ同じ内容の重複である。
これには理由がある。openssl caの2つのオプションには次の関係があるためである。
-extfile: An additional configuration file to read certificate extensions from (using its unnamed (
default) section unless the -extensions option is also used).
-extensions: The section of the configuration file containing certificate extensions to be added when a certificate is issued (defaults to x509_extensions unless the -extfile option is used).
(openssl-ca)
つまり次のようになる。
-
-extfileを指定すると、-extensionsはそのファイルの中の節を指すようになる - CA設定ファイル側の同名の節は参照されなくなる
-
san.cnfにsubjectAltNameしか書かないと、他の拡張がすべて抜け落ちる
しかも生成そのものは成功する。拡張が入っていない証明書ができあがるだけなので、気づきにくい。7.6.1の確認を必ず行うこと。
なお、4.3の[ server_cert ]節はこの記事の手順では使われない。12章のスクリプトとの対応をとるために残してある(4.3.4. 設定ファイルの読まれ方)。
7.4. 中間CAに署名してもらう
6章と同じく、CSRを作ってから署名してもらう。
$OPENSSL req -new -nodes -newkey rsa:2048 -sha256 \
-keyout certs/server/server.key.pem \
-out certs/server/server.csr.pem \
-subj "/C=JP/O=Example Corp/CN=${SERVER_HOSTNAME}"
chmod 600 certs/server/server.key.pem
$OPENSSL ca -batch -config certs/intermediate/openssl.cnf \
-extensions server_cert -extfile certs/server/san.cnf \
-days 825 -notext -md sha256 \
-in certs/server/server.csr.pem \
-out certs/server/server.cert.pem
実行すると、署名の内容が表示される。
Using configuration from certs/intermediate/openssl.cnf
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
countryName :PRINTABLE:'JP'
organizationName :ASN.1 12:'Example Corp'
commonName :ASN.1 12:'gw.example.internal'
Certificate is to be certified until Dec 20 09:50:14 2028 GMT (825 days)
Write out database with 1 new entries
Database updated
Signature okと表示され、最後がDatabase updatedで終わっていればよい。6.2と同じ流れで、今度は中間CAの台帳に記録される。
7.4.1. 6章との違い
CSRを作るコマンドは6章とほぼ同じである。違いは鍵長だけで、CAではないので2048ビットにしている(5.1.2. オプションの鍵長の項を参照)。
署名するコマンドには、6章になかったオプションが1つ増えている。
| オプション | 6章 | 7章 |
|---|---|---|
-config |
certs/root/openssl.cnf |
certs/intermediate/openssl.cnf |
-extensions |
v3_intermediate_ca |
server_cert |
-extfile |
なし |
あり(certs/server/san.cnf) |
-days |
1825 | 825 |
-configが中間CAの設定を指しているので、署名者が中間CAになる。ルートCAは登場しない。
各オプションの意味は6.2.2. オプションを参照。openssl caそのものについては6.2.1. openssl caサブコマンドを参照。
7.5. server-fullchain.pemを作る
nginxが自分を証明するために提示するファイルである。サーバ証明書と中間CAを連結する。
cat certs/server/server.cert.pem certs/intermediate/ca.cert.pem > certs/server/server-fullchain.pem
catによる連結の仕組みは6.3.1. catによる連結を参照。
7.5.1. ca-chain.cert.pemとの違い
6.3で作ったファイルと名前が似ているが、中身も役割も違う。
| ファイル | 中身 | nginxのディレクティブ | 役割 |
|---|---|---|---|
ca-chain.cert.pem |
中間CA + ルートCA | ssl_client_certificate |
クライアント証明書を検証するためのCA |
server-fullchain.pem |
サーバ証明書 + 中間CA | ssl_certificate |
サーバが自分を証明するために提示する |
サーバが提示するほうにルートCAを入れない理由は、2.4.3. 証明書チェーン(別記事)で説明したとおりである。受け取る側は既にルートCAを信頼ストアに持っているはずで、持っていないなら送っても信頼されない。
順序にも意味がある。nginxのドキュメントに明記されている。
If intermediate certificates should be specified in addition to a primary certificate, they should be specified in the same file in the following order: the primary certificate comes first, then the intermediate certificates.
(ngx_http_ssl_module)
取り違えたときの症状は次のとおり。
| 間違い | 症状 |
|---|---|
ssl_certificateに中間CAを入れ忘れた |
チェーンが途切れる。ルートCAを信頼している端末でも信頼されない。ブラウザが中間CAを補完することがあり、環境によって通ったり通らなかったりする |
ssl_certificateにルートCAまで入れた |
動作はするが、毎回の握手で不要なデータを送る |
ssl_client_certificateにルートCAを入れ忘れた |
クライアント証明書の検証がチェーンをたどりきれず失敗する |
7.6. できたか確認する
7.6.1. SANが入っているか
この章で最も重要な確認である。SANが入っていなければ、この証明書はホスト名の照合に使えない。ブラウザもcurlもSANしか見ないため、CNにホスト名を書いていても接続は失敗する(7.3.2)。
証明書の中からsubjectAltName拡張だけを抜き出して表示する。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL x509 -in certs/server/server.cert.pem -noout -text | grep -A1 'Alternative Name'
| 記述 | 意味 |
|---|---|
x509 -in ... |
証明書を読む |
-noout |
証明書そのもの(PEM)を出力しない。付けないとPEMが全部流れる |
-text |
証明書の内容を人が読める形式で表示する |
| |
前のコマンドの出力を次に渡す(パイプ) |
grep -A1 'Alternative Name' |
一致した行と、その後ろ1行を表示する |
X509v3 Subject Alternative Name:
DNS:gw.example.internal, DNS:localhost, IP Address:192.0.2.10, IP Address:127.0.0.1
2行目に4つの名前が並んでいればよい。7.3でsan.cnfに書いた値がそのまま入っている。
| 表示 | 7.3での位置づけ |
|---|---|
DNS:gw.example.internal |
本番想定のホスト名。手元では使わない |
DNS:localhost |
11章のcurlが接続する名前。これが無いと動作確認できない |
IP Address:192.0.2.10 |
本番想定のIP。手元では使わない |
IP Address:127.0.0.1 |
IPで接続する場合の予備 |
ここが空、または表示自体が無い場合は-extfileが効いていない。7.3.3を読み直すこと。
出力の読み方
この確認コマンドの出力は、どれも2行で1組になっている。
- 1行目が拡張の名前
- 2行目がその値
grep -A1で「一致した行とその後ろ1行」を出しているのは、この2行をそろえて見るためである。
設定ファイルに書いた内容とは、次のように対応する。
設定ファイル(san.cnf) |
証明書での表示 | |
|---|---|---|
| 拡張の名前 | subjectAltName |
X509v3 Subject Alternative Name |
| 値 | DNS:gw.example.internal, ... IP:192.0.2.10, ... |
DNS:gw.example.internal, ... IP Address:192.0.2.10, ... |
名前も値も、表示用の文字列に置き換わる。 設定ファイルの字面とは一致しない。
- 名前は
subjectAltNameがX509v3 Subject Alternative Nameになる - 値の中では
IP:がIP Address:になる(DNS:は変わらない)
以降の確認コマンドも同じ形なので、この対応を頭に入れておくと読みやすい。
7.6.2. サーバ用途に限定されているか
証明書の中からextendedKeyUsage拡張だけを抜き出して表示する。
$OPENSSL x509 -in certs/server/server.cert.pem -noout -text | grep -A1 'Extended Key Usage'
X509v3 Extended Key Usage:
TLS Web Server Authentication
TLS Web Server Authenticationと表示されていればよい。
7.6.1と同じく、1行目が拡張の名前、2行目が値である。
設定ファイル(san.cnf) |
証明書での表示 | |
|---|---|---|
| 拡張の名前 | extendedKeyUsage |
X509v3 Extended Key Usage |
| 値 | serverAuth |
TLS Web Server Authentication |
値がserverAuthからTLS Web Server Authenticationに置き換わっている点に注目する。どちらも同じものを指している。
ここがTLS Web Client Authenticationになっていたら、-extensionsにclient_certを指定してしまっている。
CAではないことも確認しておく。
$OPENSSL x509 -in certs/server/server.cert.pem -noout -text | grep -A1 'Basic Constraints'
X509v3 Basic Constraints:
CA:FALSE
CA:FALSEと表示されていればよい。5章と6章で作った証明書はCA:TRUEだったが、末端の証明書なのでここはCA:FALSEになる。
こちらも同じ形で、basicConstraintsという名前がX509v3 Basic Constraints、値のCA:FALSEはそのまま表示される。
7.6.3. 誰が署名したか、何枚目か
subject、issuer、シリアル番号をまとめて表示する。
$OPENSSL x509 -in certs/server/server.cert.pem -noout -subject -issuer -serial
subject=CN=gw.example.internal, C=JP, O=Example Corp
issuer=CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
serial=1000
読み方は6.4.1. 誰が誰に署名したかと同じである。issuerが中間CAになっていれば、中間CAが署名したことになる。
serialが1000なのは、中間CAが発行した1枚目だからである。中間CAのserialファイルには4.2で1000と書いた。クライアント証明書は1001から始まる。
台帳にも記録されている。
cat certs/intermediate/index.txt
V 281220095014Z 1000 unknown /CN=gw.example.internal/C=JP/O=Example Corp
ここまでで、ルートCAの台帳に1枚(中間CA)、中間CAの台帳に1枚(サーバ証明書)が記録された状態である。
台帳は何のためにあるか
ここで一度、台帳の役割を整理しておく。4.2で「openssl caは台帳を前提に動く」と書いたが、その台帳が何に使われるかはまだ説明していない。
用途は3つある。
| 用途 | 使う場面 | 台帳のどこを見るか |
|---|---|---|
| 採番 | 発行のたびに番号を1つ増やす |
serialファイル |
| 重複の判定 | 同じCNの値の証明書を2枚出してよいか決める |
index.txtのSubject |
| 失効の管理 | 失効させた証明書を記録し、CRLを組み立てる |
index.txtの状態とシリアル |
3つ目がこの記事の目的に直結する。
証明書は、いったん発行すると中身を書き換えられない。有効期限を早めることも、無効にすることもできない。にもかかわらず「この端末はもう使わせない」を実現するには、証明書の外側に「無効にした」という記録を持つしかない。その記録の置き場が台帳である。
証明書を発行 → 台帳に V(有効)として記録 ← 6章・7章・8章
↓
証明書を失効 → 台帳を R(失効済み)に書き換える ← 9.2
↓
CRLを生成 → 台帳のRの行から一覧を組み立てる ← 9.3
↓
nginxが参照 → CRLに載っていれば接続を拒否 ← 11.4
いまindex.txtに見えている先頭のVが、9章でRに変わる。その1文字が、最終的にnginxの動作を変える。
台帳を失うと、発行した証明書を失効させる手段が無くなる。CAの秘密鍵と同じく、台帳もバックアップの対象である。
7.6.4. fullchainの中身と順序
連結したファイルに入っている証明書を、先頭から順に表示する。
$OPENSSL crl2pkcs7 -nocrl -certfile certs/server/server-fullchain.pem | $OPENSSL pkcs7 -print_certs -noout
subject=CN=gw.example.internal, C=JP, O=Example Corp
issuer=CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
subject=CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
issuer=C=JP, O=Example Corp, CN=Example Corp Dev Root CA
読み方は6.4.3. チェーンファイルに2枚入っているかと同じである。subjectを見て判断する。
確認する点は3つある。
| 確認 | 期待される状態 |
|---|---|
| 1枚目 |
subjectがサーバ証明書(CN=gw.example.internal) |
| 2枚目 |
subjectが中間CA |
| 3枚目 | 無い。ルートCAは入れない |
6.4.3で確認したca-chain.cert.pemは、1枚目が中間CA、2枚目がルートCAだった。2つのファイルで中身が違うことを、ここで見比べておくとよい。
8. クライアント証明書を作成する
端末に入れる証明書を作る。この記事の目的であるmTLSで、サーバに「私はこの端末である」と示すための証明書である。
3.2で挙げたとおり、検証したいパターンごとに5枚作る。
| ファイル名 | CN |
用途 | 特別な指定 |
|---|---|---|---|
device-001 |
device-001 |
正常系 | なし |
device-001-renewed |
device-001 |
更新後を模したもの | CNの値が1枚目と同じ |
device-002 |
device-002 |
許可リストから外して遮断する確認用 | なし |
device-003 |
device-003 |
9章で失効させる | なし |
device-999 |
device-999 |
有効期限切れ | 過去の日付で発行 |
同じ手順を5回繰り返すので、先に関数にまとめる。その前に、出力先のディレクトリを作る。以下のコマンドをターミナルに貼り付けて実行。
mkdir -p certs/clients
8.1. 発行用の関数を定義する
5枚とも手順は同じである。変わるのは「ファイル名」「CNの値」「有効期間」の3つだけなので、この3つを引数で受け取る関数を作る。毎回同じコマンドを書かずに済む。
次の内容をターミナルにまとめて貼り付けると、issue_clientという関数が使えるようになる。関数の貼り付け方は4.0. 作業ディレクトリとコードの扱いを参照。
issue_client() {
local name="$1" cn="$2"; shift 2
$OPENSSL req -new -nodes -newkey rsa:2048 -sha256 \
-keyout "certs/clients/${name}.key.pem" \
-out "certs/clients/${name}.csr.pem" \
-subj "/C=JP/O=Example Corp/OU=Devices/CN=${cn}"
chmod 600 "certs/clients/${name}.key.pem"
$OPENSSL ca -batch -config certs/intermediate/openssl.cnf \
-extensions client_cert -notext -md sha256 "$@" \
-in "certs/clients/${name}.csr.pem" \
-out "certs/clients/${name}.cert.pem"
}
関数の中でやっていることは7章と同じである。CSRを作り、中間CAに署名してもらう。この時点では証明書は1枚もできない。定義しただけで、実行は8.2でおこなう。
8.1.1. 引数の受け取り方
関数を呼び出すとき、引数は次のように渡る。
issue_client "device-001" "device-001" -days 825
↑ ↑ ↑ ↑
│ │ │ └─ 第3引数以降
│ │ └─ 第2引数:証明書のCN
│ └─ 第1引数:ファイル名
└─ 関数名
| 記述 | 意味 |
|---|---|
local name="$1" cn="$2" |
第1引数と第2引数を、それぞれ変数に入れる。localは関数の外に漏らさない指定 |
shift 2 |
引数を2つぶん捨てる。残りが繰り上がる |
"$@" |
残った引数すべて。shift後なので第3引数以降が入る |
第1引数と第2引数を分けているのが要点である。ファイル名とCNの値を別々に指定できるようにしてあり、8.2.1でこれを使う。
"$@"をそのままopenssl caに渡しているので、呼び出し側で-days 825とも-startdate ... -enddate ...とも書ける。有効期間の指定方法が証明書ごとに違うためである。
8.1.2. 7章との違い
サーバ証明書を作ったときとの違いは2点だけである。
| 7章(サーバ証明書) | 8章(クライアント証明書) | |
|---|---|---|
-extensions |
server_cert |
こちらを使う。client_cert
|
-extfile |
あり(san.cnf) |
なし |
-extfileが要らないのは、クライアント証明書にはSANを書かないからである。SANは「この証明書が有効なホスト名」を示すものであり、接続を受ける側にしか意味がない。端末側は接続する側なので不要である。
したがって拡張は4.3のCA設定ファイルにある[ client_cert ]節をそのまま使える。7.3.3で説明した-extfileの落とし穴も、ここでは関係しない。
DNにOU=Devicesが増えている点も7章と違うが、これは識別しやすくするために入れているだけで、必須ではない(4.3のpolicy_looseではCNだけが必須)。
各オプションの意味は6.2.2. オプションを参照。-subjの書き方は5.1.3. -subjに書いた値を参照。
8.1.3. クライアント証明書のCNの値は識別子として使える
7.3.2で、サーバ証明書のCNは照合に使われないと説明した。クライアント証明書のCNは事情が違う。
- nginxが
$ssl_client_s_dnという変数でアプリケーションに渡せる - アプリケーション側は、その値を端末の識別子として扱える
- 更新しても
CNの値を変えなければ、同じ端末として扱い続けられる
規格が禁止しているのは「サーバが名乗ったホスト名の照合にCNを使うこと」であって、クライアント証明書のCNの使い道には規定がない。何を入れるかは設計者が決めてよい。
用語解説(別記事)
8.2. 5枚を発行する
8.1で定義した関数を5回呼び出す。1回の呼び出しで証明書が1枚できる。
次の5行を、まとめてターミナルに貼り付けて実行する。
issue_client "device-001" "device-001" -days 825
issue_client "device-001-renewed" "device-001" -days 825
issue_client "device-002" "device-002" -days 825
issue_client "device-003" "device-003" -days 825
issue_client "device-999" "device-999" -startdate 20250101000000Z -enddate 20250102000000Z
各行が何を作るかは次のとおりである。
| 行 | ファイル名 | CN |
有効期間 |
|---|---|---|---|
| 1 | device-001 |
device-001 |
825日 |
| 2 | device-001-renewed |
device-001 ← 1行目と同じ
|
825日 |
| 3 | device-002 |
device-002 |
825日 |
| 4 | device-003 |
device-003 |
825日 |
| 5 | device-999 |
device-999 |
2025年1月1日から1月2日 |
実行すると、1行ごとに署名の内容が表示される。
Certificate is to be certified until Dec 20 10:51:27 2028 GMT (825 days)
Database updated
これが5回繰り返されれば成功である。
2行目と5行目だけ、他の3行と指定の仕方が違う。2行目はCNの値を1行目と同じにしてあり、5行目は有効期間に過去の日付を指定している。 それぞれ8.2.1と8.2.2で説明する。
8.2.1. 同じCNの値で2枚発行する
2行目は、ファイル名がdevice-001-renewedなのに、CNの値はdevice-001のままである。
issue_client "device-001-renewed" "device-001" -days 825
↑ ↑
ファイル名は別 CNは1枚目と同じ
証明書を更新したとき、同じ端末として扱い続けられるかを確認するための2枚である。8.3.2で見比べる。
ここで普通なら発行が止まる。openssl caは既定で「1つのSubjectにつき有効な証明書は1枚」を強制するためである。
ERROR:There is already a certificate for /C=JP/O=Example Corp/OU=Devices/CN=device-001
止まらないのは、4.3の設定に次の1行を入れてあるからである。
unique_subject = no
用語解説(別記事)
8.2.2. 過去の日付で発行する
5行目は-daysを使わず、開始日と終了日を直接指定している。
-startdate 20250101000000Z -enddate 20250102000000Z
| オプション | 意味 |
|---|---|
-startdate |
有効期間の開始日時 |
-enddate |
有効期間の終了日時 |
書式はYYYYMMDDHHMMSSZで、末尾のZまで含めて指定する。2025年1月1日から1月2日までなので、実行時点では既に期限切れである。
期限切れの証明書をCAが発行すること自体は、何の問題もなく通る。有効期間はCAが決める値であり、過去を指定してはならないという制約はない。
-daysと併用した場合は、明示した期限のほうが優先される。
用語解説(別記事)
8.3. 発行結果を確認する
8.3.1. 5枚できているか、シリアルは連番か
5枚ぶんのファイル名、CNの値、シリアル番号をまとめて表示する。以下のコマンドをターミナルに貼り付けて実行。
for f in certs/clients/*.cert.pem; do
printf '%-34s %-12s %s\n' "$(basename $f)" \
"$($OPENSSL x509 -in $f -noout -subject | sed 's/.*CN=\([^,]*\).*/\1/')" \
"$($OPENSSL x509 -in $f -noout -serial | cut -d= -f2)"
done
device-001-renewed.cert.pem device-001 1002
device-001.cert.pem device-001 1001
device-002.cert.pem device-002 1003
device-003.cert.pem device-003 1004
device-999.cert.pem device-999 1005
5行表示され、シリアルが1001から1005まで連番になっていればよい。
2枚のCNの値が同じで、シリアルだけ違うことを確認する。対象はdevice-001とdevice-001-renewedである。
並び順がファイル名順になるため、device-001-renewedが先に出る。シリアル番号を見れば発行順が分かる。
サーバ証明書が1000だったので、クライアント証明書は1001から始まる。
8.3.2. 更新前後を見比べる
1枚目と2枚目のsubjectとシリアル番号を、並べて表示する。
$OPENSSL x509 -in certs/clients/device-001.cert.pem -noout -subject -serial
subject=CN=device-001, C=JP, O=Example Corp, OU=Devices
serial=1001
serial=1001で、subjectのCNがdevice-001であればよい。
$OPENSSL x509 -in certs/clients/device-001-renewed.cert.pem -noout -subject -serial
subject=CN=device-001, C=JP, O=Example Corp, OU=Devices
serial=1002
subjectが1枚目と完全に同じで、serialだけが1002に変わっていればよい。これが「証明書を更新した」状態の再現である。
ここから、許可リストをどう持つかの判断が分かれる。
| 持ち方 | 利点 | 欠点 |
|---|---|---|
CNの値で持つ |
更新のたびにリストを触らなくてよい | CAが同じCNの値を何枚でも再発行できてしまう |
| シリアル番号で持つ | 1枚ごとに制御できる | 更新のたびにリストの更新が要る |
どちらが正しいということはなく、証明書の更新をどう運用するか次第である。11章でCNの値による許可リストを実際に試す。
8.3.3. 用途がクライアント向けになっているか
証明書の中からextendedKeyUsage拡張だけを抜き出して表示する。
$OPENSSL x509 -in certs/clients/device-001.cert.pem -noout -text | grep -A1 'Extended Key Usage'
X509v3 Extended Key Usage:
TLS Web Client Authentication
7.6.2で見たサーバ証明書はTLS Web Server Authenticationだった。ここがClientになっていれば、-extensions client_certが効いている。
読み方は7.6.2. サーバ用途に限定されているかを参照。
8.3.4. 期限切れの1枚
有効期間の開始日時と終了日時を表示する。
$OPENSSL x509 -in certs/clients/device-999.cert.pem -noout -dates
notBefore=Jan 1 00:00:00 2025 GMT
notAfter=Jan 2 00:00:00 2025 GMT
notBeforeが2025年1月1日、notAfterが2025年1月2日になっていればよい。8.2で-startdateと-enddateに指定した値が、そのまま入る。
8.3.5. 台帳
中間CAの台帳を表示する。
cat certs/intermediate/index.txt
V 281220105116Z 1000 unknown /CN=gw.example.internal/C=JP/O=Example Corp
V 281220105127Z 1001 unknown /CN=device-001/C=JP/O=Example Corp/OU=Devices
V 281220105127Z 1002 unknown /CN=device-001/C=JP/O=Example Corp/OU=Devices
V 281220105127Z 1003 unknown /CN=device-002/C=JP/O=Example Corp/OU=Devices
V 281220105127Z 1004 unknown /CN=device-003/C=JP/O=Example Corp/OU=Devices
V 250102000000Z 1005 unknown /CN=device-999/C=JP/O=Example Corp/OU=Devices
サーバ証明書1枚とクライアント証明書5枚で、計6枚が記録されている。シリアル1001と1002が同じCNの値で2行あるのが、unique_subject = noの効果である。
読み方は6.4.4を参照。
cat certs/intermediate/serial
1006
1006と表示されていればよい。1000から数えて6枚発行したので、次に使う番号が1006になる。
ls certs/intermediate/newcerts
1000.pem 1001.pem 1002.pem 1003.pem 1004.pem 1005.pem
1000.pemから1005.pemまで6つ並んでいればよい。発行した証明書のコピーが、シリアル番号を名前にして保存される。
期限切れなのにVと表示される
device-999は既に期限が過ぎているが、状態はV(有効)のままである。台帳の状態は発行時に書き込まれ、時間の経過では自動で変わらないためである。
openssl caに-updatedbを渡すと、期限を過ぎたものがE(Expired)に書き換わる。
$OPENSSL ca -config certs/intermediate/openssl.cnf -updatedb
Using configuration from certs/intermediate/openssl.cnf
1005=Expired
1005=Expiredと表示されればよい。期限を過ぎた1枚が見つかったことを示している。
grep device-999 certs/intermediate/index.txt
E 250102000000Z 1005 unknown /CN=device-999/C=JP/O=Example Corp/OU=Devices
先頭がVからE(Expired)に変わっていればよい。
この記事では実行しなくても支障はない。台帳の状態が自動更新されないという性質を知っておけばよい。
8.4. .p12に変換する
ここまでで、1つの端末につき次の3つのファイルができている。
-
certs/clients/device-001.key.pem— 秘密鍵 -
certs/clients/device-001.cert.pem— 証明書 -
certs/ca-chain.cert.pem— CAチェーン(全端末で共通)
この3つを端末に配るのは現実的ではない。 個別に読み込ませる必要があり、1つでも入れ忘れると動かない。そこで1ファイルにまとめる。それがPKCS#12(.p12)である。
5枚ぶんをまとめて変換する。forループで1枚ずつ処理し、5つの.p12ファイルを作る。
for n in device-001 device-001-renewed device-002 device-003 device-999; do
cn=$($OPENSSL x509 -in "certs/clients/${n}.cert.pem" -noout -subject | sed 's/.*CN=\([^,]*\).*/\1/')
$OPENSSL pkcs12 -export \
-inkey "certs/clients/${n}.key.pem" \
-in "certs/clients/${n}.cert.pem" \
-certfile "certs/ca-chain.cert.pem" \
-name "Example Corp ${cn}" \
-passout "pass:mtls-dev" \
-out "certs/clients/${n}.p12"
done
8.4.1. オプション
| オプション | 意味 |
|---|---|
pkcs12 |
PKCS#12を扱うサブコマンド |
-export |
.p12を作る。付けないと逆に展開する動作になる |
-inkey |
まとめる秘密鍵 |
-in |
まとめる証明書 |
-certfile |
同梱するCAチェーン。端末側で中間CAを別途入れる手間が省ける |
-name |
端末の一覧に表示される名前(フレンドリー名) |
-passout |
保護パスワード。接頭辞が要る(pass:) |
-out |
出力先 |
ループの中でcnを取り出しているのは、-nameに表示名として使うためである。ファイル名ではなくCNの値を使うので、device-001-renewed.p12のフレンドリー名はExample Corp device-001になる。
-passoutの書式に注意する。-passout mtls-devとは書けず、pass:を付けて-passout "pass:mtls-dev"とする。ファイルから読むfile:や環境変数から読むenv:も指定できる。
8.4.2. なぜ1ファイルにまとめるのか
PEMのまま配る場合と比べると、違いは次のとおりである。
| PEMファイル3つ |
.p12ファイル1つ |
|
|---|---|---|
| ファイル数 | 3 | 1 |
| 秘密鍵の保護 | 平文のまま(-nodesで作ったため) |
パスワードで暗号化される |
| OSへの取り込み | 対応していないOSもある | macOS、Windows、iOS、Androidすべて対応 |
| 鍵と証明書の対応 | 人が正しく組み合わせる必要がある | ファイル内で対応づけ済み |
macOSでは.p12をダブルクリックするとキーチェーンに取り込まれ、パスワードを聞かれる。
用語解説(別記事)
8.4.3. できたか確認する
作られた.p12ファイルを一覧する。
ls certs/clients/*.p12
certs/clients/device-001-renewed.p12
certs/clients/device-001.p12
certs/clients/device-002.p12
certs/clients/device-003.p12
certs/clients/device-999.p12
5つの.p12ファイルができていればよい。
中身を見る。
$OPENSSL pkcs12 -in certs/clients/device-001.p12 -info -noout -passin pass:mtls-dev
MAC: sha256, Iteration 2048
MAC length: 32, salt length: 16
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
Certificate bag
Certificate bag
Certificate bag
PKCS7 Data
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
確認する点は2つである。
| 表示 | 意味 |
|---|---|
Shrouded Keybag |
暗号化された秘密鍵が入っている。1つだけであること |
Certificate bagが3つ |
クライアント証明書 + 中間CA + ルートCA。-certfileで同梱した2枚が効いている |
Certificate bagが1つしかない場合、-certfileの指定が抜けている。その.p12を端末に入れても、中間CAが無いためチェーンをたどれない。
なお-infoは中身の構成を表示するオプション、-passinは読み込むときのパスワードである。作るときの-passoutと対になっている。
9. 失効リスト(CRL)を作成する
9.1. このステップですること
端末を紛失した場合などに備えて、期限前に証明書を無効化する仕組みを用意する。動作確認のため、device-003を1枚失効させてCRLを作る。
certs/crl/ ← このステップで作る
├── intermediate.crl.pem ← 中間CAが発行する失効リスト
├── root.crl.pem ← ルートCAが発行する失効リスト
└── ca-chain.crl.pem ← 上の2つを連結したもの(nginxに渡す)
まず、CRLを置くディレクトリを作る。以下のコマンドをターミナルに貼り付けて実行。
mkdir -p certs/crl
9.2. 証明書を失効させる
device-003を失効させる。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL ca -batch -config certs/intermediate/openssl.cnf \
-revoke certs/clients/device-003.cert.pem -crl_reason keyCompromise
Using configuration from certs/intermediate/openssl.cnf
Revoking Certificate 1004.
Database updated
9.2.1. オプション
| オプション | 意味 |
|---|---|
ca |
CAとしての操作をおこなうサブコマンド。6.2.1で署名に使ったものと同じ |
-batch |
確認プロンプトを出さずに実行する(6.2.2) |
-config certs/intermediate/openssl.cnf |
どのCAとして失効させるかを決める |
-revoke certs/clients/device-003.cert.pem |
失効させる証明書。CSRではなく発行済みの証明書ファイルを渡す |
-crl_reason keyCompromise |
失効の理由。台帳とCRLの両方に記録される |
$OPENSSLは4.1で定義した変数、行末の\は次の行に続くという印である。詳しくは5.1.1. コマンドの読み方を参照。
-configに渡すのは、その証明書を発行したCAの設定ファイルである。失効とはCAの台帳への記録なので、発行したCA以外の台帳に書いても意味がない。device-003は中間CAが発行したものなので、ここはcerts/intermediate/openssl.cnfになる。
9.2.2. 台帳に記録されたか
この操作は証明書ファイルを書き換えない。 変わるのはCA側の台帳だけである。
grep '^R' certs/intermediate/index.txt
R 281218160916Z 260915160927Z,keyCompromise 1004 unknown /CN=device-003/C=JP/O=Example Corp/OU=Devices
先頭がV(valid)からR(revoked) に変わり、失効日時と理由が記録されていればよい。
用語解説(別記事)
9.3. CRLを生成する
失効した証明書の一覧をファイルに書き出す。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL ca -batch -config certs/intermediate/openssl.cnf \
-gencrl -crlexts crl_ext -out certs/crl/intermediate.crl.pem
Using configuration from certs/intermediate/openssl.cnf
読み込んだ設定ファイルのパスが1行出るだけで、成功したことを知らせるメッセージは出ない。9.2がRevoking Certificate 1004.とDatabase updatedを出したのとは違い、ここでは出力から成否を判断できない。エラーが出ずに終わっていればよく、中身が正しいかどうかは9.4で確かめる。
台帳の内容からCRLが組み立てられ、certs/crl/intermediate.crl.pemに書き出される。
9.3.1. オプション
| オプション | 意味 |
|---|---|
ca |
CAとしての操作をおこなうサブコマンド。6.2.1で署名に使ったものと同じ |
-batch |
確認プロンプトを出さずに実行する(6.2.2) |
-config certs/intermediate/openssl.cnf |
どのCAのCRLを作るかを決める。ここで指定したCAの台帳を読む |
-gencrl |
CRLを生成する。openssl caは既定ではCSRへの署名をおこなうため、このオプションで動作を切り替える |
-crlexts crl_ext |
CRLに付ける拡張として、設定ファイルの[ crl_ext ]節を使う(4.3.4. 設定ファイルの読まれ方) |
-out certs/crl/intermediate.crl.pem |
出力先。6.2.2では証明書の出力先だったが、ここではCRLが書かれる |
$OPENSSLは4.1で定義した変数、行末の\は次の行に続くという印である。詳しくは5.1.1. コマンドの読み方を参照。
-gencrlは9.2の-revokeと同じ性質のオプションである。openssl caは渡されたオプションによって「署名する」「失効させる」「CRLを作る」のどれをおこなうかが変わる。実行結果の1行目が9.2とまったく同じUsing configuration from ...なのも、同じサブコマンドが動いているためである。
用語解説(別記事)
9.3.2. ルートCAのCRLも作る
中間CAのCRLだけでは足りない。CRLを見る検証では、チェーンの各段について、その段を発行したCAのCRLが要るためである。
クライアント証明書 ←発行 中間CA ←発行 ルートCA
↑ ↑
中間CAのCRL ルートCAのCRL
(9.3で作成) (ここで作成)
ルートCAのCRLを作る。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL ca -batch -config certs/root/openssl.cnf \
-gencrl -crlexts crl_ext -out certs/crl/root.crl.pem
Using configuration from certs/root/openssl.cnf
コマンドの形は9.3と同じで、-configがルートCAの設定に変わっただけである。ルートCAの設定ファイルにも[ crl_ext ]があり、crlnumberも4.2で用意してあるので、追加の準備は要らない。
2つのCRLを1つのファイルに連結する。以下のコマンドをターミナルに貼り付けて実行。
cat certs/crl/intermediate.crl.pem certs/crl/root.crl.pem > certs/crl/ca-chain.crl.pem
nginxに渡すのはこのca-chain.crl.pemである。CRLは1つのファイルに複数入れられる。6.3でca-chain.cert.pemを作ったのと同じ考え方である。
このファイルを使わず中間CAのCRLだけをnginxに渡すと、失効していない証明書まで拒否される。nginxはチェーンの全段についてCRLを確認するためである。10.5で実際に確かめる。
9.3.3. 注意点
- 失効状態を示す責任は その証明書を発行したCA が負う(RFC 5280 §3.3「CAs are responsible for indicating the revocation status of the certificates that they issue.」)
- よって中間CAが出した証明書は中間CAで失効させる
- なおCRLに署名する主体は別のエンティティに委任することもできる。この記事では委任しない
- 実務上も、
openssl ca -revokeはそのCAのindex.txtに記録がある証明書しか失効させられない
-
-gencrlにはcrlnumberファイルが要る(4.2で作ったもの) - CRLには有効期限がある。既定は設定の
default_crl_days(この記事では30日)- 期限切れのCRLはnginx側で検証エラーになるので、定期的な再生成が要る
- 中間CAが複数ある場合、nginxに渡すCRLファイルは1つにまとめる
When using intermediate certificates, their CRLs should be specified in the same file.
(ngx_http_ssl_module)
用語解説(別記事)
9.4. できたか確認する
作られたCRLの中身を表示する。
$OPENSSL crl -in certs/crl/intermediate.crl.pem -noout -text
Certificate Revocation List (CRL):
Version 2 (0x1)
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=Example Corp mTLS Intermediate CA, C=JP, O=Example Corp
Last Update: Sep 15 16:09:27 2026 GMT
Next Update: Oct 15 16:09:27 2026 GMT
CRL extensions:
X509v3 Authority Key Identifier:
75:FE:AC:55:60:AC:8E:B7:B5:58:6F:E5:03:67:10:BD:AB:FE:9D:0B
X509v3 CRL Number:
4096
Revoked Certificates:
Serial Number: 1004
Revocation Date: Sep 15 16:09:27 2026 GMT
CRL entry extensions:
X509v3 CRL Reason Code:
Key Compromise
確認する点は4つである。
| 項目 | 期待される値 | 意味 |
|---|---|---|
Version |
2 |
-crlextsが効いている |
Issuer |
中間CA | 中間CAが発行したCRLである |
Next Update |
発行の30日後 |
default_crl_days = 30が効いている |
Serial Number |
1004 |
device-003が失効している(8.3.1の一覧と一致) |
X509v3 CRL Numberが4096と表示されるのは、crlnumberファイルの1000(16進)を10進で表示しているためである。
証明書のほうを見ても、失効したかどうかは分からない。 証明書自体は何も変わらないためである。失効の情報はCRLにしか存在しない。
連結できているか
9.3.2で作ったca-chain.crl.pemに、CRLが2つ入っていることも確認する。以下のコマンドをターミナルに貼り付けて実行。
grep -c 'BEGIN X509 CRL' certs/crl/ca-chain.crl.pem
2
2と表示されていればよい。1なら9.3.2の連結ができていない。中間CAとルートCAのCRLが1つのファイルに入っている状態が、nginxに渡せる形である。
6.4.3でca-chain.cert.pemの枚数を数えたのと同じ確認である。
10. 生成物を検証する
10.1. このステップですること
nginxに渡す前に、作ったチェーンが実際に通ることを確認する。新しく作るファイルは無い。
10.2. チェーンが通ることを確認する
まずサーバ証明書から確かめる。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL verify -CAfile certs/root/ca.cert.pem \
-untrusted certs/intermediate/ca.cert.pem \
certs/server/server.cert.pem
certs/server/server.cert.pem: OK
末尾がOKになっていればよい。OKが出るのは、ルートCAから中間CAを経由してサーバ証明書までたどれたときだけである。
10.2.1. オプション
| 項目 | 意味 |
|---|---|
verify |
証明書のチェーンをたどって、信頼できるかどうかを判定するサブコマンド。5章のreq、6章のcaと同じくサブコマンドの1つである |
-CAfile certs/root/ca.cert.pem |
信頼の起点として使う証明書。ここに指定したものは無条件で信頼される |
-untrusted certs/intermediate/ca.cert.pem |
チェーンをつなぐために中継として使う証明書。信頼はされず、経路をたどるためだけに使われる |
| 最後の引数 | 検証したい証明書 |
-CAfileが信頼の起点、-untrustedは経路をつなぐだけ、という役割の違いを押さえておく。10.3ではこの違いを実際に確かめる。
$OPENSSLは4.1で定義した変数、行末の\は次の行に続くという印である。詳しくは5.1.1. コマンドの読み方を参照。
用語解説(別記事)
10.2.2. クライアント証明書5枚を確認する
5枚を1枚ずつ検証する。以下のコマンドをターミナルに貼り付けて実行。
for f in certs/clients/*.cert.pem; do
$OPENSSL verify -CAfile certs/root/ca.cert.pem \
-untrusted certs/intermediate/ca.cert.pem "$f" 2>&1 | tail -1
done
certs/clients/device-001-renewed.cert.pem: OK
certs/clients/device-001.cert.pem: OK
certs/clients/device-002.cert.pem: OK
certs/clients/device-003.cert.pem: OK
error certs/clients/device-999.cert.pem: verification failed
5行のうち4行がOKで、device-999だけがfailedになっていればよい。 失敗するのが正しい挙動である。理由を確認する。
| 記述 | 意味 |
|---|---|
for f in ...; do ... done |
certs/clients/にある証明書を1枚ずつfに入れて繰り返す |
"$f" |
繰り返しの対象になっている1枚のファイル名 |
2>&1 |
エラー出力を通常の出力にまとめる。verifyは失敗の内容をエラー出力に書くため、これが無いとtailに渡らない |
| tail -1 |
出力の最後の1行だけを表示する。1枚につき結果が1行になる |
$OPENSSL verify -CAfile certs/root/ca.cert.pem \
-untrusted certs/intermediate/ca.cert.pem certs/clients/device-999.cert.pem
CN=device-999, C=JP, O=Example Corp, OU=Devices
error 10 at 0 depth lookup: certificate has expired
error certs/clients/device-999.cert.pem: verification failed
error 10(certificate has expired)と表示されていればよい。8.2で過去の日付を指定して作った1枚なので、期限切れで弾かれる。
なおdevice-003がここでOKになるのは、openssl verifyは既定でCRLを見ないためである。失効の確認は10.4でおこなう。
10.3. 中間CAを外すと失敗することを確認する
「正しく失敗すること」も確認しておく。10.2のコマンドから-untrustedを外しただけである。中継する中間CAを渡さないので、ルートCAからdevice-001までたどれなくなる。
$OPENSSL verify -CAfile certs/root/ca.cert.pem certs/clients/device-001.cert.pem
CN=device-001, C=JP, O=Example Corp, OU=Devices
error 20 at 0 depth lookup: unable to get local issuer certificate
error certs/clients/device-001.cert.pem: verification failed
error 20(unable to get local issuer certificate)と表示されていればよい。OKと出た場合は、コマンドから-untrustedを外し忘れていないか確認する。
ルートCAだけではチェーンをたどりきれない。これを確認しておくと、後でnginxが通らないときに、設定の問題なのか証明書の問題なのかを切り分けられる。
用語解説(別記事)
10.4. 失効が効くことを確認する
openssl verifyに-crl_checkと-CRLfileを渡すと、CRLも見るようになる。
$OPENSSL verify -crl_check \
-CAfile certs/root/ca.cert.pem \
-untrusted certs/intermediate/ca.cert.pem \
-CRLfile certs/crl/intermediate.crl.pem \
certs/clients/device-003.cert.pem
error 23 at 0 depth lookup: certificate revoked
error certs/clients/device-003.cert.pem: verification failed
10.2ではOKだったdevice-003が、error 23(certificate revoked)で弾かれるようになっていればよい。9章で作ったCRLが効いていることの確認である。
10.4.1. オプション
| オプション | 意味 |
|---|---|
-crl_check |
検証のときにCRLも確認する。付けないとCRLは見られない |
-CRLfile certs/crl/intermediate.crl.pem |
参照するCRLファイル。9.3で作ったもの |
-CAfile、-untrusted、最後の引数は10.2.1. オプションと同じである。
-crl_checkを付けなければ、失効した証明書でもOKになる。10.2でdevice-003がOKだったのはこのためである。
10.5. nginxと同じ条件で確認する
10.4の-crl_checkは、末端の証明書だけをCRLと照合する。一方nginxはssl_crlを設定すると、チェーンの全段についてCRLを確認する。条件が違うので、10.4が通ってもnginxで拒否されることがある。
nginxと同じ条件で確かめておく。以下のコマンドをターミナルに貼り付けて実行。
$OPENSSL verify -crl_check_all \
-CAfile certs/ca-chain.cert.pem \
-CRLfile certs/crl/ca-chain.crl.pem \
certs/clients/device-001.cert.pem
certs/clients/device-001.cert.pem: OK
OKと表示されていればよい。error 3 at 1 depth lookup: unable to get certificate CRLが出る場合は、9.3.2で作ったルートCAのCRLがca-chain.crl.pemに入っていない。
10.5.1. オプション
| オプション | 意味 |
|---|---|
-crl_check_all |
チェーンの全段についてCRLを確認する。10.4の-crl_checkは末端の証明書だけ |
-CAfile certs/ca-chain.cert.pem |
信頼の起点。nginxのssl_client_certificateに相当する |
-CRLfile certs/crl/ca-chain.crl.pem |
参照するCRL。nginxのssl_crlに相当する |
10.2から10.4までは-CAfileにルートCAだけを渡し、中間CAを-untrustedで補っていた。ここでca-chain.cert.pemを-CAfileに渡しているのは、nginxがssl_client_certificateに中間CAとルートCAをまとめて渡す形に合わせるためである。
nginxが全段を確認するのは、ssl_crlを設定したときにX509_V_FLAG_CRL_CHECK_ALLを立てているためである(ngx_event_openssl.cのngx_ssl_crl())。
11. nginxで動作確認する
11.1. このステップですること
ここまでに作った証明書をnginxに渡し、5枚のクライアント証明書がそれぞれ想定どおりの結果になることを確認する。
必要なものはDockerとcurlだけである。
~/mtls-demo/
├── certs/ ← ここまでで作ったもの
├── nginx/
│ └── default.conf ← このステップで作る
└── docker-compose.yml ← このステップで作る
まず、nginxの設定ファイルを置くディレクトリを作る。4.0. 作業ディレクトリとコードの扱いで作った~/mtls-demoの中で実行する。以下のコマンドをターミナルに貼り付けて実行。
mkdir -p nginx
11.2. nginxの設定を書く
mTLSを有効にしたnginxの設定ファイルを作る。以下のコマンドをターミナルに貼り付けて実行。
cat > nginx/default.conf <<'NGINX_EOF'
server {
error_log /dev/stderr info;
listen 443 ssl;
server_name localhost;
# --- サーバとしての身元(サーバ証明書 + 中間CA)---
ssl_certificate /etc/nginx/certs/server/server-fullchain.pem;
ssl_certificate_key /etc/nginx/certs/server/server.key.pem;
# --- クライアント証明書の検証(中間CA + ルートCA)---
ssl_client_certificate /etc/nginx/certs/ca-chain.cert.pem;
ssl_verify_client on;
ssl_crl /etc/nginx/certs/crl/ca-chain.crl.pem;
location / {
default_type text/plain;
return 200 "OK
verify=$ssl_client_verify
subject=$ssl_client_s_dn
serial=$ssl_client_serial
expires_in=$ssl_client_v_remain days
";
}
}
NGINX_EOF
ポイントは2つである。
-
ssl_certificateとssl_client_certificateに渡すファイルは別物(7.4参照) - 検証結果を変数でそのまま返している — 通った/通らないの切り分けが楽になる
パスが/etc/nginx/certs/...になっているのは、次の手順でコンテナ内にそのパスで割り当てるためである。
用語解説(別記事)
11.3. nginxを起動する
以降のコマンドは、すべて~/mtls-demoの中で実行する。docker composeはカレントディレクトリのdocker-compose.ymlを読むため、別の場所で実行すると無関係なコンテナを操作することになる。
nginxを動かすためのdocker-compose.ymlを作る。以下のコマンドをターミナルに貼り付けて実行。
cat > docker-compose.yml <<'YML_EOF'
services:
gw:
image: nginx:1.27-alpine
ports:
- "8443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
YML_EOF
docker compose up -d
起動したか確認する。以下のコマンドをターミナルに貼り付けて実行。
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
mtls-demo-gw-1 nginx:1.27-alpine "/docker-entrypoint.…" gw 6 seconds ago Up 5 seconds 0.0.0.0:8443->443/tcp, [::]:8443->443/tcp
STATUSがUpで、PORTS列に0.0.0.0:8443->443/tcpが出ていればよい。
PORTS列が443/tcpだけの場合、ホスト側にポートが公開されていない。この状態では11.4のcurlがFailed to connectで失敗する。portsを書く前に作られたコンテナが再利用されると起こるので、作り直す。
docker compose down
docker compose up -d
起動に失敗している場合は、docker compose logs gwでnginxのログを確認する。[emerg]で始まる行があれば設定ファイルに誤りがある。
port is already allocatedと出た場合、そのポートを他のプロセスが使っている。docker-compose.ymlの"8443:443"を"18443:443"などに変え、以降のURLも合わせて読み替える。
11.4. 各パターンを確認する
--cacertにルートCAを渡す。自作CAはcurlの信頼ストアに無いので、これを付けないとサーバ証明書の検証で落ちる。
正常系(device-001)
device-001の証明書と鍵を付けて接続する。
curl https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-001.cert.pem \
--key certs/clients/device-001.key.pem
OK
verify=SUCCESS
subject=OU=Devices,O=Example Corp,C=JP,CN=device-001
serial=1001
expires_in=818 days
verify=SUCCESSと表示され、subjectにCN=device-001、serialに1001が出ていればよい。
subjectの並びが証明書の表示と逆になっている。nginxの$ssl_client_s_dnはRFC 2253形式で、DNを末尾の項目から並べるためである。11.5の許可リストではこの並びのまま書く必要がある。
更新後(device-001-renewed)
device-001-renewedの証明書と鍵を付けて接続する。
curl https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-001-renewed.cert.pem \
--key certs/clients/device-001-renewed.key.pem
OK
verify=SUCCESS
subject=OU=Devices,O=Example Corp,C=JP,CN=device-001
serial=1002
expires_in=818 days
subjectが正常系とまったく同じで、serialだけが1002に変わっていればよい。8.3.2で見た「許可リストをCNの値で持つかシリアルで持つか」の判断材料が、ここで実地に得られる。
クライアント証明書なし
クライアント証明書を付けずに接続する。
curl https://localhost:8443/ --cacert certs/root/ca.cert.pem
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx/1.27.5</center>
</body>
</html>
No required SSL certificate was sentと返っていればよい。404でも403でもなくTLSの段階で拒否されていることが要点である。HTTPとして処理される前に切られている。
失効済み(device-003)
9章で失効させたdevice-003の証明書を付けて接続する。
curl https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-003.cert.pem \
--key certs/clients/device-003.key.pem
<html>
<head><title>400 The SSL certificate error</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
<hr><center>nginx/1.27.5</center>
</body>
</html>
The SSL certificate errorと返っていればよい。
期限切れ(device-999)
有効期限の切れたdevice-999の証明書を付けて接続する。
curl https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-999.cert.pem \
--key certs/clients/device-999.key.pem
<html>
<head><title>400 The SSL certificate error</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
<hr><center>nginx/1.27.5</center>
</body>
</html>
失効済みのときとまったく同じ画面が返る。クライアント側からは、失効で拒否されたのか期限切れで拒否されたのかを区別できない。
失効と期限切れを見分ける
理由が分かるのはサーバのログだけである。11.2の設定にerror_log /dev/stderr info;を入れてあるのはこのためである。nginxは検証エラーをinfoレベルで書くので、この指定が無いとログに出ない。
上の2つのcurlを実行したあとで、以下のコマンドをターミナルに貼り付けて実行。
docker compose logs gw | grep 'verify error'
gw-1 | 2026/09/24 08:31:46 [info] 21#21: *1 client SSL certificate verify error: (23:certificate revoked) while reading client request headers, client: 192.168.65.1, server: localhost, request: "GET / HTTP/1.1", host: "localhost:8443"
gw-1 | 2026/09/24 08:31:46 [info] 22#22: *2 client SSL certificate verify error: (10:certificate has expired) while reading client request headers, client: 192.168.65.1, server: localhost, request: "GET / HTTP/1.1", host: "localhost:8443"
2行出ていればよい。23がdevice-003、10がdevice-999である。
この番号は10章で見たものと同じである。10.4でerror 23(certificate revoked)、10.2でerror 10(certificate has expired)を確認した。同じ理由で拒否されていることが、番号で確かめられる。
11.5. 許可リストで絞る
device-002は「CA的には正しいが、業務上は許可したくない」端末を模したものである。証明書としては正しく発行されているため、このままでは200が返る。CAの検証だけでは弾けないので、nginx側でCNの値を見て落とす。
設定ファイルを書き直す。11.2の内容にmapとlocationの条件を足したものである。以下のコマンドをターミナルに貼り付けて実行。
cat > nginx/default.conf <<'NGINX_EOF'
map $ssl_client_s_dn $allowed_device {
default 0;
"OU=Devices,O=Example Corp,C=JP,CN=device-001" 1;
}
server {
error_log /dev/stderr info;
listen 443 ssl;
server_name localhost;
# --- サーバとしての身元(サーバ証明書 + 中間CA)---
ssl_certificate /etc/nginx/certs/server/server-fullchain.pem;
ssl_certificate_key /etc/nginx/certs/server/server.key.pem;
# --- クライアント証明書の検証(中間CA + ルートCA)---
ssl_client_certificate /etc/nginx/certs/ca-chain.cert.pem;
ssl_verify_client on;
ssl_crl /etc/nginx/certs/crl/ca-chain.crl.pem;
location / {
# --- 許可リストで絞る(CNを見る)---
if ($allowed_device = 0) {
return 403;
}
default_type text/plain;
return 200 "OK
verify=$ssl_client_verify
subject=$ssl_client_s_dn
serial=$ssl_client_serial
expires_in=$ssl_client_v_remain days
";
}
}
NGINX_EOF
mapをserver { ... }の外に置いているのは、mapがhttpコンテキストのディレクティブだからである。conf.d配下のファイルはhttpの中に読み込まれるので、ファイルの先頭に書けば正しい位置になる。
mapに書くDNの表記に注意する。nginxの$ssl_client_s_dnはRFC 2253形式で、DNを末尾の項目から並べる。11.4の実行結果で見たとおりOU=Devices,O=Example Corp,C=JP,CN=device-001であり、証明書の表示(CN=device-001, C=JP, O=Example Corp, OU=Devices)とは並びが逆である。逆順のまま書かないと一致しない。
反映する。以下のコマンドをターミナルに貼り付けて実行。
docker compose restart gw
3つの証明書で結果を見比べる。-iはステータス行も表示するオプションである。403のときは本文だけでは判断しにくいため付けている。
curl -i https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-001.cert.pem \
--key certs/clients/device-001.key.pem
HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Thu, 24 Sep 2026 08:44:17 GMT
Content-Type: text/plain
Content-Length: 103
Connection: keep-alive
OK
verify=SUCCESS
subject=OU=Devices,O=Example Corp,C=JP,CN=device-001
serial=1001
expires_in=818 days
curl -i https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-002.cert.pem \
--key certs/clients/device-002.key.pem
HTTP/1.1 403 Forbidden
Server: nginx/1.27.5
Date: Thu, 24 Sep 2026 08:44:17 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.27.5</center>
</body>
</html>
curl -i https://localhost:8443/ \
--cacert certs/root/ca.cert.pem \
--cert certs/clients/device-001-renewed.cert.pem \
--key certs/clients/device-001-renewed.key.pem
HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Thu, 24 Sep 2026 08:44:17 GMT
Content-Type: text/plain
Content-Length: 103
Connection: keep-alive
OK
verify=SUCCESS
subject=OU=Devices,O=Example Corp,C=JP,CN=device-001
serial=1002
expires_in=818 days
device-001が200、device-002が403、device-001-renewedが200になっていればよい。
device-002の403にはverify=SUCCESSが出ていない。TLSの検証は通ったうえで、HTTPの層で拒否されているためである。11.4の失効や期限切れがTLSの層で切られていたのとは、止まる場所が違う。
device-001-renewedが通るのが、CNの値で許可リストを持つ利点である。シリアル番号で持っていた場合、更新のたびにリストを書き換える必要がある。
これで結果が分かれる。
| 証明書 | 期待される結果 | どこで弾かれるか |
|---|---|---|
device-001 |
200 OK | — |
device-001-renewed |
200 OK(CNの値が同一・シリアル変化) |
— |
device-002 |
403 Forbidden | nginxの許可リスト(HTTP層) |
device-003 |
TLSエラー(revoked) | CRL(TLS層) |
device-999 |
TLSエラー(expired) | 有効期限(TLS層) |
| なし | TLSエラー(certificate required) | ハンドシェイク(TLS層) |
この差が、mTLSと通常の認可の役割分担そのものである。
- mTLSが答えるのは「正規の端末か」まで
- 「その端末に権限があるか」は別のレイヤーの仕事
11.6. 後始末
nginxのコンテナを停止して削除する。
docker compose down
検証をすべて終えたら、生成物も削除する。
rm -rf certs/
この構成(curlのみ)なら--cacertで都度指定しているだけなので、OSの信頼ストアには何も残らない。端末にルートCAを登録して試した場合は、その削除も忘れずにおこなう(13章参照)。
12. スクリプト全文
この章は別記事にある。→ 12. スクリプト全文
13. 運用上の注意
検証用とはいえ、本物のCAを作っている。後始末まで含めて手順である。
-
秘密鍵をリポジトリに入れない
- 生成物のディレクトリは
.gitignoreに入れておく -
.p12も秘密鍵を含んでいるので同様
- 生成物のディレクトリは
-
検証が終わったら、端末に入れたルートCAの信頼設定を消す
- 信頼したまま残すと、このCAで発行した証明書が他の用途でも通ってしまう
- 手元のCAの秘密鍵が漏れると、任意のサイトを詐称する証明書を作れてしまう
- macOSはキーチェーンアクセスから削除する
-
.p12のパスワードを検証用と本番用で共有しない- 端末へ渡すまでの一時的な保護でしかない
-
ルートCAの秘密鍵にはパスワードをかける(本番)
- この記事では
-nodesで省略しているが、本番では必須
- この記事では
次は14章のまとめに進む。