常用密码学算法及其工程实践

本文涉及的产品
云原生 API 网关,700元额度,多规格可选
密钥管理服务KMS,1000个密钥,100个凭据,1个月
简介: 在工程实践中,加解密算法、单向散列函数、消息认证码、数字签名等密码学内容经常出现。由于我之前对密码学一知半解,经常有摸不着头脑的情况。比如我遇到过以下两种情况,我相信很多对密码学不熟悉的同学可能也有跟我一样的疑惑:情况一,同样的明文,同样的密钥,每次加密生成的密文居然不一样?不一样的密文为什么能解密为同样的明文?我在开发友盟+数擎一体机时,在加密数据库中发现了这样的情况,当时非常不理解。

在工程实践中,加解密算法、单向散列函数、消息认证码、数字签名等密码学内容经常出现。由于我之前对密码学一知半解,经常有摸不着头脑的情况。

比如我遇到过以下两种情况,我相信很多对密码学不熟悉的同学可能也有跟我一样的疑惑:

情况一,同样的明文,同样的密钥,每次加密生成的密文居然不一样?不一样的密文为什么能解密为同样的明文?我在开发友盟+数擎一体机时,在加密数据库中发现了这样的情况,当时非常不理解。

情况二,在使用RSA非对称加密算法时,经常遇到报错:待加密的密文过长。经过上网搜索,我得知:使用RSA加密的明文必须比密钥少11字节。为什么一个加密算法不能加密任意长度的数据?为什么是11字节?

最近对密码学进行了深入研究,并对常用代码进行了编写。随着我的学习和研究,上述疑惑也一一打消。

下面我对工程实践中常见的密码学内容进行介绍,并给出Java示例代码,希望可以对不熟悉密码学的同学有所帮助。

1 单向散列函数(MessageDigest)与消息认证码(MAC)

1.1 单向散列函数(MessageDigest)

单向散列函数大家都非常熟悉了,常见的算法比如MD5、SHA1、SHA256等等。

单向散列函数的特点是,针对任意长度的数据,可以生成固定长度的散列值。此过程不能逆向复原,也不容易发生碰撞。

各种算法背后的数学原理不是本文讨论的重点,工程实践需要了解的主要是以下几点:

  1. 单向散列函数的输入和输出都是二进制串,不管输入的是文件还是字符串,最终输入的肯定是二进制。所以一段中文字符,使用GBK编码和使用UTF8编码,MD5散列之后得到的值肯定是不一样的。
  2. 不管是什么单向散列函数,输出的二进制位数是固定的。比如MD5,固定输出128位二进制串;SHA1,固定输出160位二进制串。
  3. 由于输出是二进制串,既可以使用16进制字符串输出,也可以使用base64输出。

MD5的Java示例代码

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

public class Md5Util {
    public static byte[] md5(byte[] content) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("MD5");
        messageDigest.update(content);
        return messageDigest.digest();
    }

    public static void main(String[] args) throws NoSuchAlgorithmException {
        byte[] s = "这是MD5的示例代码。".getBytes(StandardCharsets.UTF_8);
        byte[] md5 = md5(s);
        System.out.println(Arrays.toString(md5));
    }
}

MessageDigest是JDK提供的一个工厂方法。观察上述示例代码,可以注意到md5方法的输入输出都是字节数组。

上述代码的输出为:

[15, 27, 91, 63, -84, -43, -25, -54, 8, -41, 119, 102, 123, -121, -20, 16]

Process finished with exit code 0

可以看到输出一共是16字节,16*8=128位。这就是单向散列函数最大的特点,不管输入多长,输出都是固定的位数。

根据上述字节数组,可以依实际需要转化为16进制串或base64串。一般常用的是16进制串。

1.2 消息认证码(MAC)

消息认证码就是加了密的单向散列函数。

大家都知道单向散列函数可以校验消息或者文件的完整性。比如下载了大文件后,经常要使用md5算法,校验一下下载下来的文件的md5值和网页上的一不一样。但是这个校验每个人都可以计算,使用MAC可以保证这个校验和生成只有拥有密钥的人才能操作。

在网关服务中,如果网关想要既校验消息的完整性,又保证消息是由合法客户发送来的,又想防止重放攻击,就必须使用消息认证码了。

比如阿里云API网关,就使用了消息认证码。阿里云API网关产品文档中,明确写出了支持以下两种算法:HmacSHA256、HmacSHA1。

image-20220204162247889

Hmac系列算法使用了一个密钥,只有拥有密钥的人才能生成散列值。可以简单的理解为先做了一次SHA哈希,再用密钥对哈希值加密;或者理解为对哈希操作加了盐。不过密码学家发明的Hmac系列算法更高级、更安全。

比如阿里云API网关产品文档中的这个示例请求:

POST
application/json; charset=utf-8
application/x-www-form-urlencoded; charset=utf-8
Wed, 09 May 2018 13:30:29 GMT+00:00
x-ca-key:203753385
x-ca-nonce:c9f15cbf-f4ac-4a6c-b54d-f51abf4b5b44
x-ca-signature-method:HmacSHA256
x-ca-timestamp:1525872629832
/http2test/test?param1=test&password=123456789&username=xiaoming

x-ca-key指定了用户的key,网关可以根据key找到MAC的密钥。
x-ca-nonce是每次都不重复的随机字符串,用于防止重放攻击。由于这个串每次都不一样,所以即使所有的参数和Body都一样,最终生成的MAC也不一样,就避免了重放攻击。
对上述串使用HmacSHA256算法,发送方可以生成MAC值:xfX+bZxY2yl7EB/qdoDy9v/uscw3Nnj1pgoU+Bm6xdM=,网关收到后,验证这个值,就可以:

  1. 校验消息完整性
  2. 保证消息是合法用户发送来的
  3. 保证消息不是重放攻击发来的

HmacSHA256的Java示例代码

JDK8的文档,进入Developer Guides页面,可以进入Security页面。
Security页面介绍了所有加解密相关的模块。
StandardNames页面搜索,可以找到上述阿里云API网关使用的HmacSHA256算法,说明JDK原生支持这种算法。
JDK的所有密码学类都是工厂方法,上面的页面可以找到JDK支持的算法,如果JDK不支持,就需要第三方的Provider了。

import javax.crypto.KeyGenerator;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class HmacUtil {
    public static void generateKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance("HmacSHA256");
        SecretKey secretKey = keyGenerator.generateKey();
        System.out.printf("algorithm=%s%n", secretKey.getAlgorithm());
        System.out.printf("encoded=%s%n", Base64.getEncoder().encodeToString(secretKey.getEncoded()));
        System.out.printf("format=%s%n", secretKey.getFormat());
    }

    public static byte[] calculateMac(byte[] key, byte[] content) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key, "HmacSHA256"));
        return mac.doFinal(content);
    }

    public static void main(String[] args) throws Exception {
//        generateKey();
        byte[] content = "我要被Mac啦".getBytes(StandardCharsets.UTF_8);
        byte[] key = Base64.getDecoder().decode("aBLsRGm+X5doEDmWxZJcCLtfJisW7r/NCvmRrFhdIW4=");
        byte[] mac = calculateMac(key, content);
        System.out.printf("mac=%s%n", Base64.getEncoder().encodeToString(mac));
    }
}

执行generateKey()生成一个密钥:

algorithm=HmacSHA256
encoded=aBLsRGm+X5doEDmWxZJcCLtfJisW7r/NCvmRrFhdIW4=
format=RAW

Process finished with exit code 0

然后使用上述密钥对字符串我要被Mac啦的UTF8编码进行HmacSHA256操作,输出如下:

mac=AhBwPBaddMIMutQq4PbupERyUGawJUJ4E+bszyeeQ4c=

Process finished with exit code 0

2 对称加密算法(Symmetric-key algorithm)

对称加密算法为什么对称呢?我理解主要是因为,加密密钥和解密密钥是一样的,所以密钥就像镜子一样,对称地映射着明文与密文。

对称加密算法分为块加密和流加密。AES是目前常用的块加密算法,ChaCha20是目前常用的流加密算法。由于目前最常用的就是AES算法,因此我们着重介绍AES算法。

AES是块加密算法,顾名思义,是一块一块加密的。一个块也叫一个分组,对于AES来说,一个分组是128位二进制。因此,对于一个不足128位的明文串,就需要扩充到128位;对于一个大于128位的明文串,就需要按照128位一个分组截断,再分别对每组加密。

如何安全地对明文串扩充和截断呢?这就需要引入块加密算法的填充方法(Padding)和工作模式(Mode)了。

2.1 对称加密算法的填充方法(Padding)

比如下面待加密的字符串:

被加密

使用UTF-8编码后,16进制串为:

e8a2abe58aa0e5af86

长度为9字节,也就是72位,小于AES的分组128位。为了让这句话能被AES加密,需要补充到128位。

一个很显然的想法是在串后面补0,补到128位,但如果要加密的串末尾本身就有0,再补0后就会发生混淆。

常用的填充方法是使用PKCS7 (公钥密码学标准第 7 号)填充方法。每个填充字节的值是用于填充的字节数,也就是说,如果需要填充 N 个字节,则每个填充字节值都是 N 。

举个例子:比如上面的串

e8a2abe58aa0e5af86

长度为9字节,需要补7字节才是128位,因此就在串末尾补7个0x07,补成这样:

e8a2abe58aa0e5af8607070707070707

