Ir al contenido

Cifrar un PDF y restringir sus permisos

Esta recipe cifra un documento con el gestor de seguridad estándar AES-256. Define una contraseña de usuario (necesaria para abrirlo) y una contraseña de propietario (acceso completo), y restringe operaciones mediante una máscara de bits de permisos. La recipe subraya deliberadamente la naturaleza cooperativa del lector de esos permisos: el cifrado aporta confidencialidad, no integridad, y los bits de permisos solo los respeta el software que coopera. La recipe sigue examples/22-protection.php.

Límite de confianza (tenerlo presente en cada afirmación sobre permisos). El cifrado de PDF protege la confidencialidad del contenido frente a quienes no tienen la contraseña (ISO 32000-2 §7.6). No protege la integridad: no detecta ni impide la modificación. La entrada de permisos P es un conjunto de banderas de 32 bits sin signo que se pide respetar a los lectores conformes; no constituye un control de acceso. Una herramienta no conforme, o cualquier herramienta utilizada con la contraseña de propietario, puede realizar cualquiera de las operaciones «denegadas». No debe describirse un PDF cifrado como «seguro», «a prueba de manipulaciones» o «protegido contra copia».

Ventana de terminal
composer require nextpdf/core:^3

Se debe habilitar la extensión de PHP openssl. El cifrador AES-256 la usa para el cifrado y la derivación de claves.

El gestor de seguridad estándar se selecciona mediante los códigos V/R del diccionario de cifrado (ISO 32000-2 §7.6). El Aes256Encryptor de NextPDF implementa el filtro de cifrado AESV3 en la revisión 6 del gestor de seguridad (V=5/R=6): una clave aleatoria de cifrado de archivo de 256 bits, derivación de claves por hash iterativo con sal (Algoritmo 2.B) y cifrado AES-256-CBC por objeto con un vector de inicialización aleatorio. CBC es un modo de confidencialidad (NIST SP 800-38A). Sus IV deben ser impredecibles.

El IV cambia por objeto y por ejecución, así que los bytes sin procesar difieren de una ejecución a otra. Por lo tanto, el perfil de reproducibilidad es structural. Antes de comparar dos ejecuciones, el arnés canonicaliza el IV de cifrado, el orden de los objetos y el /ID del trailer. Este perfil es más estricto que el de una recipe que omite el cifrado.

La máscara de bits de permisos establece la entrada P. El bit 3 concede la impresión y el bit 6 concede la anotación y el rellenado de formularios; el valor es la cantidad documentada sin signo de 32 bits.

