Java で SSL の証明書エラーを解決/回避して HTTPS で接続する方法

Java (OpenJDK 10) から SSL (HTTPS) で接続するときに、 エラーが出ました。 使っていたのは OpenJDK 10.0.2 でした。 
(Java 8 から Java 10 に上げたので、 もしかしたら Java 9 でも起きていたのかも。)

Java 12 にしたらこのエラーは出なくなりました。

## エラー内容

これはメール送信をしようとした時のエラーログです。

```
2018-09-07 11:30:54.743 ERROR 16595 --- [pool-1-thread-1] o.s.s.s.TaskUtils$LoggingErrorHandler    : Unexpected error occurred in scheduled task.

org.springframework.mail.MailAuthenticationException: Authentication failed; nested exception is javax.mail.AuthenticationFailedException: 220 Ready to start TLS
;
  nested exception is:
        javax.mail.MessagingException: Could not convert socket to TLS;
  nested exception is:
        javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
        at org.springframework.mail.javamail.JavaMailSenderImpl.doSend(JavaMailSenderImpl.java:438) ~[spring-context-support-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at org.springframework.mail.javamail.JavaMailSenderImpl.send(JavaMailSenderImpl.java:359) ~[spring-context-support-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at org.springframework.mail.javamail.JavaMailSenderImpl.send(JavaMailSenderImpl.java:354) ~[spring-context-support-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at XXXXX
        at XXXXX
        at jdk.internal.reflect.GeneratedMethodAccessor130.invoke(Unknown Source) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:564) ~[na:na]
        at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:65) ~[spring-context-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54) ~[spring-context-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at org.springframework.scheduling.concurrent.ReschedulingRunnable.run(ReschedulingRunnable.java:93) [spring-context-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:514) [na:na]
        at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) [na:na]
        at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304) [na:na]
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1135) [na:na]
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) [na:na]
        at java.base/java.lang.Thread.run(Thread.java:844) [na:na]
Caused by: javax.mail.AuthenticationFailedException: 220 Ready to start TLS

        at com.sun.mail.smtp.SMTPTransport$Authenticator.authenticate(SMTPTransport.java:960) ~[javax.mail-1.6.1.jar:1.6.1]
        at com.sun.mail.smtp.SMTPTransport.authenticate(SMTPTransport.java:876) ~[javax.mail-1.6.1.jar:1.6.1]
        at com.sun.mail.smtp.SMTPTransport.protocolConnect(SMTPTransport.java:780) ~[javax.mail-1.6.1.jar:1.6.1]
        at javax.mail.Service.connect(Service.java:366) ~[javax.mail-1.6.1.jar:1.6.1]
        at org.springframework.mail.javamail.JavaMailSenderImpl.connectTransport(JavaMailSenderImpl.java:515) ~[spring-context-support-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        at org.springframework.mail.javamail.JavaMailSenderImpl.doSend(JavaMailSenderImpl.java:435) ~[spring-context-support-5.0.7.RELEASE.jar:5.0.7.RELEASE]
        ... 16 common frames omitted
Caused by: javax.mail.MessagingException: Could not convert socket to TLS
        at com.sun.mail.smtp.SMTPTransport.startTLS(SMTPTransport.java:2155) ~[javax.mail-1.6.1.jar:1.6.1]
        at com.sun.mail.smtp.SMTPTransport$Authenticator.authenticate(SMTPTransport.java:935) ~[javax.mail-1.6.1.jar:1.6.1]
        ... 21 common frames omitted
Caused by: javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
        at java.base/sun.security.ssl.Alerts.getSSLException(Alerts.java:198) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.fatal(SSLSocketImpl.java:1974) ~[na:na]
        at java.base/sun.security.ssl.Handshaker.fatalSE(Handshaker.java:345) ~[na:na]
        at java.base/sun.security.ssl.Handshaker.fatalSE(Handshaker.java:339) ~[na:na]
        at java.base/sun.security.ssl.ClientHandshaker.checkServerCerts(ClientHandshaker.java:1968) ~[na:na]
        at java.base/sun.security.ssl.ClientHandshaker.serverCertificate(ClientHandshaker.java:1777) ~[na:na]
        at java.base/sun.security.ssl.ClientHandshaker.processMessage(ClientHandshaker.java:264) ~[na:na]
        at java.base/sun.security.ssl.Handshaker.processLoop(Handshaker.java:1098) ~[na:na]
        at java.base/sun.security.ssl.Handshaker.processRecord(Handshaker.java:1026) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.processInputRecord(SSLSocketImpl.java:1137) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:1074) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.readRecord(SSLSocketImpl.java:973) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.performInitialHandshake(SSLSocketImpl.java:1402) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1429) ~[na:na]
        at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:1413) ~[na:na]
        at com.sun.mail.util.SocketFetcher.configureSSLSocket(SocketFetcher.java:620) ~[javax.mail-1.6.1.jar:1.6.1]
        at com.sun.mail.util.SocketFetcher.startTLS(SocketFetcher.java:547) ~[javax.mail-1.6.1.jar:1.6.1]
        at com.sun.mail.smtp.SMTPTransport.startTLS(SMTPTransport.java:2150) ~[javax.mail-1.6.1.jar:1.6.1]
        ... 22 common frames omitted
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
        at java.base/sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:385) ~[na:na]
        at java.base/sun.security.validator.PKIXValidator.engineValidate(PKIXValidator.java:290) ~[na:na]
        at java.base/sun.security.validator.Validator.validate(Validator.java:264) ~[na:na]
        at java.base/sun.security.ssl.X509TrustManagerImpl.validate(X509TrustManagerImpl.java:343) ~[na:na]
        at java.base/sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:226) ~[na:na]
        at java.base/sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:133) ~[na:na]
        at java.base/sun.security.ssl.ClientHandshaker.checkServerCerts(ClientHandshaker.java:1947) ~[na:na]
        ... 35 common frames omitted
Caused by: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
        at java.base/sun.security.provider.certpath.SunCertPathBuilder.build(SunCertPathBuilder.java:141) ~[na:na]
        at java.base/sun.security.provider.certpath.SunCertPathBuilder.engineBuild(SunCertPathBuilder.java:126) ~[na:na]
        at java.base/java.security.cert.CertPathBuilder.build(CertPathBuilder.java:297) ~[na:na]
        at java.base/sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:380) ~[na:na]
        ... 41 common frames omitted
```

