【一文通关】Java加密与安全

1,664 阅读27分钟

加密与安全

为什么需要加密

加密是为了保护信息的安全,防止有非法人员访问,篡改或破坏伪造信息。在如今的信息时代,为了保护用户及国家政府的权益,维护信息安全变得极其重要,为此,出现了一批批优秀的加密算法。

在了解加密算法之前,我们还得先了解了解编码

编码算法

比如ASCLL编码,它仅仅能表示128位, 因为它仅仅是七位bit构成的, 2的七次方

image.png

用16进制来表示: 把七位bit拆开,每四位由一个16进制数来表示,不够的在前面补0,这就是A的ASCLL编码为0x41=> 01000001

小提示:计算机中以 o, d ,b, h分别表示八进制, 十进制, 二进制, 十六进制

因为ASCLL编码能表示的文字较少, 要对中文文字进行编码,需要使用到Unicode。

Unicode是一种字符编码标准定义了每个字符所对应的唯一编码值,可以表示几乎所有的语言和符号。Unicode字符集包含了超过130,000个字符,每个字符都有一个唯一的编号,称为Unicode码点。

UTF-8是一种Unicode字符编码方式使用1到4个字节来表示一个Unicode码点,能够表示几乎所有的Unicode字符,同时也兼容ASCII码。UTF-8编码的特点是编码简单、节省空间、可变长等,因此被广泛应用于互联网和计算机系统中。

Unicode对中文是使用两个字节来表示,而UTF-8是使用三个字节(为了表示更多的中文字符,有些中文字两个字节不够).

image.png

URL编码

之所以需要URL编码,是因为出于兼容性考虑,很多服务器只识别ASCII字符。但如果URL中包含中文、日文这些非ASCII字符怎么办?不要紧,URL编码有一套规则:'

  • 如果字符是A~ Z, a ~ z0~9以及-_.*,则保持不变;
  • 如果是其他字符,先转换为UTF-8编码,然后对每个字节以%XX表示。 比如中文字符"中", 先将其转化为UTF-8的编码 "Oxe4b8ad",再转化为%E4%B8%AD,转化后一律用大写表示,每一个字节前使用一个%隔开。 使用Java的URLEncoder类来编码发现中确实是我们预测的结果
String s = URLEncoder.encode("中", StandardCharsets.UTF_8); 
System.out.println(s); // %E4%B8%AD

总之,URL编码就是把任意文本变为能被服务器读取的ASCLL形式。

Base64编码

URL编码是把文本变为ASCLL形式,而base64编码是专门处理二进制文件的,将二进制文件转化为纯文本文件。

base64是将读取三个字节长度的二进制数据,按照每组六个bit来分,分为4组,每组表示为一个字节(往前面补0)。 6bit最多表示64个:字符A到Z对应索引0到25,字符a到z对应索引26到51,字符0到9对应索引52到61,最后两个索引62到63分别用字符+和/表示。所以说bse64是纯文本数据。 比如: 编码:(3字节变为四字节)

byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad };
String b64encoded = Base64.getEncoder().encodeToString(input);
System.out.println(b64encoded); // 5Lit

解码:(四字节变三字节)

String b64encoded = Base64.getEncoder().encodeToString(input);
byte[] decode = Base64.getDecoder().decode(b64encoded);
System.out.println(Arrays.toString(decode)); // [-28, -72, -83]

但是当输入的二进制数组不是三的整数倍该怎么办? 这就不用你担心了,当字节不是三的倍数可以在后面添加0x00,差一个加一个,差两个加两个。编码之后的表现是后面有=,一个=表示一个0x00。
编码之后有没有等号其实都不影响,毕竟编码之后的数永远是四的倍数,添加多少成为了四的倍数,表示添加了多少0x00。 所以有没有等号,解码之后还是原来的字节,会自动判断添加的0x00。

byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad, (byte) 0x21 };
String b64encoded = Base64.getEncoder().encodeToString(input);
// withoutPadding 去掉 == 
String b64encoded2 = Base64.getEncoder().withoutPadding().encodeToString(input);
System.out.println(b64encoded); // 5LitIQ==
System.out.println(b64encoded2); // 5LitIQ
byte[] output = Base64.getDecoder().decode(b64encoded2);
System.out.println(Arrays.toString(output)); // [-28, -72, -83, 33] 编码前的字节数组

