头图

密码学基础:编码方式、消息摘要算法、加密算法总结

叉叉哥
English

字节码转文本的编码方式

在计算机中,无论是内存、磁盘、网络传输,涉及到的数据都是以二进制格式来存储或传输的。

每一个二进制位(bit)只能是 0 或 1。二进制位不会单独存在,而是以 8 个二进制位组成 1 个字节(byte)的方式存在,即 1 byte = 8 bit。

字节码无法直接转为可打印的文本字符,有时想通过文本方式配置、存储、传输一段二进制字节码,比如配置文件、HTML/XML、URL、e-mail 正文、HTTP Header 等仅支持文本的场景下,就需要将二进制字节码转为文本字符串。

二进制字节码转文本字符有很多种方式,最简单的方式是直接用 0 和 1 来表示。但是这样的话,8 个 0/1 字符才能表示 1 个字节,长度太长很不方便。

下面介绍两种更加紧凑的方式:HEX 编码和 Base64 编码。

HEX 编码

HEX 是 16 进制的编码方式,所以又称为 Base16。

如果把一个字节中的二进制数值转为十六进制,使用 0-9 和 a-e(忽略大小写)这 16 个字符,那每个字符就可以表示 4 个二进制位(因为 2 的 4 次方等于 16),那么仅需要两个可打印字符就可以表示一个字节。

Java 中使用 HEX 编码(依赖 Apache Commons Codec):

String str = "相对论";
byte[] bytes = str.getBytes("UTF-8");

// Hex 编码
String encodeString = Hex.encodeHexString(bytes);
System.out.println(encodeString); // 输出:e79bb8e5afb9e8aeba

// Hex 解码
byte[] decodeBytes = Hex.decodeHex(encodeString);
System.out.println(new String(decodeBytes, "UTF-8")); // 输出:相对论

HEX 编码使用场景非常多。下面介绍几种常见的使用场景:

RGB 颜色码

RGB 颜色通常用 HEX 方式表示。如橘红色可以用 #FF4500 来表示:

