Java基础——加密与安全

217 阅读22分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情

  • 防窃听
  • 防篡改
  • 防伪造

一、编码

汉字Unicode编码UTF-8编码
0x4e2d0xe4b8ad
0x65870xe69687
0x7f160xe7bc96
0x78010xe7a081

URL编码

URL编码是对字符进行编码,表示成%xx的形式。

URL编码是浏览器发送数据给服务器时使用的编码,它通常附加在URL的参数部分,之所以需要URL编码,是因为出于兼容性考虑,很多服务器只识别ASCII字符。但如果URL中包含中文这些非ASCII字符怎么办?不要紧,URL编码有一套规则:

  • 如果字符是AZaz0~9以及-_.*,则保持不变
  • 如果是其他字符,先转换为UTF-8编码,然后对每个字节以%XX表示
  • URL编码总是大写

和标准的URL编码稍有不同,URLEncoder把空格字符编码成+,而现在的URL编码标准要求空格被编码为%20,不过,服务器都可以处理这两种情况。

Base64编码

Base64编码是对二进制数据进行编码,表示成文本格式。

可以把任意长度的二进制数据变为纯文本,且只包含AZaz0~9+/=这些字符。它的原理是把3字节的二进制数据按6bit一组,用4个int整数表示,然后查表,把int整数用索引对应到字符,得到编码后的字符串。

因为6位整数的范围总是063,所以,能用64个字符表示:字符AZ对应索引025,字符az对应索引2651,字符09对应索引52~61,最后两个索引6263分别用字符+/表示。

如果输入的byte[]数组长度不是3的整数倍?这种情况下,需要对输入的末尾补一个或两个0x00,编码后,在结尾加一个=表示补充了1个0x00,加两个=表示补充了2个0x00,解码的时候,去掉末尾补充的一个或两个0x00即可。

实际上,因为编码后的长度加上=总是4的倍数,所以即使不加=也可以计算出原始输入的byte[]。Base64编码的时候可以用withoutPadding()去掉=,解码出来的结果是一样的

因为标准的Base64编码会出现+/=,所以不适合把Base64编码后的字符串放到URL中。一种针对URL的Base64编码可以在URL中使用的Base64编码,它仅仅是把+变成-/变成_

1
package com.study.security;
23
import java.io.UnsupportedEncodingException;
4
import java.net.URLDecoder;
5
import java.net.URLEncoder;
6
import java.util.Arrays;
7
import java.util.Base64;
89
public class EncodeDemo {
10
    public static void main(String[] args) throws UnsupportedEncodingException {
11
        urlDemo();
12
        System.out.println("==================================");
1314
        base64Demo();
15
        System.out.println("==================================");
1617
        base64Demo2();
18
        System.out.println("==================================");
1920
        base64Demo3();
21
    }
2223
    private static void urlDemo() throws UnsupportedEncodingException {
24
        String encode = URLEncoder.encode("中文!", "UTF-8");
25
        System.out.println(encode); // %E4%B8%AD%E6%96%87%21
2627
        String decoded = URLDecoder.decode("%E4%B8%AD%E6%96%87%21", "UTF-8");
28
        System.out.println(decoded);
29
    }
3031
    private static void base64Demo() {
32
        byte[] input = {(byte) 0xe4, (byte) 0xb8, (byte) 0xad};
33
        final String b64encode = Base64.getEncoder().encodeToString(input);
34
        System.out.println(b64encode); // 5Lit
3536
        byte[] output = Base64.getDecoder().decode("5Lit");
37
        System.out.println(Arrays.toString(output));
38
    }
3940
    private static void base64Demo2() {
41
        byte[] input = new byte[]{(byte) 0xe4, (byte) 0xb8, (byte) 0xad, 0x21};
42
        String b64encoded = Base64.getEncoder().encodeToString(input);
43
        String b64encoded2 = Base64.getEncoder().withoutPadding().encodeToString(input);
44
        System.out.println(b64encoded);
45
        System.out.println(b64encoded2);
46
        byte[] output = Base64.getDecoder().decode(b64encoded2);
47
        System.out.println(Arrays.toString(output));
48
    }
4950
    private static void base64Demo3() {
51
        byte[] input = new byte[]{0x01, 0x02, 0x7f, 0x00};
52
        String b64encoded = Base64.getUrlEncoder().encodeToString(input);
53
        System.out.println(b64encoded);
54
        byte[] output = Base64.getUrlDecoder().decode(b64encoded);
55
        System.out.println(Arrays.toString(output));
56
    }
57
}
58

