Java 加解密组件再设计

关于 Java 加密方案,我若干年前写过一篇博客《一套清晰、简洁的 Java AES/DES/RSA 加密解密 API 》。那时最大的收获,是通过重构代码进而感悟到"面向对象"的极大优势。但如今回头反思,虽然当时已经应用了 OOP,却仍显不成熟------从代码的痕迹中能明显感觉到为 OO 而 OO,非常不自然,甚至肉眼可见地生硬。另一方面,自己当时也没有真正理解 RSA/AES 加解密的思想,只是停留在 API 层面的生搬硬套。

当然了,那时候也没有 AI 辅助编程,只是参考了相关博文及代码,然后自己手搓了一套。于是趁着这次 AI 重构基础库的机会,我也把加解密的库一并重构,将代码放在 Codex 中一行一行地过。虽说不上多完美,但也是心目中较为理想的一个代码版本。

与纯粹 AI 生成代码不同,我先是有了基本的构思然后与 AI 讨论方案,接着把旧代码自己重构然后交给 AI review 修改这样子定稿的。我觉得这相当于 AI 是我学习某一门技术的导师,编码只是这个学习中的一个过程。最终是让我学会了不但怎么去实现功能,还有什么才为之好的代码,以及完整的交付(单测、文档等)。当然了,我也不是小白编程,肯定有我自己的代码风格及细节要求约束。这些都跟 AI 交待,以便最终形成自己中意的代码。同时一个侧面也说明了这个时代里面代码虽然廉价但输出符合个性化定制的代码,还是有相当的教学意义的。

更新版本说明

舍弃某些 API

DES/3DES/PBE 加密算法已经不再安全了,故不再提供。我没有直接删除代码或者保留在类库中标记为"遗留的",而是将这部分的代码移至 test 代码,算是一种妥当的安置方式(毕竟是亲手搓的心血)。

如此一来 API 便只剩 AES 及 RSA,对于实际情形也足够了。其中 AES 并不是单纯一种算法,而是有 N 种算法。当前 AES API 支持 GCM/CBC/PBE/Legacy,足够涵盖大多数安全的l需求。

API 重构

上一版已经是 OO 风格了,这一版仍是并持续改进。首先是入参的安排设计。涉及加解密的入参往往包括算法(字符串类型)、Key 密钥(Key 对象类型或者 Base64)、nonce 随机数、原文/密文等繁多的参数。应该怎么安排它们呢?对于一些固定的参数安排在构造器参数中,其余的则安排在实例方法中。有些参数比如原文/密文都是 String 入参(UTF8 字符串与 Base64 字符串),这时候就不能通过方法重载去定义了,只能单设一个方法来区分。例如DoCipher.doCipher()方法定义是 UTF8 字符串入参,doCipherFromBase64()则是供 Base64 字符串用。

至于出参,最开始的想法是通过方法重载加枚举类型指定的方式来适应不同类型的出参,但这样下来感觉不是太合理,重复的逻辑也多。于是,我不得不考虑新的方案,就是跳出这个类,执行完核心业务逻辑之后返回一个中介的 Java Bean(如下所示CipherResult类),存储一个数据类型比如byte[]在 bean 的构造器入参,然后有关的数据类型转换方法均在此 bean 上执行,按需求调用即可。

java 复制代码
/**
 * Represents the raw result of a cryptographic cipher operation.
 *
 * <p>The underlying result is binary data. It may be obtained directly as
 * bytes or represented as UTF-8, Base64 or hexadecimal text.</p>
 *
 * <p>Encrypted data is arbitrary binary data and should normally be represented
 * using {@link #toBase64()} or {@link #toHex()}. {@link #toUtf8()} is primarily
 * intended for decrypted plaintext known to contain UTF-8 encoded text.</p>
 */
@RequiredArgsConstructor
public class CipherResult {
    /**
     * Raw result bytes produced by the cipher operation.
     */
    @Getter
    private final byte[] result;