那可以使用base64来传递url吗?答案是可以的,但是base64里有些特殊字符 /,+ 会影响url,因此有一个专门的URLBase64类去操作,将 / 改为 _ , + 改为 - :

byte[] input = new byte[] { 0x01, 0x02, 0x7f, 0x00 };
String b64encoded = Base64.getUrlEncoder().encodeToString(input);
System.out.println(b64encoded); // AQJ_AA==
byte[] output = Base64.getUrlDecoder().decode(b64encoded);
System.out.println(Arrays.toString(output)); // [1, 2, 127, 0]

遇到中文还需要先把中文转化为二进制形式。

那又怎么解码呢,当你通过base64解码器获得了字节数组,怎么把它还原呢?

// 使用utf-8获得字节数组
byte[] bytes = "中国".getBytes();
// 编为base64
String b64encoded = Base64.getUrlEncoder().encodeToString(bytes);
// 还原为字节数组
byte[] output = Base64.getUrlDecoder().decode(b64encoded);
// 使用utf-8读取,与获得字节数组的编码方式要一致
String text = new String(output, StandardCharsets.UTF_8);
System.out.println(text); // 中国

编码小结

URL编码和Base64编码与URL-Base64都是编码算法,它们不是加密算法;

URL编码的目的是把任意文本数据编码为%前缀表示的文本,便于浏览器和服务器处理;

Base64编码的目的是把任意二进制数据编码为文本,但编码后数据量会增加1/3。 还有Base32,48,58. 拿Base32来说(每5bit表示一个字符): 0到31.这种的编码效率肯定更低(同样的二进制,base64需要的字符更少)。 3个字节: base64需要4个字符,base32需要5个字符。


加密算法

哈希算法

哈希算法是一种将任意长度的数据映射为固定长度数据的算法。也可以称为消息摘要算法(digest)、散列函数等。哈希算法将输入数据经过计算后,生成一个固定长度的哈希值,这个哈希值可以用于数据完整性校验、密码加密、数据指纹等领域。

hash算法应用于String类的hashCode()方法,根据字符串输出一个int值,每个字符串是独一无二的,但是由于int值有上限,所以重复是不可避免的,但是我们可以大大减小碰撞的几率。哈希算法是把一个无限的输入集合映射到一个有限的输出集合,必然会产生碰撞。

好的hash算法:

  • 碰撞概率低
  • 输出无规律

hash算法的特点:

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

比如我自己写一个哈希算法: 获得字符串每一个字符的ACILL值,然后将其*1 ,*10,*100.这就是一个劣质算法。

Java map的key是String对象时,就是获取的String的哈希值。在使用String对象作为Map的键时,Java会使用String对象的hashCode方法计算哈希码值,然后根据哈希码值查找对应的值。因此,如果要重写String对象的hashCode方法,需要保证哈希码值的计算方式与String类的hashCode方法一致,否则可能会导致HashMap等集合无法正常工作。

image.png

Java标准库有常用的hash算法

public class Main {
    public static void main(String[] args) throws Exception {
        // 创建一个MessageDigest实例:
        MessageDigest md = MessageDigest.getInstance("MD5");
        // 反复调用update输入数据:
        md.update("Hello".getBytes("UTF-8"));
        md.update("World".getBytes("UTF-8"));
        byte[] result = md.digest(); // 16 bytes: 68e109f0f40ca72a15e05cc22786f8e6
        System.out.println(new BigInteger(1, result).toString(16));
    }
}

hash算法的使用场景:

  1. 数据完整性校验:哈希算法可以用于验证数据的完整性,例如下载文件时,可以计算文件的哈希值,然后将哈希值与官方网站发布的哈希值进行比较,以验证文件是否被篡改。
  2. 密码加密:哈希算法可以用于密码的加密,例如将用户输入的密码进行哈希运算,然后将哈希值存储到数据库中。当用户再次登录时,将输入的密码进行哈希运算,然后将哈希值与数据库中存储的哈希值进行比较,以验证密码的正确性。
  3. 数字签名:哈希算法可以用于数字签名,例如将要签名的数据进行哈希运算,然后将哈希值与私钥一起加密生成数字签名。接收方可以使用公钥解密数字签名,然后对接收到的数据进行哈希运算,将生成的哈希值与解密后的数字签名进行比较,以验证数据的完整性和身份的真实性。
  4. 唯一标识:哈希算法可以用于生成唯一标识,例如将某个对象的属性进行哈希运算,然后生成一个唯一的哈希值作为该对象的标识。在分布式系统中,哈希算法可以用于将数据分散到不同的节点上,例如根据数据的哈希值将数据分配到不同的机器上进行处理,以实现负载均衡和数据分片等功能。
  5. 缓存优化:哈希算法可以用于缓存优化,例如在Web应用中,可以使用哈希算法将URL转换为一个哈希值,然后将该哈希值作为缓存的键,以提高缓存的命中率。