二、哈希算法

哈希算法(Hash)又称摘要算法(Digest),它的作用是:对任意一组输入数据进行计算,得到一个固定长度的输出摘要。

哈希算法最重要的特点就是:

  • 相同的输入一定得到相同的输出;
  • 不同的输入大概率得到不同的输出。

哈希算法的目的就是为了验证原始数据是否被篡改

算法输出长度(位)输出长度(字节)
MD5128 bits16 bytes
SHA-1160 bits20 bytes
RipeMD-160160 bits20 bytes
SHA-256256 bits32 bytes
SHA-512512 bits64 bytes

即使用户使用了常用口令,我们也可以采取措施来抵御彩虹表攻击,方法是对每个口令额外添加随机数,这个方法称之为加盐(salt):

1
digest = md5(salt+inputPassword)

经过加盐处理的数据库表,内容如下:

usernamesaltpassword
bobH1r0aa5022319ff4c56955e22a74abcc2c210
alice7$p2we5de688c99e961ed6e560b972dab8b6a
timz5Sk91eee304b92dc0d105904e7ab58fd2f64

加盐的目的在于使黑客的彩虹表失效,即使用户使用常用口令,也无法从MD5反推原始口令。

BouncyCastle

BouncyCastle是一个开源的第三方算法提供商;

BouncyCastle提供了很多Java标准库没有提供的哈希算法和加密算法;例如,RipeMD160哈希算法。

使用第三方算法前需要通过Security.addProvider()注册。

三、Hmac算法

Hmac算法就是一种基于密钥的消息认证码算法,它的全称是Hash-based Message Authentication Code,是一种更安全的消息摘要算法。

Hmac算法总是和某种哈希算法配合起来用的。例如,我们使用MD5算法,对应的就是HmacMD5算法,它相当于“加盐”的MD5:

1
HmacMD5 ≈ md5(secure_random_key, input)

因此,HmacMD5可以看作带有一个安全的key的MD5。使用HmacMD5而不是用MD5加salt,有如下好处:

  • HmacMD5使用的key长度是64字节,更安全;
  • Hmac是标准算法,同样适用于SHA-1等其他哈希算法;
  • Hmac输出和原有的哈希算法长度一致。

和MD5相比,使用HmacMD5的步骤是:

  1. 通过名称HmacMD5获取KeyGenerator实例;
  2. 通过KeyGenerator创建一个SecretKey实例;
  3. 通过名称HmacMD5获取Mac实例;
  4. SecretKey初始化Mac实例;
  5. Mac实例反复调用update(byte[])输入数据;
  6. 调用Mac实例的doFinal()获取最终的哈希值。
1
package com.study.security;
23
import javax.crypto.KeyGenerator;
4
import javax.crypto.Mac;
5
import javax.crypto.SecretKey;
6
import javax.crypto.spec.SecretKeySpec;
7
import java.math.BigInteger;
8
import java.nio.charset.StandardCharsets;
9
import java.util.Arrays;
1011
public class HmacDemo {
12
    public static void main(String[] args) throws Exception {
13
        KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");
14
        SecretKey key = keyGen.generateKey();
15
        // 打印随机生成的key:
16
        byte[] skey = key.getEncoded();
17
        System.out.println(Arrays.toString(skey));
18
        System.out.println(new BigInteger(1, skey).toString(16));
19
        Mac mac = Mac.getInstance("HmacMD5");
20
        mac.init(key);
21
        mac.update("HelloWorld".getBytes(StandardCharsets.UTF_8));
22
        byte[] result = mac.doFinal(); // cc222a5315bca669b965077c625f2586
23
        System.out.println(new BigInteger(1, result).toString(16));
24
        System.out.println("==================================");
2526
        // 验证
27
        byte[] hkey = {-104, 101, 124, 93, 59, 3, 40, -9, -29, -81, -43, 35,
28
                38, 48, 95, 121, 0, 26, -116, 89, -6, 91, 88, -16, -11, 20,
29
                38, -55, -31, -54, -81, -33, 112, -32, -79, 27, 75, 24, 52,
30
                90, -23, 33, 33, -86, -59, -4, 71, 115, 93, 7, 58, 49, -51,
31
                89, -42, 101, -118, -46, -61, 50, -84, 37, -109, 96};
32
        SecretKeySpec keySpec = new SecretKeySpec(hkey, "HmacMD5");
33
        Mac mac1 = Mac.getInstance("HmacMD5");
34
        mac1.init(keySpec);
35
        mac1.update("HelloWorld".getBytes("UTF-8"));
36
        byte[] result1 = mac1.doFinal();
37
        System.out.println(Arrays.toString(result1));
38
        System.out.println(new BigInteger(1, result1).toString(16));
39
    }
40
}
41

