OpenSSLでCA証明書を自作する
わたすけです。突然ですが皆さん、HTTPSは好きですか?僕は好きです。というか、最近は(少なくとも)HTTPSでないと使えないWebAPIが増えてきましたよね。例えば Web Authentication API とか。
ところで、僕は殆どの開発を自宅サーバーで行っています。ここでVPNはかなり重要な役割を果たしており、まずSSHをVPN経由で行っているという点で重要です。さらに、自宅サーバーで動いているdnsmasqがこのVPNにおけるDNSになっていて、かつnginxが自宅サーバーで動いている諸々のサービスに対するリバースプロキシを張っているため、IPアドレスではなくドメインを入力するだけでサービスにアクセスできるのがかなり嬉しいです。dnsmasq導入以前は自宅サーバーの(VPN内での)IPアドレスとポート番号を手打ちしなければいけなくてダルかったんですよね。
さて、そういうわけで、VPN内でのみ使えるドメインがあるわけです。僕はドメインを watasuke.net しか所有しておらず、これをローカルでも使うとコンフリクトしそうで嫌だったため、RFC6761 で予約済みである .test をサーバー名にくっつけて使っています1。すると、このドメインでHTTPSを使うには当然ですが証明書が必要です。しかも、Let's Encrypt は .test 用の証明書を発行してくれなかった(気がする)ため、CA証明書のレベルで自作する必要があります。そして、僕はVPNにアクセスする端末としてArch Linux・Windows・Android・iPadOSの4種類を所有しており、全てにおいてきちんと認識される証明書が必要です。
ただ参考サイトをそのままなぞるだけだと、僕の環境ではArch Linux(のFirefox)・AndroidおよびiPadOSでブラウザがエラーを吐くような証明書となってしまいました。どうにか動かすべく設定をごにょごにょしてたらいい感じになったので、記事を書くことにしました。正直なところ参考にしたサイトの何が駄目で僕が加えた変更のどこが重要だったのかよくわかっていないのですが、ひとまずどのように作成したのかを書いておきます。
この記事は、 coins Advent Calendar 2025の7日目の記事です。2日連続で嘘coinsとなります。僕もmastですが興味関心はcoinsっぽさがあるという人間です。
6日目は にとさんの「出先から自宅PCを起動したい」でした。僕も同じモチベーションで外からデスクトップPCを起動したいと思っていたのですが、何故か自宅サーバーからWoLパケットを投げても起動せず、ルーターの管理画面に入って変なWebUIポチポチをしないと起動しないのでかなり厳しいんですよね2。ルーターのIPアドレスをSSHで port forwardして管理画面にアクセスすることで事なきを得ています。まあ必要になることは滅多にないですが。
8日目はMutsuha Asadaさんの「Nixをライトに使う」です。
TL;DR
鍵を生成するシェルスクリプトです。最初のほうで定義している変数をいじればそのまま使えると思います。$OUT_DIR/local-ca/local-ca.crt がCA証明書で、 $OUT_DIR/example.crt がサーバー証明書となります。
#!/usr/bin/env bash
set -e
OUTDIR=/path/to/output
CA_DIR=$OUTDIR/local-ca
DOMAIN_DIR=$OUTDIR/example.test
mkdir -p $CA_DIR
mkdir -p $DOMAIN_DIR
cat << EOF > openssl.cnf
[req]
default_bits = 4096
encrypt_key = no
default_md = sha256
prompt = no
utf8 = yes
string_mask = utf8only
distinguished_name = dn
x509_extensions = x509_ext
req_extensions = req_ext
[dn]
C = JP
ST = Tokyo
CN = example.test
# CA cert config
[x509_ext]
basicConstraints = critical, CA:TRUE, pathlen:1
keyUsage = digitalSignature, keyCertSign, cRLSign
subjectKeyIdentifier = hash
# server cert config
[req_ext]
extendedKeyUsage = clientAuth, serverAuth
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alt_names
subjectKeyIdentifier = hash
[alt_names]
DNS.1 = example.test
DNS.2 = *.example.test
IP.1 = 192.168.0.2
EOF
openssl req -new -newkey rsa:4096 -days 45 -noenc -x509 -config openssl.cnf -keyout $CA_DIR/local-ca.key -out $CA_DIR/local-ca.crt
openssl genrsa -out $DOMAIN_DIR/example.key 4096
openssl req -new -key $DOMAIN_DIR/example.key -out $DOMAIN_DIR/example.csr -config openssl.cnf
openssl x509 -req -days 45 -CAcreateserial -extfile openssl.cnf -extensions req_ext \
-in $DOMAIN_DIR/example.csr -CA $CA_DIR/local-ca.crt -CAkey $CA_DIR/local-ca.key -out $DOMAIN_DIR/example.crt
rm -v openssl.cnf
検証環境
- 自宅サーバー:
- Arch Linux x86_64 (Linux 6.17.9-zen1-1-zen)
- OpenSSL 3.6.0 1 Oct 2025 (Library: OpenSSL 3.6.0 1 Oct 2025)
- nginx version: nginx/1.28.0
- Windows 11 Home 25H2 + Chrome 144.0.7559.6 (公式ビルド) dev (64 ビット) (cohort: Control) + Firefox 146.0b9 (64 ビット)
- Android 16
- iPadOS 26.2 (23C52)
説明
まず変数の設定をしてディレクトリを作ります。次に openssl.cnf として設定ファイルを作ります。req セクションの設定項目については openssl-req(1ssl) に記載されており、openssl req で使われることがわかります。
[req]
default_bits = 4096
encrypt_key = no
default_md = sha256
prompt = no
utf8 = yes
string_mask = utf8only
distinguished_name = dn
x509_extensions = x509_ext
req_extensions = req_ext
encrypt_key を no にすることでパスフレーズの入力をスキップできるようにしています。また、distinguished_name に dn セクションの値を設定していますね。これでCountry, State/Province, CommonNameを設定しています。通常はこれらを入力するプロンプトがでてくるのですが、prompt = no によりそれを無効化して、設定ファイルの記述を参照するようにしています。
そして、CA証明書の設定であるx509_ext については openssl-x509(1ssl) に記載があります。
[x509_ext]
basicConstraints = critical, CA:TRUE, pathlen:1
keyUsage = digitalSignature,keyCertSign,cRLSign
subjectKeyIdentifier = hash
critical を付けると、そのセクションは必ず検証および使用されなければならないということを示せるそうです3。しかしもっと重要なのは basicConstraints に CA:TRUE を与えているところです。これがないとエラーになります。あるいは、これが後で説明する req_ext のほうにあってもそれはそれでエラーになります。おそらくなのですが、AndroidやiPadOSでインストールできなかったりエラーが出たりした原因はCA:TRUEを設定していなかったからなのでは、と思っています。
次は req_ext です。ここの記述も openssl-x509(1ssl) に則っているらしいです。
[req_ext]
extendedKeyUsage = clientAuth, serverAuth
keyUsage = digitalSignature, keyEncipherment
subjectAltName = @alt_names
subjectKeyIdentifier = hash
[alt_names]
DNS.1 = example.test
DNS.2 = *.example.test
IP.1 = 192.168.0.2
ここで注目すべきは subjectAltName において alt_names フィールドを参照するように指示しているところですかね。DNS:example.test, DNS:*.example.test と記述する代わりにこのような設定ができます。
それではコマンド部分です。まず、CA証明書の秘密鍵および自己署名証明書を作成します。
openssl req -new -newkey rsa:4096 -days 45 -noenc -x509 -config openssl.cnf -keyout $CA_DIR/local-ca.key -out $CA_DIR/local-ca.crt
古い方式だと、まず秘密鍵 (.key) を生成し、それを用いて生成した公開鍵を含む証明書署名要求 (Certificate Signing Request, .csr) を作成して、CSRを秘密鍵で自己署名することによりCA証明書 (.crt) を生成する、という手順を踏むのですが、req -new -newkey とすればコマンド1つで秘密鍵とCA証明書を生成できてうれしいです。
newkey rsa:4096:アルゴリズムとして4096ビットのRSAを用いています。本当は楕円曲線暗号(EC)を使いたかったのですが、手元の環境だと何故か変なエラーが出て-days 45:有効期限を45日にしています。2029年から証明書の有効期間が47日になるらしいので短めにしています4。-noenc:reqセクションでも書いていましたが、鍵を暗号化しないよう設定してパスフレーズの入力をスキップしています。-x509:CSRを生成せず証明書を生成するよう指定しています。
これでCA証明書ができたので、次にサーバー証明書を作ります。CA証明書と同様に req -new -newkey で一発といきたいところなのですが、うまくいかなかったので順番にやります。
openssl genrsa -out $DOMAIN_DIR/example.key 4096
openssl req -new -key $DOMAIN_DIR/example.key -out $DOMAIN_DIR/example.csr -config openssl.cnf
openssl x509 -req -days 45 -CAcreateserial -extfile openssl.cnf -extensions req_ext \
-in $DOMAIN_DIR/example.csr -CA $CA_DIR/local-ca.crt -CAkey $CA_DIR/local-ca.key -out $DOMAIN_DIR/example.crt
genrsa でサーバー用の秘密鍵を生成し、req -new でCSRを作成します。そして、x509 -req でCA証明書を用いて署名を行い、サーバー証明書を得ます。このとき -CAcreateserial でシリアルナンバーファイルが存在しない場合に作成するよう指示していること、-extensioons req_ext で拡張機能を指定していることがポイントです。
以上で $OUTDIR/local-ca/local-ca.{key,crt} および $OUTDIR/local-ca/local-ca.{key,csr,crt} が生成されました。
各種セットアップ
CA証明書は作るだけでは意味がありません。まず、これを各端末にインストールする必要があります。まずlocal-ca.crtをどうにかしてダウンロードしておいてください。
Windows (11)
Win+Rで「ファイル名を指定して実行」を開き、certlm.msc を起動します。左の欄から「信頼されたルート証明機関」というのをクリックして選択します。