## 対処法1 - 証明書をインポート

EC2を使っていましたので、ログインして次のコマンドを実行しました。

cacerts のパスワードはデフォルトでは changeit です。
コマンドの中でファイルパスを指定しているところがあります。これはお使いの環境に依存しますので適宜調べてください。

```
mkdir certification
cd certification
openssl s_client -connect email-smtp.us-east-1.amazonaws.com:443 < /dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > email-smtp.us-east-1.amazonaws.com_public.crt
sudo /usr/lib/jvm/jdk-10.0.2/bin/keytool -import -alias email-smtp.us-east-1.amazonaws.com -keystore /usr/lib/jvm/jdk-10.0.2/lib/security/cacerts -file email-smtp.us-east-1.amazonaws.com_public.crt
```

他にもいくつか外部通信しているところがありましたので、いくつか証明書をインポートしました。

もし、 `keytool error: java.lang.Exception: Certificate not imported, alias already exists` というようなメッセージが出たら
別のエイリアスでインポートするか、
次のコマンドでキーを削除し手からインポートを行います。

```
keytool -delete -alias your_alias -keystore key_store_path
```

`keytool` コマンドは、 `-storepass changeit` のようにパスワードを記述できます。
`keytool` は　jdk の　bin ディレクトリにあります。

(ルート証明書をインポートすると楽なのか？ よくわかっていません。)

### ディレクトリパスの調べ方

例えば私の使っている macOS の環境では、次のコマンドをルートで実行してインポートしました。

```sh
/Library/Java/JavaVirtualMachines/adoptopenjdk-10.jdk/Contents/Home/bin/keytool -import -alias email-smtp.us-east-1.amazonaws.com -keystore  /Library/Java/JavaVirtualMachines/adoptopenjdk-10.jdk/Contents/Home/lib/security/cacerts -file email-smtp.us-east-1.amazonaws.com_public.crt
```

最初の `keytool` のパスは `$JAVA_HOME/bin/keytool`, 次の `cacerts` のパスは `$JAVA_HOME/lib/security/cacerts` です。

