返回

PHP/Java AES-GCM 解密失败?修复密钥与Tag长度不一致

php

解决 PHP 与 Java 间 AES-GCM 加解密不兼容问题

踩坑现场:Java 加密,PHP 解不开?

碰到的情况是这样:一段数据用 Java 的 javax.crypto 库通过 AES-GCM 模式加密了,拿到密文后,想用 PHP 的 openssl 扩展来解密,结果死活解不开,反过来 PHP 加密的,Java 也解不了。两边的代码看起来逻辑挺像,都是 AES-GCM,密钥、IV 也 вроде (看似) 一致,到底是哪里出了岔子?

先看看出问题的 Java 和 PHP 代码片段核心逻辑。

Java (加密):

// ... (省略了 hexfromString, hextoString 等辅助方法)
public String encrypt(String json, String key) throws ... {
    Cipher cipher = null;
    SecretKeySpec skeySpec = new SecretKeySpec(hexfromString(key), "AES"); // 注意这里 Key 的处理
    byte[] ivSrc = new byte[12]; // 固定 12 字节的 0 作为 IV
    GCMParameterSpec ivSpec = new GCMParameterSpec(128, ivSrc); // 指定 Tag 长度为 128 位 (16字节)
    cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(1, skeySpec, ivSpec);
    byte[] encstr = cipher.doFinal(json.getBytes());
    return hextoString(encstr); // 返回 Hex 编码的密文 + Tag
}

public String decrypt(String json, String key) throws ... {
    Cipher cipher = null;
    SecretKeySpec skeySpec = new SecretKeySpec(hexfromString(key), "AES"); // 同上
    byte[] ivSrc = new byte[12]; // 同样固定的 IV
    GCMParameterSpec ivSpec = new GCMParameterSpec(128, ivSrc); // 同样 16 字节 Tag
    cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(2, skeySpec, ivSpec);
    byte[] encstr = cipher.doFinal(hexfromString(json)); // 解码 Hex,然后解密 + 校验 Tag
    return new String(encstr);
}

PHP (加密):

// ... (省略了 hexfromString, hextoString 等辅助方法)
public function encrypt($json, $key) {
    $iv = str_repeat("\0", 12); // 固定 12 字节的 0 作为 IV
    $cipher = "aes-128-gcm"; // *** 问题点 1: 写死了 aes-128 ** *
    $tag = ""; // 用于接收 Tag

    // *** 问题点 2: Tag 长度指定为 12 字节 ** *
    $encrypted = openssl_encrypt($json, $cipher, $this->hexfromString($key), OPENSSL_RAW_DATA, $iv, $tag, "", 12); 
    if ($encrypted === false) {
        throw new Exception("Encryption failed");
    }

    return $this->hextoString($encrypted . $tag); // 拼接密文和 Tag,然后 Hex 编码
}

public function decrypt($json, $key) {
    $iv = str_repeat("\0", 12); // 固定 12 字节的 0 作为 IV
    $cipher = "aes-128-gcm"; // *** 问题点 1: 写死了 aes-128 ** *

    $data = $this->hexfromString($json); // 解码 Hex
    // *** 问题点 2 & 3: 假设 Tag 是 12 字节,进行分割 ** *
    $encrypted = substr($data, 0, -12); 
    $tag = substr($data, -12);

    $decrypted = openssl_decrypt($encrypted, $cipher, $this->hexfromString($key), OPENSSL_RAW_DATA, $iv, $tag);
    if ($decrypted === false) {
        throw new Exception("Decryption failed");
    }

    return $decrypted;
}

对比代码和示例数据(java EncYes "e" "Hello" "a59d..."),Java 代码实际使用的密钥 a59d... 是 64 个十六进制字符,代表 32 字节,也就是 256 位。Java 的 SecretKeySpec(..., "AES") 会根据密钥长度自动选择 AES-128, AES-192 或 AES-256。这里显然是 AES-256

为什么会这样?刨根问底找原因

加密算法的参数必须完全一致,才能保证一种语言加密的数据能被另一种语言解密。检查下来,Java 和 PHP 代码之间存在几个关键的不匹配:

AES 密钥长度不匹配

  • Java: 如上所述,传入 32 字节的密钥时,new SecretKeySpec(keyBytes, "AES")Cipher.getInstance("AES/GCM/NoPadding") 会自动使用 AES-256。
  • PHP: 代码里硬编码了 $cipher = "aes-128-gcm";,无论传入的密钥是多长,它都尝试使用 AES-128 进行操作。

密钥长度是 AES 算法的核心参数之一。AES-128 和 AES-256 是两种不同的加密强度和算法变体。用 AES-256 加密的,必须用 AES-256 解密,反之亦然。这里不匹配,自然无法成功。

GCM 认证标签 (Tag) 长度不一致