四、对称加密

算法密钥长度工作模式填充模式
DES56/64ECB/CBC/PCBC/CTR/...NoPadding/PKCS5Padding/...
AES128/192/256ECB/CBC/PCBC/CTR/...NoPadding/PKCS5Padding/PKCS7Padding/...
IDEA128ECBPKCS5Padding/PKCS7Padding/...

Java标准库提供的对称加密接口非常简单,使用时按以下步骤编写代码:

  1. 根据算法名称/工作模式/填充模式获取Cipher实例;
  2. 根据算法名称初始化一个SecretKey实例,密钥必须是指定长度;
  3. 使用SerectKey初始化Cipher实例,并设置加密或解密模式;
  4. 传入明文或密文,获得密文或明文。

ECB模式是最简单的AES加密模式,它只需要一个固定长度的密钥,固定的明文会生成固定的密文,这种一对一的加密方式会导致安全性降低,更好的方式是通过CBC模式,它需要一个随机数作为IV参数,这样对于同一份明文,每次生成的密文都不同。

在CBC模式下,需要一个随机生成的16字节IV参数,必须使用SecureRandom生成。因为多了一个IvParameterSpec实例,因此,初始化方法需要调用Cipher的一个重载方法并传入IvParameterSpec

1
package com.study.security;
2
​
3
import javax.crypto.Cipher;
4
import javax.crypto.SecretKey;
5
import javax.crypto.spec.IvParameterSpec;
6
import javax.crypto.spec.SecretKeySpec;
7
import java.security.SecureRandom;
8
import java.util.Base64;
9
​
10
public class AesDemo {
11
    public static void main(String[] args) throws Exception {
12
        ecbDemo();
13
        System.out.println("======================");
14
​
15
        cbcDemo();
16
    }
17
​
18
    public static void ecbDemo() throws Exception {
19
        // 原文:
20
        String message = "Hello, world!";
21
        System.out.println("Message: " + message);
22
        // 128位密钥 = 16 bytes Key:
23
        byte[] key = "1234567890abcdef".getBytes("UTF-8");
24
        // 加密:
25
        byte[] data = message.getBytes("UTF-8");
26
        byte[] encrypted = ecbEncrypt(key, data);
27
        System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
28
        // 解密:
29
        byte[] decrypted = ecbDecrypt(key, encrypted);
30
        System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
31
    }
32
​
33
    // 加密:
34
    public static byte[] ecbEncrypt(byte[] key, byte[] input) throws Exception {
35
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
36
        SecretKey keySpec = new SecretKeySpec(key, "AES");
37
        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
38
        return cipher.doFinal(input);
39
    }
40
​
41
    // 解密:
42
    public static byte[] ecbDecrypt(byte[] key, byte[] input) throws Exception {
43
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
44
        SecretKey keySpec = new SecretKeySpec(key, "AES");
45
        cipher.init(Cipher.DECRYPT_MODE, keySpec);
46
        return cipher.doFinal(input);
47
    }
48
​
49
    public static void cbcDemo() throws Exception {
50
        // 原文:
51
        String message = "Hello, world!";
52
        System.out.println("Message: " + message);
53
        // 256位密钥 = 32 bytes Key:
54
        byte[] key = "1234567890abcdef1234567890abcdef".getBytes("UTF-8");
55
        // 加密:
56
        byte[] data = message.getBytes("UTF-8");
57
        byte[] encrypted = cbcEncrypt(key, data);
58
        System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
59
        // 解密:
60
        byte[] decrypted = cbcDecrypt(key, encrypted);
61
        System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
62
    }
63
​
64
    // 加密:
65
    public static byte[] cbcEncrypt(byte[] key, byte[] input) throws Exception {
66
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
67
        SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
68
        // CBC模式需要生成一个16 bytes的initialization vector:
69
        SecureRandom sr = SecureRandom.getInstanceStrong();
70
        byte[] iv = sr.generateSeed(16);
71
        IvParameterSpec ivps = new IvParameterSpec(iv);
72
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivps);
73
        byte[] data = cipher.doFinal(input);
74
        // IV不需要保密,把IV和密文一起返回:
75
        return join(iv, data);
76
    }