.orangered { color: #FF4500; }

RGB 指红(red)绿(green)蓝(blue)三原色,这三种颜色按不同比例叠加后可以得到各式各样的颜色。三种颜色每种强度取值范围是 0~255,各需要 1 个字节来表示,共 3 个字节。

用 HEX 编码的表示某种 RGB 颜色,是一个长度为 6 位的字符串(通常还会加上 # 作为前缀,此时长度是 7 位)。例如 #FF4500 表示红绿蓝三原色的强度分别为 255、69、0。

URL 编码

由于 URL 中仅允许出现字母、数字和一些特殊符号,当 URL 中有汉字,需要经过 URL 编码才可以。

例如百度百科"相对论"的页面 URL 是: https://baike.baidu.com/item/...

其中 %E7%9B%B8%E5%AF%B9%E8%AE%BA 实际上是将 '相对论' 三个字用 UTF-8 编码后得到 9 个字节,再分别对这 9 个字节使用 HEX 编码并加上 '%' 前缀得到的结果。

IPv6 地址

由于 IPv4 的地址即将面临不够用的问题,取而代之的将会是 IPv6。IPv6 使用了 128 个二进制位的地址,通常会使用 HEX 编码方式来表示,例如:

2001:0db8:0000:0000:0000:ff00:0042:8329

Base64 编码

如果觉得 HEX 编码不够紧凑,那么还有更加紧凑的编码方式:Base64 编码。

Base64 编码共使用了 64 个字符来表示二进制位:26 个大写的 A-Z、26 个小写的 a-z、10 个数字 0-9、2 个特殊符号 + 和 /。这意味着每个字符可以表示 6 个二进制位,因为 64 等于 2 的 6 次方。

由于每个字节是 8 个二进制位,而 Base64 编码每个字符表示 6 个二进制位,那么可以每凑够 3 个字节(即 24 个二进制位),可将其编码为 4 个字符。如果被 base64 编码的原数据字节数不是 3 的倍数,那么会在末尾补上 1 或 2 个值为 0 的字节,凑到 3 的倍数后再进行 Base64 编码,编码后会在末尾添加 1 或 2 个 = 符号,表示补了多少个字节,这个在解码时会用到。

Java 中使用 Base64 编码:

String str = "相对论";
byte[] bytes = str.getBytes("UTF-8");

// Base64 编码
String encodeString = Base64.getEncoder().encodeToString(bytes);
System.out.println(encodeString); // 输出:55u45a+56K66

// Base64 解码
byte[] decodeBytes = Base64.getDecoder().decode(encodeString);
System.out.println(new String(decodeBytes, "UTF-8")); // 输出:相对论

Base64 编码的使用场景也有很多。例如,由于图片文件不是文本文件,没办法直接写入到 HTML 中,而将图片经过 Base64 编码后的结果是一串文本,可以直接放到 HTML 中:

<img src="data:image/jpg;base64,/9j/4QMZR..." />

需要注意的是,Base64 不是加密算法,有的开发人员把 Base64 当做加密算法来用,这是极其不安全的,因为 Base64 任何人都可以解码,不需要任何密钥。

消息摘要算法

消息摘要算法(Message-Digest Algorithm),又称为密码散列函数(cryptographic hash function (CHF)),可以将任意长度的字节码数据通过哈希算法计算出一个固定大小的结果。常用的消息摘要算法有 MD5、SHA-1、SHA-2 系列(包括 SHA-256、SHA-512 等)。

以 MD5 为例,对任意一个数据进行 MD5 运算,结果是一个 128 个二进制位(16 个字节)的哈希值。而我们日常看到的 32 位 MD5 字符串,实际上是对 128 个二进制位的哈希值进行 HEX 编码后得到的结果。

例如,当使用 MD5 对 "相对论" 这个字符串进行运算,得到一个 32 位字符的 MD5 值,实际上是经过以下 3 个步骤(以下代码依赖 Apache Commons Codec):

String str = "相对论";
// 1. 将字符串通过 UTF-8 编码转为字节数组
byte[] bytes = str.getBytes("UTF-8");
// 2. 对原始数组进行 MD5,得到一个 128 个二进制位(16 个字节)的哈希值
byte[] md5Bytes = DigestUtils.md5(bytes);
// 3. 将 128 位的哈希值 HEX 编码,得到一个长度为 32 的字符串
String md5Hex = Hex.encodeHexString(md5Bytes);
System.out.println(md5Hex); // 输出:fa913fb181bc1a69513e3d05a367da49

上面的代码仅仅是为了更清晰的看到计算一个字符串 MD5 值的整个过程。实际开发中可以使用更加便捷的 API,将上面的 3 个步骤合为 1 步:

String str = "相对论";
// 使用默认的 UTF-8 编码将字符串转为字节数组计算 MD5 后再进行 HEX 编码
String md5Hex = DigestUtils.md5Hex(str);
System.out.println(md5Hex); // 输出:fa913fb181bc1a69513e3d05a367da49

除此之外,Apache Commons Codec 中的 DigestUtils 还提供了 SHA-1、SHA-256、SHA-384、SHA-512 等消息摘要算法。

消息摘要算法有以下特点:

  • 相同的消息通过消息摘要算法计算得到的结果总是相同的。
  • 不同的消息通过消息摘要算法计算得到的结果要尽可能保证是不同的。如果两个不同的数据消息摘要后的结果相同,也就是发生了哈希碰撞,哈希碰撞出现的概率越大,那么这个消息摘要算法就越不安全。
  • 不可逆,无法通过哈希结果反向推算出原始数据。所以,我们一般认为消息摘要算法并不算是加密算法,因为它无法解密。另外,这里的不可逆是指运算不可逆,但是攻击者通常会使用穷举法或彩虹表来找到哈希值对应的原始数据。

下面列举一些典型的消息摘要算法的使用场景:

  • 对用户的登录密码使用消息摘要算法得到哈希值后再存储到数据库,即使数据库被黑客攻击,拿到所有的数据,也很难获得密码的原始值。这相对明文存储密码来说更加安全。当然,直接使用哈希值存储也是不安全的,特别是对于一些弱密码,黑客可以通过彩虹表轻松的查到对应的原始值。所以通常不会直接存储哈希值,而是经过一些处理,例如加盐、HMAC 等方式。
  • 对比两个文件是否一致,只需要对比两个文件的消息摘要是否一致即可,无需按字节一个个去对比。例如百度网盘曾经就是用文件的 MD5 来判断新上传的文件是否已存在,如果已经存在则不需要重复上传和存储,达到节省空间的目的。
  • 用于数字签名(Digital Signature),这个在本文后续会介绍。

在安全性要求比较高的场景下,MD5、SHA-1 目前都已经不建议使用了,现在用的比较多的是 SHA-2 系列算法。

HMAC

HMAC 全称是散列消息认证码(Hash-Based Message Authentication Code),它在消息摘要算法的基础上,加上了一个密钥(secret key)。

例如 HMAC-SHA256 就是在 SHA-256 算法基础上加了一个密钥。以下为代码示例(依赖 Apache Commons Codec):

String str = "相对论";
String key = "12345678"; // 密钥
HmacUtils hmacUtils = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key.getBytes("UTF-8"));
String result = hmacUtils.hmacHex(str.getBytes("UTF-8"));
System.out.println(result); // 输出:3bd7bbf58159a6d0bff846016b346a617a588fc1e9c43ebbdf38be53d3fc455a

相对于直接使用消息摘要算法,使用 HMAC 优势在于,它可以对消息进行真实性(authenticity)和完整性(integrity)验证。

只要密钥没有泄露,那么只有持有密钥才可以计算和验证原始数据哈希值。攻击者在没有密钥的前提下,无法发送伪造的消息,也无法篡改消息。

HMAC 可用于接口认证。例如一个暴露在网络环境中的 HTTP 接口,如果想要对调用方进行认证,可以将密钥发放给调用方,要求调用方调用接口时,给所有请求参数使用密钥通过 HMAC 计算一个签名,被调用方验证签名,就可以保证请求参数的真实性和完整性。

另外,HMAC 由于在计算哈希值时添加了密钥,相对于直接使用消息摘要算法,更加不容易被穷举法、彩虹表破解,用户密码经过 HMAC 后保存更加安全。

JWT 中的 HMAC

HMAC 的一个典型的应用场景就是 JWT。JWT 全称是 JSON Web Token。

传统的认证方式一般会将认证用户信息保存在服务端,而 JWT 直接将认证用户信息发放给客户端保存。既然 JWT 保存在客户端,那么任何人都可以伪造或篡改。如何解决这个问题,其中一种方式就是服务端会对 JWT 的 token 使用 HMAC 进行签名,并将签名也放在 token 末尾。下次客户端带上 JWT 请求时,服务端再验证签名是否正确。只要密钥不泄露,就可以保证 token 的真实性和完整性。

JWT token 分为三个部分:

  • Header:头部,指定签名算法
  • Payload:包含 token 主要传输的信息,这一部分可以包含用户信息,例如用户名等
  • Signature:签名,计算方式如下(secret 即密钥):

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret)

