通过 HTTPS 和 SSL 确保安全

安全套接字层 (SSL)(现在技术上称为传输层安全协议 (TLS))是一个通用构建块,用于在客户端与服务器之间进行加密通信。应用很可能以错误的方式使用 SSL,从而导致恶意实体能够拦截网络上的应用数据。为了帮助确保您的应用不会出现这种情况,本文重点介绍了使用安全网络协议时可能遇到的常见问题,以及如何解决使用公钥基础设施 (PKI) 时可能会遇到的一些棘手问题。

您还应阅读 Android 安全性概览以及权限概览

概念

在典型的 SSL 使用场景中,服务器会配置有一个证书,该证书包含一个公钥及一个匹配的私钥。作为 SSL 客户端与服务器握手的一部分,服务器将通过使用公钥加密对其证书进行签名来证明自己具有私钥。

不过,任何人都可以生成他们自己的证书和私钥,因此,一个简单的握手只能说明服务器知道与证书公钥匹配的私钥,除此之外无法证明服务器任何其他方面的信息。解决此问题的一个方法是让客户端拥有其信任的一个或多个证书集。如果证书不在此集合中,则不会信任服务器。

这种方法虽然简单,但有几个缺点。服务器应能够随时间的推移升级到更强的密钥(“密钥轮换”),使用新的公钥替换证书中的公钥。遗憾的是,客户端应用现在必须根据服务器配置发生的变化进行更新。如果服务器不在应用开发者的控制下(例如,如果服务器是一个第三方网络服务),则很容易出现问题。如果应用必须与网络浏览器或电子邮件应用等任意服务器通信,那么,此方法也会带来问题。

为弥补这些缺点,通常使用知名颁发机构(称为证书授权机构 (CA))颁发的证书配置服务器。主机平台一般包含其信任的知名 CA 的列表。从 Android 4.2 (Jelly Bean) 开始,Android 目前包含在每个版本中更新的 100 多个 CA。CA 具有一个证书和一个私钥,这点与服务器相似。为服务器颁发证书时,CA 使用其私钥为服务器证书签名。然后,客户端可以验证该服务器是否具有平台已知的 CA 颁发的证书。

不过,在解决一些问题的同时,使用 CA 也会引发其他问题。因为 CA 为很多服务器颁发证书,所以您仍需要某种方式来确保您与您需要的服务器通信。为解决此问题,CA 颁发的证书通过 gmail.com 等具体名称或 *.google.com 等通配型主机集识别服务器。

以下示例具体讲解了这些概念。在截取自某命令行的如下代码段中,openssl 工具的 s_client 命令会查看 Wikipedia 的服务器证书信息。它指定端口 443,因为这是 HTTPS 的默认端口。该命令将 openssl s_client 的输出发送到 openssl x509,后者将根据 X.509 标准设置证书相关信息的格式。具体而言,该命令会请求主题(包含服务器名称信息)和颁发机构(用来标识 CA)。

    $ openssl s_client -connect wikipedia.org:443 | openssl x509 -noout -subject -issuer
    subject= /serialNumber=sOrr2rKpMVP70Z6E9BT5reY008SJEdYv/C=US/O=*.wikipedia.org/OU=GT03314600/OU=See www.rapidssl.com/resources/cps (c)11/OU=Domain Control Validated - RapidSSL(R)/CN=*.wikipedia.org
    issuer= /C=US/O=GeoTrust, Inc./CN=RapidSSL CA
    

您会看到证书是由 RapidSSL CA 为与 *.wikipedia.org 匹配的服务器颁发的。

HTTPS 示例

假设您有一个由知名 CA 颁发证书的网络服务器,那么,您可以使用如下简单代码发起安全的请求:

Kotlin

    val url = URL("https://wikipedia.org")
    val urlConnection: URLConnection = url.openConnection()
    val inputStream: InputStream = urlConnection.getInputStream()
    copyInputStreamToOutputStream(inputStream, System.out)
    

Java

    URL url = new URL("https://wikipedia.org");
    URLConnection urlConnection = url.openConnection();
    InputStream in = urlConnection.getInputStream();
    copyInputStreamToOutputStream(in, System.out);
    

