用 Java 2 标准版本 (J2SE) 1.4 进行 Internet 安全编程
第 I 部分:服务器端
Qusay H. Mahmoud 著
2002 年 11 月
边城狂人 译
2002 年 11 月
任何在计算机网络或者 Internet 中传输的消息都可能被拦截,其中不乏一些比较敏感的内容,如信用卡号或者其它一些私人数据。为了更好的在企业环境和电子商务中使用 Internet,应用软件必须使用加密、验证和安全的通信协议来保护用户的数据安全。安全超文本传输协议 (secure Hypertext Transfer Protocol, HTTPS) 是建立于安全套接层 (Secure Sockets Layer, SSL) 上的 HTTP,它已经成功的应用于电子商务。
Java 安全套接扩展 (Java Secure Socket Extension, JSSE) 使 Internet 安全通信成为现实。它是 100% 纯 Java 实现的 SSL 框架。这个包让 Java 开发人员能够开发安全的网络应用;为基于 TCP/IP 的何应用协议,如 HTTP、FTP、Telnet、或者 NTTP,在客户端和服务器端之间建立安全的数据通道。
JSSE 已经整合在 Java 2 SDK 标准版本 1.4 (J2SE 1.4) 中了,这真是一个好消息。这意味着只要你安装了 J2SE 1.4,不需要再下载其它的包,就可以创建基于 SSL 的 Internet 应用程序了。这个系列的文章共有 2 篇,它是一本关于为今后的市场开发安全 Interent 应用的手册。这篇文章主要是讲的服务器端,而下一篇是讲客户端的。这篇文章从概览 SSL 开始,然后告诉你如何进行下列内容:
使用 JSSE 的 API
在你的 C/S 应用程序中结合 JSSE
开发一个简单的 HTTP 服务器
让 HTTP 服务器能够处理 HTTPS 请求
使用包含在 J2SE 中的 keytool 产生自己的证书
开发、配置和运行一个安全的 HTTP 服务器
概览 SSL
SSL 协议是 Netscape 在 1994 年开发出来的,以允许服务端 (典型的如浏览器) 和 HTTP 服务器之间能通过安全的连接来通信。它加密、来源验证、数据完整性等支持,以保护在不安全的公众网络上交换的数据。SSL 有这样一些版本:SSL 2.0 有安全隐患,现在已经几本上不用了;SSL 3.0 应用则比较广泛;最后,由 SSL 3.0 改进而来的传输层加密 (Transport Layer Security, TLS) 已经成为 Internet 标准并应用于几乎所有新近的软件中。
在数据传播之前,加密技术通过将数据转变成看起来毫无意义的内容来保护数据不被非法使用。其过程是:数据在一端 (客户端或者服务器端) 被加密,传输,再在另一端解密。
来源认证是验证数据发送者身份的一种办法。浏览器或者其它客户端第一次尝试与网页服务器进行安全连接之上的通信时,服务器会将一套信任信息以证书的形式呈现出来。
证书由权威认证机构 (CA)——值得信赖的授权者发行和验证。一个证书描述一个人的公钥。一个签名的文档会作出如下保证:我证明文档中的这个公钥属于在该文档中命名的实体。签名(权威认证机构)。目前知名的权威认证机构有 Verisign,Entrust 和 Thawte 等。注意现在使用的 SSL/TLS 证书是 X.509 证书。
数据完整性就是要确保数据在传输过程中没有被改变。
SSL 和 TCP/IP 协议的层次
SSL 是名符其实的安全套接层。它的连接动作和 TCP 的连接类似,因此,你可以想象 SSL 连接就是安全的 TCP 连接,因为在协议层次图中 SSL 的位置正好在 TCP 之上而在应用层之下,如图 1 所示。注意到这点很重要。但是,SSL 不支持某些 TCP 的特性,比如频带外数据。
图 1: SSL 和 TCP/IP 协议的的层次
可交流的加密技术
SSL 的特性之一是为电子商务的事务提供可交流的加密技术和验证算法提供标准的方法。SSL 的开发者认识到不是所有人都会使用同一个客户端软件,从而不是所有客户端都会包括任何详细的加密算法。对于服务器也是同样。位于连接两端的的客户端和服务器在初始化“握手”的时候需要交流加密和解密算法(密码组)。如果它们没有足够的公用算法,连接尝试将会失败。
注意当 SSL 允许客户端和服务器端相互验证的时候,典型的作法是只有服务器端在 SSL 层上进行验证。客户端通常在应用层,通过 SSL 保护通道传送的密码来进行验证。这个模式常用于银行、股份交易和其它的安全网络应用中。
SSL 完全“握手”协议如图 2 所示。它展示了在 SSL “握手”过程中的信息交换顺序。
图 2:SSL “握手”协议
这些消息的意思如下:
ClientHello:发送信息到服务器的客户端,这些信息如 SSL 协议版本、会话 ID 和密码组信息,如加密算法和能支持的密匙的大小。
ServerHello:选择最好密码组的服务器并发送这个消息给客户端。密码组包括客户端和服务器支持。
Certificate:服务器将包含其公钥的证书发送给客户端。这个消息是可选的,在服务器请求验证的时候会需要它。换句话说,证书用于向客户端确认服务器的身分。
Certificate Request: 这个消息仅在服务器请求客户端验证它自身的时候发送。多数电子商务应用不需要客户端对自身进行。
Server Key Exchange:如果证书包含了服务器的公钥不足以进行密匙交换,则发送该消息。
ServerHelloDone:这个消息通知客户端,服务器已经完成了交流过程的初始化。
Certificate:仅当服务器请求客户端对自己进行验证的时候发送。
Client Key Exchage:客户端产生一个密匙与服务器共享。如果使用 Rivest-Shamir-Adelman (RSA) 加密算法,客户端将使用服务器的公钥将密匙加密之后再发送给服务器。服务器使用自己的私钥或者密钥对消息进行解密以得到共享的密匙。现在,客户端和服务器共享着一个已经安全分发的密匙。
Certificate Verify:如果服务器请求验证客户端,这个消息允许服务器完成验证过程。
Change Cipher Spec:客户端要求服务器使用加密模式。
Finished:客户端告诉服务器它已经准备好安全通信了。
Change Cipher Spec:服务器要求客户端使用加密模式。
Finished:服务器告诉客户端它已经准备好安全通信了。这是 SSL “握手”结果的标志。
Encrypted Data:客户端和服务器现在可以开发在安全通信通道上进行加密信息的交流了。
JSSE
Java 安全套接扩展 (JSSE) 提供一个框架及一个 100% 纯 Java 实现的 SSL 和 TLS 协议。它提供了数据加密、服务器验证、消息完成性和可选的客户端验证等机制。JSSE 的引人之外就是将复杂的、根本的加密算法抽象化了,这样就降低了受到敏感或者危险的安全性攻击的风险。另外,由于它能将 SSL 无缝地结合在应用当然,使安全应用的开发变得非常简单。JSSE 框架可以支撑许多不同的安全通信协议,如 SSL 2.0 和 3.0 以及 TLS 1.0,但是 J2SE v1.4.1 只实现了 SSL 3.0 和 TLS 1.0。
JSSE 编程
JSSE API 提供了扩充的网络套接字类、信用和密匙管理,以及为简化套接字创建而设计的套接字工厂框架,以此扩充 java.security 和 java.net 两个包。这些类都包含在 javax.net 和 javax.net.ssl 包中。
SSLSocket 和 SSLServerSocket
javax.net.ssl.SSLSocket 是 java.net.Socket 的子类,因此他支持所有标准 Socket 的方法,和一些为安全套接字新增加的方法。javax.net.ssl.SSLServerSocket 类与 SSLSocket 类相似,只是它用于创建服务器套接子,而 SSLSocket 不是。
创建一个 SSLSocket 实例有如何两种方法:
用 SSLSocketFactory 实例执行 createSocket 方法来创建。
通过 SSLServerSocket 的 accept 方法获得。
SSLSocketFactory 和 SSLServerSocketFactory
javax.net.ssl.SSLSocketFactory 类是用于创建安全套接字的对象工厂。javax.net.ssl.SSLServerSocketFactory 也是这样的工厂,但它用于创建安全的服务器套接字。
可以通过如下方法获得 SSLSocketFactory 实例:
执行 SSLSocketFactory.getDefault 方法获得一个默认的工厂。
通过特定的配置行为构造一个新的工厂。
注意默认的工厂的配置只允许服务器验证。
使现有的 Client/Server 应用变得安全
在现有的 C/S 应用中整合 SSL 以使其变得安全比较简单,使用几行 JSSE 代码就可以做到。为了使服务器变得安全,下面的例子中加黑显示的内容是必须的:
import java.io.*;
import javax.net.ssl.*;
public class Server {
int port = portNumber;
SSLServerSocket server;
try {
SSLServerSocketFactory factory =
(SSLServerSocketFactory) SSLServerSocketFactory.getDefault();
server = (SSLServerSocket)
factory.createServerSocket(portNumber);
SSLSocket client = (SSLSocket)
server.accept();
// Create input and output streams as usual
// send secure messages to client through the
// output stream
// receive secure messages from client through
// the input stream
} catch(Exception e) {
}
}
为了使客户端变得安全,下面的例子中加黑显示的内容是必须的:
import java.io.*;
import javax.net.ssl.*;
public class Client {
...
try {
SSLSocketFactory factory = (SSLSocketFactory)
SSLSocketFactory.getDefault();
server = (SSLServerSocket)
factory.createServerSocket(portNumber);
SSLSocket client = (SSLSOcket)
factory.createSocket(serverHost, port);
// Create input and output streams as usual
// send secure messages to server through the
// output stream receive secure
// messages from server through the input stream
} catch(Exception e) {
}
}
SunJSSE 提供者
J2SE v1.4.1 和一个 JSSE 提供者,SunJSSE 一起发布。SunJSSE 安装并预登记了 Java 的加密体系。请把 SunJSSE 作为一个实现的名字来考虑,它提供了 SSL v3.0 和 TLS v1.0 的实现,也提供了普通的 SSL 和 TLS 密码组。如果你想找到你的实现 (这里是 SunJSSE) 所支持的密码组列表,可以调用 SSLSocket 的 getSupportedCipherSuites 方法。然而,不是所有这些密码组都是可用的。为了找出那些是可用的,调用 getEnabledCipherSuites 方法。这个列表可以用 setEnabledCipherSuites 方法来更改。
一个完整的例子
我发现使用 JSSE 开发最复杂的事情关系到系统设置以及管理证书和密匙。在这个例子中,我演示了如何开发、配置和运行一个完整的支持 GET 请求方法的 HTTP 服务器应用。
HTTP 概览
超文本传输协议 (Hypertext Transfer Protocol, HTTP) 是一个“请求-回应”的应用协议。这个协议支持一套固定的方法如 GET、POST、PUT、DELETE 等。一般用 GET 方法向服务器请求资源。这里有两个 GET 请求的例子:
GET / HTTP/1.0 <empty-line>
GET /names.html HTTP/1.0 <empty-line>
不安全的 HTTP 服务器
为了开发一个 HTTP 服务器,你得先搞明白 HTTP 协议是如何工作的。这个服务器是一个只支持 GET 请求方法的简单服务器。代码示例 1 是这个例子的实现。这是一个多线程的 HTTP 服务器,ProcessConnection 类用于执行不同线程中新的请求。当服务器收到一个来自浏览器的请求时,它解析这个请求并找出需要的文档。如果被请求的文档在服务器上可用,那么被请求的文档会由 shipDocument 方法送到服务器。如果被请求的文档没有打开,那么送到服务器的就是出错消息。
代码示例 1:HttpServer.java
import java.io.*;
import java.net.*;
import java.util.StringTokenizer;
/**
* This class implements a multithreaded simple HTTP
* server that supports the GET request method.
* It listens on port 44, waits client requests, and
* serves documents.
*/
public class HttpServer {
// The port number which the server
// will be listening on
public static final int HTTP_PORT = 8080;
public ServerSocket getServer() throws Exception {
return new ServerSocket(HTTP_PORT);
}
// multi-threading -- create a new connection
// for each request
public void run() {
ServerSocket listen;
try {
listen = getServer();
while(true) {
Socket client = listen.accept();
ProcessConnection cc = new
ProcessConnection(client);
}
} catch(Exception e) {
System.out.println("Exception:
"+e.getMessage());
}
}
// main program
public static void main(String argv[]) throws
Exception {
HttpServer httpserver = new HttpServer();
httpserver.run();
}
}
class ProcessConnection extends Thread {
Socket client;
BufferedReader is;
DataOutputStream os;
public ProcessConnection(Socket s) { // constructor
client = s;
try {
is = new BufferedReader(new InputStreamReader
(client.getInputStream()));
os = new DataOutputStream(client.getOutputStream());
} catch (IOException e) {
System.out.println("Exception: "+e.getMessage());
}
this.start(); // Thread starts here...this start()
will call run()
}
public void run() {
try {
// get a request and parse it.
String request = is.readLine();
System.out.println( "Request: "+request );
StringTokenizer st = new StringTokenizer( request );
if ( (st.countTokens() >= 2) &&
st.nextToken().equals("GET") ) {
if ( (request =
st.nextToken()).startsWith("/") )
request = request.substring( 1 );
if ( request.equals("") )
request = request + "index.html";
File f = new File(request);
shipDocument(os, f);
} else {
os.writeBytes( "400 Bad Request" );
}
client.close();
} catch (Exception e) {
System.out.println("Exception: " +
e.getMessage());
}
}
/**
* Read the requested file and ships it
* to the browser if found.
*/
public static void shipDocument(DataOutputStream out,
File f) throws Exception {
try {
DataInputStream in = new
DataInputStream(new FileInputStream(f));
int len = (int) f.length();
byte[] buf = new byte[len];
in.readFully(buf);
in.close();
out.writeBytes("HTTP/1.0 200 OK\r\n");
out.writeBytes("Content-Length: " +
f.length() +"\r\n");
out.writeBytes("Content-Type:
text/html\r\n\r\n");
out.write(buf);
out.flush();
} catch (Exception e) {
out.writeBytes("<html><head><title>error</title>
</head><body>\r\n\r\n");
out.writeBytes("HTTP/1.0 400 " + e.getMessage() + "\r\n");
out.writeBytes("Content-Type: text/html\r\n\r\n");
out.writeBytes("</body></html>");
out.flush();
} finally {
out.close();
}
}
}
实验一下 HttpServer 类:
将 HttpServer 的代码保存在文件 HttpServer.java 中,并选择一个目录把它存放在那里。
使用 javac 编译 HttpServer.java
建立一些 HTML 文件作为例子,要有一个“index.html”,因为它是这个例子中默认的 HTML 文档。
运行 HttpServer。服务器运行时使用 8080 端口。
打开网页浏览器,并发出请求:http://localhost:8080 或者 http://127.0.0.1:8080/index.html。
注意:你能想到 HttpServer 可能接收到一些恶意的 URL 吗?比如像 http://serverDomainName:8080/../../etc/passwd 或者 http://serverDomainName:8080//somefile.txt 等。作为一个练习,修改 HttpServer 以使其不允许这些 URL 的访问。提示:写你自己的 SecurityManager 或者使用 java.lang.SecurityManager。你可以在 main 方法的第一行添加语句 System.setSecurityManager(new Java.lang.SecurityManager) 来安装这个安全的管理器。试试吧!
扩展 HttpServer 使其能够处理 https://URL
现在,我要们修改 HttpServer 类,使它变得安全。我希望 HTTP 服务器能处理 https://URL 请求。我在前面就提到过,JSSE 让你可以很容易的把 SSL 整合到应用中去。
创建一个服务器证书
就像我前面提到的那样,SSL 使用证书来进行验证。对于需要使用 SSL 来保证通信安全的客户端和服务器,都必须创建证书。JSSE 使用的证书要用与 J2SE 一起发布的 Java keytool 来创建。用下列命令来为 HTTP 服务器创建一个 RSA 证书。
prompt> keytool -genkey -keystore serverkeys -keyalg rsa -alias qusay
这个命令会产生一个由别名 qusay 引用的证书,并将其保存在一个名为 serverkeys 的文件中。产生证书的时候,这个工具会提示我们一些信息,如下面的信息,其中加黑的内容是我写的。
Enter keystore password: hellothere
What is your first and last name?
[Unknown]: ultra.domain.com
What is the name of your organizational unit?
[Unknown]: Training and Consulting
What is the name of your organization?
[Unknown]: javacourses.com
What is the name of your City or Locality?
[Unknown]: Toronto
What is the name of your State or Province?
[Unknown]: Ontario
What is the two-letter country code for this unit?
[Unknown]: CA
Is CN=ultra, OU=Training and Consulting,
O=javacourses.com, L=Toronto, ST=Ontario, C=CA correct?
[no]: yes
Enter key password for
(RETURN if same as keystore password): hiagain
正如你所看到的,keytool 提示为 keystore 输入密码,那是因为让服务器能访问 keystore 就必须让它知道密码。那工具也要求为别名输入一个密码。如果你愿意,这些密码信息能由 keytool 从命令行指定,使用参数 -storepass 和 -keypass 就行了。注意我使用了“ultra.domain.com”作为姓名,这个名字是为我的机器假想的一个名字。你应该输入服务器的主机名或者 IP 地址。
在你运行 keytool 命令的时候,它可能会花几秒钟的时间来产生你的密码,具体速度得看你机器的速度了。
既然我为服务器创建了证书,现在可以修改 HttpServer 使其变得安全了。如果你检查 HttpServer 类,你会注意到 getServer 方法用来返回一个服务器套接子。也就是说,只需要修改 getServer 方法让它返回一个安全的服务器套接字就可以了。在代码示例 2 中加黑的部分就是所做的改变。请注意我将端口号改成了 443,这是 https 默认的端口号。还有一点非常值得注意:0 到 1023 之间的端口号都是保留的。如果你在不同的端口运行 HttpsServer,那么 URL 应该是:https://localhost:portnumber。但如果你在 443 端口运行 HttpsServer,那么 URL 应该是:https://localhost。
示例代码 2:HttpsServer.java
import java.io.*;
import java.net.*;
import javax.net.*;
import javax.net.ssl.*;
import java.security.*;
import java.util.StringTokenizer;
/**
* This class implements a multithreaded simple HTTPS
* server that supports the GET request method.
* It listens on port 44, waits client requests
* and serves documents.
*/
public class HttpsServer {
String keystore = "serverkeys";
char keystorepass[] = "hellothere".toCharArray();
char keypassword[] = "hiagain".toCharArray();
// The port number which the server will be listening on
public static final int HTTPS_PORT = 443;
public ServerSocket getServer() throws Exception {
KeyStore ks = KeyStore.getInstance("JKS");
ks.load(new FileInputStream(keystore), keystorepass);
KeyManagerFactory kmf =
KeyManagerFactory.getInstance("SunX509");
kmf.init(ks, keypassword);
SSLContext sslcontext =
SSLContext.getInstance("SSLv3");
sslcontext.init(kmf.getKeyManagers(), null, null);
ServerSocketFactory ssf =
sslcontext.getServerSocketFactory();
SSLServerSocket serversocket = (SSLServerSocket)
ssf.createServerSocket(HTTPS_PORT);
return serversocket;
}
// multi-threading -- create a new connection
// for each request
public void run() {
ServerSocket listen;
try {
listen = getServer();
while(true) {
Socket client = listen.accept();
ProcessConnection cc = new
ProcessConnection(client);
}
} catch(Exception e) {
System.out.println("Exception: "+e.getMessage());
}
}
// main program
public static void main(String argv[]) throws Exception {
HttpsServer https = new HttpsServer();
https.run();
}
}
这几行:
String keystore = "serverkeys";
char keystorepass[] = "hellothere".toCharArray();
char keypassword[] = "hiagain".toCharArray();
指定了 keystore 的名字、密码和密匙密码。直接在代码中写出密码文本是个糟糕的主意,不过我们可以在运行服务器的时候在命令行指定密码。
getServer 方法中的其它 JSSE 代码:
它访问 serverkeys keystore,JSK 是 Java KeyStore (一种由 keytool 产生的 keystore)。
用 KeyManagerFactory 为 keystore 创建 X.509 密匙管理。
SSLContext 是实现 JSSE 的环境。用它来创建可以创建 SSLServerSocket 的 ServerSocketFactory。虽然我们指定使用 SSL 3.0,但是返回来的实现常常支持其它协议版本,如 TLS 1.0。旧的浏览器中更多时候使用 SSL 3.0。
注意默认情况下不需要客户端的验证。如果你想要服务器请求客户端进行验证,使用:
serversocket.setNeedClientAuth(true).
现在用 HttpsServer 类做个实验:
将 HttpsServer 和 ProcessConnection 两个类 (上面的代码) 保存在文件 HttpsServer.java 中。
让HttpsServer.java 与 keytool 创建的 serverkyes 文件在同一目录。
使用 javac 编译 HttpsServer。
运行 HttpsServer。默认情况下它应该使用 443 端口,不过如果你不能在这个端口上使用它,请选择另一个大于 1024 的端口号。
打开网页浏览器并输入请求:https://localhost 或者 https://127.0.0.1。这是假译服务器使用 443 端口的情况。如果不是这个端口,那么使用:use: https://localhost:port
你在浏览器中输入 https://URL 的时候,你会得到一个安全警告的弹出窗口,就像图 3 那样。这是因为 HTTP 服务器证书是自己产生的。换句话说,它由未知的 CA 创建,在你的浏览器保存的 CA 中没有找到这个 CA。有一个选项让你显示证书 (检查它是不是正确的证书以及是谁签的名) 和安装该证书、拒绝该证书或者接受该证书。
图 3:由未知 CA 颁发的服务器证书
注意:在内部的私有系统中产生你自己的证书是个很好的主意。但在公共系统中,最好从知名的 CA 处获得证书,以避免浏览器的安全警告。
如果你接受证书,你就可以看到安全连接之后的页面。以后访问同一个网站的时候浏览器就不再会弹出安全警告了。注意有许多网站使用 HTTPS,而证书是自己产生或者由不知名的 CA 产生的。例如,https://www.jam.ca。如果你没访问过这个网页,你会看到一个像图 3 一样的安全警告。
注意:你接受证书以后,它只对当前的会话有效,也就是说,如果你完全退出浏览器后,它就失效了。Netscape 和 Microsoft Internet Explorer (MSIE) 都允许你永久保证证书。在 MSIE 中的作法是:选择图 3 所示的“View Certificate”并在新开的窗口中选择“Install Certificate”。
总结
这篇文章谈到了 SSL 并描述了 JSSE 框架及其实现。文中的例子可以说明把 SSL 整合到你的 C/S 应用中是一件很容易的事情。文中给出了一个安全 HTTP 服务器的例子,你可以使用它来进行实验。文中还介绍了 JSSE API 以及可以发生 HTTPS 请求的网页浏览器。
发表于计算机世界网知识中心 (http://www.ccw.com.cn/center)