Encryption: AES-256 (CBC) and AES-256-GCM
At a glance
Section titled “At a glance”Core encrypts Portable Document Format (PDF) files with AES-256 (Advanced Encryption Standard with 256-bit keys) under the ISO 32000-2:2020 §7.6 Standard security handler. The default mode is V=5 / R=6 / AESV3 (AES-256-CBC, Cipher Block Chaining). The opt-in authenticated mode is the ISO/TS 32003:2023 V=6 / R=7 AES-256-GCM (Galois/Counter Mode) path. This page defines key derivation, wire format, the permission boundary, and deployment limits.
Install
Section titled “Install”composer require nextpdf/core:^3The default path requires the openssl extension. The AES-256-GCM path uses openssl or ext-sodium. On hosts without AES-NI hardware, libsodium declines GCM; Core falls back to the slower OpenSSL implementation without changing the algorithm.
Conceptual overview
Section titled “Conceptual overview”The default handler uses the V=5 / R=6 Standard security handler with the AESV3 crypt filter. When you call setEncryption(), Core generates a random 256-bit file key from the platform cryptographic random source (random_bytes()). The key is 32 bytes, matching the FIPS 197 key length. Core encrypts per-object content with AES-256-CBC. It prepends the 16-byte initialization vector to each ciphertext, as ISO 32000-2:2020 §7.6.4 directs.
Key derivation follows Algorithm 2.B at revision 6. Core first normalizes the password with SASLprep (RFC 4013), then truncates it to 127 UTF-8 bytes on a character boundary, as ISO 32000-2:2020 §7.6.4.3.3 directs. It computes the derived hash with an iterated SHA-256 / SHA-384 / SHA-512 routine driven by an AES-128-CBC step, raising the cost of offline password guessing. Core generates the user, owner, and per-key salts once per encryptor instance, so one instance emits deterministic dictionary bytes, a precondition for a multi-pass writer.
useAesGcm() enables the opt-in AES-256-GCM path. It implements the ISO/TS 32003:2023 V=6 / R=7 AESV4 crypt filter. The cipher is AES-256-GCM with parameters from NIST SP 800-38D. For each encrypted object, the wire layout is a 12-byte IV, the ciphertext, and a 16-byte authentication tag. The additional authenticated data is empty, as the TS 32003 §5.2 profile directs. Decryption verifies the tag and raises TamperedDataException on a mismatch; it never returns plaintext after a failed tag. This path adds modification detection that the default CBC path does not provide by itself.
The GCM path follows the IV-uniqueness discipline in NIST SP 800-38D §8. The upper 4 bytes of the IV are a per-instance fixed field set from a random source during construction. The lower 8 bytes are a big-endian counter that increments after each issued IV. This follows the deterministic-construction approach in §8.2.1, except the fixed field is randomized to prevent cross-document collisions rather than enumerated. A second guard records every emitted IV in a collision set and raises NonceReuseException if a value repeats. Counter rollover also raises NonceReuseException, because rollover is the IV-reuse failure mode that §8 warns against.
Two length bounds apply to the GCM path. The per-object plaintext ceiling is 2^39 − 256 bytes, the per-invocation bound derived in NIST SP 800-38D §5.2.1.1. Larger input raises a length exception with guidance to partition data across objects. The invocation safety bound is 2^32 calls per key. assertWithinSafetyBound() is an opt-in check that raises GcmInvocationLimitExceededException, letting a caller rotate the document key before the §8.3 threshold. NIST SP 800-57 Part 1 §4 treats this key-lifetime decision as a deployment responsibility.
Permission flags are advisory. Core writes the bitmask to the encrypted /Perms entry and the /P value, then recovers it with validatePerms() on read, which fails closed on a corrupt marker. A conforming reader is expected to honor the flags. The flags are not enforced by cryptography: a processor that has the decryption key and ignores the bits can read, copy, or modify the content. Describe permission flags as a reader convention, not as access control.
API surface
Section titled “API surface”| Type | Kind | Key members | Stability | Since |
|---|---|---|---|---|
Aes256Encryptor | class | encrypt(), decrypt(), encryptForObject(), buildEncryptionDictionary(), verifyUserPassword(), verifyOwnerPassword(), validatePerms(), getEncryptionKey() | stable | 1.0.0 |
Aes256GcmEncryptor | class | encrypt(), decrypt(), encryptStream(), assertWithinSafetyBound(), invocationCount(), isAvailable() | stable | 2.18.0 |
KeyMaterial | final readonly class | generate(), exposeKey(), fingerprint() | stable | 2.18.0 |
EncryptedPayloadSpec | final readonly class | toDict() | stable | 2.18.0 |
CryptoCapabilities | final class | hasAesGcm(), detectFipsMode(), assertFipsAvailableForProfile() | stable | 2.0.0 |
NonceReuseException | exception | — | stable | 2.18.0 |
TamperedDataException | exception | — | stable | 2.18.0 |
DecryptionFailedException | exception | — | stable | 2.18.0 |
GcmInvocationLimitExceededException | exception | — | stable | 3.0.0 |
Code sample — Quick start
Section titled “Code sample — Quick start”<?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');Code sample — Production
Section titled “Code sample — Production”<?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; } }}The cipher checks host capability, applies the opt-in invocation guard, logs only the non-reversible key fingerprint, and rethrows tamper rejections instead of returning suspect bytes.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- The default AES-256-CBC path provides confidentiality only. It does not detect modified ciphertext by itself. Use the AES-256-GCM path when you need modification detection.
useAesGcm()raises when PDF/A mode is active and when neitheropensslnorext-sodiumoffers AES-256-GCM. Catch both cases and surface an operator-actionable message.- On hosts without AES-NI, libsodium declines GCM. Core falls back to OpenSSL GCM, which is correct but slower; throughput drops, not security.
- The GCM per-object plaintext ceiling is
2^39 − 256bytes. Larger input raises a length exception; partition the content across multiple objects withencryptStream(). - A
KeyMaterialinstance must be exactly 32 bytes. Construction rejects a wrong length instead of truncating it. - The reader path (
verifyUserPassword(),verifyOwnerPassword(),validatePerms()) uses constant-time comparison for cryptographic material and fails closed on a corrupt permission marker.
Performance
Section titled “Performance”Per-object AES-256-CBC encryption is one OpenSSL call and is O(n) in the object body. Key derivation runs the iterated Algorithm 2.B routine once per encryptor instance; the cost is bounded and constant per document. The AES-256-GCM streaming path partitions input into 16 MiB chunks, bounding live heap use to roughly 64 MB regardless of total input size and staying under the documented 64 MB peak budget. Each GCM object adds 28 bytes of overhead (12-byte IV plus 16-byte tag). AES-NI hardware materially improves GCM throughput; without it, only throughput drops.
Security notes
Section titled “Security notes”This encryption surface has an explicit threat model. SASLprep normalization plus the iterated revision-6 key derivation raises the cost of offline password guessing, but a weak password remains the dominant residual risk. No derivation removes that risk. The GCM path detects ciphertext modification through tag verification; the default CBC path does not. On the GCM path, a counter plus a collision set prevents IV reuse, consistent with NIST SP 800-38D §8.1 IV discipline. Counter rollover refuses rather than wraps. KeyMaterial redaction and the #[\SensitiveParameter] attribute on passwords mitigate key disclosure through logs. Derived key material is zeroed after use where the platform allows.
The boundary is also explicit. Core applies AES-256 encryption as defined in ISO 32000-2:2020 §7.6 and, for the opt-in path, ISO/TS 32003:2023 §5.2. Effective protection depends on password strength, key management, the deployment environment, and the consuming reader. Conforming readers honor permission flags, but cryptography does not enforce them. The AES-ECB step used for the /Perms value is mandated by ISO 32000-2:2020 §7.6.4.4.10 for a single 16-byte block. It is not a general-purpose mode. Core exposes a check for key rotation before the 2^32 invocation bound, but does not enforce it by default; that rotation is a deployment responsibility.
Data Residency & PII Mitigations
Section titled “Data Residency & PII Mitigations”Encryption and decryption run in process; no document bytes, password, or key value leave the host through this surface. The GCM IV-collision set keys on a non-reversible key fingerprint, not on key bytes. If a deployment places the key behind an external key-management system or a PKCS#11 token, that backend is responsible for residency; OASIS PKCS#11 v3.1 C_GenerateKey is the contract point for token-resident key generation.
Safe Telemetry & Log Scrubbing
Section titled “Safe Telemetry & Log Scrubbing”Log the policy name and the 8-character key fingerprint, never the key or password. KeyMaterial::__toString() and __debugInfo() return a redacted placeholder. Exceptions from this surface include an operation label and a fingerprint, not key bytes. The GCM invocation count is safe telemetry for key-rotation dashboards.
Threat model
Section titled “Threat model”| Threat | Mitigation in Core | Residual boundary |
|---|---|---|
| Offline password guessing | SASLprep plus iterated revision-6 derivation | A weak password is still the dominant risk |
| Ciphertext modification | GCM tag verification (opt-in path) | CBC path is confidentiality-only |
| IV reuse (GCM) | Random fixed field plus counter plus collision set; rollover refuses | — |
| Over-long GCM plaintext | Length check at 2^39 − 256; partition guidance | Caller must stream large input |
| Key over-use (GCM) | assertWithinSafetyBound() at 2^32 | Opt-in; not enforced by default |
| Permission-flag bypass | None — flags are advisory | A non-conforming reader ignores the flags |
| Key disclosure via logs | KeyMaterial redaction; #[\SensitiveParameter] | A caller that logs exposeKey() defeats this |
FIPS-mode behavior
Section titled “FIPS-mode behavior”Core is not a FIPS-validated cryptographic module and is not FIPS-certified. CryptoCapabilities::detectFipsMode() is a best-effort probe that reports active, absent, or indeterminate. assertFipsAvailableForProfile() fails closed when a FIPS profile is selected on a host that does not prove a FIPS provider. The encryption surface operates in a FIPS-compatible mode when it runs against a host OpenSSL build that has loaded a FIPS-validated provider. A validated, certified posture is an Enterprise concern.
Conformance
Section titled “Conformance”| Claim | Standard | Clause | Evidence |
|---|---|---|---|
| Every GCM IV is unique per invocation via a deterministic fixed-field-plus-counter construction. | NIST SP 800-38D | §8.2.1 | |
| IV construction discipline prevents reuse across invocations on one key. | NIST SP 800-38D | §8.1 | |
| The per-object plaintext ceiling matches the per-invocation length bound. | NIST SP 800-38D | §5.2.1.1 | |
| Key cryptoperiod and rotation are a deployment responsibility. | NIST SP 800-57 Part 1 Rev. 5 | §4 | |
| The AES file key is 256 bits, matching the standard’s key length. | FIPS 197 | §4.2.1 | |
| Token-resident key generation is the external-key-store integration point. | OASIS PKCS#11 v3.1 | C_GenerateKey |
ISO 32000-2:2020 §7.6 and ISO/TS 32003:2023 §5.2 are the normative basis for the handlers documented here. Their text is license-restricted. This page paraphrases those standards, cites clauses by number, and quotes none of them. The Algorithm 2.B standards test and the external-oracle fixture in the page evidence trailer provide the verified runtime evidence for byte-exact key derivation.
Commercial context
Section titled “Commercial context”Core ships the default AES-256-CBC path, the opt-in AES-256-GCM path, a local-key surface, and the crypto-policy gate. The Enterprise edition adds an HSM/PKCS#11 key-custody backend and a FIPS-mode crypto-policy profile behind the same contracts. The public application programming interface (API) is identical; the key-custody backend and policy implementation differ.
See also
Section titled “See also”- Security — the security-module overview and the permission boundary.
- Contracts / Security Policy — the crypto-policy contract that gates the cipher.
- Security / Signing — signatures and timestamps, the sibling crypto surface.
- Conformance — PDF/A prohibition of the
Encryptkey.