跳转到内容

加密:AES-256(CBC)与 AES-256-GCM

Core 依 ISO 32000-2:2020 §7.6 的标准安全处理程序,以 AES-256 加密 PDF。默认模式为 V=5 / R=6 / AESV3(AES-256-CBC)。可选模式为 ISO/TS 32003:2023 V=6 / R=7 AES-256-GCM 认证路径。本页说明密钥派生、传输格式、权限边界,以及部署时必须了解的限制。

Terminal window
composer require nextpdf/core:^3

默认路径需要 openssl 扩展。AES-256-GCM 路径会使用 opensslext-sodium。在没有 AES-NI 硬件的主机上,libsodium 会拒绝 GCM,Core 会回退到较慢的 OpenSSL 实现,而不会降级算法。

默认处理程序是 V=5 / R=6 标准安全处理程序,并搭配 AESV3 加密过滤器。调用 setEncryption() 时,Core 会从平台密码学随机数源(random_bytes())生成一把随机的 256 位文件密钥。字节长度为 32 字节,与 FIPS 197 的密钥长度一致。每个对象的内容都以 AES-256-CBC 加密。按照 ISO 32000-2:2020 §7.6.4 的规定,16 字节初始化向量会前置于每段密文。

密钥派生依循修订版 6 的算法 2.B。按照 ISO 32000-2:2020 §7.6.4.3.3 的规定,密码会先以 SASLprep(RFC 4013)规范化,再按字符边界截断至 127 个 UTF-8 字节。派生哈希通过由 AES-128-CBC 步骤驱动的迭代 SHA-256 / SHA-384 / SHA-512 流程计算,以提高离线猜测密码的成本。用户、所有者与每把密钥的盐值,会在每个加密器实例创建时分别生成一次,因此单个实例会输出确定性的字典字节,这是多轮写入器的前提条件。

useAesGcm() 会启用可选的 AES-256-GCM 路径。它实现 ISO/TS 32003:2023 V=6 / R=7 AESV4 加密过滤器。所用密码算法为 AES-256-GCM,参数取自 NIST SP 800-38D。每个加密对象的传输格式为 12 字节 IV、密文,后接 16 字节认证标签。按照 TS 32003 §5.2 配置文件的规定,附加认证数据为空。解密会验证标签,并在不匹配时引发 TamperedDataException;标签验证失败时,绝不会返回明文。这条路径补上了默认 CBC 路径本身无法提供的篡改检测。

GCM 路径上的 IV 唯一性规则遵循 NIST SP 800-38D §8。IV 的高 4 字节为每个实例的固定字段,在构造时由随机数源设定。低 8 字节是一个大端序计数器,每发出一个 IV 后递增一次。这与 §8.2.1 的确定性构造做法一致,只是固定字段采用随机化以避免跨文件碰撞,而不是逐个枚举。第二道防护会把每个已发出的 IV 记录到碰撞集合中;如果出现重复值,就引发 NonceReuseException。计数器溢出同样会引发 NonceReuseException,因为溢出正是 §8 所警告的 IV 重用失效模式。

GCM 路径上有两项长度界限适用。每个对象的明文上限为 2^39 − 256 字节,即 NIST SP 800-38D §5.2.1.1 推导出的单次调用界限。输入若超过此上限,会引发长度异常,并提示如何跨对象分割。每把密钥的调用安全界限为 2^32 次调用。assertWithinSafetyBound() 是一项可选检查,会引发 GcmInvocationLimitExceededException,让调用方在达到 §8.3 阈值前先轮换文件密钥。NIST SP 800-57 第 1 部分 §4 将此密钥生命周期决策定位为部署责任。

权限标志属于建议性约束。位掩码会写入加密后的 /Perms 项目与 /P 值,并在读取时以 validatePerms() 还原;遇到损坏的标记时,会以安全失败(fail closed)处理。符合规范的读取器应遵守这些标志。这些标志并不由密码学机制强制执行:持有解密密钥且忽略这些位的处理器,仍可读取、复制或修改内容。请将权限标志描述为读取器惯例,而非访问控制。