`/usr/libexec/java_home` を実行すると JAVA_HOME のパスが得られます(これが全マシンでできるのかは知りません、手持ちの macOS ではできました)。 これを利用すると、インポートのコマンドは次のように書けます。

```sh
`/usr/libexec/java_home`/bin/keytool -import -alias email-smtp.us-east-1.amazonaws.com -keystore  `/usr/libexec/java_home`/lib/security/cacerts -file email-smtp.us-east-1.amazonaws.com_public.crt
```

### 参考

* [Connectiong to SSL Services](https://confluence.atlassian.com/kb/connecting-to-ssl-services-802171215.html)
* [Unable to connect to SSL services due to PKIX path building failed](https://confluence.atlassian.com/kb/unable-to-connect-to-ssl-services-due-to-pkix-path-building-failed-779355358.html)
* [How to obtain the location of cacerts of the default java installation?](https://stackoverflow.com/questions/11936685/how-to-obtain-the-location-of-cacerts-of-the-default-java-installation)

### 関連

* [OpenJDK 10 Now Includes Root CA Certificates](https://blogs.oracle.com/jtc/openjdk-10-now-includes-root-ca-certificates)
* [「Java」 HttpsURLConnection で HTTPS 通信する ( サーバ認証あり )](http://a4dosanddos.hatenablog.com/entry/2015/03/29/125111)

## 対処法2 - ドメインの認証を無視する

証明書をインポートする方法だと、証明書の期限が切れた場合にまた変える必要がありますので、
下のように書いて、ドメインの認証を無視するようにします。

この方法は自分で通信用のクラスを書く場合はできるのですが、
ライブラリを用いて通信する場合にできるのかはよくわかっていません。

下のコードの `object` は `WebGateway.post(...)` のようにして使うことを想定して作っていました。
`post` メソッドよりも上の部分が、証明書を無視するのに必要なコードです。

```kotlin:Kotlin
package com.example.app

import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.security.cert.X509Certificate
import javax.net.ssl.*
import java.security.cert.CertificateException

object WebGateway {
    private val sslContext = SSLContext.getInstance("SSL")
    private val trustManager = arrayOf<TrustManager>(object : X509TrustManager {
        override fun getAcceptedIssuers(): Array<X509Certificate>? {
            return null
        }

        @Throws(CertificateException::class)
        override fun checkClientTrusted(
            chain: Array<X509Certificate>,
            authType: String
        ) {
        }

        @Throws(CertificateException::class)
        override fun checkServerTrusted(
            chain: Array<X509Certificate>,
            authType: String
        ) {
        }
    })

    init {
        sslContext.init(null, trustManager, null)
        HttpsURLConnection.setDefaultHostnameVerifier { hostname, session -> true }
    }

    private fun buildConnection(
        url: URL,
        payload: String,
        method: String,
        sendJson: Boolean = false,
        requestJson: Boolean = false
    ): HttpURLConnection {
        val connection = url.openConnection() as HttpURLConnection
        (connection as? HttpsURLConnection)?.let {
            it.sslSocketFactory = sslContext.socketFactory
        }

        connection.requestMethod = method
        connection.doOutput = true
        
        /* Code */

        return connection
    }

    fun post(
        url: URL,
        payload: String,
        isJson: Boolean = false,
        requestJson: Boolean = false
    ): String {
        val connection =
            buildConnection(url, payload, "POST", isJson, requestJson)
        connection.connect()

        val os = connection.outputStream
        os.write(payload.toByteArray())
        os.close()

        if (connection.responseCode != HttpsURLConnection.HTTP_OK) {
            throw Exception(
                connection.responseCode.toString() + ":" +
                        connection.responseMessage
            )
        }

        val reader = BufferedReader(
            InputStreamReader(connection.inputStream, "UTF-8")
        )
        val sb = StringBuilder()
        do {
            val line = reader.readLine() ?: break
            sb.appendln(line)
        } while (true)

        connection.disconnect()

        return sb.toString()
    }
}
```

### 参考

* [Disable Certificate Validation in Java SSL Connections](https://nakov.com/blog/2009/07/16/disable-certificate-validation-in-java-ssl-connections/)
* [Java HttpsURLConnectionで、証明書エラーを無視して接続する](https://symfoware.blog.fc2.com/blog-entry-1165.html)