最近、「OpenSSL + Python(cryptographyライブラリ)」を使ったローカルHTTPS化の手順が、開発者の間で改めて注目されていますね!どうやら、「OpenSSL + Python(cryptographyライブラリ)」を使うと、コマンド数行+Pythonスクリプトだけでlocalhostを本格的にHTTPS化できるようです!

そこで今回は、Windows・Mac・Linux共通の環境で「OpenSSL + Python(cryptographyライブラリ)」によるSSL証明書の自作からOSへの登録までを行ってみました!コマンド全量付きで丁寧に解説していますので、ぜひ皆さんも記事を読んで試してみてください!

この記事で分かること

  • OpenSSLでルートCA証明書・サーバー証明書を自作する完全手順(SAN設定含む)
  • PythonのcryptographyライブラリでSSL証明書を自動生成・有効期限管理する方法
  • NET::ERR_CERT_AUTHORITY_INVALID など頻出エラーの原因と解決策

なぜlocalhostのHTTPS化が必要なのか

「HTTPのまま開発していたら、本番環境で動かない機能があった…」そんな経験はありませんか?ブラウザのセキュリティポリシーが年々厳格化されており、HTTPS環境でしか動作しないWeb APIが増え続けています。つまり、ローカル開発もHTTPS化しておかないと、本番とのギャップで詰まる場面が増えるのです。

HTTPS必須になったブラウザAPI一覧

以下のAPIは、HTTP環境ではそもそも動作しないか、著しく制限されます。

  • Service Worker:オフラインキャッシュ・プッシュ通知の基盤。HTTP環境では登録不可
  • getUserMedia(カメラ・マイク):Chrome 47以降、HTTPS必須
  • Geolocation API:位置情報取得もHTTPS環境のみ
  • SameSite Cookie / Secure属性:セキュアなCookie送受信はHTTPS必須
  • Web Crypto API:暗号処理APIもセキュアコンテキスト限定
  • Web Bluetooth / Web NFC:デバイス通信系APIも同様

HTTP環境で起きる具体的なエラー事例

たとえば、Service Workerを登録しようとすると DOMException: Only secure origins are allowed というエラーが発生します。また、カメラAPIを呼び出すと「このサイトはカメラへのアクセスが許可されていません」とダイアログが出る前に即拒否されることもあります。つまり、エラーメッセージすら出ずに機能が無言でスキップされるケースも多く、原因特定に時間を取られがちです。

OpenSSLで証明書を自作する手順

OpenSSLを使った証明書生成は、大きく「①ルートCAの作成」「②サーバー証明書の作成&署名」「③SANの設定」の3ステップです。順番を間違えるとブラウザに弾かれるので、手順通りに進めましょう。

手順1:ルートCA証明書の作成(コマンド付き)

まずローカル専用の認証局(CA)を作成します。この「CA」が後続のサーバー証明書を署名・保証する親になります。

# ファイル名: 01_create_root_ca.sh

# CA用の秘密鍵を生成(4096ビットで強度を高める)
openssl genrsa -out localCA.key 4096

# ルートCA証明書を自己署名で作成
# -days 3650 → 約10年有効(ローカル専用なので長めでOK)
# -subj → 証明書の所有者情報をコマンドラインで指定してプロンプトをスキップ
openssl req -x509 -new -nodes \
  -key localCA.key \
  -sha256 \
  -days 3650 \
  -out localCA.crt \
  -subj "/C=JP/ST=Aichi/L=Nagoya/O=LocalDev CA/CN=LocalDev Root CA"

生成された localCA.crt が「ルートCA証明書」です。これを後でOSに登録することで、このCAが署名したすべての証明書がブラウザに信頼されます。

手順2:サーバー証明書への署名手順

次に、localhostで使うサーバー証明書を作成し、先ほどのルートCAで署名します。

# ファイル名: 02_create_server_cert.sh

# サーバー用の秘密鍵を生成
openssl genrsa -out localhost.key 2048

# CSR(証明書署名要求)を作成
# CN=localhost → Common Nameにlocalhostを指定
openssl req -new \
  -key localhost.key \
  -out localhost.csr \
  -subj "/C=JP/ST=Aichi/L=Nagoya/O=LocalDev/CN=localhost"