类型类别主要成员稳定性起始版本
Aes256Encryptorclassencrypt(), decrypt(), encryptForObject(), buildEncryptionDictionary(), verifyUserPassword(), verifyOwnerPassword(), validatePerms(), getEncryptionKey()稳定1.0.0
Aes256GcmEncryptorclassencrypt(), decrypt(), encryptStream(), assertWithinSafetyBound(), invocationCount(), isAvailable()稳定2.18.0
KeyMaterialfinal readonly classgenerate(), exposeKey(), fingerprint()稳定2.18.0
EncryptedPayloadSpecfinal readonly classtoDict()稳定2.18.0
CryptoCapabilitiesfinal classhasAesGcm(), detectFipsMode(), assertFipsAvailableForProfile()稳定2.0.0
NonceReuseException异常稳定2.18.0
TamperedDataException异常稳定2.18.0
DecryptionFailedException异常稳定2.18.0
GcmInvocationLimitExceededException异常稳定3.0.0
examples/22-protection.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
// AES-256-CBC, V=5/R=6. Call before addPage().
$doc->setEncryption(
userPassword: 'demo',
ownerPassword: 'admin',
permissions: 4, // printing only; copy/modify denied for a conforming reader
);
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 8, 'Confidential', newLine: true);
$doc->save(__DIR__ . '/output/22-protection.pdf');
examples/security/gcm-authenticated-encryption.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use NextPDF\Security\CryptoCapabilities;
use NextPDF\Security\Encryption\Aes256GcmEncryptor;
use NextPDF\Security\Exception\TamperedDataException;
use NextPDF\Security\KeyMaterial;
use Psr\Log\LoggerInterface;
final readonly class AuthenticatedBlobCipher
{
public function __construct(private LoggerInterface $logger) {}
/**
* Seal a payload with AES-256-GCM and return the wire-format bytes.
*
* @param non-empty-string $plaintext The payload to protect.
*
* @return non-empty-string IV(12) || ciphertext || tag(16).
*/
public function seal(string $plaintext, KeyMaterial $key): string
{
if (!CryptoCapabilities::hasAesGcm()) {
throw new \RuntimeException('Host cannot perform AES-256-GCM.');
}
$cipher = new Aes256GcmEncryptor($key);
// Opt-in NIST SP 800-38D §8.3 key-rotation guard.
$cipher->assertWithinSafetyBound();
$wire = $cipher->encrypt($plaintext);
$this->logger->info('Payload sealed', [
'key_fingerprint' => $key->fingerprint(),
'invocations' => $cipher->invocationCount(),
]);
return $wire;
}
/**
* Open a sealed payload; a modified payload raises, never returns plaintext.
*
* @param non-empty-string $wire IV(12) || ciphertext || tag(16).
*/
public function open(string $wire, KeyMaterial $key): string
{
try {
return (new Aes256GcmEncryptor($key))->decrypt($wire);
} catch (TamperedDataException $e) {
$this->logger->warning('Tampered payload rejected', [
'key_fingerprint' => $key->fingerprint(),
]);
throw $e;
}
}
}

这个加密器会检查主机能力、应用可选的调用防护、只记录不可逆的密钥指纹,并在检测到篡改时重新抛出异常,而不是返回可疑字节。

  • 默认的 AES-256-CBC 路径只提供机密性。它本身无法检测被修改过的密文。需要篡改检测时,请改用 AES-256-GCM 路径。
  • useAesGcm() 在 PDF/A 模式启用时会引发异常;当 opensslext-sodium 两者都未提供 AES-256-GCM 时,也会引发异常。请同时拦截这两种情况,并呈现一条运维人员可采取行动的消息。
  • 在没有 AES-NI 的主机上,libsodium 会拒绝 GCM。Core 会回退到 OpenSSL GCM,结果仍正确但速度较慢;吞吐量会下降,安全性不变。
  • GCM 每个对象的明文上限为 2^39 − 256 字节。输入若超过此上限,会引发长度异常;请使用 encryptStream() 将内容分割到多个对象。
  • 每个 KeyMaterial 实例必须恰好为 32 字节;长度错误时会在构造时被拒绝,而不会被截断。
  • 读取路径(verifyUserPassword()verifyOwnerPassword()validatePerms())会对密码学材料使用常量时间比较,并在权限标记损坏时以安全失败处理。

每个对象的 AES-256-CBC 加密都是一次 OpenSSL 调用,相对于对象主体为 O(n)。密钥派生会在每个加密器实例中执行一次迭代的算法 2.B 流程;每份文件的成本有界且固定。AES-256-GCM 流式路径会把输入分割成 16 MiB 区块,因此无论输入总量大小,都能把活跃堆内存控制在约 64 MB,不超过已记录的 64 MB 峰值预算。每个 GCM 对象会增加 28 字节额外开销(12 字节 IV 加 16 字节标签)。AES-NI 硬件能显著提升 GCM 吞吐量;缺少它只会降低吞吐量。

