HTTP协议属于明文传输,在公共网络上传输数据时很容易被不法分子截获到,对于像登录、转账之类比较私密的信息传输不适合用HTTP协议,为此业界又推出了HTTPS协议,它实际在HTTP协议和TCP协议之间添加了安全套接字(SSL,Secure Socket Layer)层,SSL层借助于加密解密过程实现网络上传输加密内容,不法分子即使获取到了密文,在没有正确的解密秘钥情况下无法获的真正的用户数据信息。
加密算法
在加解密领域初始的数据被称作明文,通过某种算法加密之后得到的数据被称作密文,明文对用户来说很容易理解,密文则是看似随机的字符串。在使用算法加密过程中,可能会需要额外的数据来进行加密转换,这种额外的数据被称作加密秘钥;在使用算法解密密文的过程中,也可能会需要额外的数据进行解密转换,这种额外的数据被称作解密秘钥。
加密算法通常被分成对称加密和非对称加密算法,两者的区别是前者加密秘钥和解密秘钥是同一个数据对象,而后者加密秘钥和解密秘钥是两个不相同的数据对象。还有一类加密算法属于单向加密,也就是说只能从明文得到密文,无法从密文获取到明文,这种加密算法通常用来判定明文是否被修改过,用户可以对比修改前和修改后得到的密文,两者不相同就表示明文曾经被修改过。
常见的对称加密算法是AES高级加密算法,JDK已经支持这种加密算法,只需要通过Cipher接口就能够得到AES的算法实现对象,下面的代码去掉了所有的异常处理。注意生成秘钥时传递进的安全随机值SecureRandom对象,它的存在使得用户即使每次提供了相同的口令key,生成的秘钥数据其实是不一样的。在测试AES堆成加密算法时需要先生成加解密秘钥对象,之后利用明文和秘钥执行加密操作就产生密文,接着使用秘钥和密文执行解密操作就可以得到明文。
// AES加密算法示例
public static SecretKey initKeyForAES(String key) { // 生成AES加解密秘钥
KeyGenerator kgen = KeyGenerator.getInstance("AES");
kgen.init(128, new SecureRandom(key.getBytes()));
SecretKey secretKey = kgen.generateKey();// 根据用户密码,生成一个密钥
byte[] enCodeFormat = secretKey.getEncoded();// 返回基本编码格式的密钥
return new SecretKeySpec(enCodeFormat, "AES");// 转换为AES专用密钥
}
// AES加密
public static byte[] AESEncode(SecretKey key, String content){
// 根据指定算法AES自成密码器
Cipher cipher = Cipher.getInstance("AES/ECB/ZeroBytePadding");
// 初始化密码器,第一个参数为加密(Encrypt_mode)操作,第二个参数为使用的KEY
cipher.init(Cipher.ENCRYPT_MODE, key);
// 获取加密内容的字节数组(这里要设置为utf-8)不然内容中如果
// 有中文和英文混合中文就会解密为乱码
byte [] byte_encode = content.getBytes("utf-8");
// 根据密码器的初始化方式--加密:将数据加密
return cipher.doFinal(byte_encode);
}
// AES 解密
public static byte[] AESDecode(SecretKey key, byte[] byte_content){
// 根据指定算法AES自成密码器
Cipher cipher = Cipher.getInstance("AES/ECB/ZeroBytePadding");
// 初始化密码器,第一个参数为解密(Decrypt_mode)操作,第二个参数为使用的KEY
cipher.init(Cipher.DECRYPT_MODE, key);
// 解密
return cipher.doFinal(byte_content);
}
// 简单测试AES加解密算法
String message = "Hello World!!!";
String key = "GoodBye";
SecretKey secretKey = EncryUtils.initKeyForAES(key);
byte[] cipher = EncryUtils.AESEncode(secretKey, message);
Log.e(TAG, "cipher = " + Base64.encodeToString(cipher, 0));
byte[] plain = EncryUtils.AESDecode(secretKey, cipher);
Log.e(TAG, "plain = " + new String(plain, "utf-8"));
//~ 运行结果
// E/HttpsTestActivity: cipher = c1pz6IVj9RDVW0cpDr6zQw==
// E/HttpsTestActivity: plain = Hello World!!!
常见的非对称加密算法就是RSA加密算法,在使用它是需要先产生一对密钥对,其中一个可以被公开称作公钥,另外一个私人保留称作私钥,如果使用公钥加密就需要使用私钥解密,反之如果用私钥加密就需要用公钥解密。
// RSA加密算法示例
// 生成公私钥对
public static Pair<String, String> genKeyPair() throws NoSuchAlgorithmException {
// KeyPairGenerator类用于生成公钥和私钥对,基于RSA算法生成对象
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
// 初始化密钥对生成器,密钥大小为96-1024位
keyPairGen.initialize(1024, new SecureRandom());
// 生成一个密钥对,保存在keyPair中
KeyPair keyPair = keyPairGen.generateKeyPair();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); // 得到私钥
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); // 得到公钥
String publicKeyString = Base64.encodeToString(publicKey.getEncoded(), 0);
// 得到私钥字符串
String privateKeyString = Base64.encodeToString(privateKey.getEncoded(), 0);
// 将公钥和私钥保存到Map
return new Pair<>(publicKeyString, privateKeyString);
}
// RSA加密算法
public static String encrypt( String str, String publicKey) throws Exception{
// base64编码的公钥
byte[] decoded = Base64.decode(publicKey, 0);
RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(decoded));
//RSA加密
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, pubKey);
return Base64.encodeToString(cipher.doFinal(str.getBytes("UTF-8")), 0);
}
// RSA解密算法
public static String decrypt(String str, String privateKey) throws Exception{
//64位解码加密后的字符串
byte[] inputByte = Base64.decode(str.getBytes("UTF-8"), 0);
//base64编码的私钥
byte[] decoded = Base64.decode(privateKey, 0);
RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(decoded));
//RSA解密
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, priKey);
return new String(cipher.doFinal(inputByte));
}
String message = "Hello World!!!";
Pair<String, String> secretKey = EncryUtils.genKeyPair();
Log.e(TAG, "public key = " + secretKey.first);
Log.e(TAG, "private key = " + secretKey.second);
String cipher = EncryUtils.encrypt(message, secretKey.first);
Log.e(TAG, "cipher = " + cipher);
String plain = EncryUtils.decrypt(cipher, secretKey.second);
Log.e(TAG, "plain = " + plain);
//~ 运行结果
public key = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCaYgae5mp(还有很长...)
private key = MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAzxD(还有很长...)
cipher = jiJ9jpB7din35T7Vtt1AtccROnYX7bw/ajEdQAkMx0Yw6biMzR7Vdjy4gAiPQ1RK+XtKu
c+EqhNV1/7RXHShtXoAUZ19aYKu1+/+w91sKOQvN7lDrsNuJQpxu/AKTpl0JrlnmRV830I17HbI
CdZBJtdkci1McvvPPxbcSPN/X+E=
plain = Hello World!!!
上面的代码简单使用RSA加密算法实现加解密操作,首先需要生成秘钥对,加密使用第一个秘钥,解密使用第二个秘钥,当然也可以反过来,运行结果可以发现加解密操作都已经成功。MD5算法是常见的单向加密算法,它能够将不定长度的文本映射成固定长度的字符串值,JDK也提供了它的实现对象。
// MD5加密算法示例
public static String MD5Encode(String origin) { // md5加密
String resultString = origin;
MessageDigest md = MessageDigest.getInstance("MD5");
resultString = byteArrayToHexString(md.digest(resultString.getBytes("UTF-8")));
return resultString;
}
private static String byteArrayToHexString(byte b[]){ // 将byte数组转换成十六进制字符串
StringBuilder resultSb = new StringBuilder();
for (int i = 0; i < b.length; i++){
resultSb.append(byteToHexString(b[i]));
}
return resultSb.toString();
}
private static String byteToHexString(byte b){
int n = b;
if (n < 0){
n += 256;
}
int d1 = n / 16;
int d2 = n % 16;
return hexDigIts[d1] + hexDigIts[d2];
}
String message = "Hello World!!!"; // 原始数据
String md5 = EncryUtils.MD5Encode(message);
Log.e(TAG, "md5 =" + md5);
String text = "HelloWorld!!!"; // 去掉空格
md5 = EncryUtils.MD5Encode(text);
Log.e(TAG, "md5 =" + md5);
//~ 运行结果
plain = Hello World!!!
md5 =236bf30c70dc03f69175f030afbe38f3
md5 =2a78a9a4d4671fa513d8d9f36469daa2
MD5测试原始字符串和修改一位数据之后MD5单向加密的结果,会发现它们之间的差别是非常大的,不过由于MD5没有反向解密过程,也就无法通过密文重新得到明文。
公钥基础设施
加密算法很好地解决了明文在公共线路上传输不安全的问题,但解密时需要秘钥数据,如果在公共线路上直接传输秘钥,不法分子截获密文和秘钥就能够获得明文数据,因而秘钥的传输也就成了新的问题。除此之外非对称加密算法在加解密数据操作是处理非常慢,容易造成应用性能瓶颈,在传输大数据时往往需要使用对称加密算法。
针对公共线路上传输秘钥的问题,业界推出了公钥基础设施(PKI,Public Key Infrastructure),也就是利用非对称加密来实现用户的身份识别和私密数据传输。PKI中会要求每个用户都生成一对公私密钥对,私钥由用户自己保存,公钥开放出来可以被其他用户通过公共网络获取。
在PKI中存在着第三方认证机构,它是所有用户都信任的PKI用户,它会发布自己的证书,通常包括自己的公钥、发布者、过期时间等信息,然后使用单向加密将证书内容映射成长度固定的字符串,最后使用自己的私钥加密单向加密字符串,私钥加密过的数据就被称作用户签名。通常的用户证书都会包含公钥、发布者、用户签名这三个重要的信息。常见的第三方认证机构的证书都会被内置在操作系统中。
PKI中的普通用户在生成秘钥对后需要向第三方认证机构提出认证申请,申请通过后第三方机构会为普通用户生成证书。证书内容包含普通用户的公钥、发布者、过期时间以及认证机构的信息,第三方机构会使用单向加密加密前面的数据,最后使用第三方机构的私钥加密单向加密数据作为签名放在普通用户证书里。
在网络请求中服务器端通常作为PKI的普通用户存在,它们有自己的证书,客户端使用HTTPS请求时会先得到服务器端的证书,通过查找客户端操作系统中内置的第三方认证机构证书判定服务器证书的有效性。查找到内置的第三方证书后得到第三方的公钥,通过公钥解密服务器证书里的签名得到证书其他部分的单向加密数据,再通过单向加密证书中其他部分的值对比,二者一致说明证书确实是第三方签发的,否则证书验证不通过。还有一种证书是自认证证书,它是用户自己签名的证书,客户端需要自己决定是否信任自签名的证书。JDK提供了keytool工具用于生成自签名的证书,通过该命令生成证书tomcat.cer,可以把它放到assets目录下,通过CertificationFactory解析证书的内容。
keytool -genkey -alias tomcat -keyalg RSA -keypass 123456
-storepass 123456 -keystore tomcat.keystore -validity 3600
代码3-11 解析生成的自签名证书内容
CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
X509Certificate certificate = (X509Certificate) certificateFactory.generateCertificate(getAssets().open("tomcat.cer"));
Log.e(TAG, certificate.getSigAlgName()); // 签名算法
Log.e(TAG, certificate.getIssuerDN().toString()); // 签名者标识
Log.e(TAG, certificate.getSubjectDN().toString()); // 证书拥有者标识
Log.e(TAG, certificate.getPublicKey().toString()); // 证书拥有者公钥数据
Log.e(TAG, Base64.encodeToString(certificate.getSignature(), 0)); // 签名数据
//~ 运行结果
SHA256withRSA
CN=zhang, OU=com.example, O=com.example, L=Beijing, ST=Beijing, C=CN
CN=zhang, OU=com.example, O=com.example, L=Beijing, ST=Beijing, C=CN
公钥:OpenSSLRSAPublicKey{modulus=a8d000a84c67d(还有很长)
签名数据:feDJG4aPD3QDSZlWLQ4ujZAa4GSwe6YIhU8s+dLAne2AFV3t(还有很长)
HTTPS请求
HTTPS使用了SSL层的安全套接字实现网络访问,在JDK中使用SSLContext上下文对象获取安全套接字工厂对象,SSLContext上下文对象又需要TrustManager信任管理器对象作为服务器证书验证对象,在TrustManger.checkServerTrusted()方法中会传递进来获得的服务器证书,用户服务器证书和本地证书的比对就能够决定证书是否可用。
// 检验服务器证书
TrustManager trustManager = new MyTrustManager();
SSLContext sslContext = SSLContext.getInstance("TLS");
// 初始化SSL上下文对象,最后的SecureRandom就是生成对称秘钥时使用的
sslContext.init(null, new TrustManager[]{trustManager}, new SecureRandom());
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
private static class MyTrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException { }
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X509");
try {
X509Certificate certificate = (X509Certificate) certificateFactory
.generateCertificate(MyApplication.getContext().getAssets().open("tomcat.cer"));
X509Certificate server = chain[0];
// 验证服务器端证书是否有效
if (certificate.getIssuerDN().equals(server.getIssuerDN())) {
if (certificate.getPublicKey().equals(server.getPublicKey())) {
certificate.checkValidity();
return;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}
接下来创建HttpsURLConnection对象可以使用https协议打开连接,还要注意设置hostNameVerifer和sslSocketFactory两个属性,其他的请求配置都和普通的HTTP协议一致。
// HTTPS请求实现
final String LOGIN_URL = "https://192.168.137.240:8443/HttpServer/login";
HttpsURLConnection httpsURLConnection = (HttpsURLConnection)
new URL(LOGIN_URL).openConnection();
httpsURLConnection.setRequestMethod("POST");
httpsURLConnection.setConnectTimeout(3000);
httpsURLConnection.setReadTimeout(3000);
httpsURLConnection.setDoInput(true);
httpsURLConnection.setDoOutput(false);
httpsURLConnection.setInstanceFollowRedirects(true);
httpsURLConnection.setHostnameVerifier(hostnameVerifier); // 主机域名验证
httpsURLConnection.setSSLSocketFactory(sslSocketFactory); // 安全套接字工厂对象
// 写入登录数据,发送请求
OutputStream outputStream = httpsURLConnection.getOutputStream();
outputStream.write("name=xxxx".getBytes("UTF-8"));
outputStream.flush();
outputStream.close();
httpsURLConnection.getResponseCode();
// hostNameVerifer用来验证请求的连接服务器地址是否符合要求,
// 如果连接地址不正确可以不允许发送网络请求,通常情况下可以不做处理。
final HostnameVerifier hostnameVerifier = new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
Log.e(TAG, "hostname = " + hostname);
return true;
}
};
运行上面的HTTPS请求代码并发送请求到Tomcat服务器(Tomcat服务器配置支持HTTPS请参考第七章服务器实现),为了能够更好地看到底层网络请求发送过程,推荐使用Wireshark网络抓包工具来观察,Charles通常只能抓取HTTP层的报文数据,Wireshark则可以看到TCP/IP四层的数据,相对来说后者的抓包功能更加强大。
在HTTPS请求开始阶段会有一个HandShake握手阶段,客户端会首先向服务器发送请求;服务器端返回它支持的各种加密套件,服务器端选择某种加密算法并且发送服务器证书;客户端验证服务端证书通过获取到服务端公钥,使用公钥加密后面数据传输使用的对称秘钥;服务器端获得公钥加密的对称秘钥,使用自己的私钥解密,之后所有向客户端发送的数据都是用对称秘钥加密;客户端接收到服务器端数据后使用对称秘钥解密其中的数据。