MacPortsのPythonでurllib.request.urlopen()を使用してHTTPSリクエストをおこなうと「certificate verify failed: unable to get local issuer certificate」が発生する場合の対処方法

## 要約

<!-- textlint-disable ja-technical-writing/sentence-length -->

MacPortsのPythonで[urllib.request.urlopen()](https://docs.python.org/3.12/library/urllib.request.html#urllib.request.urlopen)を使用したHTTPSリクエストをおこなうと「certificate verify failed: unable to get local issuer certificate」というメッセージの例外が発生して、失敗することがあります。

<!-- textlint-enable ja-technical-writing/sentence-length -->

解決方法は、MacPortsで[curl-ca-bundle](https://ports.macports.org/port/curl-ca-bundle/)をインストールすることです。curl-ca-bundleにルート証明書が含まれているため、例外が発生しなくなります。

## 問題

<!-- textlint-disable ja-technical-writing/sentence-length -->

MacPortsのPythonで[urllib.request.urlopen()](https://docs.python.org/3.12/library/urllib.request.html#urllib.request.urlopen)を使用したHTTPSリクエストをおこなうと「certificate verify failed: unable to get local issuer certificate」というメッセージの例外が発生して、失敗することがあります。

たとえば、次のように例外が発生します。以下は、手元のMacPorts上のPython 3.12.5で、`urllib.request.urlopen('https://www.python.org/')`を実行して、[https://www.python.org/](https://www.python.org/)にHTTPSリクエストをおこなった結果です。

<!-- textlint-enable ja-technical-writing/sentence-length -->

```zsh
% python3.12
Python 3.12.5 (main, Aug 10 2024, 00:08:15) [Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import urllib.request
>>> urllib.request.urlopen('https://www.python.org/')
Traceback (most recent call last):
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 1344, in do_open
    h.request(req.get_method(), req.selector, req.data, headers,
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1336, in request
    self._send_request(method, url, body, headers, encode_chunked)
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1382, in _send_request
    self.endheaders(body, encode_chunked=encode_chunked)
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1331, in endheaders
    self._send_output(message_body, encode_chunked=encode_chunked)
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1091, in _send_output
    self.send(msg)
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1035, in send
    self.connect()
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/client.py", line 1477, in connect
    self.sock = self._context.wrap_socket(self.sock,
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/ssl.py", line 455, in wrap_socket
    return self.sslsocket_class._create(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/ssl.py", line 1042, in _create
    self.do_handshake()
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/ssl.py", line 1320, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 215, in urlopen
    return opener.open(url, data, timeout)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 515, in open
    response = self._open(req, data)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 532, in _open
    result = self._call_chain(self.handle_open, protocol, protocol +
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 492, in _call_chain
    result = func(*args)
             ^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 1392, in https_open
    return self.do_open(http.client.HTTPSConnection, req,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/local/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/urllib/request.py", line 1347, in do_open
    raise URLError(err)
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)>
```

## 解決方法

MacPortsの[curl-ca-bundle](https://ports.macports.org/port/curl-ca-bundle/)をインストールすると解決します。

```zsh
% sudo port install curl-ca-bundle
```

curl-ca-bundleをインストールしたあとは、例外は発生しません。

```zsh
% python3.12
Python 3.12.5 (main, Aug 10 2024, 00:08:15) [Clang 15.0.0 (clang-1500.1.0.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import urllib.request
>>> urllib.request.urlopen('https://www.python.org')
<http.client.HTTPResponse object at 0x11033e440>
```

なぜこのような現象が起こるのでしょうか？

## 原因

<!-- textlint-disable max-appearence-count-of-words -->

MacPortsのPythonには、ルート証明書が含まれていないためです。ルート証明書とは、最上位の認証局の証明書です。この証明書がないと、HTTPS通信でWebサーバーから送られてくるサーバー証明書を検証できません。

<!-- textlint-enable max-appearence-count-of-words -->

ルート証明書は、curl-ca-bundleに含まれています。そのため、curl-ca-bundleのインストールが必要です。

## 詳細

以下では、これまで説明した内容の詳細を説明します。具体的には、以下について説明します。

* 「certificate verify failed: unable to get local issuer certificate」とは
* ルート証明書の場所
* curl-ca-bundleについて

説明に先立ち、動作確認をおこなった環境や、前提となる知識について示します。

### 確認環境

動作を確認した環境は、以下のとおりです。

* macOS 13.6.9
* MacPorts 2.10.0
  * [python312](https://ports.macports.org/port/python312/) 3.12.5
  * [openssl](https://ports.macports.org/port/openssl/) 3_18
  * [openssl3](https://ports.macports.org/port/openssl3/) 3.3.1_1
  * [curl-ca-bundle](https://ports.macports.org/port/curl-ca-bundle/) 8.9.1

### 前提知識

HTTPSやTLS/SSL、証明書について基本的な知識があることを前提にします。

たとえば、朝日ネットのhaku氏の記事「[意外と知らないSSL証明書の話](https://techblog.asahi-net.co.jp/entry/2020/03/12/201112)」の「[証明書の構成](https://techblog.asahi-net.co.jp/entry/2020/03/12/201112#%E8%A8%BC%E6%98%8E%E6%9B%B8%E3%81%AE%E6%A7%8B%E6%88%90)」までの知識があれば十分です。

### 「certificate verify failed: unable to get local issuer certificate」とは

例外のメッセージ「certificate verify failed: unable to get local issuer certificate」は、なにを意味しているのでしょうか？

前半の「certificate verify failed」は、証明書の検証に失敗したことをあらわしています。

後半の「unable to get local issuer certificate」は、失敗した理由です。

この「unable to get local issuer certificate」の意味を理解するために、Python 3.12がTLS/SSLをどのように実現しているか調べてみます。

Python 3.12では、TLS/SSLにOpenSSLを使用しています。Python 3.12の[sslモジュールのドキュメント](https://docs.python.org/3.12/library/ssl.html)には「This module uses the OpenSSL library.」という記述があります。

<!-- textlint-disable ja-technical-writing/sentence-length -->

OpenSSLのドキュメント「[OpenSSL Guide: An introduction to SSL/TLS in OpenSSL](https://github.com/openssl/openssl/blob/openssl-3.3.1/doc/man7/ossl-guide-tls-introduction.pod)」には、「unable to get local issuer certificate」のメッセージの意味について書かれています。

<!-- textlint-enable ja-technical-writing/sentence-length -->

> The "unable to get local issuer certificate" error means that OpenSSL has been unable to find a trusted CA for the chain of certificates provided by the server in its trusted certificate store. Check your trusted certificate store configuration again.

<!-- textlint-disable ja-technical-writing/sentence-length -->

ここから、「unable to get local issuer certificate」のメッセージは、HTTPS通信でWebサーバーから送られてきたサーバー証明書を発行した認証局を検証しようとしたものの、信頼できる認証局の証明書が見つからなかったことをあらわしています。

<!-- textlint-enable ja-technical-writing/sentence-length -->

[urllib.request.urlopen()](https://docs.python.org/3.12/library/urllib.request.html#urllib.request.urlopen)には、認証局の証明書を指定するオプション引数があります。この引数を省略すると、システムにインストールされたルート証明書が使用されます。

つまり、例外が発生した原因は、システムにインストールされたルート証明書が見つからなかったためです。

### ルート証明書の場所

それでは、ルート証明書はどこにあるのでしょうか。

<!-- textlint-disable max-appearence-count-of-words,ja-technical-writing/sentence-length -->

OpenSSLのドキュメント「[OpenSSL Guide: An introduction to SSL/TLS in OpenSSL](https://github.com/openssl/openssl/blob/openssl-3.3.1/doc/man7/ossl-guide-tls-introduction.pod)」によれば、OPENSSLDIRの直下にあるcertsディレクトリ内の証明書か、cert.pemがルート証明書です。

<!-- textlint-enable max-appearence-count-of-words,ja-technical-writing/sentence-length -->

以下では、コマンドを実行しながら、ルート証明書の場所を探してみます。

OPENSSLDIRは、`openssl version -d`を実行すると取得できます。

MacPortsのOpenSSLで、上記コマンドを実行した結果は以下のとおりです。

```zsh
% /opt/local/bin/openssl version -d
OPENSSLDIR: "/opt/local/libexec/openssl3/etc/openssl"
```

/opt/local/libexec/openssl3/etc/openssl/には、certsディレクトリはありません。

```zsh
% ls /opt/local/libexec/openssl3/etc/openssl/certs
ls: /opt/local/libexec/openssl3/etc/openssl/certs: No such file or directory
```

/opt/local/libexec/openssl3/etc/openssl/cert.pemファイルは、一見すると存在しているように見えます。

```zsh
% ls -l /opt/local/libexec/openssl3/etc/openssl
total 48
lrwxr-xr-x  1 root  wheel     40  6 21 13:56 cert.pem -> /opt/local/share/curl/curl-ca-bundle.crt
-rw-r--r--  1 root  wheel    412  6 21 13:55 ct_log_list.cnf
-rw-r--r--  1 root  wheel    412  6 21 13:55 ct_log_list.cnf.dist
drwxr-xr-x  5 root  wheel    160  8 11 13:53 misc
-rw-r--r--  1 root  wheel  12328  6 21 13:55 openssl.cnf
-rw-r--r--  1 root  wheel  12328  6 21 13:55 openssl.cnf.dist
```

しかし、cert.pemが指す/opt/local/share/curl/curl-ca-bundle.crtは存在しません。

```zsh
% ls /opt/local/share/curl/curl-ca-bundle.crt
ls: /opt/local/share/curl/curl-ca-bundle.crt: No such file or directory
```

ここまでをまとめます。

ルート証明書は、/opt/local/share/curl/curl-ca-bundle.crtです。しかし、このファイルは存在しません。


つまり、MacPortsでPythonをインストールするだけでは、certsディレクトリもcert.pemファイルもない、と言えます。

### curl-ca-bundleとは

curl-ca-bundleは、[curl](https://curl.se/)が使用する認証局証明書をまとめたものです。

curl-ca-bundleをインストールすることで、ルート証明書をインストールできます。

```zsh
% sudo port install curl-ca-bundle
```

インストール後、curl-ca-bundleの内容を表示した結果は次のとおりです。

```zsh
% port contents curl-ca-bundle
Port curl-ca-bundle @8.9.1_0 contains:
  /opt/local/etc/openssl/cert.pem
  /opt/local/share/curl/curl-ca-bundle.crt
```

<!-- textlint-disable ja-technical-writing/sentence-length -->

/opt/local/libexec/openssl3/etc/openssl/cert.pemが指す/opt/local/share/curl/curl-ca-bundle.crtがインストールされています。

<!-- textlint-enable ja-technical-writing/sentence-length -->

## まとめ

<!-- textlint-disable ja-technical-writing/sentence-length -->

この記事では、MacPortsのPythonでurllib.request.urlopen()を使用したHTTPSリクエストをおこなうと「certificate verify failed: unable to get local issuer certificate」というメッセージの例外が発生する現象について、解決方法と原因を説明しました。

<!-- textlint-enable ja-technical-writing/sentence-length -->

原因は、MacPortsのPythonのインストールのみでは、ルート証明書が不足するためでした。ルート証明書をインストールするには、curl-ca-bundleが必要でした。