没错,就这么简单。如果您要调整 HTTP 请求,则可以切换到 HttpURLConnection. HttpURLConnection 的相关 Android 文档提供了更深入的示例,有助您了解如何处理请求和响应标头、发布内容、管理 Cookie、使用代理以及缓存响应等。但对于验证证书和主机名的细节,Android 框架在 API 中为您考虑了这些细节。这可能就是您想实现的目标。但是,您仍需要注意以下事项。

验证服务器证书时的常见问题

假设验证过程中并未收到来自 getInputStream() 的内容,而是引发了异常:

    javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
            at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:374)
            at libcore.net.http.HttpConnection.setupSecureSocket(HttpConnection.java:209)
            at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.makeSslConnection(HttpsURLConnectionImpl.java:478)
            at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:433)
            at libcore.net.http.HttpEngine.sendSocketRequest(HttpEngine.java:290)
            at libcore.net.http.HttpEngine.sendRequest(HttpEngine.java:240)
            at libcore.net.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:282)
            at libcore.net.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:177)
            at libcore.net.http.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:271)
    

出现这种情况的原因有很多,其中包括:

  1. 颁发服务器证书的 CA 未知
  2. 服务器证书不是 CA 签名的,而是自签名的
  3. 服务器配置缺少中间 CA

下面几部分将讨论如何解决这些问题,同时确保与服务器的连接安全无虞。

未知的证书授权机构

在这种情况下,由于您的 CA 不受系统信任,将发生 SSLHandshakeException。原因可能是您有一个由 Android 尚不信任的新 CA 颁发的证书,或您的应用在没有 CA 的较旧版本上运行。CA 未知的原因通常是因为它不是公共 CA,而是由政府、公司或教育机构等组织颁发的仅供其自己使用的私有 CA。

幸运的是,您可以指示 HttpsURLConnection 信任特定的 CA 集。这个过程可能有点复杂,下面的示例展示了这个过程:从 InputStream 获取一个特定的 CA,用该 CA 创建 KeyStore,然后用后者创建和初始化 TrustManagerTrustManager 是系统用于验证来自服务器的证书的工具,可以通过包含一个或多个 CA 的 KeyStore 创建,而创建的 TrustManager 将仅信任这些 CA。

由于 TrustManager 是新建的,此示例将启动一个新的 SSLContext,它会提供一个 SSLSocketFactory,可用于替换来自 HttpsURLConnection 的默认 SSLSocketFactory。这样一来,连接将使用您的 CA 来验证证书。

下面是使用华盛顿大学的机构 CA 的完整示例:

Kotlin

    // Load CAs from an InputStream
    // (could be from a resource or ByteArrayInputStream or ...)
    val cf: CertificateFactory = CertificateFactory.getInstance("X.509")
    // From https://www.washington.edu/itconnect/security/ca/load-der.crt
    val caInput: InputStream = BufferedInputStream(FileInputStream("load-der.crt"))
    val ca: X509Certificate = caInput.use {
        cf.generateCertificate(it) as X509Certificate
    }
    System.out.println("ca=" + ca.subjectDN)

    // Create a KeyStore containing our trusted CAs
    val keyStoreType = KeyStore.getDefaultType()
    val keyStore = KeyStore.getInstance(keyStoreType).apply {
        load(null, null)
        setCertificateEntry("ca", ca)
    }

    // Create a TrustManager that trusts the CAs inputStream our KeyStore
    val tmfAlgorithm: String = TrustManagerFactory.getDefaultAlgorithm()
    val tmf: TrustManagerFactory = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
        init(keyStore)
    }

    // Create an SSLContext that uses our TrustManager
    val context: SSLContext = SSLContext.getInstance("TLS").apply {
        init(null, tmf.trustManagers, null)
    }

    // Tell the URLConnection to use a SocketFactory from our SSLContext
    val url = URL("https://certs.cac.washington.edu/CAtest/")
    val urlConnection = url.openConnection() as HttpsURLConnection
    urlConnection.sslSocketFactory = context.socketFactory
    val inputStream: InputStream = urlConnection.inputStream
    copyInputStreamToOutputStream(inputStream, System.out)
    