# ルートCAでCSRに署名してサーバー証明書を発行
# -CAcreateserial → シリアル番号ファイルを自動作成
# -sha256 → SHA-256で署名(SHA-1は現代ブラウザに拒否される)
openssl x509 -req \
  -in localhost.csr \
  -CA localCA.crt \
  -CAkey localCA.key \
  -CAcreateserial \
  -out localhost.crt \
  -days 825 \
  -sha256 \
  -extfile san.cnf  # ← 次のステップで作成するSAN設定ファイル

手順3:SAN(Subject Alternative Name)の設定方法

現代ブラウザはSAN(サブジェクト代替名)が設定されていない証明書を ERR_CERT_COMMON_NAME_INVALID として拒否します。つまり、SANの設定は必須です。

# ファイル名: san.cnf
# SAN拡張設定ファイル

[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no

[req_distinguished_name]
CN = localhost

[v3_req]
# subjectAltName → ブラウザがCNの代わりに参照する接続先ホスト名リスト
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost          # ホスト名でのアクセス用
DNS.2 = *.localhost        # サブドメイン対応
IP.1  = 127.0.0.1          # IPアドレスでのアクセス用
IP.2  = ::1                # IPv6ループバック対応

PythonのcryptographyライブラリでSSL証明書を自動生成する

OpenSSLコマンドを毎回手で叩くのは面倒ですよね。Pythonスクリプトにすれば、証明書の生成・更新を自動化できます。ここでは現在最も推奨されている cryptographyライブラリ を使った実装を解説します。

cryptography / pyOpenSSL との違い

証明書操作ができるPythonライブラリは複数あります。それぞれの特徴を整理すると以下のようになります。

項目cryptographypyOpenSSL
メンテナンス状況✅ 積極的に開発中⚠️ 非推奨(deprecated)へ移行中
X.509 v3対応✅ 完全対応・直感的なAPI△ 対応しているが旧式なAPI
SAN設定✅ ネイティブサポート△ 拡張で対応可能だが複雑
推奨度⭐⭐⭐ 第一選択⭐ 既存コードの維持のみ

つまり、新規コードにはcryptographyライブラリ一択です。pyOpenSSLの公式ドキュメントにも「cryptographyへの移行を推奨する」と明記されています。

実装コード(コピペ可能)

インストールはpipで一発です。

# ファイル名: install.sh
pip install cryptography

以下が証明書自動生成の完全スクリプトです。

# ファイル名: generate_localhost_cert.py
import datetime
import ipaddress
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa

def generate_ca_cert():
    """ルートCA証明書と秘密鍵を生成して返す"""
    # CA用秘密鍵の生成(4096ビット)
    ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)

    subject = issuer = x509.Name([
        x509.NameAttribute(NameOID.COUNTRY_NAME, "JP"),
        x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LocalDev CA"),
        x509.NameAttribute(NameOID.COMMON_NAME, "LocalDev Root CA"),
    ])

    ca_cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(issuer)
        .public_key(ca_key.public_key())
        .serial_number(x509.random_serial_number())
        # 有効期間:10年(ローカル開発用)
        .not_valid_before(datetime.datetime.utcnow())
        .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
        # CA証明書であることを示す重要な拡張フィールド
        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
        .sign(ca_key, hashes.SHA256())
    )
    return ca_key, ca_cert


def generate_server_cert(ca_key, ca_cert):
    """localhostサーバー証明書を生成してCA署名する"""
    server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

    subject = x509.Name([
        x509.NameAttribute(NameOID.COUNTRY_NAME, "JP"),
        x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
    ])

    # SAN(Subject Alternative Name)の設定
    # ブラウザはCNではなくSANを検証するため必須
    san = x509.SubjectAlternativeName([
        x509.DNSName("localhost"),
        x509.DNSName("*.localhost"),
        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
        x509.IPAddress(ipaddress.IPv6Address("::1")),
    ])

    server_cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(ca_cert.subject)  # 発行者 = ルートCA
        .public_key(server_key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(datetime.datetime.utcnow())
        # Appleポリシー:825日以内に収める必要あり
        .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=825))
        .add_extension(san, critical=False)
        .sign(ca_key, hashes.SHA256())  # CAの秘密鍵で署名
    )
    return server_key, server_cert


def save_pem(filename, obj):
    """証明書または秘密鍵をPEMファイルとして保存する"""
    with open(filename, "wb") as f:
        if hasattr(obj, "private_bytes"):
            # 秘密鍵の場合
            f.write(obj.private_bytes(
                encoding=serialization.Encoding.PEM,
                format=serialization.PrivateFormat.TraditionalOpenSSL,
                encryption_algorithm=serialization.NoEncryption(),
            ))
        else:
            # 証明書の場合
            f.write(obj.public_bytes(serialization.Encoding.PEM))