最终对这三个部分 Base64 编码后组合为 JWT 的 token:

加密算法

加密算法分为对称加密算法和非对称加密算法:

  • 对称加密算法(symmetric-key cryptography):加密和解密时使用相同的密钥。最常用的是 AES 算法。
  • 非对称加密算法(asymmetric-key cryptography):加密和解密使用不同的密钥,例如公钥加密的内容只能用私钥解密,所以又称为公钥加密算法(public-key cryptography)。使用最广泛的是 RSA 算法。

对称加密算法

常见的对称加密算法有 DES、3DES、AES,其中 DES 和 3DES 标准由于安全性问题,已经逐渐被 AES 取代。

AES 有多种工作模式(mode of operation)和填充方式(padding):

  • 工作模式:如 ECB、CBC、OFB、CFB、CTR、XTS、OCB、GCM,不同的模式参数和加密流程不同。
  • 填充方式:由于 AES 是一种区块加密(block cipher)算法,加密时会将原始数据按大小拆分成一个个 128 比特(即 16 字节)区块进行加密,如果需要加密的原始数据不是 16 字节的整数倍时,就需要对原始数据进行填充,使其达到 16 字节的整数倍。常用的填充方式有 PKCS5Padding、ISO10126Padding 等,另外如果能保证待加密的原始数据大小为 16 字节的整数倍,也可以选择不填充,即 NoPadding。