Java

    // Load CAs from an InputStream
    // (could be from a resource or ByteArrayInputStream or ...)
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    // From https://www.washington.edu/itconnect/security/ca/load-der.crt
    InputStream caInput = new BufferedInputStream(new FileInputStream("load-der.crt"));
    Certificate ca;
    try {
        ca = cf.generateCertificate(caInput);
        System.out.println("ca=" + ((X509Certificate) ca).getSubjectDN());
    } finally {
        caInput.close();
    }

    // Create a KeyStore containing our trusted CAs
    String keyStoreType = KeyStore.getDefaultType();
    KeyStore keyStore = KeyStore.getInstance(keyStoreType);
    keyStore.load(null, null);
    keyStore.setCertificateEntry("ca", ca);

    // Create a TrustManager that trusts the CAs in our KeyStore
    String tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
    tmf.init(keyStore);

    // Create an SSLContext that uses our TrustManager
    SSLContext context = SSLContext.getInstance("TLS");
    context.init(null, tmf.getTrustManagers(), null);

    // Tell the URLConnection to use a SocketFactory from our SSLContext
    URL url = new URL("https://certs.cac.washington.edu/CAtest/");
    HttpsURLConnection urlConnection =
        (HttpsURLConnection)url.openConnection();
    urlConnection.setSSLSocketFactory(context.getSocketFactory());
    InputStream in = urlConnection.getInputStream();
    copyInputStreamToOutputStream(in, System.out);
    

借助了解您的 CA 的自定义 TrustManager,系统可以验证您的服务器证书是否来自受信任的颁发机构。

注意:很多网站都会介绍一种效果不佳的替代解决方案,即让您安装一个不起作用的 TrustManager。如果您这样做,那还不如不加密通信,因为任何人都可以在公共 WLAN 热点下,使用伪装成您的服务器的代理发送您的用户流量,通过 DNS 欺骗攻击您的用户。然后,攻击者可以记录密码和其他个人数据。攻击者之所以能得逞,是因为他们可以生成证书;如果没有可以切实验证相应证书是否来自受信任来源的 TrustManager,与您的应用通信的可能是任何人。因此,请不要这样做,即使是暂时性的也不例外。您可以随时将您的应用设置为信任服务器证书的颁发机构,因此行动起来吧。

自签名的服务器证书

导致出现 SSLHandshakeException 的第二种情况是自签名证书,也就是服务器充当自己的 CA。这与证书授权机构未知的情况相似,因此您可以使用前面部分介绍的方法。

您可以创建自己的 TrustManager,这次直接信任服务器证书。这种方法具有前面所述的将应用与证书直接关联的所有弊端,但可以安全地操作。不过,您应谨慎为之,以确保您的自签名证书具有合理的强密钥。从 2012 年开始,可以接受一个指数为 65537 的 2048 位 RSA 签名,此签名的有效期为一年。轮换使用密钥时,您应查看授权机构(例如 NIST)针对可接受的密钥提供的建议

缺少中间证书授权机构

导致出现 SSLHandshakeException 的第三种情况是缺少中间 CA。大多数公共 CA 都不直接对服务器证书进行签名。相反,它们使用自己的主要 CA 证书(称为根 CA)对中间 CA 进行签名。这样一来,根 CA 可以离线存储,从而降低泄露风险。不过,Android 等操作系统通常仅直接信任根 CA,这会在服务器证书(由中间 CA 签名)与证书验证程序(了解根 CA)之间留下一个小的信任缺口。为了解决这个问题,服务器在 SSL 握手期间不会仅向客户端发送它的证书,而是发送一个证书链,包括服务器 CA 以及到达可信的根 CA 所需要的任意中间证书。

