跳转到内容

加密 PDF 并限制权限

本 recipe(示例)使用 AES-256 标准安全处理程序加密一份文件。它设置用户密码(打开文件时必填)和所有者密码(拥有完整访问权限),并通过权限位掩码限制各项操作。本示例特意强调这些权限依赖阅读器配合的本质:加密提供的是机密性,而不是完整性;权限位只有在配合的软件中才会被遵守。本示例对应 examples/22-protection.php

信任边界(每次提到权限时都要记住)。 PDF 加密保护内容的机密性,使没有密码的一方无法读取内容(ISO 32000-2 §7.6)。 它并不保护完整性:它不会检测或防止内容被修改。权限 P 项是一组 32 位无符号标志, 用于请求符合规范的阅读器遵守;它们并不是一种访问控制。不符合规范的工具,或任何使用所有者密码操作的工具, 都能执行每一项被「拒绝」的操作。不要把加密 PDF 描述为 「安全」、「防篡改」或「防复制」。

Terminal window
composer require nextpdf/core:^3

启用 openssl PHP 扩展。AES-256 加密器会使用它执行加密运算和密钥派生。

标准安全处理程序由加密字典中的 V/R 代码选定(ISO 32000-2 §7.6)。NextPDF 的 Aes256Encryptor 在安全处理程序修订版 6V=5/R=6)下实现 AESV3 加密过滤器:使用随机生成的 256 位文件加密密钥、加盐的迭代哈希密钥派生(算法 2.B),并使用随机初始化向量对每个对象执行 AES-256-CBC 加密。CBC 是一种机密性模式(NIST SP 800-38A)。它的 IV 必须不可预测。

每个对象、每次执行都会重新生成 IV,因此原始字节在每次执行时都会不同。因此其可重现性类别为 structural。在比较两次执行结果之前,测试工具(harness)会将加密 IV、对象顺序和尾部的 /ID 规范化。这个类别比省略加密的 recipe 所属类别更严格。

权限位掩码会设置 P 项。位 3 授予打印权限,位 6 授予 annotation/form-fill 权限;其值是规范规定的 32 位无符号数值。

NextPDF\Core\Concerns\HasSecurity(混入到 Document):

  • setEncryption(#[SensitiveParameter] string $userPassword, #[SensitiveParameter] string $ownerPassword = '', int $permissions = -1): static — 设置 AES-256 标准安全处理程序加密。permissions = -1 会授予所有权限。当 ownerPassword 为空时,会重复使用用户密码作为所有者密码。请在 addPage() 之前调用。
  • getEncryptor(): ?Aes256Encryptor — 获取已设置的加密器;若未设置,则返回 null
  • useAesGcm(?bool $enabled = true): static — 选择启用 ISO/TS 32003 的 AES-256-GCM;若主机上的 OpenSSL/libsodium 不支持该加密算法,则会抛出异常。

两个密码参数都标注了 #[SensitiveParameter],因此 PHP 会在堆栈跟踪中遮蔽它们。

权限位(P 项,常用的是低位 3–6):

操作
34打印文件
48修改文件内容
516复制/提取文字和图形
632新增或修改注释,以及填写表单字段
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
$doc->setTitle('Confidential Memo');
// Grant printing only (bit 3 = 4). MUST run before addPage().
$doc->setEncryption(
userPassword: 'open-me',
ownerPassword: 'owner-secret',
permissions: 4,
);
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, 'Encrypted with AES-256; printing allowed only.', newLine: true);
$doc->save(__DIR__ . '/confidential.pdf');
echo "Wrote confidential.pdf\n";