这个接口的威胁模型很明确。离线猜测密码的成本,会因 SASLprep 规范化加上迭代的修订版 6 密钥派生而提高,但弱密码仍是最主要的残余风险。没有任何派生方式能消除这项风险。密文遭修改时,在 GCM 路径上会通过标签验证检测出来,但在默认的 CBC 路径上并不会被检测。GCM 路径上的 IV 重用由计数器加碰撞集合防止,与 NIST SP 800-38D §8.1 的 IV 规则一致。计数器溢出会拒绝执行,而不会回绕。经由日志泄漏密钥的风险,由 KeyMaterial 掩码以及密码参数上的 #[\SensitiveParameter] 属性来缓解。在平台允许的情况下,派生出的密钥材料会在使用后归零。

这条边界同样明确。AES-256 加密的应用方式如 ISO 32000-2:2020 §7.6 所定义,可选路径则依 ISO/TS 32003:2023 §5.2;实际保护效果取决于密码强度、密钥管理、部署环境,以及读取端使用的读取器。权限标志由符合规范的读取器遵守,并非通过密码学机制强制执行。用于 /Perms 值的 AES-ECB 步骤,是 ISO 32000-2:2020 §7.6.4.4.10 针对单个 16 字节区块强制要求的。它并不是通用加密模式。在达到 2^32 调用界限前轮换密钥,是部署责任;Core 为此提供了一项检查,但默认并不强制执行。

加密与解密都在进程内执行;不会有任何文件字节、密码或密钥值通过这个接口离开主机。GCM 的 IV 碰撞集合以不可逆的密钥指纹为键,而不是密钥字节。若部署使用外部密钥管理或 PKCS#11 令牌作为密钥前端,该后端的数据驻留问题由该部署负责;OASIS PKCS#11 v3.1 的 C_GenerateKey 是令牌内密钥生成的契约接入点。

请记录策略名称与 8 字符密钥指纹,绝不要记录密钥或密码。KeyMaterial::__toString()__debugInfo() 会返回经过掩码处理的占位内容。来自这个接口的异常消息会带有一个操作标签与一个指纹,而不是密钥字节。GCM 调用次数是密钥轮换仪表板中的安全遥测信号。

威胁Core 中的缓解残余边界
离线猜测密码SASLprep 加上迭代的修订版 6 派生弱密码仍是最主要的风险
密文遭修改GCM 标签验证(可选路径)CBC 路径仅提供机密性
IV 重用(GCM)随机固定字段加计数器加碰撞集合;溢出即拒绝
GCM 明文过长2^39 − 256 处进行长度检查;提供分割指引调用方必须以流式方式处理大量输入
密钥过度使用(GCM)2^32 处由 assertWithinSafetyBound() 检查可选;默认不强制执行
权限标志遭绕过无 — 标志属建议性质不符合规范的读取器会忽略这些标志
经由日志泄漏密钥KeyMaterial 掩码;#[\SensitiveParameter]记录 exposeKey() 的调用方会破坏这项防护

Core 并非经 FIPS 验证的密码学模块,也未取得 FIPS 认证。CryptoCapabilities::detectFipsMode() 是一项尽力而为的探测,会报告已启用、不存在或无法判定;assertFipsAvailableForProfile() 会在选用 FIPS 配置文件、但主机无法证明具备 FIPS 提供者时,以安全失败处理。当加密接口运行在已加载 FIPS 验证提供者的主机 OpenSSL 构建版本上时,它会以兼容 FIPS 的模式运作。取得经验证、经认证的合规状态,属于 Enterprise 版的考虑范围。

声明标准条款证据
每个 GCM IV 都通过确定性的固定字段加计数器构造,并在每次调用时保持唯一。NIST SP 800-38D§8.2.1
IV 构造规则可防止在同一把密钥的多次调用间重用。NIST SP 800-38D§8.1
每个对象的明文上限与单次调用的长度界限相符。NIST SP 800-38D§5.2.1.1
密钥使用周期与轮换属于部署责任。NIST SP 800-57 第 1 部分修订版 5§4
AES 文件密钥为 256 位,与标准的密钥长度相符。FIPS 197§4.2.1
令牌内密钥生成是外部密钥库的集成接入点。OASIS PKCS#11 v3.1 标准C_GenerateKey(函数)

ISO 32000-2:2020 §7.6 与 ISO/TS 32003:2023 §5.2 是本页所述处理程序的规范依据。它们的文字受授权限制。本页用自己的表述改写这些内容,并以条号引用,未引述其中任何原文。字节精确的密钥派生实现,其验证证据包括算法 2.B 标准测试,以及页面证据块末尾所列的外部 oracle(测试载具)夹具。

Core 同时提供默认的 AES-256-CBC 路径与可选的 AES-256-GCM 路径,搭配本地密钥接口与加密策略 gate。Enterprise 版在同一契约背后增加 HSM/PKCS#11 密钥保管后端,以及 FIPS 模式的加密策略配置文件。公开 API 完全相同,差别在于密钥保管后端与策略实现。