下面是通过 openssl s_client 命令看到的 mail.google.com 证书链,目的是帮助您了解实际使用时的效果:

    $ openssl s_client -connect mail.google.com:443
    ---
    Certificate chain
     0 s:/C=US/ST=California/L=Mountain View/O=Google LLC/CN=mail.google.com
       i:/C=ZA/O=Thawte Consulting (Pty) Ltd./CN=Thawte SGC CA
     1 s:/C=ZA/O=Thawte Consulting (Pty) Ltd./CN=Thawte SGC CA
       i:/C=US/O=VeriSign, Inc./OU=Class 3 Public Primary Certification Authority
    ---
    

这表明服务器会为 mail.google.com 发送一个由 Thawte SGC CA(中间 CA)颁发的证书,为 Thawte SGC CA 发送另一个由 Verisign CA(Android 信任的主要 CA)颁发的证书。

不过,对服务器进行配置以便不添加必要的中间 CA 也是屡见不鲜。例如,下面的服务器会引发 Android 浏览器错误和 Android 应用异常:

    $ openssl s_client -connect egov.uscis.gov:443
    ---
    Certificate chain
     0 s:/C=US/ST=District Of Columbia/L=Washington/O=U.S. Department of Homeland Security/OU=United States Citizenship and Immigration Services/OU=Terms of use at www.verisign.com/rpa (c)05/CN=egov.uscis.gov
       i:/C=US/O=VeriSign, Inc./OU=VeriSign Trust Network/OU=Terms of use at https://www.verisign.com/rpa (c)10/CN=VeriSign Class 3 International Server CA - G3
    ---
    

有趣的是,在大多数桌面浏览器中访问此服务器不会引发完全未知的 CA 或自签名服务器证书所引发的类似错误。这是因为大多数桌面浏览器都会将可信的中间 CA 缓存一段时间。当浏览器从某个网站访问和了解中间 CA 后,下次它就不需要将中间 CA 添加到证书链中。

有些网站会专门针对提供资源的辅助网络服务器这样做。例如,他们可能让具有完整证书链的服务器提供主 HTML 页面,让不包含 CA 的服务器提供图像、CSS 或 JavaScript 等资源,以节省带宽。遗憾的是,这些服务器有时候可能会提供您正在尝试从 Android 应用调用的网络服务,这一点让人难以接受。

可以通过两种方法解决此问题:

  • 配置服务器以便在服务器链中添加中间 CA。大多数 CA 都可以提供有关如何为所有常用网络服务器执行此操作的文档。如果您需要网站至少通过 Android 4.2 使用默认 Android 浏览器,那么这是唯一的方法。
  • 或者,像对待其他任何未知 CA 一样对待中间 CA,并创建一个 TrustManager 以直接信任它,如前面两个部分中所述。

主机名验证方面的常见问题

正如本文开头所述,验证 SSL 连接有两个关键环节。首先是验证证书是否来自值得信任的来源,这是前面部分重点讲述的内容。而此部分侧重于第二个环节:确保您正在通信的服务器提供正确的证书。如果提供的证书不正确,您通常会看到如下错误:

    java.io.IOException: Hostname 'example.com' was not verified
            at libcore.net.http.HttpConnection.verifySecureSocketHostname(HttpConnection.java:223)
            at libcore.net.http.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:446)
            at libcore.net.http.HttpEngine.sendSocketRequest(HttpEngine.java:290)
            at libcore.net.http.HttpEngine.sendRequest(HttpEngine.java:240)
            at libcore.net.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:282)
            at libcore.net.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:177)
            at libcore.net.http.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:271)
    

出现这种情况的原因之一可能是服务器配置错误。配置服务器所使用的证书不具有与您尝试连接的服务器匹配的主题或主题备用名称字段。很多不同的服务器可能使用一个证书。例如,通过 openssl s_client -connect google.com:443 | openssl x509 -text 查看 google.com 证书时,您不仅可以发现支持 *.google.com 的主题,也会发现 *.youtube.com 和 *.android.com 等的主题备用名称。仅当您要连接的服务器名称没有被证书列为可接受时才会发生这种错误。