在实际工作中,需要跨团队跨语言对数据加密解密,经常出现使用一个语言加密后,另一个语言无法解密的情况。这一般都是两边选择的工作模式和填充方式不一致导致的。

下面的代码以 ECB 模式结合 PKCS5Padding 填充方式为例,对数据进行加密和解密:

public static byte[] encryptECB(byte[] data, byte[] key) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static byte[] decryptECB(byte[] data, byte[] key) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static void main(String[] args) throws IllegalBlockSizeException, InvalidKeyException, BadPaddingException, NoSuchAlgorithmException, NoSuchPaddingException {
    String data = "Hello World"; // 待加密的明文
    String key = "12345678abcdefgh"; // key 长度只能是 16、25 或 32 字节

    byte[] ciphertext = encryptECB(data.getBytes(), key.getBytes());
    System.out.println("ECB 模式加密结果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));

    byte[] plaintext = decryptECB(ciphertext, key.getBytes());
    System.out.println("解密结果:" + new String(plaintext));
}

输出:

ECB 模式加密结果(Base64):bB0gie8pCE2RBQoIAAIxeA==
解密结果:Hello World

上面的 ECB 模式虽然简单易用,但是安全性不高。由于该模式对每个 block 进行独立加密,会导致同样的明文块被加密成相同的密文块。下图就是一个很好的例子:

在 CBC 模式中,引入了初始向量(IV,Initialization Vector)的概念,用于解决 ECB 模式的问题。

下面是 CBC 模式结合 PKCS5Padding 填充方式的代码示例,加密解密时相比 ECB 模式多了一个初始向量 iv 参数:

public static byte[] encryptCBC(byte[] data, byte[] key, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static byte[] decryptCBC(byte[] data, byte[] key, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static void main(String[] args) throws IllegalBlockSizeException, InvalidKeyException, BadPaddingException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException {
    String data = "Hello World"; // 待加密的原文
    String key = "12345678abcdefgh"; // key 长度只能是 16、25 或 32 字节
    String iv = "iviviviviviviviv"; // CBC 模式需要用到初始向量参数

    byte[] ciphertext = encryptCBC(data.getBytes(), key.getBytes(), iv.getBytes());
    System.out.println("CBC 模式加密结果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));

    byte[] plaintext = decryptCBC(ciphertext, key.getBytes(), iv.getBytes());
    System.out.println("解密结果:" + new String(plaintext));
}

输出:

CBC 模式加密结果(Base64):K7bSB51+KxfqaMjJOsPAQg==
解密结果:Hello World

AES 使用非常广泛,可以说只要上网,无论是使用手机 APP 还是 Web 应用,几乎都离不开 AES 加密算法。目前大部分网站,包括手机 APP 后端接口,都已经使用 HTTPS 协议,而 HTTPS 在数据传输阶段大多都是使用 AES 对称加密算法。

但是,以 AES 为代表的的对称加密算法面临一个问题,就是如何安全的传输密钥。网络中发生数据交换的双方,需要用同一个密钥进行加密和解密,密钥一旦暴露,传输的内容就不再安全。密钥本身如果需要传输,如何保证安全?对于这个问题,就需要用到非对称加密算法。

非对称加密算法

1977 年,Rivest、Shamir、Adleman 设计了 RSA 非对称加密算法,并以此获得了 2002 年的图灵奖(计算机领域的国际最高奖项,被誉为"计算机界的诺贝尔奖")。至今,RSA 算法一直是最广为使用的非对称加密算法。

RSA 有两个密钥:公钥(public key)和私钥(private key)。

公钥可以完全公开,任何人都可以获取到。私钥是私有的,要保证不能被泄露出去。

公钥加密的内容,只有私钥可以解密。私钥加密的内容,也只有公钥可以解密。

基于以上规则,RSA 有两种不同的用法:

  • 公钥加密,私钥解密:服务端把公钥公开出去,客户端拿到公钥,把想要传输给服务端的数据通过公钥加密后传输,那么这个数据只有服务端能够解密,因为只有服务端拥有私钥,其他任何中间人即使在传输过程中拿到数据,既不能解密,也无法篡改。
  • 私钥签名,公钥验证签名:内容发布者将发布的内容用消息摘要算法(如 SHA-256)计算哈希值,再用私钥加密哈希值,得到一个签名,并将签名加在发布内容中一起发布,其他人得到这个内容后,可以用公开的公钥解密签名得到哈希值,再对比这个哈希值和内容生成的哈希值是否一致,来保证这份内容没有被篡改过。

    由于只是验证数据的真实性完整性,所以无需对整个内容进行加密,仅需对内容的哈希值加密即可验证,所以通常会结合消息摘要算法。例如 SHA256 with RSA 签名,就是先用 SHA-256 计算出哈希值,再用 RSA 私钥加密。

上面说到的私钥加密、公钥解密只是理论上成立,实际上不会直接这样用,而是只用于签名。因为一段私钥加密的数据,解密的公钥是公开的,意味着谁都可以解密,这样加密就没有任何意义了。

接下来通过 Java 代码来体验一下 RSA 算法。

首先,需要生成一对公钥和私钥。下面通过 openssl 命令来生成一对公钥和私钥:

# 创建一个 PKCS#8 格式 2048 位的私钥
openssl genpkey -out private_key.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048
# 通过私钥生成公钥
openssl pkey -in private_key.pem -pubout -out public_key.pem

生成的公钥和私钥是 Base64 编码的文本文件,可以直接用文本编辑器打开。拷贝到下面的代码中,可以验证公钥加密、私钥解密,以及私钥签名、公钥验证签名:

public static void main(String[] args) throws Exception {
    String publicKeyBase64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0XYlulDsTzDWUb6X66Ia\n" +
            "giSn1dKriHvLHYth9hCcaGomdeIQahGnxzE1o76slEyS2HZ164QHqx8Za+LuT6IV\n" +
            "yLhU/ZNLWAZABe/sdNEkhti6vSSOdJE43KS4UVADeSgtN+7uXDuVgm35EPWZjkfV\n" +
            "5hiRX4nT5ALr1niyi1Ax4BWWyG4qX00n1HzY8MvoyiLdNob71qB+amjUNy9bDhcz\n" +
            "CDWtgA/ywOYU5Ec6vMgYfbAXPKGWwo318rS3UH8QtsO8iGcQbZ76q05LNEL8G3fo\n" +
            "0Kssj4fjrVGwSsyGztRRMLfGkW/hOPCDj82+D6dGQlGB3gyB7P1xVbkD67FujQA/\n" +
            "jwIDAQAB";
    String privateKeyBase64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDRdiW6UOxPMNZR\n" +
            "vpfrohqCJKfV0quIe8sdi2H2EJxoaiZ14hBqEafHMTWjvqyUTJLYdnXrhAerHxlr\n" +
            "4u5PohXIuFT9k0tYBkAF7+x00SSG2Lq9JI50kTjcpLhRUAN5KC037u5cO5WCbfkQ\n" +
            "9ZmOR9XmGJFfidPkAuvWeLKLUDHgFZbIbipfTSfUfNjwy+jKIt02hvvWoH5qaNQ3\n" +
            "L1sOFzMINa2AD/LA5hTkRzq8yBh9sBc8oZbCjfXytLdQfxC2w7yIZxBtnvqrTks0\n" +
            "Qvwbd+jQqyyPh+OtUbBKzIbO1FEwt8aRb+E48IOPzb4Pp0ZCUYHeDIHs/XFVuQPr\n" +
            "sW6NAD+PAgMBAAECggEABT96joJ8iTdmB0JJOCQlmeElO1w94/uGCUV2vN2JrawL\n" +
            "LqTtWFr84ya+e03JsSWCAF5ncfEq6AStdGCJLAGZnh/QMVJBbwEpFXz/ZaXfzmkb\n" +
            "tKV31D/XNuABpjfk/mIdT+tymWj8w/nRZbVhlYkDOPKgoc4oOuw/0G3Ru1/VABI+\n" +
            "yulNx93A/JNFGk3Bkm4E7jRWyl0BkAqAX2BZkFbXG/u3Jc0eYXrG74JfMH+MEihG\n" +
            "GDMSpBKNyX5zWkUT6XxpG82t2erHPWYEoNSoFzAUu+7rZ4ECEXxazAQclEHTkR3r\n" +
            "duUZ/XF0GL1WB0GC7+qvV/Z0gxjXuwG9oToFO/0MQQKBgQDu4DuTPWcYwSWY0R1f\n" +
            "qZUOuYRwD+5OQnJMIlKAD32QmvYT/jnvigjss5Qf1IUwf1UMynj2FnVF4D7L+kvq\n" +
            "O7LzYvHAeDQwZGGt2xWBlqjfhumlfBqfklkkqUiH2A5DvfvtbX/kkiY3n9C+oYZp\n" +
            "2ejiOtSC+NqQeB74TluxroEkvwKBgQDgehynybpFl4KkmDhgj++BH5RR+xzXIChb\n" +
            "gtIbbspdE1EyXy7Z9iNAJ8PVjHkSwh8iEfAO4EuJFnonF8UNIsWLr3gsKbQytRxR\n" +
            "cewqaBhTL54Vgl5dmODNrYjkZva5HHDsCLioYGgljdrj5e/gPSAWBrgT6kI+HypQ\n" +
            "/5xyp+KJMQKBgQCMxut1P8eliBa/M+YqvYdR8TVC0bCwwGoZwlR6kiZ+9UQ2zimY\n" +
            "qPHPhZmzFI0V4sTdz+lvphahAqIfljftKBezZklxE6Y2KsKCMk4/W+nUKe9Cjpwm\n" +
            "FJqih31uSX9Gnw18hH7N1u/c8juUTR8o/LpJsUASm9Q7Nf+SeKODWINVgwKBgDEx\n" +
            "UXpLsPBzRYQAf8pZgKkRXJWirC1QtMdpIdY1L0+6Xf7l8QR+9janADmaMSY1OFFl\n" +
            "EPCRorwGGvraMKqyRgxYhcNX2E+MdQo8Jv8cFMiWFNSt3zQvvoQUVX2IOuVSIET5\n" +
            "nE354pjoP2HWD/1aJ9/r1Qc4PRAUEFfzzDssI27hAoGAOsYKtvW6iRn/WVduIRcy\n" +
            "UtBRHHX0U16zGv+I7nOOBIYK5Uan6AjgzG2MfPOBj3cUhMMBDPfVg1cTbonw5Y8F\n" +
            "nSO4VLOtqKy0BRxCIUFqltJXUmj1zAJs84IweCBQ3un/OLVUMgE7qGtaIQy2PBsy\n" +
            "M8mwuUjo3Fu7l11E2Vgz/qY=";

    // Base64 解码
    byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64.replace("\n", ""));
    byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64.replace("\n", ""));

    // 生成 PublicKey(公钥) 和 PrivateKey(私钥) 对象
    PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(publicKeyBytes));
    PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes));

    // 待加密、签名的原文
    String data = "Hello World";

    // 公钥加密,私钥解密
    byte[] ciphertext = encrypt(data.getBytes(), publicKey);
    System.out.println("RSA 公钥加密结果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));
    byte[] plaintext = decrypt(ciphertext, privateKey);
    System.out.println("RSA 私钥解密结果:" + new String(plaintext));

    // 私钥签名,公钥验证签名
    byte[] signature = sign(data.getBytes(), privateKey);
    System.out.println("RSA 私钥签名结果(Base64):" + Base64.getEncoder().encodeToString(signature));
    boolean verifySuccess = verify(data.getBytes(), signature, publicKey);
    System.out.println("RSA 公钥验证签名结果:" + verifySuccess);
}