密码加密,我们很常用,比如用户密码我们不能直接以明文的形式放在数据库,否则会有极大的安全隐患(坏管理员与坏黑客)。

如果是使用MD5算法,我们可以存储得到的MD5得到的值,判断用户输入是否正确就获取MD5的值进行比对。 image.png

但是对于MD5这种加密方式,黑客也有应对的办法:彩虹表。彩虹表中记录了常用密码对应的MD5值,这样大大提高了破解效率。所以不要用常见密码

安全员们也不傻,既然用户用常用密码,我把常用密码加点料(salt)然后存储在数据库,再去获得MD5值。 这个过程称为加盐, 使用户的口令添加一个随机数,彩虹表不就失效了吗?

加盐之后,黑客只获得了MD5的值,没有salt,也没有用。

Java标准安全库使用方法

和刚才使用MD5算法一样,改个名字,获取不同的实例。 SHA-256,SHA-512 比如SHA-1:

    public static void main(String[] args) throws Exception {
        // 创建一个MessageDigest实例:
        MessageDigest md = MessageDigest.getInstance("SHA-1");
        // 反复调用update输入数据:
        md.update("Hello".getBytes("UTF-8"));
        md.update("World".getBytes("UTF-8"));
        byte[] result = md.digest(); // 20 bytes: db8ac1c259eb89d4a131b253bacfca5f319d54f2
        System.out.println(new BigInteger(1, result).toString(16));
    }
}

Java允许第三方库在Security中直接注册:(其实就是将这个方法添加到了它Security类里维护的一个列表,装的是可以调用的加密算法,因此第三方设计需要按照Java的规范) 比如BouncyCastle:

public class Main {
    public static void main(String[] args) throws Exception {
        // 注册BouncyCastle:
        Security.addProvider(new BouncyCastleProvider());
        // 按名称正常调用:
        MessageDigest md = MessageDigest.getInstance("RipeMD160");
        md.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = md.digest();
        System.out.println(new BigInteger(1, result).toString(16));
    }
}

HMac算法(相当于加盐的MD5算法)

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

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

Hmac本质上就是把key混入摘要的算法。验证此哈希时,除了原始的输入数据,还要提供key。


public class Main {
    public static void main(String[] args) throws Exception {
        // 获取HmacDm key实例
        KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");
        // 生成随机key
        SecretKey key = keyGen.generateKey();
        // 打印随机生成的key,使用时不需要转换
        byte[] skey = key.getEncoded();
        // 这里的new BigInteger第一个参数表示将其转化为正数,然后将0-255的字节用16进制表示
        System.out.println(new BigInteger(1, skey).toString(16));
        // 获取加密算法实例
        Mac mac = Mac.getInstance("HmacMD5");
        // 初始化填入key,输入用户口令
        mac.init(key);
        mac.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = mac.doFinal(); // 84e7d2a8a037be1fcf1045da85814517
        System.out.println(new BigInteger(1, result).toString(16));
    }
}
graph TD
 通过名称`HmacMD5`获取`KeyGenerator`实例 --> 通过`KeyGenerator`创建一个`SecretKey`实例 --> 通过名称`HmacMD5`获取`Mac`实例 --> 用`SecretKey`初始化`Mac`实例 --> 反复调用update输入数据  --> mac.dofinal获取结果值

image.png

存储既要存储key(BINARY(64))又要存储结果。

那我们有key有result该怎么验证呢。

update()方法时建议使用 getBytes(StandardCharsets.UTF_8) 方法,以避免使用不支持的编码名称和确保在不同平台上的行为一致。