77
​
78
    // 解密:
79
    public static byte[] cbcDecrypt(byte[] key, byte[] input) throws Exception {
80
        // 把input分割成IV和密文:
81
        byte[] iv = new byte[16];
82
        byte[] data = new byte[input.length - 16];
83
        System.arraycopy(input, 0, iv, 0, 16);
84
        System.arraycopy(input, 16, data, 0, data.length);
85
        // 解密:
86
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
87
        SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
88
        IvParameterSpec ivps = new IvParameterSpec(iv);
89
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
90
        return cipher.doFinal(data);
91
    }
92
​
93
    public static byte[] join(byte[] bs1, byte[] bs2) {
94
        byte[] r = new byte[bs1.length + bs2.length];
95
        System.arraycopy(bs1, 0, r, 0, bs1.length);
96
        System.arraycopy(bs2, 0, r, bs1.length, bs2.length);
97
        return r;
98
    }
99
}
100
​

五、口令加密算法

用户输入的口令并不能直接作为AES的密钥进行加密(除非长度恰好是128/192/256位),并且用户输入的口令一般都有规律,安全性远远不如安全随机数产生的随机口令。因此,用户输入的口令,通常还需要使用PBE算法,采用随机数杂凑计算出真正的密钥,再进行加密。

PBE就是Password Based Encryption的缩写,PBE的作用就是把用户输入的口令和一个安全随机的口令采用杂凑后计算出真正的密钥。以AES密钥为例,我们让用户输入一个口令,然后生成一个随机数,通过PBE算法计算出真正的AES口令,再进行加密。

使用PBE时,我们还需要引入BouncyCastle,并指定算法是PBEwithSHA1and128bitAES-CBC-BC。观察代码,实际上真正的AES密钥是调用Cipherinit()方法时同时传入SecretKeyPBEParameterSpec实现的。在创建PBEParameterSpec的时候,我们还指定了循环次数1000,循环次数越多,暴力破解需要的计算量就越大。

1
package com.study.security;
2
​
3
import org.bouncycastle.jce.provider.BouncyCastleProvider;
4
​
5
import javax.crypto.Cipher;
6
import javax.crypto.SecretKey;
7
import javax.crypto.SecretKeyFactory;
8
import javax.crypto.spec.PBEKeySpec;
9
import javax.crypto.spec.PBEParameterSpec;
10
import java.math.BigInteger;
11
import java.security.SecureRandom;
12
import java.security.Security;
13
import java.util.Base64;
14
​
15
public class PbeDemo {
16
    public static void main(String[] args) throws Exception {
17
        // 把BouncyCastle作为Provider添加到java.security:
18
        Security.addProvider(new BouncyCastleProvider());
19
        // 原文:
20
        String message = "Hello, world!";
21
        // 加密口令:
22
        String password = "hello12345";
23
        // 16 bytes随机Salt:
24
        byte[] salt = SecureRandom.getInstanceStrong().generateSeed(16);
25
        System.out.printf("salt: %032x\n", new BigInteger(1, salt));
26
        // 加密:
27
        byte[] data = message.getBytes("UTF-8");
28
        byte[] encrypted = encrypt(password, salt, data);
29
        System.out.println("encrypted: " + Base64.getEncoder().encodeToString(encrypted));
30
        // 解密:
31
        byte[] decrypted = decrypt(password, salt, encrypted);
32
        System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
33
    }
34
​
35
    // 加密:
36
    public static byte[] encrypt(String password, byte[] salt, byte[] input) throws Exception {
37
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
38
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
39
        SecretKey skey = skeyFactory.generateSecret(keySpec);
40
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
41
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
42
        cipher.init(Cipher.ENCRYPT_MODE, skey, pbeps);
43
        return cipher.doFinal(input);
44
    }
45
​
46
    // 解密:
47
    public static byte[] decrypt(String password, byte[] salt, byte[] input) throws Exception {
48
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
49
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
50
        SecretKey skey = skeyFactory.generateSecret(keySpec);
51
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
52
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
53
        cipher.init(Cipher.DECRYPT_MODE, skey, pbeps);
54
        return cipher.doFinal(input);
55
    }
56
}
57
​