/**
 * 公钥加密
 */
public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception {
    Cipher cipher = Cipher.getInstance("RSA");
    cipher.init(Cipher.ENCRYPT_MODE, publicKey);
    byte[] result = cipher.doFinal(data);
    return result;
}

/**
 * 私钥解密
 */
public static byte[] decrypt(byte[] data, PrivateKey privateKey) throws Exception {
    Cipher cipher = Cipher.getInstance("RSA");
    cipher.init(Cipher.DECRYPT_MODE, privateKey);
    byte[] result = cipher.doFinal(data);
    return result;
}

/**
 * 私钥签名,使用 SHA256 with RSA 签名
 */
public static byte[] sign(byte[] data, PrivateKey privateKey) throws Exception {
    Signature signature = Signature.getInstance("SHA256withRSA");
    signature.initSign(privateKey);
    signature.update(data);
    byte[] result = signature.sign();
    return result;
}

/**
 * 公钥验证签名
 */
public static boolean verify(byte[] data, byte[] sign, PublicKey publicKey) throws Exception {
    Signature signature = Signature.getInstance("SHA256withRSA");
    signature.initVerify(publicKey);
    signature.update(data);
    return signature.verify(sign);
}

输出:

RSA 公钥加密结果(Base64):zoY6KM/RdCjAs7upJ9SIwqfXsSn3hAPu/z/ZPHbKgWN6+X0PpyVJVYT8jacEkzB7S2sJe/wLkO2TqXB2gqvL1AuDRgepVlxV2f6Uwx4DxM2/5RE0fAdTiICV5JEEIw81oLix0GGQ7nLjOhJxN9LaTJ2cXtwgR8gUtLtJ0tdWrxSMuN8FHLA45Nv8Ea1EAUQCvfanYZ2L39l++3/zBdg2wYQwCE6XGFnWnayUsGKYjC7JIufnq5f9VDL/kguLKceLmeTHqq31ccRTOQyhuoZjHCsbfXPlW2AT9ejgAcXy7LkXhYCfma50DBM+KUCfC4YrKBg6wKRqdZee90ZPcUKTkw==
RSA 私钥解密结果:Hello World
RSA 私钥签名结果(Base64):AbP5zSV/qvkF8fCseVkEaZMscvznQBUDtO3g0U/FIXVmzeR6WXFwPsMd3cC3oCHtnnqsL/aRQrpW6pHU6EzSJ5w6FgY6kD4kWREq9f8LOnyQm7CoS6CK0tUiAjIgG16rtmS+oPbG+mYaZkLzo1Cpkpz2MzuMMbWNivvXRMbj3wLiXyIMqUefawipvm+GPwrWRxesRot2sGtuZcxtMMZs3NHpJ0CXV/mQlYJWEzIiHUY4mqfqpMDL/djPf9td74ABpjk38O6r1Jt75TLnMvkwRdh7pHBQLZ0Tn/6Vx2cVD2D+sE9BuhinO66B6I0QOGVcl3a5C2whp+85zEovvdGlSg==
RSA 公钥验证签名结果:true