public class Main {
    public static void main(String[] args) throws Exception {
        String keyString = "53c54f5ce4d38ffefe1be308301b98c1f3b1d43e1ffbc7923506c3714123fdca5641c8d5ff69b1682be03e91dd3b48231bf2811d4dc44763b45cec6fb59395b8";
        byte[] keyBytes = new BigInteger(keyString, 16).toByteArray(); // 将密钥字符串转换为字节数组
        System.out.println(Arrays.toString(keyBytes)); 
        SecretKey key = new SecretKeySpec(keyBytes, "HmacMD5"); // 构建密钥
        Mac mac = Mac.getInstance("HmacMD5"); // 获取HMAC-MD5实例
        mac.init(key); // 初始化MAC
        // 这里验证的编码方式必须与注册时的编码方式一致,否则不通过
        mac.update("HelloWorld".getBytes("UTF-8"));
        byte[] result = mac.doFinal(); // 获取哈希结果
        String hashResult = new BigInteger(1, result).toString(16); // 将哈希结果转换为16进制字符串
        System.out.println("哈希结果:" + hashResult);
        
        String dbHashResult = "3f67f7a308dfeb84fc5c5b1fe1f5c512"; // 假设数据库中存储的哈希结果为
        if (dbHashResult.equals(hashResult)) {
            System.out.println("消息完整性验证通过!");
        } else {
            System.out.println("消息完整性验证失败!");
        }
    }
}

HMAC可以结合SHA-1, SHA-256等hash算法使用,只需要把名称换了就行


对称加密算法

对称加密算法就是传统的用一个密钥进行加密和解密。 对称加密算法适用于需要保证数据传输机密性的场景,例如:

  1. 网络通信:在网络通信中,数据需要通过公共网络传输,为了保护数据的机密性,可以使用对称加密算法对数据进行加密,确保只有经过授权的人员才能解密并读取数据。
  2. 存储数据:在将数据存储到磁盘或数据库中时,如果需要保护数据的机密性,可以使用对称加密算法对数据进行加密,以防止未经授权的访问。
  3. 移动存储介质:在将数据存储到移动存储介质(如U盘、移动硬盘等)中时,为了避免数据丢失或泄露,可以使用对称加密算法对数据进行加密,以保护数据的机密性。

image.png 现在最常用AES算法。

ECB加密模式,这是最简单的用法,使用固定长度的密钥,16Byte,UTF-8的情况下就是16个字母或数字的组合。

import java.security.*;
import java.util.Arrays;
import java.util.Base64;

import javax.crypto.*;
import javax.crypto.spec.*;

public class Main {
    public static void main(String[] args) throws Exception {
        // 原文:
        String message = "我是你爸爸!";
        System.out.println("Message: " + message);
        // 128位密钥 = 16 bytes Key:
        byte[] key = "1234567890abcdef".getBytes("UTF-8");
        // 加密:
        // 将消息按UTF-8方式获得字节数组
        byte[] data = message.getBytes("UTF-8");
        byte[] encrypted = encrypt(key, data);
        // 密文为字节数组
        System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
        // 解密: 使用 密钥 解密 密文
        byte[] decrypted = decrypt(key, encrypted);
        // 正文的输出格式与转化为字节数组使用的编码方式一致
        System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
    }

    // 加密:
    public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
        // 获得对称加密实例
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        // 新建密钥
        SecretKey keySpec = new SecretKeySpec(key, "AES");
        // 加密初始化
        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
        return cipher.doFinal(input);
    }

    // 解密:
    public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
        // 新建对称加密实例
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        // 新建密钥实例
        SecretKey keySpec = new SecretKeySpec(key, "AES");
        // 初始化解密实例
        cipher.init(Cipher.DECRYPT_MODE, keySpec);
        // 返回解密后的字节数组
        return cipher.doFinal(input);
    }
}

更好的是使用CBC模式:密文中加入随机数,导致每次生成的密文都不同。key是32字节的长度

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;

public class Main {
    public static void main(String[] args) throws Exception {
        // 原文:
        String message = "Hello, world!";
        System.out.println("Message: " + message);
        // 256位密钥 = 32 bytes Key:
        byte[] key = "1234567890abcdef1234567890abcdef".getBytes("UTF-8");
        // 加密:
        byte[] data = message.getBytes("UTF-8");
        byte[] encrypted = encrypt(key, data);
        System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
        // 解密:
        byte[] decrypted = decrypt(key, encrypted);
        System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
    }

