web-dev-qa-db-ja.com

JSSEを使用する場合、ホスト名の検証はどのように行う必要がありますか?

Java(デスクトップJREとAndroidの両方で動作する必要があります)で、TLSを介して実行される独自のプロトコル(私の会社に固有)のクライアントを作成しています。 JavaでTLSクライアントを作成するための最良の方法を見つけ、特に、ホスト名の検証が適切に行われることを確認します。(編集:それによって、私はman-in-the-middle攻撃を回避するために、ホスト名がX.509証明書と一致することを確認することを意味します。)

JSSEはTLSクライアントを作成するための明白なAPIですが、 " 世界で最も危険なコード "の論文(および実験から)から、JSSEはホスト名を検証しないことに気付きました。 SSLSocketFactoryAPIを使用します。 (私のプロトコルはHTTPSではないので、これを使用する必要があります。)

したがって、JSSEを使用する場合は、ホスト名の検証を自分で行う必要があるようです。そして、そのコードを最初から書くのではなく(私はほぼ間違いなく間違っているので)、機能する既存のコードを「借りる」必要があるようです。したがって、私が見つけた最も可能性の高い候補は、Apache HttpComponentsライブラリを使用することです(私は実際にはHTTPを行っていないため、皮肉なことです) 標準のjavax.net.ssl.SSLSocketFactoryクラスの代わりにorg.Apache.http.conn.ssl.SSLSocketFactoryクラスを使用します

私の質問は:これは合理的な行動方針ですか?または、私は物事を完全に誤解し、深いところから離れました。実際には、HttpComponentsのようなサードパーティのライブラリを使用せずに、JSSEでホスト名の検証を取得するはるかに簡単な方法がありますか?

TLS用の非JSSEAPIを備えたBouncyCastleも調べましたが、証明書チェーンの検証も行われず、ホスト名の検証も行われないため、さらに制限されているようです。 -スターター。

編集:この質問 回答済み for Java 7、しかし私はまだJava 6およびAndroidの「ベストプラクティス」が何であるかを知りたい。(特に、私のアプリケーションではAndroid)をサポートする必要があります。)

再編集:「ApacheHttpComponentsからの借用」の提案をより具体的にするために、 小さなライブラリ を作成しました。 Apache HttpComponentsから抽出されたHostnameVerifier実装(特にStrictHostnameVerifierとBrowserCompatHostnameVerifier)が含まれています。 (必要なのはベリファイアだけであり、当初考えていたようにApacheのSSLSocketFactoryは必要ないことに気付きました。)自分のデバイスに任せた場合、これが使用するソリューションです。しかし、最初に、私がこのようにすべきではない理由はありますか? (私の目標がhttpsと同じ方法でホスト名の検証を行うことであると仮定します。それ自体が議論の余地があり、暗号化リストのスレッドで議論されていることを認識していますが、今のところ、HTTPSのようなホスト名を使用していますHTTPSを使用していなくても、検証を行います。)

私のソリューションに「間違った」ものが何もないと仮定すると、私の質問はこれです:Java 6、Java 7、およびAndroid?(「より良い」とは、より慣用的で、すでに広く使用されている、および/または必要な外部コードが少ないことを意味します。)

29
user2666524

Java 7(およびそれ以降)

これを使用して、Java 7で導入されたX509ExtendedTrustManagerを暗黙的に使用できます( この回答 を参照)。

SSLParameters sslParams = new SSLParameters();
sslParams.setEndpointIdentificationAlgorithm("HTTPS");
sslSocket.setSSLParameters(sslParams); // also works on SSLEngine

Android

私はAndroidにあまり詳しくありませんが、Apache HTTPクライアントをバンドルする必要があるため、実際には追加のライブラリではありません。そのため、org.Apache.http.conn.ssl.StrictHostnameVerifierを使用できるはずです。 (私はこのコードを試していません。)

SSLSocketFactory ssf = (SSLSocketFactory) SSLSocketFactory.getDefault();
// It's important NOT to resolve the IP address first, but to use the intended name.
SSLSocket socket = (SSLSocket) ssf.createSocket("my.Host.name", 443);

socket.startHandshake();
SSLSession session = socket.getSession();

StrictHostnameVerifier verifier = new StrictHostnameVerifier();
if (!verifier.verify(session.getPeerHost(), session)) {
    // throw some exception or do something similar.
}

その他