这样,解密时,先读取最后一个字节,读到是0x07,如果串末尾包含连续7个0x07,就可以把这7个0x07去掉了。即使倒数第8个字节也是0x07,也不会误删。

2.2 对称加密算法的工作模式(Mode)

再看一个明文较长的例子,比如下面待加密的字符串:

我是要被加密的一句话。

使用UTF-8编码后,16进制串为:

e68891e698afe8a681e8a2abe58aa0e5af86f09f9490e79a84e4b880e58fa5e8af9de38082

他的长度是37字节,也就是37*8=296位,超过了AES的分组大小128位。

一个很显然的想法是将这个串分成三组,给第三组填充,就像这样:

e68891e698afe8a681e8a2abe58aa0e5 af86f09f9490e79a84e4b880e58fa5e8 af9de380820b0b0b0b0b0b0b0b0b0b0b

填充后,分别对三组AES加密,再concat起来。

这就是电子密码本(ECB)工作模式。

ECB工作模式有一个显著缺陷:同样的明文块会被加密成相同的密文块,因此攻击者可以根据重复出现的密文找到一定的数据特点。比如对下面图片加密,连续的128位像素数据重复出现,并不能很好地隐藏图片内容。(此图来自维基百科。)

1976年,IBM发明了密码块链接(CBC)工作模式。在CBC模式中,每个明文块先与前一个密文块进行异或后,再进行加密。在这种方法中,每个密文块都依赖于它前面的所有明文块。同时,为了保证每条消息的唯一性,在第一个块中需要使用初始化向量。

上图来自维基百科。

这个初始化向量(Initialization Vector, IV)需要事先指定,一般每次使用不同的随机字符串。

比如上文中需要加密的3个分组:

e68891e698afe8a681e8a2abe58aa0e5 af86f09f9490e79a84e4b880e58fa5e8 af9de380820b0b0b0b0b0b0b0b0b0b0b

我们生成一组随机字符串作为IV:

05da3002697b08578aeed69bb4d55060

然后用IV和第一组明文异或:

e68891e698afe8a681e8a2abe58aa0e5 XOR 05da3002697b08578aeed69bb4d55060 = eba0e3f69f8ae54d45f560e8c23ba336

第一组加密,就应该是对异或后的eba0e3f69f8ae54d45f560e8c23ba336加密。

因为每次IV都使用不同的随机字符串,所以相同的明文,每次加密生成的密文都不一样。使用CBC模式再次对上述企鹅图片加密,可以很好的隐藏图片内容。

这也就解释了文章开头我的第一个疑惑。相同的明文、相同的密钥,由于每次随机IV的不同,会产生不同的密文。

AES的Java示例代码

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;

public class AesUtil {
    static final String ALGORITHM = "AES/CBC/PKCS5Padding";

    public static void generateKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
        keyGenerator.init(256);
        SecretKey secretKey = keyGenerator.generateKey();
        System.out.printf("algorithm=%s%n", secretKey.getAlgorithm());
        System.out.printf("encoded=%s%n", Base64.getEncoder().encodeToString(secretKey.getEncoded()));
        System.out.printf("format=%s%n", secretKey.getFormat());
    }

    public static byte[] encrypt(byte[] content, byte[] keyByte, byte[] iv) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyByte, "AES"), new IvParameterSpec(iv));
        return cipher.doFinal(content);
    }

    public static byte[] decrypt(byte[] encryptedContent, byte[] keyByte, byte[] iv) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyByte, "AES"), new IvParameterSpec(iv));
        return cipher.doFinal(encryptedContent);
    }

    public static void main(String[] args) throws Exception {
//        generateKey();
        byte[] content = "我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了我要被加密了".getBytes(StandardCharsets.UTF_8);
        byte[] keyByte = Base64.getDecoder().decode("h2ko3ClV4bRTDv4vFRDhoHJ7w1Z9/HgHkISHq2YPlXc=");
        byte[] iv = new byte[16];
        new SecureRandom().nextBytes(iv);
        byte[] encryptedContent = encrypt(content, keyByte, iv);
        byte[] decryptedContent = decrypt(encryptedContent, keyByte, iv);
        System.out.println(new String(decryptedContent, StandardCharsets.UTF_8));
    }
}

密钥使用工厂模式的KeyGenerator生成。上文提到AES的密钥长度可以是128位、192位或256位,我们这里选择最安全的256位密钥。

生成结果如下:

algorithm=AES
encoded=h2ko3ClV4bRTDv4vFRDhoHJ7w1Z9/HgHkISHq2YPlXc=
format=RAW

Process finished with exit code 0

h2ko3ClV4bRTDv4vFRDhoHJ7w1Z9/HgHkISHq2YPlXc=即为生成密钥的base64表示。