    /**
     * Interprets the result bytes as UTF-8 text.
     *
     * <p>This method is primarily intended for decrypted plaintext known to
     * contain UTF-8 encoded text. Arbitrary encrypted binary data should normally
     * be represented using {@link #toBase64()} or {@link #toHex()}.</p>
     *
     * @return the result interpreted as a UTF-8 string
     */
    public String toUtf8() {
        return new StringBytes(result).getUTF8_String();
    }

    /**
     * Encodes the result bytes as Base64.
     *
     * @return Base64-encoded result
     */
    public String toBase64() {
        return new Base64Utils(result).encodeAsString();
    }

    /**
     * Encodes the result bytes as hexadecimal text.
     *
     * @return hexadecimal representation of the result
     */
    public String toHex() {
        return StringBytes.bytesToHex(result);
    }
}

根据使用的需要返回下面四种类型结果。

  • 原始数据byte[]
  • UTF8 字符串
  • Base64 编码字符串
  • 16进制的字符串

AES 对称加密

对称加密就放在DoCipher基类中围绕方法CipherResult doCipher(int mode, byte[] data, AlgorithmParameterSpec spec, byte[] associatedData)完成。虽然只是一个方法,逻辑上也不复杂,但入参比较多,还是值得用一个类去完成。

java 复制代码
public CipherResult doCipher(int mode, byte[] data, AlgorithmParameterSpec spec, byte[] associatedData) {
	Cipher cipher = Cipher.getInstance(algorithmName);
	
	if (spec != null)
	    cipher.init(mode, key, spec);
	else
	    cipher.init(mode, key);
	
	if (associatedData != null)
	    cipher.updateAAD(associatedData);
	
	return new CipherResult(cipher.doFinal(data));
}

基础 AES(不安全但简单)

类AesLegacy是最初的 AES 的版本,本来安全性不足理应被遗弃使用的(@Deprecated),但考虑到足够简单,故而暂定保留并冠以 leagcy。

该方法无非就是明文+密码 key 即可完成加密,没有其他额外的入参,因此可以想象安全性是非常脆弱的,不推荐使用。

使用方法:

java 复制代码
String result = new AesLegacy("psw").encrypt("content");
String decrypt = new AesLegacy("psw").decrypt(result);

assertEquals("content", decrypt);

AES-GCM

AES-GCM 比上述的基础 AES 加密在安全性上大大加强,是目前主流的加密方式之一,也是本 API 默认推荐的方式。对比基础 AES 在 API 用法上可以明显感觉如下几点:

  • 密钥仍可以是任意字符串,但增加两种密钥的数据格式:一种是字符串的 byte\[\],另外是密钥字符串的 Base64 编码字符串。也就是说支持密钥非明文的方式传输使用
  • 增加 nonce 参数,通常为"随机数"或"一次性数值",这是必须的。
  • 可选的byte[] associatedData参数,用于保证数据的完整性。

密钥 key

AES 的密钥本质上是固定长度的字节数组(byte[]),而不是任意字符串。密钥可以由密码学安全随机数生成器产生,也可以通过 AES-PBE 从密码派生(下面会介绍,当然不止这一种方式,当前我们 API 只支持这种)。

这里有一个常见的认知误区需要澄清:"密钥"与"密码"是两个不同的概念 。密码是人类可读的字符串(如 "myPassword123"),而密钥是密码学意义上的随机字节序列。密码必须经过 KDF(如 PBKDF2、scrypt、Argon2)拉伸和派生后才能成为合格的 AES 密钥;直接拿密码字符串的字节当密钥使用,会因长度不匹配带来严重的安全隐患。这也是本 API 将"密码密钥"单独划入 AES-PBE 模式的原因。

AES 本身只关心最终得到的合法密钥字节,所以密钥必须是符合 AES 要求的字节序列,长度为:

  • 16 字节:AES-128
  • 24 字节:AES-192
  • 32 字节:AES-256

你可以通过 API 方便地生成:

java 复制代码
// 都返回 byte[]
DoCipher.randomBytes(16);
DoCipher.randomBytes(24);
DoCipher.randomBytes(32);

如果输入字符串本身是已经生成好的密钥的编码形式(例如 Base64),则可以解码得到原始密钥字节,不需要再进行密码派生。API 支持直接传入 Base64 字符串作为构造器参数入参。