    // 加密:
    public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
                                                     // 中间的ECB换成了CBC
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        // 根据算法来产生密钥
        SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
        // CBC模式需要生成一个16 bytes的initialization vector:
        SecureRandom sr = SecureRandom.getInstanceStrong();
        // 生成16 字节的随机数
        byte[] iv = sr.generateSeed(16);
        // 生成16 字节的矢量
        IvParameterSpec ivps = new IvParameterSpec(iv);
        // 初始化加密
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivps);
        // 获得密文
        byte[] data = cipher.doFinal(input);
        // IV不需要保密,把IV和密文一起返回:
        return join(iv, data);
    }

    // 解密: 既需要key,也需要向量,向量从密文拿,一般在头部 16 Byte 长
    public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
        // 把input分割成IV和密文:
        byte[] iv = new byte[16];
        byte[] data = new byte[input.length - 16];
        System.arraycopy(input, 0, iv, 0, 16);
        System.arraycopy(input, 16, data, 0, data.length);
        // 解密:
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
        IvParameterSpec ivps = new IvParameterSpec(iv);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
        return cipher.doFinal(data);
    }

    public static byte[] join(byte[] bs1, byte[] bs2) {
        byte[] r = new byte[bs1.length + bs2.length];
        // 看源码参数解释,第一个为源数组,第二个为复制的起始下标,然后是目标数组,然后是目标起始下标,然后是复制长度
        System.arraycopy(bs1, 0, r, 0, bs1.length);
        System.arraycopy(bs2, 0, r, bs1.length, bs2.length);
        return r;
    }
}

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

口令加密算法

书接上文: 我们使用的AES算法,它的密钥都是固定的16Byte的和32Byte的。但实际上我们输入的时候一般就是6-16位,它是怎么回事呢? ( 下面讲解对对称加密的密钥进行加密。)

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

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

import org.bouncycastle.jce.provider.BouncyCastleProvider;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.security.Security;
import java.util.Base64;

public class Main {
    public static void main(String[] args) throws Exception {
        // 把BouncyCastle作为Provider添加到java.security:
        Security.addProvider(new BouncyCastleProvider());
        // 原文:
        String message = "Hello, world!";
        // 加密口令:
        String password = "hello12345";
        // 16 bytes随机Salt:
        byte[] salt = SecureRandom.getInstanceStrong().generateSeed(16);
        System.out.printf("salt: %032x\n", new BigInteger(1, salt));
        // 加密:
        byte[] data = message.getBytes("UTF-8");
        byte[] encrypted = encrypt(password, salt, data);
        System.out.println("encrypted: " + Base64.getEncoder().encodeToString(encrypted));
        // 解密:
        byte[] decrypted = decrypt(password, salt, encrypted);
        System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
    }

    // 加密:
    public static byte[] encrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
        // 创建PBE实例,参数为输入的密码 字节数组
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        // 新建算法实例,选择对称算法,这是第三方库里的算法
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        // 获得key
        SecretKey skey = skeyFactory.generateSecret(keySpec);
        // pbe使用的参数集(关键在这里)
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
        // 在这段代码中,使用PBEParameterSpec类创建了一个pbeps对象,用于传递给口令加密算法。其中,salt是一个随机生成的字节数组,
        // 用于增加加密的安全性,迭代次数iteration count表示对口令应用的哈希函数迭代的次数。迭代次数越多,加密的安全性越高,但加密的时间也会越长。
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        cipher.init(Cipher.ENCRYPT_MODE, skey, pbeps);
        // 返回密文
        return cipher.doFinal(input);
    }

    // 解密:
    public static byte[] decrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
        // 构造PBEKey
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        SecretKey skey = skeyFactory.generateSecret(keySpec);
        PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
        Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
        cipher.init(Cipher.DECRYPT_MODE, skey, pbeps);
        return cipher.doFinal(input);
    }
}

小提示:

Cipher类使用工厂模式来创建加密器和解密器对象,它提供了多个静态方法来获取加密器和解密器对象。常用的方法包括:

  • getInstance(String transformation):根据给定的加密算法名称、加密模式和填充方式创建Cipher对象。
  • getInstance(String transformation, Provider provider):使用指定的提供程序提供的Cipher实现来创建Cipher对象。
  • getInstance(String transformation, String provider):使用指定的提供程序提供的Cipher实现来创建Cipher对象。