Cipher也是工厂模式的生成器,生成了AES/CBC/PKCS5Padding的加解密器。

AES就是我们所用的加密算法,CBC是上文提到的工作模式,PKCS5Padding是填充方法。

由于使用了CBC模式,因此初始化Cipher时需要用SecureRandom生成16字节的随机IV。这个IV解密的时候也需要用到。

运行上述代码,可以看到字符串被加密后,又被成功解密。

上述代码连续运行几次,可以看到每次生成的密文都不一样。

3 非对称加密算法(Public-key cryptography)和数字签名(Digital Signature)

非对称加密算法使用一对公私钥进行加密解密。一般公钥是公开的,别人用公钥对数据进行加密,只有拥有私钥的人才能解密。

用私钥对数据签名,由于公钥是公开的,因此所有人都能验证签名的真实性,但是只有拥有私钥的人能做签名,因此数字签名可以用于政府机密以及法庭文件,代替手写签名(手写签名还有被模仿的风险)。

RSA是最常用的非对称加密算法,我们主要介绍RSA算法。

和对称加密算法一样,RSA也有填充方法(Padding)。

常用的RSA填充方法为PKCS1Padding,这个填充方法规定明文必须比密钥少11字节,对应了我文章开头提出的疑惑二。这是为什么呢?

在查阅大量博客文章,以及RSA Cryptography Specifications规范文档后,我才终于明白怎么回事。规范文档中,可以找到以下内容。

第一步,如果明文长度大于密钥长度-11字节,返回"message too long"

第二步,生成一段长度为密钥长度-明文长度-3字节的随机非0串。这个串至少是8字节。很显然,8+3=11字节。也就是说,如果密钥长度是2048,明文长度最长只能是2048-11=2037字节,否则就无法生成“至少是8字节”的随机非0串了。那么还有3字节是干嘛的呢?看第二步的b段:将明文padding成0x00 || 0x02 || 随机串 || 0x00 || 明文的样子。由于随机串是非0的,因此前面的0x00 0x02和后面的0x00可以唯一地把随机串包裹起来。如果明文很短,那么就多生成一些随机串,把整个串凑成2048字节;如果明文很长,最长也不能超过密钥长度2048-8字节随机串-3字节标识位,也就是说,明文必须比密钥少11字节。解答了我文章开头的疑惑。

第三步、第四步就是使用RSA算法加密的过程,就不细说了。

为什么要使用上述这样的Padding方式呢?我理解有以下几点原因:

  1. 每次加密,至少会包含8字节的随机串,就保证了每次加密后,即使明文相同,密文也不会相同。因为非对称加密算法的计算量比较大,因此常用来加密很短的数据。如果每次相同的明文加密成相同的密文,攻击者很容易就知道一段密文代表着什么。
  2. 这样的Padding方式,可以把任意一串小于密钥-11字节的数据,补成和密钥一样长。RSA需要一次加密和密钥一样长的数据。使用非0的随机串,用0x00包裹起来,也不会因此明文和随机串的混淆。

因此使用RSA加密数据前,一定要先判断要加密的明文是否过长。JDK本身也不支持像AES一样,一组一组地使用RSA加解密长数据。

为什么JDK没有像AES一样,支持一个分组一个分组地使用RSA加密长数据呢?我在网上查了很久,很多人说是因为RSA加密非常慢,因此工程上没有人用RSA加密长数据,一般是用RSA加密AES密钥,然后用AES加密长数据。也有很少的人提到使用RSA加密长数据是不安全的,但是都没说清楚具体原因。我个人认为RSA如果使用CBC的工作模式,应该是可以安全地加密长数据的。这块如果谁了解地多一些,希望不吝赐教。

下面说说数字签名。数字签名是和非对称加密绑定的。我们以MD5withRSA数字签名算法为例。这个算法一看就知道,是使用了MD5和RSA。

如果先对明文使用MD5,生成128位的哈希值,然后用RSA的私钥加密这串哈希值生成签名,那么每个人都可以验证这个签名:先对明文使用MD5生成哈希值,然后用公钥解密签名,比较哈希值和签名解密值是否一致即可。

下面是RSA加解密算法和MD5withRSA数字签名算法的示例代码。