目前随处可见的 HTTPS 协议,是基于 SSL/TLS 协议的。在 SSL/TLS 协议中,建立加密的传输通道前,首先有一个握手过程。在握手过程中,客户端会生成一个随机值,并使用公钥加密后传给服务端。这个随机值用于生成对称加密算法的密钥,仅有服务端的私钥可以解密,任何第三方都无法解密,这就解决了前面所说到的对称加密算法密钥传输过程中的安全问题。而握手成功后的通信阶段,则使用对称加密算法进行通信。因为非对称加密算法更加复杂,相对于对称加密算法来说效率不高,不适合用来做大量数据的加密解密。

另外,SSL/TLS 中用到的数字证书(digital certificate),为了防止伪造,也会由 CA 机构进行数字签名。目前大多数 HTTPS 网站使用的数字证书都是使用 SHA256 with RSA 签名。

例如,在浏览器上打开 https://xxgblog.com/ ,点击地址栏左侧的小锁按钮,查看网站使用的证书,其数字签名算法就是 SHA256 with RSA :

阅读 1.4k

Java论道
定期分享Java技术文章,囊括Spring全家桶、Linux、架构设计、分布式、微服务、消息中间件、面试题等。微...
3.7k 声望
55 粉丝
0 条评论
3.7k 声望
55 粉丝
文章目录
宣传栏