このシリーズについて
この記事は「OpenSSLでプライベートCAを作る(全15章)」の一部である。章番号はシリーズ全体で通し番号になっている。はじめに・開発環境・まとめ・参考文献は目次記事にある。
12. スクリプト全文
ここまでの手順を1本にまとめたものである。
#!/usr/bin/env bash
#
# mTLS 検証用の証明書一式を生成する。
#
# ルートCA ──▶ 中間CA(mTLS専用)──┬─▶ サーバ証明書(nginx用)
# └─▶ クライアント証明書(端末用・検証パターン別)
#
# 生成物はすべて開発・検証専用。本番環境では使用しない。
#
# 使い方:
# ./gen-certs.sh # 生成(既に certs/ があれば中断)
# ./gen-certs.sh --force # certs/ を作り直す
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="${OUT_DIR:-${SCRIPT_DIR}/certs}"
OPENSSL="${OPENSSL:-openssl}"
SERVER_HOSTNAME="${SERVER_HOSTNAME:-$(hostname -s).local}"
SERVER_IP="${SERVER_IP:-127.0.0.1}"
P12_PASSWORD="${P12_PASSWORD:-mtls-dev}"
ROOT_DAYS=3650 # ルートCA: 10年
INTERMEDIATE_DAYS=1825 # 中間CA: 5年
LEAF_DAYS=825 # サーバ・クライアント証明書: 約2年
ROOT_DIR="${OUT_DIR}/root"
INT_DIR="${OUT_DIR}/intermediate"
SRV_DIR="${OUT_DIR}/server"
CLI_DIR="${OUT_DIR}/clients"
CRL_DIR="${OUT_DIR}/crl"
CA_CHAIN="${OUT_DIR}/ca-chain.cert.pem" # 中間 + ルート(ssl_client_certificate 用)
SRV_FULLCHAIN="${SRV_DIR}/server-fullchain.pem" # サーバ + 中間(ssl_certificate 用)
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
step() { printf ' - %s\n' "$*"; }
# ---------------------------------------------------------------- 事前チェック
if ! "$OPENSSL" version | grep -q '^OpenSSL 3'; then
echo "エラー: OpenSSL 3.x が必要です(現在: $("$OPENSSL" version))" >&2
echo " macOS 標準の LibreSSL では動作しません。brew install openssl@3 を実行し、" >&2
echo " OPENSSL=\"\$(brew --prefix openssl@3)/bin/openssl\" ./gen-certs.sh のように指定してください。" >&2
exit 1
fi
if [ -e "$OUT_DIR" ]; then
if [ "${1:-}" = "--force" ]; then
log "既存の ${OUT_DIR} を削除します"
rm -rf "$OUT_DIR"
else
echo "エラー: ${OUT_DIR} が既に存在します。作り直す場合は --force を付けてください。" >&2
exit 1
fi
fi
# ------------------------------------------------------------ CA ディレクトリ
# openssl ca コマンドは「発行済み台帳(index.txt)」「連番(serial)」を前提に動く。
# CRL の生成にもこの台帳が要るため、CA ごとに一式を用意する。
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"
}
write_ca_cnf() {
local dir="$1"
cat > "$dir/openssl.cnf" <<CNF_EOF
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = ${dir}
new_certs_dir = \$dir/newcerts
database = \$dir/index.txt
serial = \$dir/serial
crlnumber = \$dir/crlnumber
private_key = \$dir/private/ca.key.pem
certificate = \$dir/ca.cert.pem
default_md = sha256
default_days = ${LEAF_DAYS}
default_crl_days = 30
preserve = no
email_in_dn = no
unique_subject = no
policy = policy_loose
copy_extensions = none
[ policy_loose ]
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ req ]
default_bits = 2048
default_md = sha256
string_mask = utf8only
prompt = no
distinguished_name = req_dn
[ req_dn ]
[ v3_intermediate_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true, pathlen:0
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
[ server_cert ]
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer:always
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
[ client_cert ]
basicConstraints = CA:FALSE
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
[ crl_ext ]
authorityKeyIdentifier = keyid:always
CNF_EOF
}
# ------------------------------------------------------------------ ルートCA
log "ルートCA を作成します"
init_ca_dir "$ROOT_DIR"
write_ca_cnf "$ROOT_DIR"
"$OPENSSL" req -x509 -new -nodes -newkey rsa:4096 -sha256 -days "$ROOT_DAYS" \
-keyout "$ROOT_DIR/private/ca.key.pem" \
-out "$ROOT_DIR/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" >/dev/null 2>&1
chmod 600 "$ROOT_DIR/private/ca.key.pem"
step "$ROOT_DIR/ca.cert.pem"
# ------------------------------------------------------------------ 中間CA
log "中間CA(mTLS専用)を作成します"
init_ca_dir "$INT_DIR"
write_ca_cnf "$INT_DIR"
"$OPENSSL" req -new -nodes -newkey rsa:4096 -sha256 \
-keyout "$INT_DIR/private/ca.key.pem" \
-out "$INT_DIR/ca.csr.pem" \
-subj "/C=JP/O=Example Corp/CN=Example Corp mTLS Intermediate CA" >/dev/null 2>&1
chmod 600 "$INT_DIR/private/ca.key.pem"
"$OPENSSL" ca -batch -config "$ROOT_DIR/openssl.cnf" \
-extensions v3_intermediate_ca -days "$INTERMEDIATE_DAYS" -notext -md sha256 \
-in "$INT_DIR/ca.csr.pem" -out "$INT_DIR/ca.cert.pem" >/dev/null 2>&1
step "$INT_DIR/ca.cert.pem"
cat "$INT_DIR/ca.cert.pem" "$ROOT_DIR/ca.cert.pem" > "$CA_CHAIN"
step "$CA_CHAIN (nginx の ssl_client_certificate に指定する)"
# -------------------------------------------------------------- サーバ証明書
log "サーバ証明書を作成します(${SERVER_HOSTNAME} / ${SERVER_IP})"
mkdir -p "$SRV_DIR"
# -extfile を渡すと -extensions は「このファイル内の節」を指す。
# そのため、サーバ証明書用の拡張一式をここに書ききる。
cat > "$SRV_DIR/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
"$OPENSSL" req -new -nodes -newkey rsa:2048 -sha256 \
-keyout "$SRV_DIR/server.key.pem" \
-out "$SRV_DIR/server.csr.pem" \
-subj "/C=JP/O=Example Corp/CN=${SERVER_HOSTNAME}" >/dev/null 2>&1
chmod 600 "$SRV_DIR/server.key.pem"
"$OPENSSL" ca -batch -config "$INT_DIR/openssl.cnf" \
-extensions server_cert -extfile "$SRV_DIR/san.cnf" -days "$LEAF_DAYS" -notext -md sha256 \
-in "$SRV_DIR/server.csr.pem" -out "$SRV_DIR/server.cert.pem" >/dev/null 2>&1
cat "$SRV_DIR/server.cert.pem" "$INT_DIR/ca.cert.pem" > "$SRV_FULLCHAIN"
step "$SRV_FULLCHAIN (nginx の ssl_certificate に指定する)"
# ---------------------------------------------------------- クライアント証明書
mkdir -p "$CLI_DIR"
# $1: ファイル名, $2: CN, $3: 説明, 以降: openssl ca への追加引数
issue_client() {
local name="$1" cn="$2" desc="$3"; shift 3
"$OPENSSL" req -new -nodes -newkey rsa:2048 -sha256 \
-keyout "$CLI_DIR/${name}.key.pem" \
-out "$CLI_DIR/${name}.csr.pem" \
-subj "/C=JP/O=Example Corp/OU=Devices/CN=${cn}" >/dev/null 2>&1
chmod 600 "$CLI_DIR/${name}.key.pem"
"$OPENSSL" ca -batch -config "$INT_DIR/openssl.cnf" \
-extensions client_cert -notext -md sha256 "$@" \
-in "$CLI_DIR/${name}.csr.pem" -out "$CLI_DIR/${name}.cert.pem" >/dev/null 2>&1
# 端末へ入れるための .p12。鍵・証明書・CAチェーンを1ファイルにまとめる。
"$OPENSSL" pkcs12 -export \
-inkey "$CLI_DIR/${name}.key.pem" \
-in "$CLI_DIR/${name}.cert.pem" \
-certfile "$CA_CHAIN" \
-name "Example Corp ${cn}" \
-passout "pass:${P12_PASSWORD}" \
-out "$CLI_DIR/${name}.p12" >/dev/null 2>&1
local serial
serial="$("$OPENSSL" x509 -in "$CLI_DIR/${name}.cert.pem" -noout -serial | cut -d= -f2)"
step "$(printf '%-24s CN=%-12s serial=%s %s' "${name}.p12" "$cn" "$serial" "$desc")"
}
log "クライアント証明書を作成します(検証パターン別)"
issue_client "device-001" "device-001" "正常系" -days "$LEAF_DAYS"
issue_client "device-001-renewed" "device-001" "更新後(CN は同じ・連番のみ変わる)" -days "$LEAF_DAYS"
issue_client "device-002" "device-002" "許可リストから外して遮断する確認用" -days "$LEAF_DAYS"
issue_client "device-003" "device-003" "CA 側で失効させる確認用" -days "$LEAF_DAYS"
issue_client "device-999" "device-999" "有効期限切れ" -startdate 20250101000000Z -enddate 20250102000000Z
# ------------------------------------------------------------------ 失効リスト
log "device-003 を失効させ、失効リスト(CRL)を作成します"
mkdir -p "$CRL_DIR"
"$OPENSSL" ca -batch -config "$INT_DIR/openssl.cnf" \
-revoke "$CLI_DIR/device-003.cert.pem" -crl_reason keyCompromise >/dev/null 2>&1
"$OPENSSL" ca -batch -config "$INT_DIR/openssl.cnf" \
-gencrl -crlexts crl_ext -out "$CRL_DIR/intermediate.crl.pem" >/dev/null 2>&1
step "$CRL_DIR/intermediate.crl.pem"
# nginx は ssl_crl を設定するとチェーンの全段について CRL を確認する。
# そのためルートCA の CRL も要る。2つを 1 ファイルに連結して nginx に渡す。
"$OPENSSL" ca -batch -config "$ROOT_DIR/openssl.cnf" \
-gencrl -crlexts crl_ext -out "$CRL_DIR/root.crl.pem" >/dev/null 2>&1
step "$CRL_DIR/root.crl.pem"
cat "$CRL_DIR/intermediate.crl.pem" "$CRL_DIR/root.crl.pem" > "$CRL_DIR/ca-chain.crl.pem"
step "$CRL_DIR/ca-chain.crl.pem"
# ---------------------------------------------------------------------- 検証
log "生成物を検証します"
"$OPENSSL" verify -CAfile "$ROOT_DIR/ca.cert.pem" -untrusted "$INT_DIR/ca.cert.pem" \
"$SRV_DIR/server.cert.pem" | sed 's/^/ /'
for f in "$CLI_DIR"/*.cert.pem; do
if "$OPENSSL" verify -CAfile "$ROOT_DIR/ca.cert.pem" -untrusted "$INT_DIR/ca.cert.pem" "$f" 2>/dev/null | grep -q OK; then
printf ' %s: OK\n' "$(basename "$f")"
else
# 期限切れの1枚はここで OK にならないのが正しい挙動
printf ' %s: %s\n' "$(basename "$f")" \
"$("$OPENSSL" verify -CAfile "$ROOT_DIR/ca.cert.pem" -untrusted "$INT_DIR/ca.cert.pem" "$f" 2>&1 | grep 'lookup:' | head -1 | sed 's/^.*lookup: *//')"
fi
done
# 中間CA を外すと検証が失敗すること
if "$OPENSSL" verify -CAfile "$ROOT_DIR/ca.cert.pem" "$CLI_DIR/device-001.cert.pem" >/dev/null 2>&1; then
printf ' \033[1;31m注意: 中間CA 無しでも検証が通っています(想定外)\033[0m\n'
else
printf ' 中間CA を外すと検証は失敗する(想定どおり)\n'
fi
# nginx と同じ条件(チェーンの全段を CRL で確認)でも通ること
if "$OPENSSL" verify -crl_check_all -CAfile "$CA_CHAIN" \
-CRLfile "$CRL_DIR/ca-chain.crl.pem" "$CLI_DIR/device-001.cert.pem" >/dev/null 2>&1; then
printf ' nginx と同じ条件でも検証は通る(想定どおり)\n'
else
printf ' \033[1;31m注意: nginx と同じ条件で検証が失敗しています(CRL 不足)\033[0m\n'
fi
cat <<SUMMARY_EOF
生成が完了しました。
出力先 ${OUT_DIR}
サーバ名 ${SERVER_HOSTNAME} (IP: ${SERVER_IP})
.p12 パスワード ${P12_PASSWORD}
nginx に渡すファイル
ssl_certificate ${SRV_FULLCHAIN}
ssl_certificate_key ${SRV_DIR}/server.key.pem
ssl_client_certificate ${CA_CHAIN}
ssl_crl ${CRL_DIR}/ca-chain.crl.pem
端末に入れるファイル
発行元(信頼設定が必要) ${ROOT_DIR}/ca.cert.pem
端末証明書 ${CLI_DIR}/device-001.p12 ほか
SUMMARY_EOF
12.1. 実行結果
上のスクリプトを~/mtls-demo/gen-certs.shとして保存し、実行権限を付けてから実行する。
実行コマンド
chmod +x gen-certs.sh
OPENSSL="$(brew --prefix openssl@3)/bin/openssl" ./gen-certs.sh
実行結果
==> ルートCA を作成します
- ~/mtls-demo/certs/root/ca.cert.pem
==> 中間CA(mTLS専用)を作成します
- ~/mtls-demo/certs/intermediate/ca.cert.pem
- ~/mtls-demo/certs/ca-chain.cert.pem (nginx の ssl_client_certificate に指定する)
==> サーバ証明書を作成します(gw.example.internal / 192.0.2.10)
- ~/mtls-demo/certs/server/server-fullchain.pem (nginx の ssl_certificate に指定する)
==> クライアント証明書を作成します(検証パターン別)
- device-001.p12 CN=device-001 serial=1001 正常系
- device-001-renewed.p12 CN=device-001 serial=1002 更新後(CN は同じ・連番のみ変わる)
- device-002.p12 CN=device-002 serial=1003 許可リストから外して遮断する確認用
- device-003.p12 CN=device-003 serial=1004 CA 側で失効させる確認用
- device-999.p12 CN=device-999 serial=1005 有効期限切れ
==> device-003 を失効させ、失効リスト(CRL)を作成します
- ~/mtls-demo/certs/crl/intermediate.crl.pem
- ~/mtls-demo/certs/crl/root.crl.pem
- ~/mtls-demo/certs/crl/ca-chain.crl.pem
==> 生成物を検証します
~/mtls-demo/certs/server/server.cert.pem: OK
device-001-renewed.cert.pem: OK
device-001.cert.pem: OK
device-002.cert.pem: OK
device-003.cert.pem: OK
device-999.cert.pem: certificate has expired
中間CA を外すと検証は失敗する(想定どおり)
nginx と同じ条件でも検証は通る(想定どおり)
生成が完了しました。
出力先 ~/mtls-demo/certs
サーバ名 gw.example.internal (IP: 192.0.2.10)
.p12 パスワード mtls-dev
nginx に渡すファイル
ssl_certificate ~/mtls-demo/certs/server/server-fullchain.pem
ssl_certificate_key ~/mtls-demo/certs/server/server.key.pem
ssl_client_certificate ~/mtls-demo/certs/ca-chain.cert.pem
ssl_crl ~/mtls-demo/certs/crl/ca-chain.crl.pem
端末に入れるファイル
発行元(信頼設定が必要) ~/mtls-demo/certs/root/ca.cert.pem
端末証明書 ~/mtls-demo/certs/clients/device-001.p12 ほか
最後に「生成が完了しました。」が出て、検証の行がすべて想定どおりになっていればよい。
device-999.cert.pem: certificate has expiredは、期限切れの証明書を意図的に作っているためである(10.2参照)。「中間CA を外すと検証は失敗する」「nginx と同じ条件でも検証は通る」の2行は、10.3と10.5に対応する。
certs/が既にある場合は中断する。作り直すときは--forceを付ける。
実行コマンド
OPENSSL="$(brew --prefix openssl@3)/bin/openssl" ./gen-certs.sh --force
次は13. 運用上の注意に戻る。
全体の目次は「OpenSSLでプライベートCAを作る(全15章)」にある。