import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class RsaUtil {
    static final String ENCRYPT_ALGORITHM = "RSA/ECB/PKCS1Padding";
    static final String SIGN_ALGORITHM = "MD5withRSA";
    private static final int RSA_KEY_SIZE = 2048;

    public static void main(String[] args) throws Exception {
//        generateKeyPair();
        byte[] content = "我要被加密啦".getBytes(StandardCharsets.UTF_8);
        byte[] privateKeyByte = Base64.getDecoder().decode("MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCmaEk1VfOXfOG6zOsBRjzYmIMy839Ui+fgCVQxfnclLQLdN3z9JLc03KpCuwhs9yHLey0JvbqJGgLzkytrabFVirTUQ9PTXLA/eOBi5A3Ag0iZhl3EzC6MwmeBhnRWA4eycMtgYxnL3/D/MedmIhorYXCeZbn1zVQuUihSCxRSG5mKF1ZFsQIiYG6JIN+pzzmHMigo0xMzgDV1+EEILfrMfuGRUXwIWZQb9Rq6hXSFgMtthGKKIcza+lumPoXgSRwMcBYmKNi0T/eqmWKaoVx+I0KYDpAK7VxzEA/JWr/75/1aLOxnwC/P8n1qZJ6XEJid+z4c//9Xp8vzpcP6GuRRAgMBAAECggEBAJXDaY+s6Ww/IlCiOCaPdhdhO0LRzpjiyS7idnmM3eIBXoCFfeG993yF1F32QiD/UdT16JTJwmW9mUZp/zvOhaD9Er2uxaeF1cFqIlgd8xp9jQtO2HlTYdmg5NK3lWAMEUZRKVh4GDFaPGUQHrfWnULJkTedSf2ka8y8eDlOa11wr4W3a9DLAW/tV6km7+LKSs0HH3QUIjuU8DtO2bqDOtDXKaAil5hAIEsJrr30kfRDaItjr7HBCEjwNeymYCJ25hF6fojpo/yU/mojaRCitenuvXoTPtwzWin+RBwH0CzjgwkNu2HoeAGuj+hyePkRlxCJ5VjFLHtne2kHIoZfsVkCgYEA55XEQF9b/IzaUeJfxoDetsI0ctnrfJQRyw8sqpP1hq3u/qE9n9uoIRSRuSqtx5lfmRfcNPJFrRvXYEo56+UTS281Qz/FBCRA/QBdeUhQeRjU/zcWgeW/NK6jre2qXMN/t1nCBiH5d1wAihZy7panF+0ej22TUMDE0KBkDv02Z+8CgYEAt/Nxl1yKiWvT1wWjtbu8kVsa8UTI6xzIcW6Inva4gRaBrJ4YzyZmoy8RQTgwSZ8H/kg8V8HjY7dJh1tOC3WVxKIfimSzaQj1NfUUPFwv0kiPtXtfTGV1licevg295Mr2pzjYz8GxUruZ4y+u+sCbbtoFduIkotnHlQoRay3BN78CgYAU7gktlDC5E3XLvrzPMOhv9f9Nffp1aOBuzLFJvVOMV33pD2OFZhG846ID7SKFjowARxLEyjyX15NQhYTUmAB1adiTeljw9eHVu8m24106hI8DfdQP61ariTkLyBYEijqptHf/m+Ry8CKwWDUM8Rqq4+hGKC4PN0zSWhyQ6juXiwKBgQCNv11HurrSXDG4XpMhZlJPW/nt8wg0DFD0/6zteccBShuQrZ8GeVvb4VgVfrvO72oUawt8wF59p25UjGoecHSBOkC9vw11Ib763ijCvnLnQpzixvfPgdtTYj/RSfuLQ08/2pFPvrzquL9DjqnyddsQV5agXnDGwLSHx2NWyMI8NwKBgQCBXY+DQSyLoob7GOPaR+M8xK7EWEiyHihtbj4N89RR0/pbZ7y4uT+ifjAg3Rv04d0viN00ambI2JdUiFcFtrTL1w/uUtH7i/H+fSg5nbubVbEwyHfnxrG9o85FryoTFO+gpFjKHLNiQ4JLLmDS2L2LXFQM0lO7A4//jhhbn+l6Tw==");
        byte[] publicKeyByte = Base64.getDecoder().decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApmhJNVXzl3zhuszrAUY82JiDMvN/VIvn4AlUMX53JS0C3Td8/SS3NNyqQrsIbPchy3stCb26iRoC85Mra2mxVYq01EPT01ywP3jgYuQNwINImYZdxMwujMJngYZ0VgOHsnDLYGMZy9/w/zHnZiIaK2FwnmW59c1ULlIoUgsUUhuZihdWRbECImBuiSDfqc85hzIoKNMTM4A1dfhBCC36zH7hkVF8CFmUG/UauoV0hYDLbYRiiiHM2vpbpj6F4EkcDHAWJijYtE/3qplimqFcfiNCmA6QCu1ccxAPyVq/++f9WizsZ8Avz/J9amSelxCYnfs+HP//V6fL86XD+hrkUQIDAQAB");
        byte[] encryptedContent = encrypt(content, publicKeyByte);
        byte[] decryptedContent = decrypt(encryptedContent, privateKeyByte);
        System.out.println(new String(decryptedContent, StandardCharsets.UTF_8));
        byte[] sign = sign(content, privateKeyByte);
        System.out.println(verify(content, sign, publicKeyByte));
    }

    private static void generateKeyPair() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
        keyPairGen.initialize(RSA_KEY_SIZE);
        KeyPair keyPair = keyPairGen.generateKeyPair();
        PrivateKey privateKey = keyPair.getPrivate();
        PublicKey publicKey = keyPair.getPublic();
        System.out.printf("privateKey %n " + "algorithm=%s %n " + "encode base64 string=%s %n " + "format=%s %n", privateKey.getAlgorithm(), Base64.getEncoder().encodeToString(privateKey.getEncoded()), privateKey.getFormat());
        System.out.printf("publicKey %n " + "algorithm=%s %n " + "encode base64 string=%s %n " + "format=%s %n", publicKey.getAlgorithm(), Base64.getEncoder().encodeToString(publicKey.getEncoded()), publicKey.getFormat());
    }

    public static byte[] decrypt(byte[] encryptedContent, byte[] privateKeyByte) throws Exception {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyByte));
        Cipher cipher = Cipher.getInstance(ENCRYPT_ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
        return cipher.doFinal(encryptedContent);
    }

    public static byte[] encrypt(byte[] content, byte[] publicKeyByte) throws Exception {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        Key publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyByte));
        Cipher cipher = Cipher.getInstance(ENCRYPT_ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);
        return cipher.doFinal(content);
    }

    public static byte[] sign(byte[] content, byte[] privateKeyByte) throws Exception {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyByte));
        Signature signature = Signature.getInstance(SIGN_ALGORITHM);
        signature.initSign(privateKey);
        signature.update(content);
        return signature.sign();
    }

    public static boolean verify(byte[] content, byte[] sign, byte[] publicKeyByte) throws Exception {
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyByte));
        Signature signature = Signature.getInstance(SIGN_ALGORITHM);
        signature.initVerify(publicKey);
        signature.update(content);
        return signature.verify(sign);
    }
}