残念ながら、ベリファイアは手動で実装する必要があります。 Oracle JREには明らかにホスト名ベリファイアの実装がいくつかありますが、私が知る限り、パブリックAPIを介して利用することはできません。

この最近の回答 にルールの詳細があります。

これが私が書いた実装です。それは確かにレビューされることで行うことができます...コメントとフィードバックを歓迎します。

public void verifyHostname(SSLSession sslSession)
        throws SSLPeerUnverifiedException {
    try {
        String hostname = sslSession.getPeerHost();
        X509Certificate serverCertificate = (X509Certificate) sslSession
                .getPeerCertificates()[0];

        Collection<List<?>> subjectAltNames = serverCertificate
                .getSubjectAlternativeNames();

        if (isIpv4Address(hostname)) {
            /*
             * IP addresses are not handled as part of RFC 6125. We use the
             * RFC 2818 (Section 3.1) behaviour: we try to find it in an IP
             * address Subject Alt. Name.
             */
            for (List<?> sanItem : subjectAltNames) {
                /*
                 * Each item in the SAN collection is a 2-element list. See
                 * <a href=
                 * "http://docs.Oracle.com/javase/7/docs/api/Java/security/cert/X509Certificate.html#getSubjectAlternativeNames%28%29"
                 * >X509Certificate.getSubjectAlternativeNames()</a>. The
                 * first element in each list is a number indicating the
                 * type of entry. Type 7 is for IP addresses.
                 */
                if ((sanItem.size() == 2)
                        && ((Integer) sanItem.get(0) == 7)
                        && (hostname.equalsIgnoreCase((String) sanItem
                                .get(1)))) {
                    return;
                }
            }
            throw new SSLPeerUnverifiedException(
                    "No IP address in the certificate did not match the requested Host name.");
        } else {
            boolean anyDnsSan = false;
            for (List<?> sanItem : subjectAltNames) {
                /*
                 * Each item in the SAN collection is a 2-element list. See
                 * <a href=
                 * "http://docs.Oracle.com/javase/7/docs/api/Java/security/cert/X509Certificate.html#getSubjectAlternativeNames%28%29"
                 * >X509Certificate.getSubjectAlternativeNames()</a>. The
                 * first element in each list is a number indicating the
                 * type of entry. Type 2 is for DNS names.
                 */
                if ((sanItem.size() == 2)
                        && ((Integer) sanItem.get(0) == 2)) {
                    anyDnsSan = true;
                    if (matchHostname(hostname, (String) sanItem.get(1))) {
                        return;
                    }
                }
            }

            /*
             * If there were not any DNS Subject Alternative Name entries,
             * we fall back on the Common Name in the Subject DN.
             */
            if (!anyDnsSan) {
                String commonName = getCommonName(serverCertificate);
                if (commonName != null
                        && matchHostname(hostname, commonName)) {
                    return;
                }
            }
            throw new SSLPeerUnverifiedException(
                    "No Host name in the certificate did not match the requested Host name.");
        }
    } catch (CertificateParsingException e) {
        /*
         * It's quite likely this exception would have been thrown in the
         * trust manager before this point anyway.
         */
        throw new SSLPeerUnverifiedException(
                "Unable to parse the remote certificate to verify its Host name: "
                        + e.getMessage());
    }
}

public boolean isIpv4Address(String hostname) {
    String[] ipSections = hostname.split("\\.");
    if (ipSections.length != 4) {
        return false;
    }
    for (String ipSection : ipSections) {
        try {
            int num = Integer.parseInt(ipSection);
            if (num < 0 || num > 255) {
                return false;
            }
        } catch (NumberFormatException e) {
            return false;
        }
    }
    return true;
}

public boolean matchHostname(String hostname, String certificateName) {
    if (hostname.equalsIgnoreCase(certificateName)) {
        return true;
    }
    /*
     * Looking for wildcards, only on the left-most label.
     */
    String[] certificateNameLabels = certificateName.split(".");
    String[] hostnameLabels = certificateName.split(".");
    if (certificateNameLabels.length != hostnameLabels.length) {
        return false;
    }
    /*
     * TODO: It could also be useful to check whether there is a minimum
     * number of labels in the name, to protect against CAs that would issue
     * wildcard certificates too loosely (e.g. *.com).
     */
    /*
     * We check that whatever is not in the first label matches exactly.
     */
    for (int i = 1; i < certificateNameLabels.length; i++) {
        if (!hostnameLabels[i].equalsIgnoreCase(certificateNameLabels[i])) {
            return false;
        }
    }
    /*
     * We allow for a wildcard in the first label.
     */
    if ("*".equals(certificateNameLabels[0])) {
        // TODO match wildcard that are only part of the label.
        return true;
    }
    return false;
}

