Java 基础体系 · 第 51/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 密码学 API:随机数、哈希、MAC、对称加密、签名和密钥

Java 的密码学 API 不是一个单独的“加密工具类”,而是一组围绕密码学对象、算法实现和密钥生命周期组织起来的标准接口。主要入口位于:

  • java.security:随机数、哈希、签名、密钥对、密钥工厂、提供者;
  • javax.crypto:对称加密、MAC、密钥生成、密钥派生;
  • java.security.specjavax.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。算法名称和可用实现分为三层:

  1. 标准名称:例如 SHA-256AES/GCM/NoPaddingHmacSHA256
  2. 提供者支持情况:某个 JDK 或第三方 provider 是否实现该名称;
  3. 参数和密钥限制:例如密钥长度、曲线、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");

这些对象通常是有状态且不可并发共享的

MessageDigestMacCipherSignature 都会保存当前操作状态。例如:

  • 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.RandomThreadLocalRandom 的目标是可重复或高效的普通随机数,而不是对攻击者不可预测。它们不适合生成:

  • 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 计算无密钥摘要

哈希的形式和性质

哈希函数可以写成:

H:{0,1}{0,1}nH:\{0,1\}^{*}\rightarrow\{0,1\}^{n}

其中:

  • 输入是任意长度的比特串;
  • 输出是固定长度的 nn 位摘要;
  • HH 是确定性的:相同输入得到相同输出。

密码学哈希通常希望满足:

  1. 原像抗性:给定摘要 yy,难以找到 mm 使 H(m)=yH(m)=y
  2. 第二原像抗性:给定 mm,难以找到不同的 mm' 使 H(m)=H(m)H(m)=H(m')
  3. 抗碰撞性:难以找到任意不同的 m,mm,m' 使摘要相同。

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));

这里的关键点是:

  1. 文本先使用明确的字符集转换成字节;
  2. digest(byte[]) 计算整个输入;
  3. 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 的定义

消息认证码可以抽象为:

T=MACK(M)T=\operatorname{MAC}_K(M)

其中:

  • KK 是发送方和接收方共享的秘密密钥;
  • MM 是消息;
  • TT 是认证标签。

验证时计算:

T=MACK(M)T'=\operatorname{MAC}_K(M)

只有 T=TT'=T 时才接受消息。攻击者即使知道算法和消息,也不能在不知道 KK 的情况下生成有效标签。

Java 中常用 HMAC:

Mac mac = Mac.getInstance("HmacSHA256");
SecretKey key = ...;
mac.init(key);

mac.update(message);
byte[] tag = mac.doFinal();

HmacSHA256 是基于 SHA-256 的 HMAC。HMAC 的关键不是“把密钥拼到消息前后再哈希”,而是使用哈希函数规定的内外层结构:

HMACK(M)=H((Kopad)H((Kipad)M))\operatorname{HMAC}_K(M) = H((K'\oplus opad)\parallel H((K'\oplus ipad)\parallel M))

其中:

  • KK' 是按哈希块大小规范化后的密钥;
  • ipadopad 是固定填充;
  • \parallel 表示拼接。

验证标签不能使用普通字符串比较

错误做法:

if (Arrays.equals(expectedTag, receivedTag)) {
    // ...
}

普通比较可能在发现第一个不同字节时提前返回,使比较时间泄露部分信息。应使用:

boolean valid = MessageDigest.isEqual(expectedTag, receivedTag);

或者由协议库提供常数时间验证。验证前还应检查标签长度和消息格式,避免把异常路径变成可利用的差异。

MAC 与签名的区别

MAC 的双方都拥有同一个秘密密钥,因此接收方能确认“知道这个共享密钥的一方生成了标签”,但不能向第三方证明是发送方单独生成的。

数字签名使用私钥签名、公钥验证。验证方不需要拥有签名者的私密材料,因此适合跨组织验证和公开分发。


对称加密:Cipher 与 AES-GCM

对称加密的基本形式

对称加密使用同一个秘密密钥:

C=EncK(M)C=\operatorname{Enc}_K(M)

解密是:

M=DecK(C)M=\operatorname{Dec}_K(C)

现代协议一般不直接使用“裸 AES”,而使用模式。模式决定如何处理多个块、是否需要 nonce、是否提供认证。

AES/ECB/NoPadding 等模式不适合作为一般消息加密方案。ECB 会泄露相同明文块的重复结构;CBC 等仅加密模式还需要额外的完整性保护。

GCM 是 AEAD 模式

AEAD(Authenticated Encryption with Associated Data)同时处理:

  • 明文 M:需要保密;
  • 附加认证数据 A:不加密,但必须认证;
  • nonce N:参与认证和加密;
  • 密文 C
  • 认证标签 T

可以表示为:

(C,T)=AEAD.EncK(N,A,M)(C,T)=\operatorname{AEAD.Enc}_K(N,A,M)

解密只有在标签验证成功后才应接受明文:

M=AEAD.DecK(N,A,C,T)M=\operatorname{AEAD.Dec}_K(N,A,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");
        }
    }
}

每一步成立的原因如下:

  1. KeyGenerator 生成的是 SecretKey,而不是普通的 byte[]
  2. nonce 使用 SecureRandom 生成;
  3. GCMParameterSpec 的标签长度单位是 bit,因此 128 表示 128 位标签;
  4. updateAAD 必须在 doFinal 前调用,并且解密时必须提供完全相同的 AAD;
  5. GCM 的 Java 输出通常是 密文 || 认证标签,标签由 doFinal 产生并附在结果末尾;
  6. 解密时标签验证失败,常见表现是 AEADBadTagException
  7. 篡改 nonce、AAD 或密文都会导致认证失败。