运行上述代码,输出:

我要被加密啦
true

Process finished with exit code 0

可以验证,这段代码成功加密并解密了数据、生成并验证了数字签名。

上面提到MD5withRSA就是先对明文MD5然后用RSA私钥加密。出于好奇,我写了如下代码测试:

public static void main(String[] args) throws Exception {
        byte[] content = "我要被加密啦".getBytes(StandardCharsets.UTF_8);
        byte[] privateKeyByte = Base64.getDecoder().decode("MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCmaEk1VfOXfOG6zOsBRjzYmIMy839Ui+fgCVQxfnclLQLdN3z9JLc03KpCuwhs9yHLey0JvbqJGgLzkytrabFVirTUQ9PTXLA/eOBi5A3Ag0iZhl3EzC6MwmeBhnRWA4eycMtgYxnL3/D/MedmIhorYXCeZbn1zVQuUihSCxRSG5mKF1ZFsQIiYG6JIN+pzzmHMigo0xMzgDV1+EEILfrMfuGRUXwIWZQb9Rq6hXSFgMtthGKKIcza+lumPoXgSRwMcBYmKNi0T/eqmWKaoVx+I0KYDpAK7VxzEA/JWr/75/1aLOxnwC/P8n1qZJ6XEJid+z4c//9Xp8vzpcP6GuRRAgMBAAECggEBAJXDaY+s6Ww/IlCiOCaPdhdhO0LRzpjiyS7idnmM3eIBXoCFfeG993yF1F32QiD/UdT16JTJwmW9mUZp/zvOhaD9Er2uxaeF1cFqIlgd8xp9jQtO2HlTYdmg5NK3lWAMEUZRKVh4GDFaPGUQHrfWnULJkTedSf2ka8y8eDlOa11wr4W3a9DLAW/tV6km7+LKSs0HH3QUIjuU8DtO2bqDOtDXKaAil5hAIEsJrr30kfRDaItjr7HBCEjwNeymYCJ25hF6fojpo/yU/mojaRCitenuvXoTPtwzWin+RBwH0CzjgwkNu2HoeAGuj+hyePkRlxCJ5VjFLHtne2kHIoZfsVkCgYEA55XEQF9b/IzaUeJfxoDetsI0ctnrfJQRyw8sqpP1hq3u/qE9n9uoIRSRuSqtx5lfmRfcNPJFrRvXYEo56+UTS281Qz/FBCRA/QBdeUhQeRjU/zcWgeW/NK6jre2qXMN/t1nCBiH5d1wAihZy7panF+0ej22TUMDE0KBkDv02Z+8CgYEAt/Nxl1yKiWvT1wWjtbu8kVsa8UTI6xzIcW6Inva4gRaBrJ4YzyZmoy8RQTgwSZ8H/kg8V8HjY7dJh1tOC3WVxKIfimSzaQj1NfUUPFwv0kiPtXtfTGV1licevg295Mr2pzjYz8GxUruZ4y+u+sCbbtoFduIkotnHlQoRay3BN78CgYAU7gktlDC5E3XLvrzPMOhv9f9Nffp1aOBuzLFJvVOMV33pD2OFZhG846ID7SKFjowARxLEyjyX15NQhYTUmAB1adiTeljw9eHVu8m24106hI8DfdQP61ariTkLyBYEijqptHf/m+Ry8CKwWDUM8Rqq4+hGKC4PN0zSWhyQ6juXiwKBgQCNv11HurrSXDG4XpMhZlJPW/nt8wg0DFD0/6zteccBShuQrZ8GeVvb4VgVfrvO72oUawt8wF59p25UjGoecHSBOkC9vw11Ib763ijCvnLnQpzixvfPgdtTYj/RSfuLQ08/2pFPvrzquL9DjqnyddsQV5agXnDGwLSHx2NWyMI8NwKBgQCBXY+DQSyLoob7GOPaR+M8xK7EWEiyHihtbj4N89RR0/pbZ7y4uT+ifjAg3Rv04d0viN00ambI2JdUiFcFtrTL1w/uUtH7i/H+fSg5nbubVbEwyHfnxrG9o85FryoTFO+gpFjKHLNiQ4JLLmDS2L2LXFQM0lO7A4//jhhbn+l6Tw==");
        byte[] publicKeyByte = Base64.getDecoder().decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApmhJNVXzl3zhuszrAUY82JiDMvN/VIvn4AlUMX53JS0C3Td8/SS3NNyqQrsIbPchy3stCb26iRoC85Mra2mxVYq01EPT01ywP3jgYuQNwINImYZdxMwujMJngYZ0VgOHsnDLYGMZy9/w/zHnZiIaK2FwnmW59c1ULlIoUgsUUhuZihdWRbECImBuiSDfqc85hzIoKNMTM4A1dfhBCC36zH7hkVF8CFmUG/UauoV0hYDLbYRiiiHM2vpbpj6F4EkcDHAWJijYtE/3qplimqFcfiNCmA6QCu1ccxAPyVq/++f9WizsZ8Avz/J9amSelxCYnfs+HP//V6fL86XD+hrkUQIDAQAB");
        byte[] sign = sign(content, privateKeyByte);
        System.out.printf("sign=%s%n",Base64.getEncoder().encodeToString(sign));
    }