GCM 模式是一种认证加密模式,它在加密数据的同时会生成一个认证标签(Tag),用于验证数据的完整性和真实性。解密时,需要同样的 Tag 来校验。

  • Java: 代码 new GCMParameterSpec(128, ivSrc) 明确指定了 Tag 长度为 128 位,即 16 字节
  • PHP: openssl_encrypt 函数的最后一个参数用于指定 Tag 长度(单位:字节)。代码里写的是 12,即生成 12 字节 的 Tag。在解密 decrypt 函数中,也错误地假设 Tag 是 12 字节 (substr($data, -12))。

加密时生成的 Tag 和解密时期望的 Tag 长度不一致,导致校验失败,解密自然也就失败了。openssl_decrypt 在 Tag 校验失败时会返回 false

(重要安全警示) 静态 IV 的“坑”

虽然这个问题不是导致 Java 和 PHP 不兼容的直接原因(因为两边都用了相同的静态 IV),但必须强调:在 GCM 模式下,对同一个密钥,绝对不能重复使用相同的 IV (Initialization Vector) 来加密不同的消息!

Java 代码 byte[] ivSrc = new byte[12]; 和 PHP 代码 $iv = str_repeat("\0", 12); 都使用了固定的、全零的 12 字节 IV。这是极其危险的做法。GCM 对 IV 的要求是唯一性(对于给定密钥)。重用 IV 会导致严重的安全性问题,可能使得攻击者能够恢复明文甚至伪造密文。

即使当前两边保持了一致(都用了不安全的静态 IV),在修复兼容性问题时,强烈建议一并修复这个安全隐患。

对症下药:让 PHP 和 Java “和解”

要解决这个问题,需要让 PHP 的实现细节跟 Java 对齐,并且采用安全的 IV 管理方式。

统一 AES 密钥长度

原理: 确保 PHP 使用与 Java 相同的 AES 变体。根据示例中的密钥长度(32 字节),应该是 AES-256。

操作 (PHP): 将 PHP 代码中的 $cipher 变量从 "aes-128-gcm" 改为 "aes-256-gcm"

代码示例 (PHP):

// 在 encrypt 和 decrypt 方法中修改:
$cipher = "aes-256-gcm"; // 改为 aes-256-gcm

这样 PHP 就会使用 AES-256 算法,与 Java 的行为保持一致(当使用 32 字节密钥时)。

统一 GCM 认证标签 (Tag) 长度

原理: 确保 PHP 生成和期望的 Tag 长度与 Java 一致,都是 16 字节(128 位)。

操作 (PHP):

  1. encrypt 方法中,调用 openssl_encrypt 时,将最后一个参数从 12 改为 16
  2. decrypt 方法中,分割数据时,使用 -16 来提取 Tag,并相应调整密文部分的提取。

代码示例 (PHP):

// 在 encrypt 方法中修改 openssl_encrypt 调用:
$encrypted = openssl_encrypt($json, $cipher, $this->hexfromString($key), OPENSSL_RAW_DATA, $iv, $tag, "", 16); // Tag 长度改为 16

// 在 decrypt 方法中修改数据分割逻辑:
$encrypted = substr($data, 0, -16); // 密文是数据总长度减去 16 字节
$tag = substr($data, -16); // Tag 是最后 16 字节

(重要!) 告别静态 IV,拥抱随机性

原理: GCM 模式要求每次加密使用唯一的 IV。最佳实践是为每次加密生成一个随机的 IV,并将这个 IV 与密文一起传输。通常推荐将 IV 预置 (prepend) 到密文数据前面。解密时,先从接收到的数据中分离出 IV,然后再用这个 IV 和密钥进行解密。GCM 推荐的 IV 长度是 12 字节 (96 位),因为它能触发内部优化,效率更高。

操作 (Java & PHP):

  1. 生成随机 IV: 每次调用 encrypt 时,生成 12 字节的随机数据作为 IV。
  2. 预置 IV: 将生成的 IV 附加到加密后得到的 密文+Tag 数据的前面。
  3. 传输/存储:IV + 密文 + Tag 整体进行 Hex 编码(或其他编码方式)。
  4. 分离 IV: 解密前,先对接收到的数据进行 Hex 解码,然后从数据开头分离出 12 字节的 IV。
  5. 解密: 使用分离出的 IV、密钥和剩下的 密文+Tag 数据进行解密。

代码示例 (Java - 改进):

import java.security.SecureRandom; // 需要引入 SecureRandom

// ... 其他 import 和类定义 ...

public String encrypt(String json, String key) throws ... {
    Cipher cipher = null;
    SecretKeySpec skeySpec = new SecretKeySpec(hexfromString(key), "AES");

    // 1. 生成随机 12 字节 IV
    byte[] iv = new byte[12];
    new SecureRandom().nextBytes(iv); // 使用安全的随机源生成 IV
    GCMParameterSpec ivSpec = new GCMParameterSpec(128, iv); // 使用随机 IV,Tag 长度 128 位 (16 字节)

    cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.ENCRYPT_MODE, skeySpec, ivSpec); // 用 Cipher.ENCRYPT_MODE 更清晰

    byte[] cipherTextWithTag = cipher.doFinal(json.getBytes()); // 加密得到 密文+Tag

    // 2. 预置 IV
    byte[] ivCipherText = new byte[iv.length + cipherTextWithTag.length];
    System.arraycopy(iv, 0, ivCipherText, 0, iv.length);
    System.arraycopy(cipherTextWithTag, 0, ivCipherText, iv.length, cipherTextWithTag.length);

    // 3. 返回 Hex 编码的 IV + 密文 + Tag
    return hextoString(ivCipherText);
}