Cipher类的主要方法包括:

  • init(int opmodeKey key):初始化Cipher对象,指定加密或解密操作和密钥。
  • init(int opmode, Key key, AlgorithmParameterSpec params):初始化Cipher对象,指定加密或解密操作、密钥和其他算法参数。
  • update(byte[] input):对输入数据进行加密或解密操作。
  • doFinal():完成加密或解密操作,并返回最终结果。

密钥交换算法

书接上文, 我们要使用密钥,但是密钥总有使用的风险: 当我们在传递密钥的过程中,密钥有可能会被截获,为此,密钥交换算法出世了: 它不直接使用密钥,而是双方协商规定。

  1. 双方协商选择一个素数p和一个原根g,这些参数是公开的。

  2. 双方各自生成一个私钥a和b,其中a和b都是小于p的随机整数。

  3. 双方各自计算出公钥A和B,公钥的计算公式为:

    A = g^a mod p
    B = g^b mod p

  4. 双方交换公钥A和B。

  5. 双方各自计算出会话密钥K,会话密钥的计算公式为:

    K = B^a mod p = A^b mod p

    注意,由于指数和模数的顺序可以互换,所以双方计算的结果应该是相同的。

  6. 双方使用会话密钥K来加密和解密消息。

完全是数学基础,具体为什么这样不清楚,我们只需要知道我们只交换了公钥。而且最终计算时,我们使用自己的私钥与对方的公钥组成的式子得出相同的结果来作验证,而这个结果就是最终的密钥 DH算法通过数学定律保证了双方各自计算出的secretKey是相同的

import org.bouncycastle.jce.provider.BouncyCastleProvider;

import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.math.BigInteger;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class Main {
    public static void main(String[] args) {
        // Bob和Alice:
        Person bob = new Person("Bob");
        Person alice = new Person("Alice");

        // 各自生成KeyPair:
        bob.generateKeyPair();
        alice.generateKeyPair();

        // 双方交换各自的PublicKey:
        // Bob根据Alice的PublicKey生成自己的本地密钥:
        bob.generateSecretKey(alice.publicKey.getEncoded());
        // Alice根据Bob的PublicKey生成自己的本地密钥:
        alice.generateSecretKey(bob.publicKey.getEncoded());

        // 检查双方的本地密钥是否相同:
        bob.printKeys();
        alice.printKeys();
        // 双方的SecretKey相同,后续通信将使用SecretKey作为密钥进行AES加解密...
    }
}

class Person {
    public final String name;

    public PublicKey publicKey;
    private PrivateKey privateKey;
    private byte[] secretKey;

    public Person(String name) {
        this.name = name;
    }

    // 生成本地KeyPair:
    public void generateKeyPair() {
        try {
            // 获得算法实例
            KeyPairGenerator kpGen = KeyPairGenerator.getInstance("DH");
            // 使用initialize方法初始化生成器,传入一个int类型的参数,表示密钥位数。这里指定密钥长度为512位。
            kpGen.initialize(512);
            // 调用generateKeyPair方法生成密钥对KeyPair对象。
            KeyPair kp = kpGen.generateKeyPair();
            this.privateKey = kp.getPrivate();
            this.publicKey = kp.getPublic();
        } catch (GeneralSecurityException e) {
            throw new RuntimeException(e);
        }
    }

    public void generateSecretKey(byte[] receivedPubKeyBytes) {
        try {
            // 从byte[]恢复PublicKey: 传递密钥使用字节数组
            // Java的X509EncodedKeySpec类将接收到的公钥字节数组转换为X.509编码的公钥规范
            // 当然你也可以选择其它的规范,不过两个人的规范要相同
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes);
            // 生成算法实例
            KeyFactory kf = KeyFactory.getInstance("DH");
            // 生成公钥
            PublicKey receivedPublicKey = kf.generatePublic(keySpec);
            // 生成本地密钥:
            KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
            keyAgreement.init(this.privateKey); // 自己的PrivateKey
            keyAgreement.doPhase(receivedPublicKey, true); // 对方的PublicKey
            // 生成SecretKey密钥:
            this.secretKey = keyAgreement.generateSecret();
        } catch (GeneralSecurityException e) {
            throw new RuntimeException(e);
        }
    }

    public void printKeys() {
        System.out.printf("Name: %s\n", this.name);
        System.out.printf("Private key: %x\n", new BigInteger(1, this.privateKey.getEncoded()));
        System.out.printf("Public key: %x\n", new BigInteger(1, this.publicKey.getEncoded()));
        System.out.printf("Secret key: %x\n", new BigInteger(1, this.secretKey));
    }
}