遗憾的是,还有另外一个原因也会引发此错误,即虚拟托管。当多个使用 HTTP 的主机名共享服务器时,网络服务器可以通过 HTTP/1.1 请求识别客户端正在寻找哪个目标主机名。遗憾的是,对于 HTTPS,情况会变得非常复杂,因为服务器必须在看到 HTTP 请求前知道要返回哪个证书。为了解决这个问题,新版本的 SSL(特别是 TLSv.1.0 及更高版本)会支持服务器名称指示 (SNI),以允许 SSL 客户端向服务器指明目标主机名,方便后者返回正确的证书。

幸运的是,自 Android 2.3 开始,HttpsURLConnection 就支持 SNI。如果您需要支持 Android 2.2(及更旧的版本),一种解决办法是在一个唯一端口上设置备用虚拟主机,以便了解要返回哪个服务器证书。

比较极端的替代方法是不使用服务器默认情况下返回的验证程序,而是将 HostnameVerifier 替换为不使用您的虚拟机主机名的验证程序。

注意:如果其他虚拟主机不在您的掌控之内,那么替换 HostnameVerifier 可能非常危险,因为中间人攻击可能会在您不知情的情况下将流量引向其他服务器。

如果您仍确定要替换主机名验证,请看下面的示例,它将针对单个 URLConnection 的验证程序替换为确认主机名至少符合应用预期的验证程序:

Kotlin

    // Create an HostnameVerifier that hardwires the expected hostname.
    // Note that is different than the URL's hostname:
    // example.com versus example.org
    val hostnameVerifier = HostnameVerifier { _, session ->
        HttpsURLConnection.getDefaultHostnameVerifier().run {
            verify("example.com", session)
        }
    }

    // Tell the URLConnection to use our HostnameVerifier
    val url = URL("https://example.org/")
    val urlConnection = url.openConnection() as HttpsURLConnection
    urlConnection.hostnameVerifier = hostnameVerifier
    val inputStream: InputStream = urlConnection.inputStream
    copyInputStreamToOutputStream(inputStream, System.out)
    

Java

    // Create an HostnameVerifier that hardwires the expected hostname.
    // Note that is different than the URL's hostname:
    // example.com versus example.org
    HostnameVerifier hostnameVerifier = new HostnameVerifier() {
        @Override
        public boolean verify(String hostname, SSLSession session) {
            HostnameVerifier hv =
                HttpsURLConnection.getDefaultHostnameVerifier();
            return hv.verify("example.com", session);
        }
    };

    // Tell the URLConnection to use our HostnameVerifier
    URL url = new URL("https://example.org/");
    HttpsURLConnection urlConnection =
        (HttpsURLConnection)url.openConnection();
    urlConnection.setHostnameVerifier(hostnameVerifier);
    InputStream in = urlConnection.getInputStream();
    copyInputStreamToOutputStream(in, System.out);
    

但请记住,如果您发现自己更换了主机名验证,特别是因虚拟托管引起的更换,那么,当其他虚拟主机不在您的控制之下时,这样做仍非常危险,您应找到一个可以避免此问题的备用托管安排。

有关直接使用 SSLSocket 的警告

到目前为止,所举示例都侧重于使用 HttpsURLConnection 的 HTTPS。有时,应用不使用 HTTP,而是需要单独使用 SSL。例如,某个电子邮件应用可能使用 SSL 的变体 SMTP、POP3 或 IMAP。在这些情况下,应用将需要直接使用 SSLSocket,与 HttpsURLConnection 在内部执行的操作非常相似。

目前为止所介绍的用于处理证书验证问题的技术也适用于 SSLSocket。事实上,使用自定义 TrustManager 时,传递到 HttpsURLConnection 的是 SSLSocketFactory。因此,如果您需要结合使用自定义 TrustManagerSSLSocket,请遵循相同的步骤,并使用 SSLSocketFactory 创建您的 SSLSocket

注意SSLSocket 不会执行主机名验证。由您的应用执行自己的主机名验证,最好通过使用预期的主机名调用 getDefaultHostnameVerifier() 进行验证。另请注意,出现错误时,HostnameVerifier.verify() 不会抛出异常,而是返回一个布尔值结果,您必须明确检查该结果。