public String decrypt(String hexEncodedIvCipherText, String key) throws ... {
    Cipher cipher = null;
    SecretKeySpec skeySpec = new SecretKeySpec(hexfromString(key), "AES");

    // 解码 Hex
    byte[] ivCipherText = hexfromString(hexEncodedIvCipherText);

    // 4. 分离 IV (前 12 字节)
    byte[] iv = new byte[12];
    System.arraycopy(ivCipherText, 0, iv, 0, iv.length);
    byte[] cipherTextWithTag = new byte[ivCipherText.length - iv.length];
    System.arraycopy(ivCipherText, iv.length, cipherTextWithTag, 0, cipherTextWithTag.length);

    GCMParameterSpec ivSpec = new GCMParameterSpec(128, iv); // 使用分离出的 IV,Tag 长度 128 位 (16 字节)
    cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(Cipher.DECRYPT_MODE, skeySpec, ivSpec); // 用 Cipher.DECRYPT_MODE 更清晰

    // 5. 解密 (使用分离出的 密文+Tag)
    byte[] decryptedText = cipher.doFinal(cipherTextWithTag);
    return new String(decryptedText);
}

// 注意:hexfromString 和 hextoString 方法保持不变

代码示例 (PHP - 改进):

// ... 类定义 ...

public function encrypt($json, $key) {
    $cipher = "aes-256-gcm"; // 修正为 aes-256-gcm

    // 1. 生成随机 12 字节 IV
    $iv = openssl_random_pseudo_bytes(12); 
    if ($iv === false) {
        throw new Exception("Failed to generate IV");
    }

    $tag = ""; // 用于接收 Tag

    // 使用随机 IV,Tag 长度指定为 16 字节
    $encrypted = openssl_encrypt($json, $cipher, $this->hexfromString($key), OPENSSL_RAW_DATA, $iv, $tag, "", 16);
    if ($encrypted === false) {
        throw new Exception("Encryption failed: " . openssl_error_string());
    }

    // 2 & 3. 预置 IV 并 Hex 编码
    return $this->hextoString($iv . $encrypted . $tag); // IV + 密文 + Tag
}

public function decrypt($hexEncodedIvCipherText, $key) {
    $cipher = "aes-256-gcm"; // 修正为 aes-256-gcm

    // 解码 Hex
    $data = $this->hexfromString($hexEncodedIvCipherText);
    if (strlen($data) < (12 + 16)) { // 基本校验:长度至少是 IV(12) + Tag(16)
         throw new Exception("Invalid encrypted data length");
    }

    // 4. 分离 IV (前 12 字节)
    $iv = substr($data, 0, 12);

    // 分离 Tag (后 16 字节)
    $tag = substr($data, -16);

    // 分离密文 (中间部分)
    $encrypted = substr($data, 12, -16); 

    // 5. 解密
    $decrypted = openssl_decrypt($encrypted, $cipher, $this->hexfromString($key), OPENSSL_RAW_DATA, $iv, $tag);
    if ($decrypted === false) {
        throw new Exception("Decryption failed: " . openssl_error_string());
    }

    return $decrypted;
}

// 注意:hexfromString 和 hextoString 方法保持不变

安全建议重申: 务必使用随机生成的、每次加密都不同的 IV。将 IV 和密文一起传输(例如预置 IV)是标准做法。千万不要再使用静态 IV。

优化与进阶

关于 IV 长度

虽然 GCM 理论上支持不同长度的 IV,但 NIST SP 800-38D 推荐使用 96 位(12 字节)的 IV。当使用 12 字节 IV 时,GCM 可以直接将其用作计数器初始值,效率较高。如果使用其他长度的 IV,GCM 会先对其进行一次 GHASH 运算来得到计数器初始值,稍微增加一点开销。因此,坚持使用 12 字节的随机 IV 是个不错的选择。

错误处理

在 PHP 代码示例中,增加了对 openssl_encryptopenssl_decrypt 返回 false 的检查,并通过 openssl_error_string() 获取更详细的错误信息。这对于调试非常重要。Java 代码也应该包含更健壮的异常处理逻辑。

密钥管理

这篇主要讨论加解密实现细节的兼容性。但实际应用中,如何安全地生成、存储和分发密钥是更重要且复杂的问题,需要专门考虑(例如使用密钥管理系统 KMS、硬件安全模块 HSM 等)。避免将密钥硬编码在代码或配置文件中。

通过以上调整,确保了 AES 密钥长度、GCM Tag 长度的一致性,并采用了安全的随机 IV 管理策略,PHP 和 Java 之间的 AES-GCM 加解密就应该能够顺利进行了。记住,密码学操作中,任何一个参数的微小差异都可能导致完全失败。细心,细心,再细心!