如果使用者希望输入自定义密码,这得使用 AES-PBE 模式,我们下文会讲述。

nonce

关于 nonce 一般用法如下:

Nonce 参与加密计算,使得同一份明文使用相同密钥加密时,即使明文相同,只要 Nonce 不同,生成的密文通常也不同。因此,Nonce 可以帮助避免相同明文反复加密时产生相同的密文模式。同一个密钥下不能重复使用 Nonce。

个人感觉,这个跟最早做用户密码 md5 加盐时候,能够增强安全性,有异曲同工之妙。都是加密时额外加入一个参数,让每次加密结果有所变化。

更加要明确的是,这个 nonce 生成之后,不仅加密时候需要,而且解密时候也需要同一个。发送端和接收端必须使用完全一致的 nonce 字节。因此这是必须在加密之后交付的。顺带一提的是这个 nonce 不需要再加密了。

Java 是不支持多返回值,所以不能既返回 nonce 又返回密文。于是对应的做法是:要么组合"nonce+密文"返回,要么新设一个 Java bean 对象在不同的字段上返回。当前我们 API 采用后者,即返回对象AesCipherResult。

java 复制代码
public class AesCipherResult extends CipherResult {
    public AesCipherResult(byte[] result) {
        super(result);
    }

    @Getter
    @Setter
    private CipherResult nonce;
    
    /**
     * associatedData
     */
    @Getter
    @Setter
    private CipherResult aad;
}

当然这个 nonce 也可以按需返回你要的格式。

值得一提的是当前 API 中,如果你不想手动生成 nonce,API 可以为你代劳,调用encrypt(String data)或encrypt(String data, byte[] associatedData)即可。如果你要手动生成,那么 nonce 长度必须是 12 字节。所以值得注意的是,这里所谓的随机数,并不是随机字符串那么简单,更准确的是随机字节(和 key 类似),或者你用 base64 字符串那么字符长度是 16。

如下代码所示,这个方法不要求传入 nonce,内部实现。

java 复制代码
public AesCipherResult encrypt(String data, byte[] associatedData) {
    byte[] nonce = randomBytes(GCM_NONCE_LENGTH);

    return encrypt(data, nonce, associatedData);
}

associatedData 关联数据

这是一个非常有用的特性,用于校验密文的数据完整性,防止数据被篡改。做过 HTTP 报文加密的朋友都会有个关于签名的印象,那个就是用于数据校验的。有时候密钥、解密都正常,但是数据保不保真则是另外一回事了,数据转手过程中有没有被修改呢?用哈希校验是个好方法。此处关联数据就是考虑到这点,为此而设的,这样我们就不必另外再做数据校验,非常地方便。

使用的方法,还是把字符串转换为 UTF8 字节

java 复制代码
byte[] aad = "certificate".getBytes(StandardCharsets.UTF_8);
byte[] key = new byte[32];
byte[] nonce = "123456789012".getBytes(StandardCharsets.US_ASCII);

String ciphertext = new AesGcm(key).encrypt("certificate payload", nonce, aad).toBase64();

assertEquals("certificate payload", new AesGcm(key).decrypt(ciphertext, nonce, aad));

和 nonce 一样,发送端和接收端必须使用完全一致的 associatedData 字节。

该特性是可选的。

AES-CBC

CBC 是 AES 家族里另外一种加密方式,相对于 GCM 安全性稍低。微信小程序用户手机号码解密就是使用这个方式。

CBC 没有 GCM 的 nonce,取而代之的是 IV(Initialization Vector,初始化向量)。GCM nomce 所谓的"随机数"其实并不要求真随机或伪随机,甚至带编号的 id 可以,例如:

复制代码
密钥 K + Nonce 001 → 加密消息 A
密钥 K + Nonce 002 → 加密消息 B
密钥 K + Nonce 003 → 加密消息 C

但 CBC 的 IV 不可以,必须是不可预测的随机字节。在不同 AES 模式中有不同的名字和规则:

  • CBC 模式称为 IV
  • GCM 模式通常称为 Nonce