下面的完整示例对应 examples/22-protection.php,并为测试工具写入 NEXTPDF_COOKBOOK_OUTPUT

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
$userPassword = 'demo';
$ownerPassword = 'admin';
// Grant ONLY printing (bit 3 = 4); deny copy/modify/annotate.
$permissions = 4;
$doc = Document::createStandalone();
$doc->setTitle('Encrypted Document — Restricted Permissions');
$doc->setAuthor('NextPDF Example');
// setEncryption() MUST be called before addPage().
$doc->setEncryption(
userPassword: $userPassword,
ownerPassword: $ownerPassword,
permissions: $permissions,
);
$doc->addPage();
$doc->setFont('helvetica', 'B', 20);
$doc->cell(0, 14, 'Encrypted PDF Document', newLine: true);
$doc->ln(8);
$doc->setFont('helvetica', '', 11);
$doc->multiCell(0, 7, 'This document is protected with AES-256 encryption '
. '(standard security handler, revision 6). The user password is required '
. 'to open it; the owner password grants full access. The permission '
. 'bits below are honoured by conforming readers only.');
$doc->ln(5);
$permissionTable = [
['Bit 3 (4)', 'Printing', 'ALLOWED'],
['Bit 4 (8)', 'Content modification', 'DENIED'],
['Bit 5 (16)', 'Text copying / extraction', 'DENIED'],
['Bit 6 (32)', 'Annotations / form fields', 'DENIED'],
];
$doc->setFont('helvetica', 'B', 10);
$doc->cell(30, 7, 'Flag');
$doc->cell(60, 7, 'Operation');
$doc->cell(0, 7, 'Status', newLine: true);
foreach ($permissionTable as [$bit, $operation, $status]) {
$doc->setFont('courier', '', 9);
$doc->cell(30, 7, $bit);
$doc->setFont('helvetica', '', 10);
$doc->cell(60, 7, $operation);
$doc->setFont('helvetica', 'B', 10);
$doc->cell(0, 7, $status, newLine: true);
}
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
$doc->save($out !== false ? $out : __DIR__ . '/encrypted.pdf');
echo "Wrote encrypted PDF (AES-256, printing only)\n";

预期输出:

Wrote encrypted PDF (AES-256, printing only)

打开这个文件时会提示输入密码。用户密码会以受限权限集打开它。所有者密码则会以完整访问权限打开它。

  • 调用顺序。 setEncryption() 如果在 addPage() 之后才调用,不会回溯加密之前的内容。请务必先设置加密;引擎会在写出每个对象主体时将其加密。
  • 所有者密码默认值。 空的所有者密码会让引擎重复使用用户密码作为所有者密码,这样实际上就不存在特权角色了。当两种角色必须区分时,请设置不同的密码。
  • 权限语义仅供参考。 这些位只有符合规范的阅读器才会遵守。它们并非由密码学强制执行:不符合规范的工具,或任何使用所有者密码操作的工具,都能执行受限操作。请将权限视为给配合软件的策略信号,绝不要当作能够抵御恶意方的访问控制。
  • 没有完整性保证。 加密提供的是机密性,而非完整性。没有密码的攻击者无法读取内容,但格式本身不会检测篡改。若要保护完整性,需要另一套机制(数字签名,或 ISO/TS 32004 的文件 MAC)。
  • 与 PDF/A 冲突。 PDF/A 禁用尾部键 Encrypt。对一份 PDF/A 文件调用 setEncryption(),无论顺序如何,都会抛出不兼容异常。
  • 选择启用 AES-256-GCM。 当主机上的 OpenSSL 或 libsodium 支持时,useAesGcm() 会选用 ISO/TS 32003 的 GCM 批量加密;否则会抛出 InvalidConfigException。基于同样原因,它也与 PDF/A 不兼容。
  • 公钥加密尚未接通。 setPublicKeyEncryption() 冻结了 API 接口,但在写入器接线完成之前,save() 都会抛出异常(这是已知缺陷);请勿在 Core 的正式环境中使用它。