public String getCommonName(X509Certificate cert) {
    try {
        LdapName ldapName = new LdapName(cert.getSubjectX500Principal()
                .getName());
        /*
         * Looking for the "most specific CN" (i.e. the last).
         */
        String cn = null;
        for (Rdn rdn : ldapName.getRdns()) {
            if ("CN".equalsIgnoreCase(rdn.getType())) {
                cn = rdn.getValue().toString();
            }
        }
        return cn;
    } catch (InvalidNameException e) {
        return null;
    }
}

/* BouncyCastle implementation, should work with Android. */
public String getCommonName(X509Certificate cert) {
    String cn = null;
    X500Name x500name = X500Name.getInstance(cert.getSubjectX500Principal()
            .getEncoded());
    for (RDN rdn : x500name.getRDNs(BCStyle.CN)) {
        // We'll assume there's only one AVA in this RDN.
        cn = IETFUtils.valueToString(rdn.getFirst().getValue());
    }
    return cn;
}

getCommonNameの実装は2つあります。1つはjavax.naming.ldapを使用し、もう1つはBouncyCastleを使用します。

主な微妙な点は次のとおりです。

  • SANでのみIPアドレスを照合する( この質問 はIPアドレスの照合とサブジェクト代替名に関するものです)。おそらく、IPv6マッチングについても何かができるでしょう。
  • ワイルドカードマッチング。
  • DNS SANがない場合にのみ、CNにフォールバックします。
  • 「最も具体的な」CNが実際に意味するもの。これが最後だと思います。 (複数の属性値アサーション(AVA)を持つ単一のCN RDNを検討していません。BouncyCastleはそれらを処理できますが、私が知る限り、これは非常にまれなケースです。)
  • 国際化された(非ASCII)ドメイン名で何が起こるかをまったく確認していません( RFC 6125 を参照)。

[〜#〜]編集[〜#〜]

「ApacheHttpComponentsからの借用」の提案をより具体的にするために、Apache HttpComponentsから抽出されたHostnameVerifier実装(特にStrictHostnameVerifierとBrowserCompatHostnameVerifier)を含む小さなライブラリを作成しました。 [...]しかし、最初に、私がこのようにすべきではない理由はありますか?

はい、このようにしない理由があります。

まず、ライブラリを効果的にフォークしました。元のApache HttpComponentsでこれらのクラスに加えられた変更に応じて、ライブラリを維持する必要があります。私はライブラリを作成することに反対していません(私は自分で作成しました、そしてあなたがそうすることを思いとどまらせません)、しかしあなたはこれを考慮に入れなければなりません。本当にスペースを節約しようとしていますか?確かに、スペースを再利用する必要がある場合は、最終製品の未使用のコードを削除できるツールがあります(ProGuardが思い浮かびます)。

第二に、StrictHostnameVerifierでさえRFC2818またはRFC6125に準拠していません。そのコードからわかる限り、次のようになります。

  • CNでIPアドレスを受け入れるべきではないのに受け入れます。
  • DNS SANが存在しない場合にCNにフォールバックするだけでなく、CNを最初の選択肢として扱います。これにより、CN=cn.example.comとa SAN forwww.example.comがありますがSAN forcn.example.comcn.example.comに対して有効ではないはずです。
  • CNの抽出方法については少し懐疑的です。サブジェクトDN文字列のシリアル化は、特に一部のRDNにコンマが含まれている場合や、一部のRDNに複数のAVAが含まれる可能性があるという厄介なケースでは、少しおかしい場合があります。

一般的な「より良い方法」を見つけるのは難しいです。もちろん、このフィードバックをApacheHttpComponentsライブラリに提供することも1つの方法です。上記で書いたコードをコピーして貼り付けることも、確かに良い方法のようには思えません(SOのコードのスニペットは通常、維持されておらず、100%テストされておらず、エラー)。

より良い方法は、Android開発チームがJava 7で行ったのと同じSSLParametersX509ExtendedTrustManagerをサポートするように説得することです。レガシーデバイスの問題を残します。

18
Bruno