它们的目标有相似之处,但不是完全相同的机制。GCM nonce 的核心要求是同一密钥下不可重复;CBC IV 则必须不可预测,并且每次加密使用新的随机 IV。CBC 本身不提供完整性认证,而 GCM 提供认证标签来检测密文或 AAD 是否被篡改。

下面是 CBC 加密的例子:

java 复制代码
AesCbc aes = new AesCbc(new byte[16]);
AesCipherResult generatedIv = aes.encrypt("CBC content");
byte[] iv = "1234567890123456".getBytes(StandardCharsets.US_ASCII);
AesCipherResult suppliedIv = aes.encrypt("CBC content", iv);

assertEquals(16, generatedIv.getNonce().getResult().length);
assertEquals("CBC content", aes.decrypt(generatedIv.toBase64(), generatedIv.getNonce().toBase64()));
assertEquals("CBC content", aes.decrypt(suppliedIv.toBase64(), suppliedIv.getNonce().toBase64()));
assertThrows(IllegalArgumentException.class, () -> aes.encrypt("CBC content", new byte[15]));

解密的例子,复制微信的:

java 复制代码
String sessionKey = "IOv62NY75gNbTYVEe1ogWQ==";
String iv = "b/+OsOf6+y4Hl6RXJW+CjQ==";
String ciphertext = "6fxmM2gjyAk5v9mzSnPXw3xv4WTYywHH/JK9A78Zb2K8i9kehzGLd3xalzx8qNgkZ/SG4/kfL8DgpvQBEoygi7K7YNguUW7HNYHkESUiGXId+DGpziBjmxmoPquFZ8N2XF71kn6MYfXVUiwxCHRu5YYlTbKr4IjA2xqKMgAhaK6YsyD1NE9iOH4eYnT9Ky7B54BW0yWVH3NFgkTmEBQTNg==";
String decryptedText = new AesCbc(sessionKey).decrypt(ciphertext, iv);
// Add assertions to validate the decrypted text
System.out.println(decryptedText);

AES-PBE(基于密码的 AES 加密)

如果不想直接管理随机生成的 AES 密钥,而是希望使用自己设置的密码,可以使用 PBE(Password-Based Encryption,基于密码的加密)。AES-PBE 并不是一种独立的 AES 加密模式,而是先通过 PBKDF2 等密钥派生算法(KDF),结合密码、随机 Salt(盐值)和迭代次数生成 AES 密钥,再使用 AES-GCM 进行加密。

本实现使用 PBKDF2 派生 AES-128 密钥,并采用 AES-GCM 提供机密性与完整性认证。每次加密都会生成新的 12 字节随机 Nonce,并将其与密文及认证标签组合返回:

复制代码
[12-byte Nonce][Ciphertext][16-byte Authentication Tag]

Salt 和迭代次数用于重新派生密钥,因此解密时必须使用与加密时一致的密码、Salt、迭代次数及密钥派生算法。本实现返回的加密结果不包含 Salt,调用方需要自行保存或传输。

Salt 和 Nonce 都不需要保密,但用途不同:Salt 用于密码密钥派生,Nonce 用于 AES-GCM 加密操作。

用法如下:

java 复制代码
byte[] salt = DoCipher.randomBytes(AesPbe.PBE_SALT_LENGTH);
AesPbe aesPbe = new AesPbe("strong password", salt, AesPbe.MIN_PBE_ITERATIONS);
byte[] first = aesPbe.encrypt("secret");
byte[] second = aesPbe.encrypt("secret");

assertEquals(AesPbe.PBE_SALT_LENGTH, salt.length);
assertEquals("secret", aesPbe.decrypt(first));
assertFalse(Arrays.equals(first, second), "Each encryption must use a fresh GCM nonce");

当前 API 还是比较原始,并没有融入最新的 API 体系中。他日有时间再优化。

RSA 非对称加密

RSA 是一种非对称密码算法,使用一对数学上相关的密钥:

  • 公钥(Public Key):可以公开分发,用于加密、验证签名。
  • 私钥(Private Key):必须妥善保管,用于解密、生成签名。
  • 公钥可以从私钥推导出来,反之不行。

对比 AES 更安全但意味着性能消耗越高且耗时,一般可以考虑混合使用。