こういう風に青色になっていると思います。そうしたら、メニューの操作 > すべてのタスク > インポートと進んで、これを選択します。

これで出てくるメニューを進んでいって、「インポートするファイルを指定してください」と言われたら、local-ca.crt を指定します。
これでCA証明書のインストールは完了です。ChromeとかなんとかでHTTPS接続が出来るかどうか確かめてみてください。
Arch Linux
sudo trust anchor --store local-ca.crt でOKです。
Android
Security & privacy > More security & privacy > Encryption & credentials > Install a certificate で local-ca.crt を指定すれば良いです。
iPadOS
local-ca.crt ファイルがあるディレクトリをファイルアプリで開いてタップします。すると「Profile Downloaded」というウィンドウが出てきて、インストールしたいなら設定ファイルでやってねと言われます。
設定ファイルを開き、左側からGeneralを選択、下の方にある VPN & Device Management に移動します。Downloaded Profile に証明書が出てきていると思うのでタップして、出てくるウィンドウの右上にあるInstallボタンでインストールします。
しかしここで終わりではありません。Generalに戻り、About > Certificate Trust Settings に移動して、Enable Full Trust for Root certificates のトグルをONにしなければなりません。ここまできてようやくHTTPS通信がエラーなしで出来るようになります。
これでCA証明書のインストールは完了しました。あとは使う方法とかです。
nginx
こういう感じの設定を書けば良いでしょう:
server {
listen 443 ssl;
ssl_certificate /path/to/output/example.test/example.crt;
ssl_certificate_key /path/to/output/example.test/example.key;
}
Node.js
(少なくともうちのArch Linux環境では)Node.jsの fetch() がシステムの証明書を使ってくれません。困ったやつですね。--use-openssl-caを付けて起動するか、.bashrcとかに export NODE_EXTRA_CA_CERTS="/path/to/local-ca.crt" を書くかしてください。僕は後者でやっています。あるいは export NODE_OPTIONS="--use-openssl-ca"でも良いかもですね。
cron
有効期限が45日なので、毎月1日に更新すれば良さそうです。先ほどのシェルスクリプトをいい感じのところに置いて、 crontab -e でこんな雰囲気の行を追記します:
0 5 1 * * sh -c 'cd /path/to/certdir && sh create-cert.sh'
ちなみにこれが動くかどうかは知りません。まだこれやってから1ヶ月経ってないので。しかし更新されたら4端末のCA証明書ぜんぶ更新しないといけないんでしょうか。勘弁してくれ。どうにかする方法ないですか?
おわりに
CA証明書の作成方法でした。ただHTTPSで通信したいだけなのにやけにダルいですね。まあ世界の安全のためなのでしょうがないということでしょうか。
参考サイト
- localhost を https 化する
- これだとAndroidにインストールできないCA証明書になってしまいました
- Install self-signed certificates no longer working in Android Q - Stack Overflow
- iOS 13.1 'Cannot verify server identity' - Apple Community
余談
cronで実行されることを考えると、シェルスクリプトの末尾でDiscordのWebHookにPOSTするコードを書いておくと嬉しそうな気がします。メールサーバーとかないので。
JSON=$(cat << END
{
"content": "",
"embeds": [
{
"type": "rich",
"title": "CA cert is changed",
"description": "",
"color": "10011513",
"fields": [
{
"name": "Start",
"value": "$(openssl x509 -noout -in /etc/nginx/cert/local-ca.crt -startdate -dateopt 'iso_8601' | sed 's|^.*=\(.*\)Z|\1|')"
},
{
"name": "End",
"value": "$(openssl x509 -noout -in /etc/nginx/cert/local-ca.crt -enddate -dateopt 'iso_8601' | sed 's|^.*=\(.*\)Z|\1|')"
}
]
}
]
}
END
)
curl -X POST \
-H 'Content-Type: application/json' \
-d "$JSON" \
'https://discord.com/api/webhooks/xxx'
最初は openssl x509 -text の出力をsedに渡して頑張っていたんですが、-(start|end)date だけで十分でした。
Footnotes
-
実際に用いられている/用いられる可能性のあるドメインは使うべきではない。cf. https://blog.jxck.io/entries/2017-09-27/example-local-test-domains.html ↩
-
つまり少なくともデスクトップPCはWoLパケットを受けて起動する能力があるということ。ルーターがWoLパケットを破棄しているのかなあと思っています(まさかmacアドレスが間違っているなんてことないよね) ↩
-
https://docs.oracle.com/javase/8/docs/technotes/guides/security/cert3.html ↩
-
いま見て気付いたんですが、ドメイン証明書は10日になるらしいですね。やべ~ ↩
Comments
Powered by Giscus