密钥派生会对每份文件执行一次算法 2.B 的迭代哈希。逐对象的 AES-256-CBC 成本与对象主体大小呈线性关系。对于一般文件,其成本仍会稳定落在 1500 ms/64 MB 的预算之内。非常大的文件会产生逐对象的 AES 吞吐量成本。在具备 AES-NI 的主机上,GCM 会更快。

  • 仅有机密性。 重申信任边界:加密让没有密码的一方无法获取内容;它不能证明文件未被更改,而且权限位依赖阅读器配合。
  • 密码强度由你负责。 这个处理程序的强度取决于密码强度。一旦获得文件,弱用户密码就可能被离线暴力破解;格式无法限制尝试速率。
  • 所有者密码是主密钥。 任何持有所有者密码的人都能绕过每一项限制。请将它视为 root 凭证;绝不要随文件一起分发,也不要记录它。
  • #[SensitiveParameter] 是纵深防御。 它会把密码从 PHP 堆栈跟踪中遮蔽,但你仍必须确保它们不会出现在你自己的日志、异常信息与崩溃报告中。

此库在进程内进行加密。它不会把文件或密码传输到任何地方。除了你存储的加密输出之外,引擎不会把任何密码、密钥或文件字节写入磁盘。输出文件存放在何处,以及密码如何保管,都是由集成者负责的部署层面考量。此库不提供任何数据驻留保证。若明文文件含有个人数据,该数据受到的保护程度仅取决于最弱的密码,以及上述依赖阅读器配合的限制。加密无法取代尽量减少放入文件的 PII。

加密会发出一个 EncryptionAppliedEvent,其中只携带算法名称(AES-256)以及三个布尔值,用于摘要是否允许 print/copy/修改——任何密码、密钥、盐或 IV 都绝不会放入这个事件src/Event/Security/EncryptionAppliedEvent.php)。OpenTelemetry 路径会将 span 属性导入采用白名单的清理器(src/Telemetry/AttributeSanitizer.php),它会无条件拒绝密码和文件路径;只有列入白名单且值为标量的键才能保留下来。请勿在你自己的集成代码中,把密码或密钥材料加入 span、日志或异常信息——#[SensitiveParameter] 标记保护的是堆栈跟踪,而不是你自己拼出的字符串。

在范围内:获得加密文件但没有密码的对手——他们无法读取内容(视密码强度而定),且文件不会泄漏明文。在范围外:持有用户或所有者密码的对手;忽略权限位、不符合规范的阅读器;对弱密码的离线暴力破解;篡改检测(加密提供的是机密性,而非完整性);主机 OpenSSL 构建中的侧信道;以及密钥保管,这完全是集成者的责任。记录这些威胁,并不意味着主张不存在弱点。

密码学原语由主机上的 OpenSSL 构建提供,因此 FIPS 状态是主机属性,而不是库设置。CryptoCapabilities::detectFipsMode() 会返回三态的 FipsModeDetectionsrc/Security/FipsModeDetection.php):FIPS_ACTIVEFIPS_ABSENTINDETERMINATE。PHP openssl 扩展并未公开 OpenSSL 3 provider 模型的任何绑定,因此探测只能尽力而为;INDETERMINATE 会被视为「FIPS 未获证明」(fail-closed),并可在运维者能够采取行动的遥测中区分出来。NextPDF 并不宣称通过 FIPS 140 验证;在通过 FIPS 验证的 OpenSSL 上执行是运维者的责任,检测结果仅供参考。

陈述规格条款参考 ID
加密字典的 V 代码用于选定加密算法。ISO 32000-2§7.6
AESV3 加密过滤器方法由 CFM 项命名。ISO 32000-2§7.6
其中 P 项是一个 32 位无符号访问权限数值。ISO 32000-2§7.6
权限位 3 控制打印。ISO 32000-2§7.6
权限位 6 控制注释/表单填写。ISO 32000-2§7.6
加密保护内容免遭未经授权的访问(机密性)。ISO 32000-2§7.6
修订版 6 的密钥派生采用加盐的迭代哈希(算法 2.B)。ISO 32000-2§7.6
CBC 是一种机密性模式(而非完整性模式)。NIST SP 800-38A§6.2
CBC 初始化向量必须是不可预测的。NIST SP 800-38A附录 C

NextPDF 实现了所引用的这些条款;它并不主张全面符合 ISO 32000-2、通过 FIPS 140 验证,或提供任何法律或合同上的机密性保证。「支持标准安全处理程序」并不是对你的部署安全性的认证——这取决于库之外的密码保管与验证者策略。