以下示例向您展示了如何执行此操作。如示例所示,在没有 SNI 支持的情况下连接到 gmail.com 端口 443 时,您将收到为 mail.google.com 颁发的证书。这是预计会出现的情况,因此请务必确保该证书确实是为 mail.google.com 颁发的:

Kotlin

    // Open SSLSocket directly to gmail.com
    val socket: SSLSocket = SSLSocketFactory.getDefault().run {
        createSocket("gmail.com", 443) as SSLSocket
    }
    val session = socket.session

    // Verify that the certicate hostname is for mail.google.com
    // This is due to lack of SNI support in the current SSLSocket.
    HttpsURLConnection.getDefaultHostnameVerifier().run {
        if (!verify("mail.google.com", session)) {
            throw SSLHandshakeException("Expected mail.google.com, found ${session.peerPrincipal} ")
        }
    }

    // At this point SSLSocket performed certificate verification and
    // we have performed hostname verification, so it is safe to proceed.

    // ... use socket ...
    socket.close()
    

Java

    // Open SSLSocket directly to gmail.com
    SocketFactory sf = SSLSocketFactory.getDefault();
    SSLSocket socket = (SSLSocket) sf.createSocket("gmail.com", 443);
    HostnameVerifier hv = HttpsURLConnection.getDefaultHostnameVerifier();
    SSLSession s = socket.getSession();

    // Verify that the certicate hostname is for mail.google.com
    // This is due to lack of SNI support in the current SSLSocket.
    if (!hv.verify("mail.google.com", s)) {
        throw new SSLHandshakeException("Expected mail.google.com, "
                                        "found " + s.getPeerPrincipal());
    }

    // At this point SSLSocket performed certificate verification and
    // we have performed hostname verification, so it is safe to proceed.

    // ... use socket ...
    socket.close();
    

列入黑名单

SSL 高度依赖 CA 来仅向通过适当验证的服务器和网域所有者颁发证书。少数情况下,CA 也会受骗,或者像 ComodoDigiNotar 曾经遇到的那样遭到破坏,从而导致某个主机名的证书被颁发给除相应服务器或网域所有者以外的其他人。

为了降低此风险,Android 提供了将某些证书甚至整个 CA 列入黑名单的功能。尽管此名单过去已内置到操作系统中,但从 Android 4.2 开始,可以远程更新此名单,便于处理将来的泄露问题。

证书固定

通过名称为证书固定的技术,应用可以更好地保护自己,免受以欺诈方式颁发的证书的攻击。这里基本上使用上面未知 CA 案例中提供的示例,将应用的可信 CA 限制在一个很小的 CA 集范围内,应用的服务器将使用该集合。这样可以防止因泄露系统中其他 100 多个 CA 中的某个 CA 而破坏应用安全通道。

客户端证书

本文重点讲述 SSL 用户如何与服务器进行安全通信。SSL 也支持客户端证书的概念,客户端证书允许服务器验证客户端的身份。虽然这超出了本文的范围,但其中涉及的技术与指定自定义 TrustManager 类似。请在 HttpsURLConnection 的相关文档中查看有关如何创建自定义 KeyManager 的讨论。

Nogotofail:网络流量安全测试工具

在已知的 TLS/SSL 漏洞和错误配置方面,可以通过 Nogotofail 轻松确认您的应用是否安全。它是一款自动执行的工具,功能强大并且可扩展,用于测试通过它传送网络流量的任意设备的网络安全问题。

Nogotofail 可用于三个主要用例:

  • 查找错误和漏洞。
  • 验证修复并监测回归。
  • 了解哪些应用和设备正在生成哪些流量。

Nogotofail 适用于 Android、iOS、Linux、Windows、Chrome 操作系统和 OSX。事实上,任何用于连接互联网的设备都可以使用 Nogotofail。Android 和 Linux 上提供了一个用于配置设置和获取通知的易用客户端,同时还提供了一个本身可作为路由器、VPN 服务器或代理部署的攻击引擎。

您可以在 Nogotofail 开源项目网站上访问此工具。