if __name__ == "__main__":
    ca_key, ca_cert = generate_ca_cert()
    server_key, server_cert = generate_server_cert(ca_key, ca_cert)

    save_pem("localCA.key", ca_key)
    save_pem("localCA.crt", ca_cert)
    save_pem("localhost.key", server_key)
    save_pem("localhost.crt", server_cert)

    print("✅ 証明書の生成が完了しました")
    print("  localCA.crt  → OSに登録するルートCA証明書")
    print("  localhost.crt / localhost.key → Webサーバーに設定するサーバー証明書")

証明書の有効期限管理を自動化する

証明書が期限切れになると本番さながらのエラーが出てしまいます。以下のスクリプトを cronやタスクスケジューラ に登録しておくと、期限が30日以内に迫ったら自動更新できます。

# ファイル名: check_and_renew_cert.py
import datetime
from cryptography import x509
from cryptography.hazmat.backends import default_backend

CERT_PATH = "localhost.crt"
RENEW_THRESHOLD_DAYS = 30  # 残り30日以内なら更新

def check_expiry(cert_path: str) -> int:
    """証明書の残り有効日数を返す"""
    with open(cert_path, "rb") as f:
        cert = x509.load_pem_x509_certificate(f.read(), default_backend())
    remaining = (cert.not_valid_after - datetime.datetime.utcnow()).days
    return remaining

if __name__ == "__main__":
    days_left = check_expiry(CERT_PATH)
    print(f"証明書の残り有効日数: {days_left}日")

    if days_left <= RENEW_THRESHOLD_DAYS:
        print("⚠️ 有効期限が近いため、証明書を再生成します...")
        # generate_localhost_cert.py の main() を呼び出すか
        # subprocess で再実行する
        import subprocess
        subprocess.run(["python", "generate_localhost_cert.py"], check=True)
        print("✅ 証明書を更新しました。OSへの再登録を忘れずに!")
    else:
        print("✅ 証明書は有効期限内です。対応不要。")

OSに証明書をインストールして信頼させる

証明書を生成しただけではブラウザはまだ信頼しません。OSの「信頼済みルートCA」ストアに localCA.crt を登録することで、そのCAが署名したすべての証明書が有効と認識されます。

Windows(certmgrを使う方法)

GUIとコマンドラインの両方で登録できます。コマンドラインの方が確実です。

# ファイル名: install_cert_windows.ps1
# 管理者権限のPowerShellで実行すること

# 「信頼されたルート証明機関」ストアにCA証明書をインポート
# -CertStoreLocation → LocalMachine = 全ユーザー共通で有効
Import-Certificate `
  -FilePath ".\localCA.crt" `
  -CertStoreLocation Cert:\LocalMachine\Root

# インポート確認:CNに"LocalDev"を含む証明書を一覧表示
Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Subject -like "*LocalDev*" }

Mac(Keychain Access)

MacではGUIまたはsecurityコマンドで登録します。登録後に「常に信頼」を設定する手順を忘れずに。

# ファイル名: install_cert_mac.sh

# システムキーチェーンに登録(管理者パスワードが必要)
sudo security add-trusted-cert \
  -d \
  -r trustRoot \
  -k /Library/Keychains/System.keychain \
  localCA.crt

# 登録確認
security find-certificate -c "LocalDev Root CA" -a /Library/Keychains/System.keychain

Linux / Chromebook

Linuxはディストリビューションによってコマンドが異なります。ChromebookはLinux(Crostini)環境と Chrome ブラウザの両方に登録が必要な点に注意してください。

# ファイル名: install_cert_linux.sh

# --- Ubuntu / Debian 系 ---
sudo cp localCA.crt /usr/local/share/ca-certificates/localCA.crt
# update-ca-certificates → システムのCA束を再構築するコマンド
sudo update-ca-certificates

# --- RHEL / CentOS / Fedora 系 ---
sudo cp localCA.crt /etc/pki/ca-trust/source/anchors/localCA.crt
sudo update-ca-trust extract

# --- Chromebook (Chrome ブラウザへの登録) ---
# chrome://settings/certificates を開き
# 「認証局」タブ → 「インポート」→ localCA.crt を選択
# 「このCA によるウェブサイトの識別を信頼する」にチェック