nonce 通常可以按如下结构保存:

版本 | nonce | ciphertextAndTag

nonce 不需要加密,但必须和密文一起传输。最危险的错误是同一个 AES-GCM 密钥重复使用同一个 nonce。随机生成 96 位 nonce 在大多数系统中是常见方案,但如果系统产生大量消息,必须从协议层面设计碰撞风险和密钥轮换,而不能只依赖“随机应该不会重复”。

AAD 不是“额外的明文”

AAD 不会出现在解密后的明文中。例如租户编号、协议版本、对象 ID 可以作为 AAD,使接收方验证“密文是否属于该上下文”。但解密双方必须准确重建同样的字节序列;字符串编码、字段顺序或版本差异都会导致认证失败。

不要在标签验证前使用明文

AEAD 解密实现应在 doFinal 成功前不要把结果提交给业务层。错误处理流程应把 AEADBadTagException 视为“不可信输入”,而不是重试若干次或尝试忽略标签。


签名:私钥生成签名,公钥验证签名

签名的形式

数字签名可抽象为:

S=Signsk(M)S=\operatorname{Sign}_{sk}(M)

验证为:

Verifypk(M,S){true,false}\operatorname{Verify}_{pk}(M,S)\in\{\text{true},\text{false}\}

其中:

  • sk 是私钥,必须保密;
  • pk 是公钥,可以分发;
  • MM 是被签名的确切字节序列;
  • SS 是签名结果。

实际签名算法往往先对消息做摘要,然后对摘要或结构化摘要进行签名。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 的抽象形式是:

K=PBKDF2(P,S,c,)K=\operatorname{PBKDF2}(P,S,c,\ell)

其中:

  • PP 是密码;
  • SS 是随机 salt;
  • cc 是迭代次数;
  • \ell 是输出位数;
  • KK 是派生密钥。

示例:

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);
}

若密码错误、文件损坏或条目类型不匹配,可能出现 IOExceptionGeneralSecurityException 或具体的 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:编码无法转换为请求的密钥类型。

诊断时应区分三类问题:

  1. 算法不可用:检查 Java 版本、provider 和算法名称;
  2. 参数不匹配:检查密钥算法、nonce 长度、标签长度、AAD、PSS 参数;
  3. 数据不可信或被破坏:检查标签、签名、编码、传输截断和版本字段。

不要为了让请求“成功”而捕获 AEADBadTagException 后返回部分明文,也不要在日志中记录密钥、密码、完整令牌或私钥。可以记录算法名、协议版本、输入长度和错误类别,但应避免泄露可用于攻击或重放的信息。

对外部请求而言,MAC 验证失败、GCM 标签失败和签名验证失败通常都应转换成统一的认证失败响应,避免通过不同错误消息暴露内部验证阶段。


常见错误的因果关系

把哈希当作密码加密

哈希是公开确定性函数,攻击者可以枚举输入并比较摘要。密码存储应使用带 salt 的慢速密码哈希或 KDF,并通过应用策略设置工作因子。

每次加密都使用固定 IV

固定 IV 会破坏模式的安全假设。对于 GCM,同一密钥下重复 nonce 可能泄露关系并破坏认证安全;这不是“随机性稍差”,而是协议级错误。

Cipher 加密后不验证完整性

攻击者可能修改密文,导致解密结果被业务接受,或者利用解密错误构造填充预言机。优先使用 AEAD,并将标签验证作为解密成功的必要条件。

用普通 equals 比较 MAC 或敏感值

可能形成时间侧信道。对字节标签使用 MessageDigest.isEqual;对更复杂协议应采用经过验证的库实现。

直接序列化 Key 或打印 getEncoded()

Java 对象序列化不是通用密钥交换协议,且可能导致密钥材料进入日志、缓存或备份。跨系统传输应定义明确的编码格式和协议字段。

认为公钥天然可信

公钥只能验证签名数学关系。攻击者也可以生成自己的密钥对。实际系统必须验证公钥来源,例如证书链、固定公钥指纹、信任库或经过认证的密钥目录。

共享 CipherMac 实例

这些实例包含输入和初始化状态。并发调用可能交叉污染数据,产生随机失败、认证失败甚至错误结果。每次操作建立独立状态,并把密钥和 nonce 的生命周期放在业务协议层管理。


一条可审计的最小原则

对每个密码学操作,都应能回答以下问题:

  1. 输入的确切字节是什么,使用了哪种字符集和序列化规则?
  2. 使用了什么算法、模式和参数?
  3. 密钥由谁生成,是否来自 SecureRandom 或经过明确的 KDF?
  4. nonce、IV、salt 是否需要公开,是否需要唯一或随机?
  5. 输出中哪些字段是密文,哪些字段是标签、签名、nonce 和版本?
  6. 验证失败时,业务是否拒绝整个操作?
  7. 密钥是否可导出、如何存储、如何轮换和撤销?
  8. 当前算法是否由目标 Java 25 运行环境中的 provider 实际支持?

只要这些问题中有一项没有明确答案,代码即使能够调用 CipherMacSignature,也还不能说明协议是安全的。Java API 负责提供密码学原语和状态管理;算法组合、数据格式、密钥分发与信任关系仍然必须由应用协议正确完成。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。