Skip to content

Encryption: AES-256 (CBC) and AES-256-GCM

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.

Terminal window
composer require nextpdf/core:^3

The 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.

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.

TypeKindKey membersStabilitySince
Aes256Encryptorclassencrypt(), decrypt(), encryptForObject(), buildEncryptionDictionary(), verifyUserPassword(), verifyOwnerPassword(), validatePerms(), getEncryptionKey()stable1.0.0
Aes256GcmEncryptorclassencrypt(), decrypt(), encryptStream(), assertWithinSafetyBound(), invocationCount(), isAvailable()stable2.18.0
KeyMaterialfinal readonly classgenerate(), exposeKey(), fingerprint()stable2.18.0
EncryptedPayloadSpecfinal readonly classtoDict()stable2.18.0
CryptoCapabilitiesfinal classhasAesGcm(), detectFipsMode(), assertFipsAvailableForProfile()stable2.0.0
NonceReuseExceptionexceptionstable2.18.0
TamperedDataExceptionexceptionstable2.18.0
DecryptionFailedExceptionexceptionstable2.18.0
GcmInvocationLimitExceededExceptionexceptionstable3.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;
}
}
}

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.

  • 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 neither openssl nor ext-sodium offers 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 − 256 bytes. Larger input raises a length exception; partition the content across multiple objects with encryptStream().
  • A KeyMaterial instance 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.

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.

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.

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.

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.

ThreatMitigation in CoreResidual boundary
Offline password guessingSASLprep plus iterated revision-6 derivationA weak password is still the dominant risk
Ciphertext modificationGCM 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 plaintextLength check at 2^39 − 256; partition guidanceCaller must stream large input
Key over-use (GCM)assertWithinSafetyBound() at 2^32Opt-in; not enforced by default
Permission-flag bypassNone — flags are advisoryA non-conforming reader ignores the flags
Key disclosure via logsKeyMaterial redaction; #[\SensitiveParameter]A caller that logs exposeKey() defeats this

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.

ClaimStandardClauseEvidence
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.1C_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.

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.