通过 HTTPS 和 SSL 确保安全
发布日期:2018-05-04安全套接字层 (SSL)(现在技术上称为传输层安全协议 (TLS))是一个通用构建块,用于在客户端与服务器之间进行加密通信。应用很可能以错误的方式使用 SSL,从而导致恶意实体能够拦截网络上的应用数据。为了帮助您确保您的应用不会出现这种情况,本文重点介绍了使用安全网络协议的常见陷阱,并解决对使用公钥基础结构 (PKI) 关注较多的问题。
验证服务器证书的常见问题
假设没有从 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)
出现此情况的原因有很多,其中包括:
- 颁发服务器证书的 CA 未知
- 服务器证书不是由 CA 签署的,而是自签署
- 服务器配置缺少中间 CA
下面几部分将讨论如何解决这些问题,同时保持与服务器的连接处于安全状态。
未知的证书颁发机构
在这种情况下,由于您具有系统不信任的 CA,将发生 SSLHandshakeException。原因可能是您有一个来自 Android 还未信任的新 CA 的证书,或您的应用在没有 CA 的较旧版本上运行。CA 未知的原因通常是因为它不是公共 CA,而是政府、公司或教育机构等组织发放的仅供自己使用的私有 CA。
幸运的是,您可以指示 HttpsURLConnection 信任特定的 CA 集。此过程可能有点复杂,下面的示例展示了这个过程,从 InputStream 获取一个特定的 CA,用该 CA 创建 KeyStore,然后用后者创建和初始化 TrustManager。TrustManager 是系统用于从服务器验证证书的工具,可以使用一个或多个 CA 从 KeyStore 创建,而创建的 TrustManager 将仅信任这些 CA。
如果是新的 TrustManager,此示例将初始化一个新的 SSLContext,后者可以提供一个 SSLSocketFactory,您可以通过 HttpsURLConnection 用它来替换默认的 SSLSocketFactory。这样一来,连接将使用您的 CA 验证证书。
下面是使用华盛顿大学的机构 CA 的完整示例:
// 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 Inc/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 的验证程序替换为确认主机名至少符合应用预期的验证程序:
// 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。有时候应用需要单独使用 SSL与 HTTP。例如,某个电子邮件应用可能使用 SSL 的变体 SMTP、POP3 或 IMAP。在这些情况下,应用将需要直接使用 SSLSocket,与 HttpsURLConnection 在内部执行的操作非常相似。
目前为止所介绍的用于处理证书验证问题的技术也适用于 SSLSocket。事实上,使用自定义 TrustManager 时,传递到 HttpsURLConnection 的是 SSLSocketFactory。因此,如果您需要使用一个带有 SSLSocket 的自定义 TrustManager,请遵循相同的步骤,并使用 SSLSocketFactory 创建您的 SSLSocket。
注意:SSLSocket 不会执行主机名验证。由您的应用执行自己的主机名验证,最好通过使用预期的主机名调用 getDefaultHostnameVerifier() 进行验证。另外,请注意,出现错误时,HostnameVerifier.verify() 不会引发异常,而是返回一个布尔结果,您必须明确地检查该结果。
以下示例向您展示了如何执行此操作。该示例显示在没有 SNI 支持的情况下连接到 gmail.com 端口 443 时,您将收到 mail.google.com 的证书。在此情况下,这正是期待的结果,因此,请执行检查以确保证书确实是 mail.google.com 的证书:
// 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 verificaiton and // we have performed hostname verification, so it is safe to proceed. // ... use socket ... socket.close();
本文转自安卓开发者文档developer.android.google.com