六、密钥交换算法

DH算法是一个密钥协商算法,双方最终协商出一个共同的密钥,而这个密钥不会通过网络传输。

  1. 甲首选选择一个素数p,例如509,底数g,任选,例如5,随机数a,例如123,然后计算A=g^a mod p,结果是215,然后,甲发送p=509g=5A=215给乙;
  2. 乙方收到后,也选择一个随机数b,例如,456,然后计算B=g^b mod p,结果是181,乙再同时计算s=A^b mod p,结果是121;
  3. 乙把计算的B=181发给甲,甲计算s=B^a mod p的余数,计算结果与乙算出的结果一样,都是121。

所以最终双方协商出的密钥s是121。注意到这个密钥s并没有在网络上传输。而通过网络传输的pgAB是无法推算出s的,因为实际算法选择的素数是非常大的。

a看成甲的私钥,A看成甲的公钥,b看成乙的私钥,B看成乙的公钥,DH算法的本质就是双方各自生成自己的私钥和公钥,私钥仅对自己可见,然后交换公钥,并根据自己的私钥和对方的公钥,生成最终的密钥secretKey

1
package com.study.security;
23
import javax.crypto.KeyAgreement;
4
import java.math.BigInteger;
5
import java.security.*;
6
import java.security.spec.X509EncodedKeySpec;
78
public class DhDemo {
9
    public static void main(String[] args) {
10
        // Bob和Alice:
11
        Person bob = new Person("Bob");
12
        Person alice = new Person("Alice");
1314
        // 各自生成KeyPair:
15
        bob.generateKeyPair();
16
        alice.generateKeyPair();
1718
        // 双方交换各自的PublicKey:
19
        // Bob根据Alice的PublicKey生成自己的本地密钥:
20
        bob.generateSecretKey(alice.publicKey.getEncoded());
21
        // Alice根据Bob的PublicKey生成自己的本地密钥:
22
        alice.generateSecretKey(bob.publicKey.getEncoded());
2324
        // 检查双方的本地密钥是否相同:
25
        bob.printKeys();
26
        alice.printKeys();
27
        // 双方的SecretKey相同,后续通信将使用SecretKey作为密钥进行AES加解密...
28
    }
29
}
3031
class Person {
32
    public final String name;
3334
    public PublicKey publicKey;
35
    private PrivateKey privateKey;
36
    private byte[] secretKey;
3738
    public Person(String name) {
39
        this.name = name;
40
    }
4142
    // 生成本地KeyPair:
43
    public void generateKeyPair() {
44
        try {
45
            KeyPairGenerator kpGen = KeyPairGenerator.getInstance("DH");
46
            kpGen.initialize(512);
47
            KeyPair kp = kpGen.generateKeyPair();
48
            this.privateKey = kp.getPrivate();
49
            this.publicKey = kp.getPublic();
50
        } catch (NoSuchAlgorithmException e) {
51
            throw new RuntimeException(e);
52
        }
53
    }
5455
    public void generateSecretKey(byte[] receivedPubKeyBytes) {
56
        try {
57
            // 从byte[]恢复PublicKey:
58
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes);
59
            KeyFactory kf = KeyFactory.getInstance("DH");
60
            PublicKey receivedPublicKey = kf.generatePublic(keySpec);
61
            // 生成本地密钥:
62
            KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
63
            keyAgreement.init(this.privateKey); // 自己的PrivateKey
64
            keyAgreement.doPhase(receivedPublicKey, true); // 对方的PublicKey
65
            // 生成SecretKey密钥:
66
            this.secretKey = keyAgreement.generateSecret();
67
        } catch (GeneralSecurityException e) {
68
            throw new RuntimeException(e);
69
        }
70
    }