RSA 的操作,总之记住一句话:公钥加密 、私钥解密 ;私钥签名,公钥验签。

RSA 生成签名

RSA 签名的意义在于这份数据确实是私钥持有者签发的,而且数据在签名后没有被篡改,也就是数据完整性的校验。一般提到签名我们很容易想到哈希算法,比如 MD5、SHA-1、SHA-256 等等。只是说 MD5/SHA-1 已经不再安全一般不推荐使用。本 API 默认算法便是 SHA-256。

类DoSignature复制生成签名,用法很简单:

java 复制代码
// 先生成必须得私钥
KeyPair pair = RestoreKey.generateKeyPair(2048);
byte[] data = "signed payload".getBytes(StandardCharsets.UTF_8);
byte[] signature = new DoSignature(pair.getPrivate()).sign(data);

// 直接传入字符串也可以
byte[] signature = new DoSignature(pair.getPrivate()).sign("signed payload");

既然我们说私钥生成签名,那么自然私钥得入参是必须的。当前 API 支持两种方式入参,一个是PrivateKey对象传入构造器,另外一个 包含私钥的 PEM 字符串。

如果不想返回byte[]可以调用signToBase64()返回 String。

关于签名得算法默认是 SHA256_RSA,当然你也可以通过 setter 指定其他得算法。

RSA 验签 verify

既然有了签名就得拿来验证签名(也就是"验签"),这一过程就是通过公钥验证签名是否由对应私钥生成的。如果验签通过,表示:1、数据未被修改;2、签名者拥有对应私钥。

RSA 的验签实际非常简单,在类DoVerify完成,只需下面一个函数即可完成。

java 复制代码
public boolean verify(byte[] data, byte[] signatureData) {
    if (ObjectHelper.isEmptyText(algorithmName))
        throw new IllegalArgumentException("Signature algorithm is required.");

    if (data == null)
        throw new IllegalArgumentException("Data to verify is required.");

    if (signatureData == null)
        throw new IllegalArgumentException("Signature data is required.");

    if (publicKey == null)
        throw new IllegalArgumentException("Public key is required.");

    try {
        Signature signature = Signature.getInstance(algorithmName);
        signature.initVerify(publicKey);
        signature.update(data);

        return signature.verify(signatureData);
    } catch (SignatureException e) {
        throw new IllegalStateException("Signature verification failed.", e);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalArgumentException(DoCipher.NO_SUCH_ALGORITHM + algorithmName, e);
    } catch (InvalidKeyException e) {
        throw new IllegalArgumentException("Invalid Public Key", e);
    }
}

问题是入参比较多且数据类型多样。典型的入参需要如下:

  • 公钥 publicKey ,可以是PublicKey或其 Base64 字符串,在构造器参数传入
  • 算法名称 algorithmName,算法一般是SHA256withRSA,可以通过 setter 另外指定
  • 原始数据的摘要 data,可以是byte[]或其 UTF8 字符串,在verify方法传入
  • 签名数据 signatureData,可以是byte[]或其 Base64 字符串,在verify方法传入

由此可见,DoVerify API 的风格也是与DoSignature类似,用法如下:

java 复制代码
KeyPair pair = RestoreKey.generateKeyPair(2048);
byte[] data = "signed payload".getBytes(StandardCharsets.UTF_8);
byte[] signature = new DoSignature(pair.getPrivate()).sign(data);
DoVerify verifier = new DoVerify(pair.getPublic());

assertTrue(verifier.verify(data, signature));
assertTrue(verifier.verify(data, new com.ajaxjs.util.Base64Utils(signature).encodeAsString()));
assertFalse(verifier.verify("altered payload".getBytes(StandardCharsets.UTF_8), signature));

RSA 加密解密

写到这,加密解密反而没啥好说------先列出支持的 RSA 算法:

java 复制代码
/**
 * RSAES-PKCS1-v1_5 transformation; it does not use OAEP parameters.
 */
private static final String RSA_PKCS1 = "RSA/ECB/PKCS1Padding";

/**
 * Generic OAEP transformation, explicitly configured as SHA-1/MGF1-SHA-1.
 */