这样的话就算黑客获得了你的公钥和其它公开的数,他也难以获得计算出你的私钥(数学真难) 但是不能知道跟你通信的是本人

非对称加密算法

公钥加密,私钥解密

非对称加密算法的优点包括:

  1. 安全性高:非对称加密算法的安全性基于数学难题,私钥保密,外界难以破解。
  2. 私钥保护:私钥只在本地保存,不会被传输到外部,保证了私钥的安全性。
  3. 可靠性高:使用非对称加密算法进行数字签名可以验证数据的完整性和真实性。

非对称加密算法的缺点包括:

  1. 加密解密速度慢:与对称加密算法相比,非对称加密算法的加密解密速度较慢,不适合处理大量数据。
  2. 密钥管理复杂:非对称加密算法需要管理公钥和私钥,管理起来相对复杂。
  3. 安全性依赖密钥长度:非对称加密算法的安全性依赖于密钥的长度,密钥越长,安全性越高,但加密解密速度也越慢。

基于优点与缺点,平常是非对称加密与对称加密一起使用。 使用步骤:

  1. 小明生成一个随机的AES口令,然后用小红的公钥通过RSA加密这个口令,并发给小红;
  2. 小红用自己的RSA私钥解密得到AES口令;
  3. 双方使用这个共享的AES口令用AES加密通信。

就只有第一次通信的时候使用非对称加密,将AES口令安全的传过去,之后使用对称加密进行通信:这个过程中没传输过AES口令,所以相对安全的。

import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.security.*;
public class Main {
    public static void main(String[] args) throws Exception {
        // 明文:
        byte[] plain = "Hello, encrypt u".getBytes("UTF-8");
        // 创建公钥/私钥对:
        Person alice = new Person("Alice");
        // 用Alice的公钥加密:
        byte[] pk = alice.getPublicKey();
        System.out.println(String.format("public key: %x", new BigInteger(1, pk)));
        byte[] encrypted = alice.encrypt(plain);
        System.out.println(String.format("encrypted: %x", new BigInteger(1, encrypted)));
        // 用Alice的私钥解密:
        byte[] sk = alice.getPrivateKey();
        System.out.println(String.format("private key: %x", new BigInteger(1, sk)));
        byte[] decrypted = alice.decrypt(encrypted);
        // 注意,这个只是解析出了AES口令, 还要发数据需要使用这个AES去生成密文。
        System.out.println(new String(decrypted, "UTF-8"));
        byte[] encryptData = alice.encryptData("八嘎雅鹿");
        System.out.printf("这是密文的16进制文本:%x\n" ,new BigInteger(1, encryptData));
        byte[] bytes = alice.decryptData(encryptData);
        System.out.println(new String(bytes, "UTF-8"));
    }
}

class Person {
    String name;
    // 私钥:
    PrivateKey sk;
    // 公钥:
    PublicKey pk;
    // AES 口令
    byte[] AESKey;

    public Person(String name) throws GeneralSecurityException {
        this.name = name;
        // 生成公钥/私钥对:
        KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
        kpGen.initialize(1024);
        KeyPair kp = kpGen.generateKeyPair();
        this.sk = kp.getPrivate();
        this.pk = kp.getPublic();
    }

    // 把私钥导出为字节
    public byte[] getPrivateKey() {
        return this.sk.getEncoded();
    }

    // 把公钥导出为字节
    public byte[] getPublicKey() {
        return this.pk.getEncoded();
    }

    // 用公钥加密:
    public byte[] encrypt(byte[] message) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, this.pk);
        // 密文
        return cipher.doFinal(message);
    }

    // 用私钥解密:
    public byte[] decrypt(byte[] input) throws GeneralSecurityException {
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, this.sk);
        this.AESKey = cipher.doFinal(input);
        return this.AESKey;
    }
    // 获得AES之后,去加密数据
    public byte[] encryptData(String data) throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, UnsupportedEncodingException, IllegalBlockSizeException, BadPaddingException {
        // 获得对称加密实例
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        // 新建密钥
        SecretKey keySpec = new SecretKeySpec(this.AESKey, "AES");
        // 加密初始化
        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
        // 返回加密的密文
        return cipher.doFinal(data.getBytes("UTF-8"));
    }
    // 解密密文
    public byte[] decryptData(byte[] encryptedData) throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException, UnsupportedEncodingException, IllegalBlockSizeException, BadPaddingException {
        // 获得对称加密实例
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        // 新建密钥
        SecretKey keySpec = new SecretKeySpec(this.AESKey, "AES");
        // 加密初始化
        cipher.init(Cipher.DECRYPT_MODE, keySpec);
        return cipher.doFinal(encryptedData);
    }
}