NextPDF\Core\Concerns\HasSecurity (incluido en Document):

  • setEncryption(#[SensitiveParameter] string $userPassword, #[SensitiveParameter] string $ownerPassword = '', int $permissions = -1): static: configura el cifrado del gestor estándar AES-256. permissions = -1 concede todos los permisos. Cuando ownerPassword se deja vacía, la contraseña de usuario se reutiliza como contraseña de propietario. Debe llamarse antes de addPage().
  • getEncryptor(): ?Aes256Encryptor: el cifrador configurado, o null.
  • useAesGcm(?bool $enabled = true): static: activa el AES-256-GCM de ISO/TS 32003; lanza una excepción si OpenSSL/libsodium del host no proporciona ese cifrado.

Ambos parámetros de contraseña están marcados con #[SensitiveParameter], así que PHP los oculta de los rastreos de pila.

Bits de permisos (la entrada P, bits bajos 3–6 usados habitualmente):

BitValorOperación
34Imprimir el documento
48Modificar el contenido del documento
516Copiar / extraer texto y gráficos
632Agregar o modificar anotaciones y rellenar campos de formulario
<?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";

El ejemplo completo siguiente refleja examples/22-protection.php y escribe en NEXTPDF_COOKBOOK_OUTPUT para el arnés.

<?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";

Salida esperada:

Wrote encrypted PDF (AES-256, printing only)

Al abrir el archivo, se solicita una contraseña. La contraseña de usuario lo abre con el conjunto de permisos restringido. La contraseña de propietario lo abre con acceso completo.

  • Orden de llamada. setEncryption() después de addPage() no cifra retroactivamente el contenido anterior. Configurar siempre el cifrado primero; el motor cifra el cuerpo de cada objeto a medida que se escribe.
  • Valor predeterminado de la contraseña de propietario. Una contraseña de propietario vacía hace que el motor reutilice la contraseña de usuario como contraseña de propietario; entonces no hay, en la práctica, ningún rol privilegiado. Usar contraseñas distintas cuando los dos roles deban diferir.
  • La semántica de los permisos es orientativa. Los bits solo los respetan los lectores conformes. No se imponen criptográficamente: una herramienta no conforme, o cualquier herramienta usada con la contraseña de propietario, puede realizar operaciones restringidas. Tratar los permisos como una señal de política para el software que coopera, nunca como un control de acceso que resista a un actor decidido.
  • Sin garantía de integridad. El cifrado aporta confidencialidad, no integridad. Un atacante sin la contraseña no puede leer el contenido, pero el formato en sí no detecta manipulaciones. Para proteger la integridad se necesita un mecanismo aparte (una firma digital, o el MAC de documento de ISO/TS 32004).
  • Conflicto con PDF/A. PDF/A prohíbe la clave Encrypt del trailer. Llamar a setEncryption() en un documento PDF/A, en cualquier orden, lanza una excepción de incompatibilidad.
  • Activación opcional de AES-256-GCM. useAesGcm() selecciona el cifrado masivo GCM de ISO/TS 32003 cuando OpenSSL o libsodium del host lo ofrecen; de lo contrario, lanza InvalidConfigException. Es incompatible con PDF/A por la misma razón.
  • El cifrado de clave pública aún no está conectado. setPublicKeyEncryption() fija la superficie de la API, pero save() lanza una excepción hasta que llegue la conexión del escritor (un defecto conocido); no usarlo en producción en Core.

La derivación de claves ejecuta el hash iterativo del Algoritmo 2.B una vez por documento. El AES-256-CBC por objeto es lineal respecto al tamaño del cuerpo del objeto. Para documentos típicos, el coste se mantiene cómodamente dentro del presupuesto de 1500 ms / 64 MB. Los documentos muy grandes asumen un coste de AES por objeto. GCM con AES-NI es más rápido en hosts compatibles.

  • Solo confidencialidad. Para reiterar el límite de confianza: el cifrado mantiene el contenido alejado de quienes no tienen la contraseña; no prueba que el archivo esté inalterado, y los bits de permisos dependen de la cooperación del lector.
  • La fortaleza de la contraseña es responsabilidad del integrador. El gestor es tan fuerte como lo sean las contraseñas. Una contraseña de usuario débil es susceptible de fuerza bruta sin conexión una vez que se obtiene el archivo; el formato no puede limitar la tasa de intentos.
  • La contraseña de propietario es una clave maestra. Cualquiera que tenga la contraseña de propietario sortea todas las restricciones. Debe tratarse como una credencial de root; nunca enviarla con el documento ni registrarla.
  • #[SensitiveParameter] es defensa en profundidad. Oculta las contraseñas de los rastreos de pila de PHP, pero aun así deben mantenerse fuera de los registros propios, mensajes de excepción e informes de fallos.

La biblioteca realiza el cifrado en el propio proceso. No transmite el documento ni las contraseñas a ningún destino externo. El motor no escribe en disco ninguna contraseña, clave ni byte del documento, salvo la salida cifrada que se guarda. La ubicación del archivo de salida y la custodia de las contraseñas son responsabilidades del integrador en el despliegue. La biblioteca no ofrece ninguna garantía sobre residencia de datos. Si el documento en texto plano contiene datos personales, esos datos están protegidos solo en la medida de la contraseña más débil y de la advertencia anterior sobre lectores cooperativos. El cifrado no sustituye a minimizar la PII incluida en el documento.

Telemetría segura y depuración de registros

Sección titulada «Telemetría segura y depuración de registros»

El cifrado emite un EncryptionAppliedEvent que lleva únicamente el nombre del algoritmo (AES-256) y tres valores booleanos que indican si se permiten print/copy/modify; nunca se incluye en el evento ninguna contraseña, clave, sal ni IV (src/Event/Security/EncryptionAppliedEvent.php). El flujo de OpenTelemetry enruta los atributos de span a través de un saneador con lista de permitidos (src/Telemetry/AttributeSanitizer.php) que rechaza incondicionalmente contraseñas y rutas de archivo; solo se conservan las claves de la lista de permitidos con valores escalares. No añadir material de contraseñas ni de claves a spans, registros o mensajes de excepción en el código de integración propio; los marcadores #[SensitiveParameter] protegen los rastreos de pila, pero no las cadenas construidas manualmente.

Dentro del alcance: un adversario que obtiene el archivo cifrado pero no las contraseñas; no puede leer el contenido (según la fortaleza de la contraseña) y el archivo no filtra texto plano. Fuera del alcance: un adversario que tiene la contraseña de usuario o de propietario; un lector no conforme que ignora los bits de permisos; la fuerza bruta sin conexión de una contraseña débil; la detección de manipulaciones (el cifrado aporta confidencialidad, no integridad); los canales laterales de la compilación de OpenSSL del host; y la custodia de claves, que es responsabilidad exclusiva del integrador. Documentar estas amenazas no afirma la ausencia de vulnerabilidades.

Las primitivas criptográficas las proporciona la compilación de OpenSSL del host, así que la postura FIPS es una propiedad del host, no un ajuste de la biblioteca. CryptoCapabilities::detectFipsMode() devuelve un FipsModeDetection de tres estados (src/Security/FipsModeDetection.php): FIPS_ACTIVE, FIPS_ABSENT o INDETERMINATE. La extensión openssl de PHP no expone ninguna vinculación con el modelo de proveedores de OpenSSL 3, así que el sondeo es de mejor esfuerzo; INDETERMINATE se trata como «FIPS no probado» (fail-closed), distinguible en la telemetría accionable por el operador. NextPDF no declara la validación FIPS 140; ejecutar sobre un OpenSSL validado por FIPS es responsabilidad del operador y el resultado de la detección es orientativo.

AfirmaciónEspecificaciónCláusulareference_id
El código V del diccionario de cifrado selecciona el algoritmo de cifrado.ISO 32000-2§7.6
El método de filtro de cifrado AESV3 se nombra mediante la entrada CFM.ISO 32000-2§7.6
La entrada P es una cantidad de permisos de acceso sin signo de 32 bits.ISO 32000-2§7.6
El bit de permiso 3 controla la impresión.ISO 32000-2§7.6
El bit de permiso 6 controla la anotación / rellenado de formularios.ISO 32000-2§7.6
El cifrado protege el contenido frente al acceso no autorizado (confidencialidad).ISO 32000-2§7.6
La derivación de claves de la revisión 6 usa hash iterativo con sal (Algoritmo 2.B).ISO 32000-2§7.6
CBC es un modo de confidencialidad (no un modo de integridad).NIST SP 800-38A§6.2
Los vectores de inicialización de CBC deben ser impredecibles.NIST SP 800-38AAp. C

NextPDF implementa las cláusulas citadas; no afirma conformidad general con ISO 32000-2, ni validación FIPS 140, ni ninguna garantía legal o contractual de confidencialidad. «Compatibilidad con el gestor de seguridad estándar» no es una certificación de seguridad en el despliegue: eso depende de la custodia de las contraseñas y de la política del verificador, ajenas a la biblioteca.