Java 安全套接扩展 (Java Secure Socket Extension, JSSE) 使 Internet 安全通信成为现实。它是 SSL 3.0 (Secure Socket Layer) 及 TLS 1.0 (Transport Layer Security,由 SSL 3.0 改善而来) 的框架和实现。这个包让 Java 开发人员能够开发安全的网络应用;为基于 TCP/IP 的何应用协议,如 HTTP、FTP、Telnet、或者 NTTP,在客户端和服务器端之间建立安全的数据通道。 在这篇文章的第一部分 (服务器端),作者已经详细说明了 SSL 和 JSSE,并且说明了如何开发服务器端支持 SSL 应用程序。那一部分中我们开发了一个 HTTPS 服务器,这是一个非常有用的应用程序,在这一部分中同样会用到它。 在这篇文章涉及到客户端的内容,它首先简述 JSSE,然后会做这样一些事情 在客户端使用 JSSE API 一步步的开发一个支持 SSL 的客户端应用程序 开发简单的支持 SSL 的客户端应用程序 从服务器端导出证书并在客户端导入 开发一个支持 SSL 的网页浏览器 JSSE Java 安全套接扩展 (JSSE) 提供了 SSL 和 TLS 协议的框架及实现。JSSE 将复杂的、根本的加密算法抽象化了,这样就降低了受到敏感或者危险的安全性攻击的风险。正如你在本文中看到的那样,由于它能将 SSL 无缝地结合在应用当然,使安全应用的开发变得非常简单。JSSE 框架可以支撑许多不同的安全通信协议,如 SSL 2.0 和 3.0 以及 TLS 1.0,但是 J2SE v1.4 只实现了 SSL 3.0 和 TLS 1.0。 用 JSSE 编写客户端应用程序 JSSE API 提供了扩充的网络套接字类、信用和密匙管理,以及为简化套接字创建而设计的套接字工厂框架,以此扩充 java.security 和 java.net 两个包。这些类都包含在 javax.net 和 javax.net.ssl 包中。 javax.net.sll.SSLSocketFactory 类是一个创建安全套接字的对象工厂。可以通过下面两种方法获得 SSLSocketFactory 的实例: 调用 SSLSocketFactory.getDefault 来获得默认的工厂。默认的工厂被配置为只允许服务器端验证 (不允许客户端验证)。注意许多电子商务网站不需要客户端验证。 使用指定的配置来构造一个新的工厂 (这不在本文讲述的范围内)。 建立 SSLSocketFactory 实例之后,你就可以通过 SSLSocketFactory 实例的 createSocket 方法创建 SSLSocket 对象了。这里有一个例子,该例通过 SSL 端口 443 (这是 HTTPS 的默认端口) 创建套接字并连接到 Sun 的 WWW 服务器。 // Get a Socket factory SocketFactory factory = SSLSocketFactory.getDefault(); // Get Socket from factory Socket socket = factory.createSocket("www.sun.com", 443); 使用低层的 SSL 套接字 现在,让我们看一个使用低层套接字在 HTTPS 服务器上打开一个 SSL 套接字连接的完整例子。在这个例子中,打开了一个到 HTTPS 服务器的 SSL 套接字连接,并且读入默认文档的内容。示例代码 1 展示了这个应用程序,其中用于打开 SSL 套接字的代码已经加黑显示了。你将会看到,应用程序中其余代码就是常规的输入/输出流代码。 代码示例 1:ReadHttpsURL1 import java.net.*; import javax.net.*; import javax.net.ssl.*; public class ReadHttpsURL1 { static final int HTTPS_PORT = 443; public static void main(String argv[]) throws Exception { if (argv.length != 1) { System.out.println("Usage: java ReadHttpsURL1 "); System.exit(0); } // Get a Socket factory SocketFactory factory = SSLSocketFactory.getDefault(); // Get Socket from factory Socket socket = factory.createSocket(argv[0], HTTPS_PORT); BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out.write("GET / HTTP/1.0 "); out.flush(); String line; StringBuffer sb = new StringBuffer(); while((line = in.readLine()) != null) { sb.append(line); } out.close(); in.close(); System.out.println(sb.toString()); } } 用这个应用程序进行实验: 拷贝 ReadHttpsURL1 类的代码并粘贴到一个新文件中,将该文件改名为 ReadHttpsURL1.java,并保存在一个你指定的目录下。 使用 javac 编译 ReadHttpsURL1.java。 运行 ReadHttpsURL1 并提供一个域名作为参数,如: Prompt> java ReadHttpsURL1 www.sun.com 几秒种后,你会看到许多 HTML 代码显示在屏幕上。注意,即使我们提供的是域名 www.sun.com,我们打开的连接也是 https://www.sun.com,这是因为我们使用的端口号 443 是 HTTPS 的默认端口号。 再试试另一个例子,如: Prompt> java ReadHttpsURL1 www.jam.ca 这次运行会抛出如下所示的异常,你能猜到是为什么吗? Exception in thread "main" javax.net.ssl.SSLHandshakeException: java.security.cert.CertificateException: Couldn’t find trusted certificate at com.sun.net.ssl.internal.ssl.BaseSSLSocketImpl.a(DashoA6275) 缘于一个很好的理由,它不能运行——因为远端的服务器发送了一个客户端不认识的证书。我在本文的第一部分提到过,当客户端连接服务器的时候,服务器发送它的证书到客户端请求验证。这样,第一个例子中,你进入了 www.sun.com,服务器的确发送了证书,但 Java 检查了默认的证书库并认出了这个证书是由可信任的 CA 产生的,默认情况下,Java 信任这个 CA。第二个例子中,你进入的是 www.jam.ca,那个网端的证书不是它自己产生的,就是由一个 Java 不知道的 CA 产生的,因此不受信任。 -------------------------------------------------------------------------------- 注意,如果系统时钟没有设置正确,那么它的时间就可能在证书的有效期之外,服务器会认为证书无效并抛出 CertificateException 异常。 -------------------------------------------------------------------------------- 为了让示例正确运行,你得从 www.jam.ca 导入证收到 Java 信任的证书库中。 导出和导入证书 为了解释清楚如何输出和输入证书,我会使用我自己的 HTTPS 服务器。这个服务器在第一部分中讨论过。然后,跟着下面的内容开始: 运行 HTTPS 服务器,像在第一部分中讨论的那样。 运行 ReadHttpsURL1:java ReadHttpsURL1 localhost。你同样会得到上面所述的异常。 使用下面的 keytool 命令导出服务器证书: 从 serverkeys 文件中导出别名为 qusay 的证书 将导出的证书保存在 server.cert 文件中,这个文件会由 keytool 创建 如你看到的那样,我根据要求输入了密码。成功输入密码之后,服务器证书被成功的导出并保存在 server.cert 中。 Prompt> keytool -export -keystore serverkeys -alias qusay -file server.cert Enter keystore password: hellothere Certificate stored in file <server.cert> 将文件 server.cert 拷贝到 ReadHttpsURL1 所在的目录。使用 keytool 创建一个新的 keystore 并将服务器的 server.cert 证书导入其中。这里的命令示例: Prompt> keytool -import -keystore trustedcerts -alias qusay -file server.cert 这个命令会产生下面那样的输出。它要求输入密码,这是一个新的密码,用于 trustedcerts 这个 keystore 的。这个 keystore 由 keytool 创建。在输出信息的最后,它询问我是否愿意相信这个证书,我回答 yes。 Enter keystore password: clientpass Owner: CN=localhost, OU=Training and Consulting, O=javacourses.com, L=Toronto, ST=Ontario, C=CA Issuer: CN=localhost, OU=Training and Consulting, O=javacourses.com, L=Toronto, ST=Ontario, C=CA Serial number: 3dcf988a Valid from: Mon Nov 11 06:46:18 EST 2002 until: Sun Feb 09 06:46:18 EST 2003 Certificate fingerprints: MD5: 37:35:4D:3A:2B:7E:B5:09:A5:41:B3:FA:E4:3C:1D:C4 SHA1: CB:7C:77:36:79:A2:37:26:E2:98:61:C2:9D:10:50:69: 99:F9:B9:1B Trust this certificate? [no]: yes Certificate was added to keystore 现在运行 ReadHttpsURL1 并告诉它哪里能找到证书。使用下面的命令: Prompt> java -Djavax.net.ssl.trustStore=trustedcerts ReadHttpsURL1 localhost 这将会与你的 HTTPS 服务器联接、校验证书,如果正确,它会下载默认页面 index.html。 -------------------------------------------------------------------------------- 注意:信任管理器负责决定远端的证书是否值得信任。它使用下面的规则: 如果 javax.net.sll.trustStore 系统属性指定了信任库,那么信任管理器会使用提供的文件来检查证书。如果那个系统属性存在但指定的文件不存在,那么就没有使用任何信任库,会抛出一个 CertificateException 异常。 如果 javax.net.sll.trustStore 系统属性没有定义,那么它会去寻找默认的信任库: 如果在你的 java.home 目录的 lib/security 子目录下存在名为 jssecacerts 的信任库,那么使用的就是它。 如果 jssecacerts 不存在,但是 cacerts 存在 (它随 J2SDK 一起发行,含有数量有限的可信任的基本证书),使用的就是 cacerts。 在我的 Windows 2000 客户机中,java.home 目录是 c:Program Filejavajre1.4.1libsecurity,在上例中,如果你将 trustedcerts 更名为 jssecacerts 并将其移动到 lib/security 子目录中,那么你以后就不需要在命令行指定 javax.net.ssl.trustStore 属性了。 如果你不知道 java.home 在哪里,这里有一小段代码可以让你找到它: public class FindJavaHome { public static void main(String argv[]) { System.out.println(System.getProperty("java.home")); } } -------------------------------------------------------------------------------- URL 类 示例代码 1 中的 ReadHttpsURL1 使用低层的套接字打开到 SSL 服务器的连接。这样做有一个缺点,如果不进行一番解析,我们就不能在命令行清楚的写出像 https://www.jam.ca 这样的 URL。这里有一个更简单的办法在客户端应用程序中使用 SSL 和 JSSE。 java.net.URL 类支持 HTTPS 地址。例如,下面的代码段创建一个 HTTPS 地址并建立一个输入流的读入器: URL url = new URL("https://www.sun.com"); BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream())); 是不是很简单?我希望当你学习 Java 的新东西时,你能欣赏到它的美好之处。 示例代码 1 中的 ReadHttpsURL1 可以由下面使用了 URL 类的示例代码 2 代替: 示例代码 2:ReadHttpsURL2.java import java.net.*; import java.io.*; public class ReadHttpsURL2 { public static void main(String argv[]) throws Exception { if(argv.length != 1) { System.out.println("Usage: java ReadHttpsURL2 "); System.exit(0); } URL url = new URL(argv[0]); BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream())); String line; StringBuffer sb = new StringBuffer(); while ((line = in.readLine()) != null) { sb.append(line); } in.close(); System.out.println(sb.toString()); } } 如果你想试试 ReadHttpsURL2,执行它的命令和上面讨论的类似。注意,无论如何,既然我们使用 URL 类,你就能在命令行指定 URL,包括协议的名称。这里是一个例子: Prompt> java ReadHttpsURL2 https://localhost 开发一个支持 SSL 的网页浏览器 我们开发一个支持 SSL 的网页浏览器作为一个完整的例子。该浏览器要做下面的工作: 用户输入 URL,浏览器能接收它。 浏览器能打开到 URL 指定主机的连接。 浏览器能发送 HTTP 命令。 浏览器会等待 HTTP/HTTPS 服务器的回应。 浏览器能接收 HTML 回应。 浏览器能解析 HTML 并显示出页面。 我们创建的浏览器要能处理任何 URL 如 HTTP、HTTPS、FTP 等。注意我使用工具类 javax.swing.text.html.HTMLEditorKit 来解析 HTML,它提供了对 HTML 3.2 的支持。 示例代码 3 中展示了这个浏览器,QBrowser,的代码。注意 QBrowser 实现了 Runnable 接口。我这样做是因为这个浏览器没有提供“停止”按钮。 示例代码 3:QBrowser.java import java.io.*; import java.net.*; import java.awt.*; import java.awt.event.*; import javax.swing.*; public class QBrowser implements ActionListener, Runnable { private JFrame frame; private JButton go; private JEditorPane content; private JTextField url; private JLabel statusLine; // default constructor public QBrowser () { buildBrowserInterface(); } private void buildBrowserInterface() { frame = new JFrame("Q’s Browser"); // on close, exit the application using System.exit(0); frame.setDefaultCloseOperation (3); url = new JTextField("", 25); go = new JButton("Go Get It"); go.addActionListener(this); JPanel controls = new JPanel(new FlowLayout ()); controls.add(new JLabel("URL:")); controls.add(url); controls.add(go); content = new JEditorPane(); content.setEditable(false); // HTML text. Use the kit in the class javax.swing.text.html.HTMLEditorKit, which // provides support for HTML 3.2 content.setContentType("text/html"); content.setText("<center><h1>Q’s Browser</h1><p> Copyright (c) 2002 Qusay H. Mahmoud</center>"); statusLine = new JLabel("Initialization Complete"); JPanel panel = new JPanel(new BorderLayout (0, 2)); frame.setContentPane(panel); panel.add(controls, "North"); panel.add(new JScrollPane (content), "Center"); panel.add(statusLine, "South"); frame.pack(); frame.setVisible(true); } /** * You cannot stop a download with QBrowser * The thread allows multiple downloads to start * concurrently in case a download freezes */ public void actionPerformed (ActionEvent event) { Thread thread = new Thread(this); thread.start(); } // this is the Thread’s run method public void run () { try { String str = url.getText(); URL url = new URL(str); readURL(url); } catch (IOException ioe) { statusLine.setText("Error: "+ioe.getMessage()); showException(ioe); } } private void showException(Exception ex) { StringWriter trace = new StringWriter (); ex.printStackTrace (new PrintWriter (trace)); content.setContentType ("text/html"); content.setText ("<h1>" + ex + "</h1><pre>" + trace + "</pre>"); } /** * The URL class is capable of handling http:// and https:// URLs */ private void readURL(URL url) throws IOException { statusLine.setText("Opening " + url.toExternalForm()); URLConnection connection = url.openConnection(); StringBuffer buffer = new StringBuffer(); BufferedReader in=null; try { in = new BufferedReader(new InputStreamReader(connection.getInputStream())); String line; while ((line = in.readLine()) != null) { buffer.append(line).append(’ ’); statusLine.setText("Read " + buffer.length () + " bytes..."); } } finally { if(in != null) in.close(); } String type = connection.getContentType(); if(type == null) type = "text/plain"; statusLine.setText("Content type " + type); content.setContentType(type); content.setText(buffer.toString()); statusLine.setText("Done"); } public static void main (String[] args) { QBrowser browser = new QBrowser(); } } 既然 QBrowser 使用 URL 类,它就可以处理 HTTP 和 HTTPS 请求。你可以使用 HTTP 和 HTTPS 地址测试 QBrowser。这里是一些测试: 请求 http://www.javacourses.com,你会看到如图 1 所示的内容。 <img src=http://www.javaidea.net/aimg/21/1.png> 请求 https://www.jam.ca,结果抛出了异常。因为这个网页服务器的证书不受信任并且不能在默认页中找到,所以它抛出如图 2 所示的异常。 <img src=http://www.javaidea.net/aimg/21/2.png> 请求 https://localhost,这里运行着第一部分中写的 HttpServer。注意,如果你使用命令 java QBrowser 来运行 QBrowser,而服务器的证书导出后被导入默认文件 jssecacerts,那么应该将该文件拷贝到 java.home 目录的 lib/security 子目录中。如果证书被导入了其它文件,你可以使用 trustStore 选项,如:java -Djavax.net.ssl.trustStore=file QBrowser。使用其实任何一种方法,浏览器都会工作,并且你可以看到如图 3 所示的默认页面。 <img src=http://www.javaidea.net/aimg/21/3.png> HttpsURLConnection 类 这个类存在于 javax.net.ssl 包中,它扩展了 java.net.HttpURLConnection,以支持 HTTPS 描述的一些特性。它能够通过 SSL/TLS 套接字建立安全通道来请求/获取数据。示例代码 4 展示了一个小型客户端,它使用 HttpsURLConnection 类从 HTTPS 服务器下载文档。 示例代码 4:ReadHttpsURL3.java import java.io.*; import java.net.*; import javax.net.ssl.*; public class ReadHttpsURL3 { public static void main(String[] argv) throws Exception { URL url = new URL(argv[0]); HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); connection.setDoOutput(true); BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream())); String line; while ((line = in.readLine()) != null) { System.out.println(line); } in.close(); } } 现在试试 ReadHttpsURL3,完成上面讨论的内容。注意,无论如何,既然我们使用 URL 类,你就能在命令行指定 URL,包括协议的名称。这里是一个例子: Prompt> java ReadHttpsURL3 https://www.sun.com HttpsURLConnection 有一个非常有趣的特点:一旦获得了连接,你就可以在网络连接之前使用一些有用的参数对其进行配置,如 HostnameVerifier。HostnameVerifier 是一个接口,它申明了方法:public boolean verify (String hostname, SSLSession session)。而且,它像下面所述的那样工作: 如果 SSL/TLS 标准主机名校验逻辑失败,执行过程中会调用回调类的 verify 方法。回调类是实现了 HostnameVerifier 接口的类。 如果回调类检查到主机名可以接受,则允许连接,否则,连接会被终止。 回调类遵循的规则即可以是基本的验证方法,也可以依赖其它验证方法。这里说明了如何实现: public class MyVerified implements HostnameVerifier { public boolean verify(String hostname, SSLSession session) { // pop up a dialog box // ... // return either true or false } } 现在,可以这样使用它: HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); connection.setHostnameVerifier(new MyVerifier()); 信任管理器 一个 SSL 客户端,如网页浏览器,连接到 SSL 服务器 (如 HTTPS 服务器) 的时候,HTTPS 服务器将自己的证书链交给客户端验证。SSL 规范规定,如果在证书链中发现有无效的证书,客户端应该立即终止连接。一些网页浏览器,如 Netscape Communicator 和 Microsoft Internet Explorer,询问用户是否忽略无效的证书并继续检查证书链,以确定是否有可能验证通过 HTTPS 服务器。使用 javax.net.sll.TrustManager 可以很好的消除这种矛盾,它是 JSSE 信任管理器的基础接口。而这些信任管理器则是用来管理可信任的资料以及决定是否接受某个凭证的。典型的信任管理器都支持基于 X.509 的证书,它是 J2DK 的 keytool 可以管理的一个普通的证书格式。 X509TrustManager 接口 javax.net.sll.X509TrustManager 接口扩展了普通的 TrustManager 接口。使用基于 X.509 公钥证书验证方案时,信任管理器必须实现该接口。实现 X509TrustManager 可以创建信任管理器。这里有一个空实现: public class MyTrustManager implements X509TrustManager { MyTrustManager() { // constructor // create/load keystore } public void checkClientTrusted(X509Certificate chain[], String authType) throws CertificatException { } public void checkServerTrusted(X509Certificate chain[], String authType) throws CertificationException { // special handling such as poping dialog boxes } public X509Certificate[] getAcceptedIssuers() { } } 为了支持远端套接字 X.509 证书,实现了 X509TrustManager 接口的类,其实例要传递给 SSLContext 对象的 init 方法。它作为 SSL 套接字工厂。换句话说,一旦创建了信任管理器且通过 init 方法将其分配给了一个 SSLSocket,以后从 SSLContext 创建的 SocketFactories 在作信任决策时将使用新的信任管理器。下面的代码段就是个示例: X509TrustManager xtm = new MyTrustManager() TrustManager mytm[] = {xtm}; SSLContext ctx = SSLContext.getInstance("SSL"); ctx.init(null,mytm, null ); SSLSocketFactory sf = ctx.getSocketFactory(); JSSE 调试工具 Sun 的 JSSE 实现提供了动态调试跟踪支持,使用系统属性 javax.net.debug 即可。JSSE 并不正式支持这个特性,但它可以让你看到在 SSL 通信过程中幕后在干什么。这个工具可以通过如下命令使用: Prompt> java -Djavax.net.debug=option[debugSpecifiers] MySSLApp 如果你使用了 help 参数,它就会显示调试选项列表。J2SE 1.4.1 中,选项如下: all turn on all debugging ssl turn on ssl debugging The following can be used with ssl: record enable per-record tracing handshake print each handshake message keygen print key generation data session print session activity defaultctx print default SSL initialization sslctx print SSLContext tracing sessioncache print session cache tracing keymanager print key manager tracing trustmanager print trust manager tracing handshake debugging can be widened with: data hex dump of each handshake message verbose verbose handshake message printing record debugging can be widened with: plaintext hex dump of record plaintext 你必须指定参数 ssl 或者 all 中的一个,紧跟 debug 符号。可以使用一个或多个调试说明符,使用“:”或者“,”作为分隔符。说明符不是必须的,但可以增强可读性。这里是一些例子: Prompt> java -Djavax.net.debug=all MyApp Prompt> java -Djavax.net.debug=ssl MyApp Prompt> java -Djavax.net.debug=ssl:handshake:trustmanager MyApp 总结 这篇文章展示了如何使用 JSSE (SSL 协议的框架和实现) 开发安全的客户端应用程序。这篇文章中的例子展示了将 SSL 整合到 C/S 应用程序是多么简单的事情。这篇文章中讲到一个网页浏览器,QBrowser,可以处理 HTTP 和 HTTPS 请求。 QBrowser 中,如果服务器上,按输入 HTTPS 的地址中不存在有效的证书,则会抛出一个异常。你也许想修改 QBrowser 使其能够处理这个异常并且弹出一个窗口询问用户是否愿意下载安装证书,那么你可以把它做为一个练习。1.4.x 的 Java 插件使用了 JSSE,它有自己的的信任管理器,如果它不能在信任库里找到证书,而弹出窗口提示。