7172
    public void printKeys() {
73
        System.out.printf("Name: %s\n", this.name);
74
        System.out.printf("Private key: %x\n", new BigInteger(1, this.privateKey.getEncoded()));
75
        System.out.printf("Public key: %x\n", new BigInteger(1, this.publicKey.getEncoded()));
76
        System.out.printf("Secret key: %x\n", new BigInteger(1, this.secretKey));
77
    }
78
}
79

七、非对称加密算法

非对称加密相比对称加密的显著优点在于,对称加密需要协商密钥,而非对称加密可以安全地公开各自的公钥,在N个人之间通信的时候:使用非对称加密只需要N个密钥对,每个人只管理自己的密钥对。而使用对称加密需要则需要N*(N-1)/2个密钥,因此每个人需要管理N-1个密钥,密钥管理难度大,而且非常容易泄漏。

非对称加密的典型算法就是RSA算法

在实际应用的时候,非对称加密总是和对称加密一起使用。假设小明需要给小红需要传输加密文件,他俩首先交换了各自的公钥,然后:

  1. 小明生成一个随机的AES口令,然后用小红的公钥通过RSA加密这个口令,并发给小红;
  2. 小红用自己的RSA私钥解密得到AES口令;
  3. 双方使用这个共享的AES口令用AES加密通信。
1
package com.study.security;
2
​
3
import javax.crypto.Cipher;
4
import java.math.BigInteger;
5
import java.security.*;
6
​
7
public class RsaDemo {
8
    public static void main(String[] args) throws Exception {
9
        // 明文:
10
        byte[] plain = "Hello, encrypt use RSA".getBytes("UTF-8");
11
        // 创建公钥/私钥对:
12
        RsaPerson alice = new RsaPerson("Alice");
13
        // 用Alice的公钥加密:
14
        byte[] pk = alice.getPublicKey();
15
        System.out.println(String.format("public key: %x", new BigInteger(1, pk)));
16
        byte[] encrypted = alice.encrypt(plain);
17
        System.out.println(String.format("encrypted: %x", new BigInteger(1, encrypted)));
18
        // 用Alice的私钥解密:
19
        byte[] sk = alice.getPrivateKey();
20
        System.out.println(String.format("private key: %x", new BigInteger(1, sk)));
21
        byte[] decrypted = alice.decrypt(encrypted);
22
        System.out.println(new String(decrypted, "UTF-8"));
23
    }
24
}
25
​
26
class RsaPerson {
27
    String name;
28
    // 私钥:
29
    PrivateKey sk;
30
    // 公钥:
31
    PublicKey pk;
32
​
33
    public RsaPerson(String name) throws NoSuchAlgorithmException {
34
        this.name = name;
35
        // 生成公钥/私钥对:
36
        KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
37
        kpGen.initialize(1024);
38
        KeyPair kp = kpGen.generateKeyPair();
39
        this.sk = kp.getPrivate();
40
        this.pk = kp.getPublic();
41
    }
42
​
43
    // 把私钥导出为字节
44
    public byte[] getPrivateKey() {
45
        return this.sk.getEncoded();
46
    }
47
​
48
    // 把公钥导出为字节
49
    public byte[] getPublicKey() {
50
        return this.pk.getEncoded();
51
    }
52
​
53
    // 用公钥加密:
54
    public byte[] encrypt(byte[] message) throws GeneralSecurityException {
55
        Cipher cipher = Cipher.getInstance("RSA");
56
        cipher.init(Cipher.ENCRYPT_MODE, this.pk);
57
        return cipher.doFinal(message);
58
    }
59
​
60
    // 用私钥解密:
61
    public byte[] decrypt(byte[] input) throws GeneralSecurityException {
62
        Cipher cipher = Cipher.getInstance("RSA");
63
        cipher.init(Cipher.DECRYPT_MODE, this.sk);
64
        return cipher.doFinal(input);
65
    }
66
}
67
​

RSA的公钥和私钥都可以通过getEncoded()方法获得以byte[]表示的二进制数据,并根据需要保存到文件中。要从byte[]数组恢复公钥或私钥,可以这么写:

1
byte[] pkData = ...
2
byte[] skData = ...
3
KeyFactory kf = KeyFactory.getInstance("RSA");
4
// 恢复公钥:
5
X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(pkData);
6
PublicKey pk = kf.generatePublic(pkSpec);
7
// 恢复私钥:
8
PKCS8EncodedKeySpec skSpec = new PKCS8EncodedKeySpec(skData);
9
PrivateKey sk = kf.generatePrivate(skSpec);

如果修改待加密的byte[]数据的大小,可以发现,使用512bit的RSA加密时,明文长度不能超过53字节,使用1024bit的RSA加密时,明文长度不能超过117字节,这也是为什么使用RSA的时候,总是配合AES一起使用,即用AES加密任意长度的明文,用RSA加密AES口令。

八、签名算法

如果使用私钥加密,公钥解密是否可行呢?实际上是完全可行的。

不过我们再仔细想一想,私钥是保密的,而公钥是公开的,用私钥加密,那相当于所有人都可以用公钥解密。这个加密有什么意义?

这个加密的意义在于,如果小明用自己的私钥加密了一条消息,比如小明喜欢小红,然后他公开了加密消息,由于任何人都可以用小明的公钥解密,从而使得任何人都可以确认小明喜欢小红这条消息肯定是小明发出的,其他人不能伪造这个消息,小明也不能抵赖这条消息不是自己写的。

因此,私钥加密得到的密文实际上就是数字签名,要验证这个签名是否正确,只能用私钥持有者的公钥进行解密验证。使用数字签名的目的是为了确认某个信息确实是由某个发送方发送的,任何人都不可能伪造消息,并且,发送方也不能抵赖。

在实际应用的时候,签名实际上并不是针对原始消息,而是针对原始消息的哈希进行签名,即:

1
signature = encrypt(privateKey, sha256(message))

对签名进行验证实际上就是用公钥解密:

1
hash = decrypt(publicKey, signature)

然后把解密后的哈希与原始消息的哈希进行对比。

因为用户总是使用自己的私钥进行签名,所以,私钥就相当于用户身份。而公钥用来给外部验证用户身份。

常用数字签名算法有:

  • MD5withRSA
  • SHA1withRSA
  • SHA256withRSA

它们实际上就是指定某种哈希算法进行RSA签名的方式。

数字签名用于:

  • 防止伪造;
  • 防止抵赖;
  • 检测篡改。

DSA签名

除了RSA可以签名外,还可以使用DSA算法进行签名。DSA是Digital Signature Algorithm的缩写,它使用ElGamal数字签名算法。

DSA只能配合SHA使用,常用的算法有:

  • SHA1withDSA
  • SHA256withDSA
  • SHA512withDSA

和RSA数字签名相比,DSA的优点是更快。

1
package com.study.security;
2
​
3
import java.math.BigInteger;
4
import java.nio.charset.StandardCharsets;
5
import java.security.*;
6
​
7
public class SignDemo {
8
    public static void main(String[] args) throws Exception {
9
        // 生成RSA公钥/私钥:
10
        KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
11
        kpGen.initialize(1024);
12
        KeyPair kp = kpGen.generateKeyPair();
13
        PrivateKey sk = kp.getPrivate();
14
        PublicKey pk = kp.getPublic();
15
​
16
        // 待签名的消息:
17
        byte[] message = "Hello, I am Bob!".getBytes(StandardCharsets.UTF_8);
18
​
19
        // 用私钥签名:
20
        Signature s = Signature.getInstance("SHA1withRSA");
21
        s.initSign(sk);
22
        s.update(message);
23
        byte[] signed = s.sign();
24
        System.out.println(String.format("signature: %x", new BigInteger(1, signed)));
25
​
26
        // 用公钥验证:
27
        Signature v = Signature.getInstance("SHA1withRSA");
28
        v.initVerify(pk);
29
        v.update(message);
30
        boolean valid = v.verify(signed);
31
        System.out.println("valid? " + valid);
32
    }
33
}
34
​

九、数字证书

摘要算法用来确保数据没有被篡改,非对称加密算法可以对数据进行加解密,签名算法可以确保数据完整性和抗否认性,把这些算法集合到一起,并搞一套完善的标准,这就是数字证书。

