Java 基础体系 · 第 51/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 密码学 API:随机数、哈希、MAC、对称加密、签名和密钥
Java 的密码学 API 不是一个单独的“加密工具类”,而是一组围绕密码学对象、算法实现和密钥生命周期组织起来的标准接口。主要入口位于:
java.security:随机数、哈希、签名、密钥对、密钥工厂、提供者;javax.crypto:对称加密、MAC、密钥生成、密钥派生;java.security.spec与javax.crypto.spec:算法参数和密钥规格;java.security.KeyStore:密钥与证书的持久化存储。
在 Java 25 中,应用通常通过 getInstance(...) 请求算法,再由安全提供者(provider)提供具体实现。应用代码应依赖标准 API 和明确的算法名称,而不是依赖某个提供者的内部类。
先建立密码学对象之间的关系
一条典型的安全数据流可以抽象为:
flowchart LR
R[SecureRandom] --> K[密钥或随机 nonce]
M[明文] --> H[MessageDigest]
M --> MAC[Mac]
K --> C[Cipher]
M --> C
K --> S[Signature]
M --> S
H --> HD[摘要]
MAC --> TAG[认证标签]
C --> CT[密文与标签]
S --> SIG[签名]
CT --> D[解密与认证]
TAG --> V[MAC 验证]
SIG --> SV[公钥验证]
这些对象解决的是不同问题:
| API | 输入 | 输出 | 主要目的 |
|---|---|---|---|
SecureRandom |
无需秘密输入 | 不可预测随机字节 | 生成密钥、nonce、salt |
MessageDigest |
任意字节 | 固定长度摘要 | 完整性指纹、哈希承诺 |
Mac |
消息和共享密钥 | 认证标签 | 共享密钥下的完整性与来源认证 |
Cipher |
明文或密文、密钥 | 密文或明文 | 保密,AEAD 模式还提供认证 |
Signature |
消息、私钥或公钥 | 签名或验证结果 | 非对称身份认证与不可否认性语义 |
Key |
算法相关的密钥材料 | 密码学操作所需的密钥对象 | 描述和承载密钥 |
“加密”“哈希”“MAC”“签名”不是可以互相替换的技术。特别是,哈希没有密钥,不能证明消息来自某个特定实体;加密也不自动保证密文没有被篡改,除非使用带认证的模式,例如 GCM。
Java 密码学 API 的共同结构:算法、提供者、状态
getInstance 只选择实现,不生成安全策略
常见调用形式如下:
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
Mac mac = Mac.getInstance("HmacSHA256");
Signature signature = Signature.getInstance("Ed25519");
getInstance 会根据算法名称选择一个已注册的 Provider。算法名称和可用实现分为三层:
- 标准名称:例如
SHA-256、AES/GCM/NoPadding、HmacSHA256; - 提供者支持情况:某个 JDK 或第三方 provider 是否实现该名称;
- 参数和密钥限制:例如密钥长度、曲线、PSS 参数是否被该实现接受。
因此,getInstance("AES/GCM/NoPadding") 成功,只说明当前运行环境找到了一种实现;它不代表所有算法参数都有效。
可以检查实际使用的提供者:
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
System.out.println(cipher.getProvider());
生产代码通常不应随意强制指定 provider。强制 provider 会降低可移植性;只有在合规要求、硬件安全模块或明确验证过的实现约束下才应使用:
Cipher.getInstance("AES/GCM/NoPadding", "SunJCE");
这些对象通常是有状态且不可并发共享的
MessageDigest、Mac、Cipher 和 Signature 都会保存当前操作状态。例如:
MessageDigest.update(...)会累积摘要输入;Cipher.init(...)会建立加密或解密上下文;Mac.init(...)会绑定密钥;Signature.initSign(...)或initVerify(...)会决定当前方向。
不要把同一个实例放到多个线程中共享。常见做法是每次操作创建实例,或者使用线程隔离的对象;不要把“调用了 reset()”误认为已经解决了并发问题。
这些 API 的生命周期大致是:
创建实例
↓
init 或首次使用
↓
update / doFinal / sign / verify
↓
重新 init,或丢弃对象
doFinal()、digest() 和 sign() 在规范允许的范围内会结束当前输入阶段,但代码不应依赖复杂的隐式复用状态。一次业务操作使用一个清晰的实例,通常更容易审计。
随机数:SecureRandom 是安全性的起点
Random 不能替代 SecureRandom
java.util.Random 和 ThreadLocalRandom 的目标是可重复或高效的普通随机数,而不是对攻击者不可预测。它们不适合生成:
- AES、HMAC 等密钥;
- 密码重置令牌;
- 会话标识;
- 密码学 nonce;
- 密码派生所需的 salt。
密码学随机数使用:
SecureRandom random = new SecureRandom();
byte[] keyMaterial = new byte[32];
random.nextBytes(keyMaterial);
new SecureRandom() 会让当前安全提供者选择合适的实现。也可以明确请求 Java 标准名称 DRBG:
SecureRandom random = SecureRandom.getInstance("DRBG");
但具体配置和可用能力仍取决于运行环境。应用不应自行收集时间、进程号、内存地址等信息拼接成“随机种子”。
setSeed 不是“替换系统熵”
对已经初始化的 SecureRandom 调用:
random.setSeed(userSuppliedBytes);
通常表示向其内部状态补充种子,而不是把它变成一个只由 userSuppliedBytes 决定的伪随机生成器。不要把用户可控、时间可预测的数据作为唯一熵源,也不要为了“初始化随机数”而手工调用 setSeed(System.currentTimeMillis())。
nonce、salt 和 key 都是随机值,但职责不同
- 密钥(key):必须保密;
- nonce/IV:通常不必保密,但必须满足算法要求,GCM 中尤其要求同一密钥下不重复;
- salt:通常不必保密,用于让相同密码产生不同派生密钥,必须与派生结果一起保存。
“公开”不等于“可以重复”。GCM 的 nonce 可以随密文发送,但不能在同一个 AES 密钥下再次使用。
哈希:MessageDigest 计算无密钥摘要
哈希的形式和性质
哈希函数可以写成:
其中:
- 输入是任意长度的比特串;
- 输出是固定长度的 位摘要;
- 是确定性的:相同输入得到相同输出。
密码学哈希通常希望满足:
- 原像抗性:给定摘要 ,难以找到 使 ;
- 第二原像抗性:给定 ,难以找到不同的 使 ;
- 抗碰撞性:难以找到任意不同的 使摘要相同。
SHA-256 的输出是 256 位,即 32 字节。它不是加密:摘要不能按正常方式“解密”回原文。
Java 示例
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
byte[] message = "hello".getBytes(StandardCharsets.UTF_8);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] result = digest.digest(message);
System.out.println(HexFormat.of().formatHex(result));
这里的关键点是:
- 文本先使用明确的字符集转换成字节;
digest(byte[])计算整个输入;HexFormat只是展示编码,不是哈希算法的一部分。
如果使用流式输入:
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(firstPart);
digest.update(secondPart);
byte[] result = digest.digest();
其结果等价于对 firstPart || secondPart 计算摘要,其中 || 表示字节拼接。
哈希不能直接承担消息认证
攻击者可以修改消息并重新计算 SHA-256:
原消息 -> SHA-256 -> 摘要
篡改消息 -> SHA-256 -> 新摘要
如果攻击者也能替换摘要,接收方无法知道哪一个是可信的。因此,摘要适合检测“传输或存储过程中是否变化”,但前提是摘要本身通过可信渠道保护。需要密钥的认证应使用 MAC 或数字签名。
MD5 和 SHA-1 已不适合新的抗碰撞设计。MessageDigest 能否实例化某个算法不等于该算法仍然满足当前安全要求;算法选择需要结合用途,而不是只看 API 是否可调用。
MAC:使用共享密钥认证消息
MAC 的定义
消息认证码可以抽象为:
其中:
- 是发送方和接收方共享的秘密密钥;
- 是消息;
- 是认证标签。
验证时计算:
只有 时才接受消息。攻击者即使知道算法和消息,也不能在不知道 的情况下生成有效标签。
Java 中常用 HMAC:
Mac mac = Mac.getInstance("HmacSHA256");
SecretKey key = ...;
mac.init(key);
mac.update(message);
byte[] tag = mac.doFinal();
HmacSHA256 是基于 SHA-256 的 HMAC。HMAC 的关键不是“把密钥拼到消息前后再哈希”,而是使用哈希函数规定的内外层结构:
其中:
- 是按哈希块大小规范化后的密钥;
ipad和opad是固定填充;- 表示拼接。
验证标签不能使用普通字符串比较
错误做法:
if (Arrays.equals(expectedTag, receivedTag)) {
// ...
}
普通比较可能在发现第一个不同字节时提前返回,使比较时间泄露部分信息。应使用:
boolean valid = MessageDigest.isEqual(expectedTag, receivedTag);
或者由协议库提供常数时间验证。验证前还应检查标签长度和消息格式,避免把异常路径变成可利用的差异。
MAC 与签名的区别
MAC 的双方都拥有同一个秘密密钥,因此接收方能确认“知道这个共享密钥的一方生成了标签”,但不能向第三方证明是发送方单独生成的。
数字签名使用私钥签名、公钥验证。验证方不需要拥有签名者的私密材料,因此适合跨组织验证和公开分发。
对称加密:Cipher 与 AES-GCM
对称加密的基本形式
对称加密使用同一个秘密密钥:
解密是:
现代协议一般不直接使用“裸 AES”,而使用模式。模式决定如何处理多个块、是否需要 nonce、是否提供认证。
AES/ECB/NoPadding 等模式不适合作为一般消息加密方案。ECB 会泄露相同明文块的重复结构;CBC 等仅加密模式还需要额外的完整性保护。
GCM 是 AEAD 模式
AEAD(Authenticated Encryption with Associated Data)同时处理:
- 明文
M:需要保密; - 附加认证数据
A:不加密,但必须认证; - nonce
N:参与认证和加密; - 密文
C; - 认证标签
T。
可以表示为:
解密只有在标签验证成功后才应接受明文:
因此,GCM 不仅“把数据变成密文”,还会检测密文、nonce 或 AAD 是否被篡改。
完整 AES-GCM 示例
下面的程序可在 Java 25 中运行。它生成 AES 密钥,随机生成 nonce,认证一段 AAD,加密并解密,再故意篡改密文验证失败路径。
import javax.crypto.AEADBadTagException;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.HexFormat;
public class AesGcmExample {
private static final int NONCE_LENGTH = 12; // 96 bit
private static final int TAG_LENGTH = 128; // bit
public static void main(String[] args) throws Exception {
SecureRandom random = new SecureRandom();
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(256, random);
SecretKey key = keyGenerator.generateKey();
byte[] nonce = new byte[NONCE_LENGTH];
random.nextBytes(nonce);
byte[] aad = "tenant=acme;version=1".getBytes(StandardCharsets.UTF_8);
byte[] plaintext =
"Java 25 cryptography".getBytes(StandardCharsets.UTF_8);
GCMParameterSpec parameters =
new GCMParameterSpec(TAG_LENGTH, nonce);
Cipher encryptor = Cipher.getInstance("AES/GCM/NoPadding");
encryptor.init(Cipher.ENCRYPT_MODE, key, parameters);
encryptor.updateAAD(aad);
byte[] ciphertextAndTag = encryptor.doFinal(plaintext);
System.out.println("nonce = "
+ HexFormat.of().formatHex(nonce));
System.out.println("ciphertextAndTag = "
+ HexFormat.of().formatHex(ciphertextAndTag));
Cipher decryptor = Cipher.getInstance("AES/GCM/NoPadding");
decryptor.init(Cipher.DECRYPT_MODE, key, parameters);
decryptor.updateAAD(aad);
byte[] recovered = decryptor.doFinal(ciphertextAndTag);
System.out.println("recovered = "
+ new String(recovered, StandardCharsets.UTF_8));
byte[] tampered = Arrays.copyOf(ciphertextAndTag,
ciphertextAndTag.length);
tampered[0] ^= 1;
try {
Cipher verifier = Cipher.getInstance("AES/GCM/NoPadding");
verifier.init(Cipher.DECRYPT_MODE, key, parameters);
verifier.updateAAD(aad);
verifier.doFinal(tampered);
throw new AssertionError("篡改后的数据不应通过验证");
} catch (AEADBadTagException expected) {
System.out.println("tampered data rejected");
}
}
}
每一步成立的原因如下:
KeyGenerator生成的是SecretKey,而不是普通的byte[];- nonce 使用
SecureRandom生成; GCMParameterSpec的标签长度单位是 bit,因此128表示 128 位标签;updateAAD必须在doFinal前调用,并且解密时必须提供完全相同的 AAD;- GCM 的 Java 输出通常是
密文 || 认证标签,标签由doFinal产生并附在结果末尾; - 解密时标签验证失败,常见表现是
AEADBadTagException; - 篡改 nonce、AAD 或密文都会导致认证失败。
nonce 通常可以按如下结构保存:
版本 | nonce | ciphertextAndTag
nonce 不需要加密,但必须和密文一起传输。最危险的错误是同一个 AES-GCM 密钥重复使用同一个 nonce。随机生成 96 位 nonce 在大多数系统中是常见方案,但如果系统产生大量消息,必须从协议层面设计碰撞风险和密钥轮换,而不能只依赖“随机应该不会重复”。
AAD 不是“额外的明文”
AAD 不会出现在解密后的明文中。例如租户编号、协议版本、对象 ID 可以作为 AAD,使接收方验证“密文是否属于该上下文”。但解密双方必须准确重建同样的字节序列;字符串编码、字段顺序或版本差异都会导致认证失败。
不要在标签验证前使用明文
AEAD 解密实现应在 doFinal 成功前不要把结果提交给业务层。错误处理流程应把 AEADBadTagException 视为“不可信输入”,而不是重试若干次或尝试忽略标签。
签名:私钥生成签名,公钥验证签名
签名的形式
数字签名可抽象为:
验证为:
其中:
sk是私钥,必须保密;pk是公钥,可以分发;- 是被签名的确切字节序列;
- 是签名结果。
实际签名算法往往先对消息做摘要,然后对摘要或结构化摘要进行签名。Signature 会把这类细节封装起来。
Ed25519 端到端示例
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
public class Ed25519Example {
public static void main(String[] args) throws Exception {
KeyPairGenerator generator =
KeyPairGenerator.getInstance("Ed25519");
KeyPair keyPair = generator.generateKeyPair();
byte[] message =
"document version 1".getBytes(StandardCharsets.UTF_8);
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(keyPair.getPrivate());
signer.update(message);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(keyPair.getPublic());
verifier.update(message);
boolean valid = verifier.verify(signature);
System.out.println("valid = " + valid);
byte[] modified =
"document version 2".getBytes(StandardCharsets.UTF_8);
verifier.initVerify(keyPair.getPublic());
verifier.update(modified);
System.out.println("modified valid = "
+ verifier.verify(signature));
}
}
预期结果类似:
valid = true
modified valid = false
这里必须注意,签名保护的是字节,不是抽象的 Java 字符串。以下两个输入在文本意义上可能看起来相同,但编码、字段顺序、JSON 空白或换行差异都可能产生不同字节,从而导致签名不同。
RSA 签名的参数必须匹配
常见 RSA 签名算法名称包括:
SHA256withRSA:通常表示 RSA PKCS#1 v1.5 签名;RSASSA-PSS:RSA-PSS,需要显式配置哈希算法、盐长度等参数。
RSA-PSS 示例:
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
KeyPair pair = generator.generateKeyPair();
PSSParameterSpec pss = new PSSParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
32,
1);
Signature signer = Signature.getInstance("RSASSA-PSS");
signer.setParameter(pss);
signer.initSign(pair.getPrivate());
signer.update(message);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance("RSASSA-PSS");
verifier.setParameter(pss);
verifier.initVerify(pair.getPublic());
verifier.update(message);
boolean valid = verifier.verify(signature);
签名方和验证方必须使用兼容的 PSS 参数。只说“都是 RSA”并不够;哈希算法、MGF 算法和盐长度不匹配时,验证会返回 false 或在初始化时抛出参数异常。
签名只验证“某个对应私钥的持有者签署了这些字节”。它不自动建立公钥与现实身份之间的信任关系。证书链、信任库和证书验证属于 PKI 和 TLS 等更高层机制。
密钥:Key 是密码学对象,不只是字节数组
密钥接口层次
Java 中常见的密钥接口包括:
Key
├── SecretKey 对称密钥
├── PublicKey 公钥
└── PrivateKey 私钥
密钥通常有三个重要属性:
String algorithm = key.getAlgorithm();
String format = key.getFormat();
byte[] encoded = key.getEncoded();
例如:
- AES 密钥的算法名通常是
AES; - 公钥编码通常是
X.509; - 私钥编码通常是
PKCS#8; getEncoded()可能返回null,因为某些硬件或不可导出的密钥不允许导出原始材料。
不能把 getEncoded() 当成永远可用,也不能把私钥编码打印到日志中。
生成对称密钥
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey();
KeyGenerator 生成的是符合算法要求的密钥。相比于:
SecretKey key = new SecretKeySpec(randomBytes, "AES");
前者更能表达“由算法实现负责生成合法密钥”。SecretKeySpec 适用于已有正确密钥材料的包装,但它不会验证材料是否来自安全随机源,也不会自动执行密钥派生。
生成密钥对
KeyPairGenerator generator =
KeyPairGenerator.getInstance("EC");
generator.initialize(
new ECGenParameterSpec("secp256r1"));
KeyPair pair = generator.generateKeyPair();
KeyPair 同时包含:
PrivateKey privateKey = pair.getPrivate();
PublicKey publicKey = pair.getPublic();
私钥用于签名或密钥协商,公钥用于验证或公开分发。不要把 RSA 公钥加密误认为通用文件加密方案:RSA 适合加密少量密钥材料,并且还涉及 OAEP 参数;大数据通常使用混合加密,即公钥算法保护随机生成的对称密钥,对称算法保护实际数据。
从编码恢复密钥
公钥常见编码是 X.509 SubjectPublicKeyInfo,私钥常见编码是 PKCS#8:
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
KeyFactory factory = KeyFactory.getInstance("RSA");
PublicKey publicKey = factory.generatePublic(
new X509EncodedKeySpec(publicKeyBytes));
PrivateKey privateKey = factory.generatePrivate(
new PKCS8EncodedKeySpec(privateKeyBytes));
这里的编码格式不是 PEM。PEM 只是通常包含 Base64 的文本包装:
-----BEGIN PUBLIC KEY-----
Base64...
-----END PUBLIC KEY-----
解析 PEM 时必须先移除头尾和空白,再 Base64 解码,最后交给正确的 KeySpec。把 PKCS#1 RSA 私钥误当成 PKCS#8,会导致 InvalidKeySpecException。
从密码得到密钥:SecretKeyFactory 与 PBKDF2
用户密码通常熵较低,不能直接当作 AES 密钥。需要使用专门的密码派生函数(KDF),并配合随机 salt。
PBKDF2 的抽象形式是:
其中:
- 是密码;
- 是随机 salt;
- 是迭代次数;
- 是输出位数;
- 是派生密钥。
示例:
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Arrays;
char[] password = "correct horse battery staple".toCharArray();
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);
PBEKeySpec specification =
new PBEKeySpec(password, salt, 600_000, 256);
SecretKeyFactory factory =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] derivedBytes = factory.generateSecret(specification)
.getEncoded();
SecretKey aesKey = new SecretKeySpec(derivedBytes, "AES");
specification.clearPassword();
Arrays.fill(password, '\0');
Arrays.fill(derivedBytes, (byte) 0);
600_000 只是示例配置,不是 Java 规范规定的固定值。实际迭代次数应在目标硬件上基准测试,并由系统策略管理。提高迭代次数的目的是提高密码猜测成本,但不能把低熵密码变成高熵随机密钥。
salt 应与密文或账户记录一起保存,但不能复用为 nonce。密码派生、加密和认证是不同阶段;一个随机 salt 不会替代 GCM nonce 的唯一性要求。
Java 25 还提供标准化的密钥派生 API 能力,但具体 KDF 算法、参数规格和 provider 支持仍需要根据 Java 25 API 文档及部署环境确认。对于需要跨语言互操作的系统,应首先固定 KDF、版本、salt、上下文信息和输出长度的协议格式,而不是只保存一个“派生后的字节数组”。
密钥存储:KeyStore 管理密钥生命周期
KeyStore 是密钥和证书的存储抽象。它可以包含:
PrivateKeyEntry:私钥及其证书链;SecretKeyEntry:对称密钥;TrustedCertificateEntry:受信任证书。
示例:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.security.KeyStore;
char[] storePassword = "store-password".toCharArray();
char[] keyPassword = "key-password".toCharArray();
KeyStore store = KeyStore.getInstance("PKCS12");
// 首次创建空 keystore;已有文件则传入 FileInputStream
store.load(null, storePassword);
store.setKeyEntry(
"application-aes-key",
aesKey,
keyPassword,
null);
try (FileOutputStream output =
new FileOutputStream("keys.p12")) {
store.store(output, storePassword);
}
这里的几个密码职责不同:
storePassword保护 keystore 文件的整体完整性或访问过程;keyPassword保护条目;aesKey才是真正用于 AES 的密码学密钥。
具体保护机制由 keystore 类型和 provider 决定。PKCS12 是常见的标准类型,但不能仅因为文件扩展名是 .p12 就推断所有密码学属性。读取已有文件时:
try (FileInputStream input =
new FileInputStream("keys.p12")) {
store.load(input, storePassword);
}
若密码错误、文件损坏或条目类型不匹配,可能出现 IOException、GeneralSecurityException 或具体的 keystore 异常。生产系统还需要限制文件权限、备份恢复密钥,并避免把密码写进源码、命令行参数或普通日志。
硬件安全模块中的密钥可能不可导出,因此:
byte[] encoded = key.getEncoded();
可能返回 null。这不是 API 失效,而是密钥生命周期策略的一部分。
完整性、机密性和身份认证不能混为一谈
下面的对照可以帮助判断 API:
只需要检测文件是否变化
可以使用 SHA-256,但摘要必须通过可信渠道保存。例如摘要和文件都放在攻击者可写目录中,攻击者可以同时修改两者。
需要共享密钥下的完整性
使用 HmacSHA256。双方必须安全分发和保护同一个密钥。
需要保密并防止篡改
使用 AES-GCM 等 AEAD。不要把 AES-CBC 或 AES-CTR 单独当作完整方案,除非另有经过正确设计和验证的认证层。
需要公开验证或跨组织验证
使用数字签名,例如 Ed25519 或配置完整的 RSA-PSS。公钥分发过程仍需要证书、信任锚或其他可信绑定。
需要双方协商密钥
通常使用 ECDH 等密钥协商,再通过 KDF 派生对称密钥,最后使用 AEAD 加密数据。密钥协商本身不等于身份认证;没有认证的 Diffie–Hellman 可能遭受中间人攻击。
编码、序列化和协议边界
密码学 API 处理的是字节,而业务系统经常处理字符串、JSON、数据库字段和网络协议。必须明确以下内容:
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String encoded = HexFormat.of().formatHex(bytes);
byte[] restored = HexFormat.of().parseHex(encoded);
Base64 和十六进制都只是编码:
- 编码不会提供保密性;
- 同样的字节可以使用不同文本表示;
- 签名和 MAC 必须作用于协议规定的规范字节;
- nonce、salt、标签和版本字段的边界必须明确。
例如,以下两个拼接方式不能随意混用:
tenant + user
和:
length(tenant) || tenant || length(user) || user
如果字段没有长度或明确分隔符,可能产生歧义,例如:
("ab", "c") 与 ("a", "bc")
都可能拼成 "abc"。在签名、MAC 和 AAD 中,这种歧义会造成验证绕过或跨字段替换风险。
错误处理与诊断
密码学 API 常见异常包括:
NoSuchAlgorithmException:当前 provider 没有该算法;NoSuchPaddingException:不支持请求的 padding;InvalidKeyException:密钥类型、长度或状态不合法;InvalidAlgorithmParameterException:nonce、曲线或 PSS 参数不合法;IllegalBlockSizeException:输入长度不符合模式要求;BadPaddingException:解密或填充失败;AEADBadTagException:AEAD 认证标签验证失败;InvalidKeySpecException:编码无法转换为请求的密钥类型。
诊断时应区分三类问题:
- 算法不可用:检查 Java 版本、provider 和算法名称;
- 参数不匹配:检查密钥算法、nonce 长度、标签长度、AAD、PSS 参数;
- 数据不可信或被破坏:检查标签、签名、编码、传输截断和版本字段。
不要为了让请求“成功”而捕获 AEADBadTagException 后返回部分明文,也不要在日志中记录密钥、密码、完整令牌或私钥。可以记录算法名、协议版本、输入长度和错误类别,但应避免泄露可用于攻击或重放的信息。
对外部请求而言,MAC 验证失败、GCM 标签失败和签名验证失败通常都应转换成统一的认证失败响应,避免通过不同错误消息暴露内部验证阶段。
常见错误的因果关系
把哈希当作密码加密
哈希是公开确定性函数,攻击者可以枚举输入并比较摘要。密码存储应使用带 salt 的慢速密码哈希或 KDF,并通过应用策略设置工作因子。
每次加密都使用固定 IV
固定 IV 会破坏模式的安全假设。对于 GCM,同一密钥下重复 nonce 可能泄露关系并破坏认证安全;这不是“随机性稍差”,而是协议级错误。
用 Cipher 加密后不验证完整性
攻击者可能修改密文,导致解密结果被业务接受,或者利用解密错误构造填充预言机。优先使用 AEAD,并将标签验证作为解密成功的必要条件。
用普通 equals 比较 MAC 或敏感值
可能形成时间侧信道。对字节标签使用 MessageDigest.isEqual;对更复杂协议应采用经过验证的库实现。
直接序列化 Key 或打印 getEncoded()
Java 对象序列化不是通用密钥交换协议,且可能导致密钥材料进入日志、缓存或备份。跨系统传输应定义明确的编码格式和协议字段。
认为公钥天然可信
公钥只能验证签名数学关系。攻击者也可以生成自己的密钥对。实际系统必须验证公钥来源,例如证书链、固定公钥指纹、信任库或经过认证的密钥目录。
共享 Cipher 或 Mac 实例
这些实例包含输入和初始化状态。并发调用可能交叉污染数据,产生随机失败、认证失败甚至错误结果。每次操作建立独立状态,并把密钥和 nonce 的生命周期放在业务协议层管理。
一条可审计的最小原则
对每个密码学操作,都应能回答以下问题:
- 输入的确切字节是什么,使用了哪种字符集和序列化规则?
- 使用了什么算法、模式和参数?
- 密钥由谁生成,是否来自
SecureRandom或经过明确的 KDF? - nonce、IV、salt 是否需要公开,是否需要唯一或随机?
- 输出中哪些字段是密文,哪些字段是标签、签名、nonce 和版本?
- 验证失败时,业务是否拒绝整个操作?
- 密钥是否可导出、如何存储、如何轮换和撤销?
- 当前算法是否由目标 Java 25 运行环境中的 provider 实际支持?
只要这些问题中有一项没有明确答案,代码即使能够调用 Cipher、Mac 或 Signature,也还不能说明协议是安全的。Java API 负责提供密码学原语和状态管理;算法组合、数据格式、密钥分发与信任关系仍然必须由应用协议正确完成。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 序列化边界:原生序列化风险、JSON、Schema 与版本演进
- 下一篇:Java Process API:启动子进程、流、超时、退出和命令注入防护
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论