上面代码输出了数字签名的base64串:

sign=B1MSESHuz3iReh195YkFyxnF20xrLz1eVybSnkwHIeOit0S62sUOsdfHnLYyJf1Ive5c66+iwZK4+MTVvHmrinjhu1V1RChwonDUsipdN0KOzOXv39fAn/GvZvm2nyoHM3WPCVmTsQt9oqc6WDfYvNZYmev84V9Mpo21n5EyYPPmjA51IHySjbbkF7BxMUvAb10znwCrk0Sm2FlqxkMHdy17OdyQCqdIcnW6+8wyg0xqSLVzfSU92zWVHK+sZLRRz/zEGQTHk/UTYfQpmRRnOaG0fT3WShtzmbEw92mYAwBata/rf04864+jza6Ra/2Z/sXZrO9h+ycymPdGo8qJfg==

Process finished with exit code 0

如果我用公钥解密上述签名,是否就是明文的MD5串呢?

我先用本文第一节的MD5示例代码,对明文"我要被加密啦"的UTF8编码做哈希:

import org.apache.commons.codec.binary.Hex;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Md5Util {
    public static byte[] md5(byte[] content) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("MD5");
        messageDigest.update(content);
        return messageDigest.digest();
    }

    public static void main(String[] args) throws NoSuchAlgorithmException {
        byte[] content = "我要被加密啦".getBytes(StandardCharsets.UTF_8);
        byte[] md5 = md5(content);
        System.out.println(Hex.encodeHexString(md5));
    }
}

输出

b2592b33dfb23f67e96953589bca63c9

Process finished with exit code 0

然后我再用公钥解密签名:

public static void main(String[] args) throws Exception {
        byte[] publicKeyByte = Base64.getDecoder().decode("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApmhJNVXzl3zhuszrAUY82JiDMvN/VIvn4AlUMX53JS0C3Td8/SS3NNyqQrsIbPchy3stCb26iRoC85Mra2mxVYq01EPT01ywP3jgYuQNwINImYZdxMwujMJngYZ0VgOHsnDLYGMZy9/w/zHnZiIaK2FwnmW59c1ULlIoUgsUUhuZihdWRbECImBuiSDfqc85hzIoKNMTM4A1dfhBCC36zH7hkVF8CFmUG/UauoV0hYDLbYRiiiHM2vpbpj6F4EkcDHAWJijYtE/3qplimqFcfiNCmA6QCu1ccxAPyVq/++f9WizsZ8Avz/J9amSelxCYnfs+HP//V6fL86XD+hrkUQIDAQAB");
        byte[] sign = Base64.getDecoder().decode("B1MSESHuz3iReh195YkFyxnF20xrLz1eVybSnkwHIeOit0S62sUOsdfHnLYyJf1Ive5c66+iwZK4+MTVvHmrinjhu1V1RChwonDUsipdN0KOzOXv39fAn/GvZvm2nyoHM3WPCVmTsQt9oqc6WDfYvNZYmev84V9Mpo21n5EyYPPmjA51IHySjbbkF7BxMUvAb10znwCrk0Sm2FlqxkMHdy17OdyQCqdIcnW6+8wyg0xqSLVzfSU92zWVHK+sZLRRz/zEGQTHk/UTYfQpmRRnOaG0fT3WShtzmbEw92mYAwBata/rf04864+jza6Ra/2Z/sXZrO9h+ycymPdGo8qJfg==");
        byte[] signDecrypt = decryptSign(sign,publicKeyByte);
        System.out.printf("decryptSign=%s%n", Hex.encodeHexString(signDecrypt));
    }

输出

decryptSign=3020300c06082a864886f70d020505000410b2592b33dfb23f67e96953589bca63c9

Process finished with exit code 0

签名解密后的后128位b2592b33dfb23f67e96953589bca63c9和md5哈希的结果一致,那前面这一串3020300c06082a864886f70d020505000410又是什么?

又一次带着好奇,我Google了一下这串16进制串。

原来在上面提到的RSA Cryptography Specifications规范文档的第46页,还做了这样的规定:

在MD5哈希值前面,要加上这样一串:3020300c06082a864886f70d020505000410,跟我在代码里验证的一致。

目录
相关文章
|
1月前
|
机器学习/深度学习 算法 搜索推荐
从理论到实践,Python算法复杂度分析一站式教程,助你轻松驾驭大数据挑战!
【10月更文挑战第4天】在大数据时代,算法效率至关重要。本文从理论入手,介绍时间复杂度和空间复杂度两个核心概念,并通过冒泡排序和快速排序的Python实现详细分析其复杂度。冒泡排序的时间复杂度为O(n^2),空间复杂度为O(1);快速排序平均时间复杂度为O(n log n),空间复杂度为O(log n)。文章还介绍了算法选择、分而治之及空间换时间等优化策略,帮助你在大数据挑战中游刃有余。
57 4
|
1月前
|
机器学习/深度学习 算法 Python
探索机器学习中的决策树算法:从理论到实践
【10月更文挑战第5天】本文旨在通过浅显易懂的语言,带领读者了解并实现一个基础的决策树模型。我们将从决策树的基本概念出发,逐步深入其构建过程,包括特征选择、树的生成与剪枝等关键技术点,并以一个简单的例子演示如何用Python代码实现一个决策树分类器。文章不仅注重理论阐述,更侧重于实际操作,以期帮助初学者快速入门并在真实数据上应用这一算法。
|
1月前
|
机器学习/深度学习 人工智能 Rust
MindSpore QuickStart——LSTM算法实践学习
MindSpore QuickStart——LSTM算法实践学习
40 2
|
29天前
|
机器学习/深度学习 算法 数据建模
计算机前沿技术-人工智能算法-生成对抗网络-算法原理及应用实践
计算机前沿技术-人工智能算法-生成对抗网络-算法原理及应用实践
25 0
|
2月前
|
数据采集 算法 物联网
【算法精讲系列】阿里云百炼SFT微调实践分享
本内容为您提供了百炼平台SFT微调的实践案例,帮助您方便并快速借助模型微调定制化您自己的专属模型。
|
3月前
|
DataWorks 算法 调度
B端算法实践问题之配置脚本以支持blink批处理作业的调度如何解决
B端算法实践问题之配置脚本以支持blink批处理作业的调度如何解决
41 1
|
3月前
|
SQL 算法 Serverless
B端算法实践问题之使用concat_id算子获取用户最近点击的50个商品ID如何解决
B端算法实践问题之使用concat_id算子获取用户最近点击的50个商品ID如何解决
26 1
|
3月前
|
存储 SQL 消息中间件
B端算法实践问题之设计一套实时平台能力如何解决
B端算法实践问题之设计一套实时平台能力如何解决
40 1
|
3月前
|
存储 SQL 算法
B端算法实践问题之Blink在实时业务场景下的优势如何解决
B端算法实践问题之Blink在实时业务场景下的优势如何解决
44 1