在Java程序中,数字证书存储在一种Java专用的key store文件中,JDK提供了一系列命令来创建和管理key store。我们用下面的命令创建一个key store,并设定口令123456:

1
keytool -storepass 123456 -genkeypair -keyalg RSA -keysize 1024 -sigalg SHA1withRSA -validity 3650 -alias mycert -keystore my.keystore -dname "CN=www.sample.com, OU=sample, O=sample, L=BJ, ST=BJ, C=CN"

几个主要的参数是:

  • keyalg:指定RSA加密算法;
  • sigalg:指定SHA1withRSA签名算法;
  • validity:指定证书有效期3650天;
  • alias:指定证书在程序中引用的名称;
  • dname:最重要的CN=www.sample.com指定了Common Name,如果证书用在HTTPS中,这个名称必须与域名完全一致。

执行上述命令,JDK会在当前目录创建一个my.keystore文件,并存储创建成功的一个私钥和一个证书,它的别名是mycert

从key store直接读取了私钥-公钥对,私钥以PrivateKey实例表示,公钥以X509Certificate表示,实际上数字证书只包含公钥,因此,读取证书并不需要口令,只有读取私钥才需要。如果部署到Web服务器上,例如Nginx,需要把私钥导出为Private Key格式,把证书导出为X509Certificate格式。

1
import java.io.InputStream;
2
import java.math.BigInteger;
3
import java.security.*;
4
import java.security.cert.*;
5
import javax.crypto.Cipher;
6
​
7
public class Main {
8
    public static void main(String[] args) throws Exception {
9
        byte[] message = "Hello, use X.509 cert!".getBytes("UTF-8");
10
        // 读取KeyStore:
11
        KeyStore ks = loadKeyStore("/my.keystore", "123456");
12
        // 读取私钥:
13
        PrivateKey privateKey = (PrivateKey) ks.getKey("mycert", "123456".toCharArray());
14
        // 读取证书:
15
        X509Certificate certificate = (X509Certificate) ks.getCertificate("mycert");
16
        // 加密:
17
        byte[] encrypted = encrypt(certificate, message);
18
        System.out.println(String.format("encrypted: %x", new BigInteger(1, encrypted)));
19
        // 解密:
20
        byte[] decrypted = decrypt(privateKey, encrypted);
21
        System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
22
        // 签名:
23
        byte[] sign = sign(privateKey, certificate, message);
24
        System.out.println(String.format("signature: %x", new BigInteger(1, sign)));
25
        // 验证签名:
26
        boolean verified = verify(certificate, message, sign);
27
        System.out.println("verify: " + verified);
28
    }
29
​
30
    static KeyStore loadKeyStore(String keyStoreFile, String password) {
31
        try (InputStream input = Main.class.getResourceAsStream(keyStoreFile)) {
32
            if (input == null) {
33
                throw new RuntimeException("file not found in classpath: " + keyStoreFile);
34
            }
35
            KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
36
            ks.load(input, password.toCharArray());
37
            return ks;
38
        } catch (Exception e) {
39
            throw new RuntimeException(e);
40
        }
41
    }
42
​
43
    static byte[] encrypt(X509Certificate certificate, byte[] message) throws GeneralSecurityException {
44
        Cipher cipher = Cipher.getInstance(certificate.getPublicKey().getAlgorithm());
45
        cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey());
46
        return cipher.doFinal(message);
47
    }
48
​
49
    static byte[] decrypt(PrivateKey privateKey, byte[] data) throws GeneralSecurityException {
50
        Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
51
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
52
        return cipher.doFinal(data);
53
    }
54
​
55
    static byte[] sign(PrivateKey privateKey, X509Certificate certificate, byte[] message)
56
            throws GeneralSecurityException {
57
        Signature signature = Signature.getInstance(certificate.getSigAlgName());
58
        signature.initSign(privateKey);
59
        signature.update(message);
60
        return signature.sign();
61
    }
62
​
63
    static boolean verify(X509Certificate certificate, byte[] message, byte[] sig) throws GeneralSecurityException {
64
        Signature signature = Signature.getInstance(certificate.getSigAlgName());
65
        signature.initVerify(certificate);
66
        signature.update(message);
67
        return signature.verify(sig);
68
    }
69
}