然而非对称加密虽然保证了口令的安全性,但是还是不能确认对面是本人。

签名算法

私钥加密,公钥解密

常见的签名算法包括:

  1. RSA:RSA是一种基于大素数分解难题的非对称加密算法,也可以用于数字签名。在RSA签名中,私钥用于加密,公钥用于解密。
  2. DSA:DSA是一种基于离散对数问题的数字签名算法。DSA签名使用了一个随机数,因此每次签名结果可能不同,但可以保证相同的数据使用相同的私钥生成的数字签名是相同的。
  3. ECDSA:ECDSA是一种基于椭圆曲线密码学的数字签名算法,与DSA类似,但使用椭圆曲线代替了离散对数运算。

签名算法的优点包括:

  1. 数据完整性:签名算法可以验证数据的完整性,一旦数据被篡改,数字签名就无法通过验证。
  2. 数据真实性:数字签名可以证明数据的来源,保证数据的真实性。
  3. 非可否认性:数字签名是不可否认的,即签名者无法否认自己的签名。

签名算法的缺点包括:

  1. 密钥管理复杂:签名算法需要管理公钥和私钥,管理起来相对复杂。
  2. 签名效率低:与对称加密算法相比,签名算法的效率较低,不适合处理大量数据。

发送方使用自己的私钥去签名信息,签名信息只有自己知道。与此同时,看到的人使用公布出来的公钥去验证信息是否是属于发送方的。因为只有那个信息确实是发送方认证的过的,才能使公钥验证通过

import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.*;

public class Main {
    public static void main(String[] args) throws GeneralSecurityException {
        // 生成RSA公钥/私钥:
        KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
        kpGen.initialize(1024);
        KeyPair kp = kpGen.generateKeyPair();
        PrivateKey sk = kp.getPrivate();
        PublicKey pk = kp.getPublic();

        // 待签名的消息:
        byte[] message = "Hello, I am Bob!".getBytes(StandardCharsets.UTF_8);

        // 用私钥签名:
        Signature s = Signature.getInstance("SHA1withRSA");
        s.initSign(sk);
        s.update(message);
        byte[] signed = s.sign();
        System.out.println(String.format("signature: %x", new BigInteger(1, signed)));

        // 用公钥验证:
        Signature v = Signature.getInstance("SHA1withRSA");
        v.initVerify(pk);
        // 将发布的信息使用公钥去比对
        v.update(message);
        boolean valid = v.verify(signed);
        System.out.println("valid? " + valid);
    }
}

数字证书

数字证书是一种用于验证网站、服务器、个人身份等信息真实性的数字凭证。数字证书由数字证书颁发机构(CA,Certificate Authority)签发,包含了被签发者的公钥和证书信息,可以用于验证数字签名和加密通信等场景。

数字证书通常包含以下信息:

  1. 证书持有人的名称和公钥。
  2. 证书的有效期和颁发机构的名称。
  3. 证书的序列号和签名算法等信息。

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

数字证书是现代互联网通信中保证安全性的重要手段之一,常用于HTTPS协议、SSH协议、S/MIME邮件等场景中。在使用数字证书时需要注意保护私钥的安全性,以防止私钥泄露导致证书被恶意利用。只要私钥不丢失还是很安全的。

以HTTPS协议为例,浏览器和服务器建立安全连接的步骤如下:

  1. 浏览器向服务器发起请求,服务器向浏览器发送自己的数字证书;
  2. 浏览器用操作系统内置的Root CA来验证服务器的证书是否有效,如果有效,就使用该证书加密一个随机的AES口令并发送给服务器;
  3. 服务器用自己的私钥解密获得AES口令,并在后续通讯中使用AES加密。

本文知识点来自廖雪峰老师的教程哈希算法 - 廖雪峰的官方网站 (liaoxuefeng.com)