private static final String RSA_OAEP_SHA1 = "RSA/ECB/OAEPPadding";

/**
 * RSA-OAEP compatibility transformation using SHA-1 for both the OAEP digest and MGF1 digest.
 *
 * <p>Use only when an external protocol explicitly requires SHA-1 OAEP.
 * New integrations should use the default SHA-256 transformation.</p>
 */
public static final String RSA_CIPHER_SHA1 = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";

/**
 * Primary RSA cipher transformation.
 *
 * <p>This transformation uses RSA OAEP padding with SHA-256.</p>
 */
private static final String RSA_CIPHER_SHA256 = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";

/**
 * RSA-OAEP transformation using SHA-384 for both OAEP and MGF1 digests.
 */
private static final String RSA_CIPHER_SHA384 = "RSA/ECB/OAEPWithSHA-384AndMGF1Padding";

然后大家看例子即可。

java 复制代码
// 生成公钥私钥
KeyPair pair = RestoreKey.generateKeyPair(2048);
String publicPem = PemUtils.publicKeyToPem(pair.getPublic());
String privatePem = PemUtils.privateKeyToPem(pair.getPrivate());
String word = "你好,世界!";

Rsa publicCipher = new Rsa(publicPem, true);
Rsa privateCipher = new Rsa(privatePem, false);
byte[] encWord = publicCipher.encrypt(word).getResult();

String eBody = publicCipher.encrypt(word).toBase64();
String decWord2 = privateCipher.decrypt(new Base64Utils(encWord).encodeAsString());
System.out.println("加密前: " + word + "\n\r密文:" + eBody + "\n解密后: " + decWord2);
assertEquals(word, decWord2);

String english = "Hello, World!";
byte[] encEnglish = publicCipher.encrypt(english).getResult();
String decEnglish = privateCipher.decrypt(new Base64Utils(encEnglish).encodeAsString());
System.out.println("加密前: " + english + "\n\r" + "解密后: " + decEnglish);

assertEquals(english, decEnglish);

// 产生签名
String sign = new DoSignature(privatePem).signToBase64(encEnglish);
// 验证签名
assertTrue(new DoVerify(publicPem).verify(encEnglish, sign));

小结

不知不觉洋洋洒洒地写了一万多字,于是后面的笔者匆匆结尾了,------其实关于"证书、Key(公钥私钥)、PEM、X509"这些区别与联系的,还可以再说说的,不过文章太长了还是趁此打住,------咱们以后有机会再讲。

对了,其实这个加密解密组件,是 aj-util 库的一部分,源码就在:

https://gitcode.com/lightweight-component/aj-util/tree/main/aj-util/src/main/java/com/ajaxjs/util/cryptography

欢迎批评、斧正!

相关推荐
FYKJ_20101 小时前
springboot家政服务平台66766-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·mysql·spark·课程设计
caoerzhong1 小时前
开源 WMS 合规交付实务:JeeWMS 仓库管理系统在 GPL-3.0 下的源码台账与交付清单
java·开源
x-Achan1 小时前
Redis实现方案:更新策略、穿透、雪崩、击穿、工具封装
java·spring boot·redis·spring·bootstrap·mybatis
蜗牛互联网2 小时前
Java 17调用gpt-transcribe实现会议录音转写与术语提示
java·人工智能·后端
吠品2 小时前
DicomViewer24 的 CI 又红了:MPR 测试偶发失败排查记
java·服务器·前端
Wang's Blog2 小时前
Java 项目实战: 外卖平台优化-Nginx概述与源码编译安装
java·开发语言·nginx
Eason_LYC2 小时前
一个公开密钥放倒一片:Apache Shiro RememberMe 反序列化漏洞
java·网络安全·渗透测试·shiro·漏洞复现·反序列化·ai安全
ym hyd 11110 小时前
试题库管理系统源码 Java+SpringBoot+Vue3 前后分离
java·vue.js·spring boot·毕设
AC赳赳老秦10 小时前
采集行为合规自检:OpenClaw 自动校验 robots 协议与采集频率,规避违规采集风险
java·开发语言·c++·python·php·deepseek·openclaw