よくあるエラーと解決策

証明書を設定したのにブラウザが警告を出す…という状況で詰まったことはありませんか?エラーコードを正確に読むと原因が一発で分かります。

NET::ERR_CERT_AUTHORITY_INVALID の原因

このエラーは「証明書に署名したCAがブラウザに信頼されていない」ことを意味します。つまり、OSへのルートCA登録が完了していない、または登録後にブラウザを再起動していないケースがほとんどです。

  • ✅ チェック1:localCA.crt をOSの信頼済みルートCAに登録したか
  • ✅ チェック2:登録後にChromeを完全終了して再起動したか(chrome://restart が便利)
  • ✅ チェック3:サーバー証明書がルートCAで署名されているか(中間CAを挟んでいないか)

ERR_CERT_COMMON_NAME_INVALID の原因

このエラーは「証明書のSANにアクセス先のホスト名・IPが含まれていない」ことが原因です。たとえば 127.0.0.1 でアクセスしているのに証明書のSANに DNS.1=localhost しか書いていないと発生します。

  • ✅ チェック:san.cnf の alt_names に IP.1 = 127.0.0.1 を追加して証明書を再生成する
  • ✅ チェック:SANに DNS.1 = localhost と IP.1 = 127.0.0.1 の両方を含める

Firefoxだけ警告が出る理由

FirefoxはWindowsやMacのOSキーチェーンを参照せず、独自の証明書ストアを持っています。つまり、OSにCA証明書を登録してもFirefoxには反映されません。Firefox専用の登録手順が必要です。

# ファイル名: install_cert_firefox.sh

# NSS(Network Security Services)ツールをインストール
sudo apt install libnss3-tools   # Ubuntu/Debian

# Firefoxのプロファイルディレクトリを確認(通常は以下のどちらか)
# ~/.mozilla/firefox/*.default-release/
# certutil でCA証明書をインポート
# -A → 証明書追加, -t "CT,," → CA信頼フラグ(C=SSL信頼, T=メール信頼)
certutil -A \
  -n "LocalDev Root CA" \
  -t "CT,," \
  -i localCA.crt \
  -d ~/.mozilla/firefox/*.default-release/

SSL Pinningへの応用(発展)

ローカルHTTPS化ができたら、次のステップとして SSL Pinning(証明書ピン留め) の実装イメージを押さえておきましょう。つまり、特定の証明書以外を拒否するセキュリティ強化技術です。

モバイルアプリ・Pythonクライアントへの実装イメージ

PythonのrequestsライブラリではSSL Pinningをネイティブにはサポートしていませんが、ssl 標準モジュールと組み合わせることで証明書検証をカスタマイズできます。

# ファイル名: ssl_pinning_client.py
import ssl
import urllib.request

# 信頼するCA証明書を localCA.crt のみに限定する
# これにより、他のCAが署名した証明書は拒否される(=SSL Pinningの基本概念)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)

# verify_mode → CERT_REQUIRED で証明書検証を必須にする
context.verify_mode = ssl.CERT_REQUIRED

# check_hostname → True でSANのホスト名一致チェックを有効化
context.check_hostname = True

# ローカルCAのみを信頼するCA束として指定
context.load_verify_locations("localCA.crt")

# この context を使ったリクエストは localCA.crt が署名した証明書のみ通過する
with urllib.request.urlopen("https://localhost:8443/api/hello", context=context) as res:
    print(res.read().decode())

モバイルアプリ(Androidなど)では network_security_config.xml にCA証明書を埋め込む手法が一般的です。この仕組みを理解しておくと、通信プロキシによるSSLインスペクションの仕組みや、Burp Suiteによるキャプチャ手順の理解にも直結します。

まとめ:localhostをHTTPS化して快適な開発環境を手に入れよう

今回は、OpenSSLコマンドとPythonのcryptographyライブラリを使ったSSL証明書の自作・OS登録・エラー解決に挑戦してみました。

mkcertなどのツールも便利ですが、「OpenSSL + Python(cryptographyライブラリ)」はさらに柔軟で、証明書の自動生成・有効期限監視・SSL Pinningへの応用まで一気通貫で管理できるため、これは使わないのはもったいない!と感じました。

導入も簡単ですので、みなさんも今回の記事を参考に、ぜひ「OpenSSL + cryptography」を活用してローカル開発をHTTPS化してみてください!