Artículo técnico

Cifrado de PDF con certificados en Delphi: RSA-OAEP y ECDH

HotPDF cifra un PDF para titulares de certificado concretos por el security handler de clave pública de ISO 32000: EnablePubKeyEncryption toma una semilla aleatoria de 20 bytes, y cada destinatario recibe su propio envelope CMS, construido por AddPubKeyRecipientCertificate para claves RSA (transporte de clave RSA-OAEP) o por AddPubKeyAgreementRecipientWithSecret para claves de curva elíptica (ECDH sobre P-256, P-384, P-521, X25519 o X448). Nadie comparte una contraseña; quien tenga la clave privada que corresponde abre el archivo

El caso de uso es siempre alguna versión de la misma historia. Un paquete de auditoría trimestral va a tres revisores externos, legal quiere que cada uno lo lea, solo uno de ellos puede imprimirlo, y nadie quiere una contraseña sentada en un hilo de correo junto al adjunto. El cifrado por contraseña no puede expresar eso. El cifrado por certificado sí, porque cada destinatario desbloquea el documento con una clave que ya tiene, y cada destinatario puede llevar un conjunto de permisos distinto dentro de su propio envelope

¿En qué se diferencia el cifrado de PDF por certificado de una contraseña?

Un PDF cifrado con clave pública deriva su file key de una semilla aleatoria más los bytes exactos de cada envelope de destinatario, no de nada que escriba una persona. El handler está descrito en ISO 32000-1 §7.6.4 (§7.6.5 en ISO 32000-2), y los envelopes son estructuras CMS EnvelopedData según RFC 5652. HotPDF escribe /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5; para AES-256 eso significa /V 5 y una entrada /DefaultCryptFilter bajo /CF con /CFM /AESV3, y el array /Recipients vive dentro de ese crypt filter. Cada envelope cifra 24 bytes: la semilla de 20 bytes seguida de la palabra de permisos de 32 bits de ese destinatario. El valor /P del diccionario de cifrado es solo un placeholder, porque los permisos reales viajan dentro de cada envelope. En carga un lector desenvuelve un envelope, recupera la semilla, y hashea la semilla junto con cada envelope en orden de /Recipients (SHA-256 para AES-256, SHA-1 para los cifrados más viejos) para reconstruir la file key. Si todavía está decidiendo entre este modelo y las contraseñas corrientes, la guía de cifrado con contraseña AES-256 y flags de permisos cubre el otro lado de ese trade-off

Diagrama de cifrado de clave pública de HotPDF: EnablePubKeyEncryption fija una semilla de 20 bytes, cada envelope CMS EnvelopedData cifra esos 20 bytes más una palabra de permisos de 32 bits dentro de /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5 y /CFM /AESV3, y el lector desenvuelve un envelope, recupera la semilla y la hashea con cada entrada de /Recipients en orden de array para reconstruir la file key
El valor /P del diccionario de cifrado es solo un placeholder porque los permisos reales viajan dentro de cada envelope, y nada aguas abajo puede reordenar o recodificar el array sobre el que corre el digest

Escribir recipients RSA con EnablePubKeyEncryption

Para certificados RSA, llame a EnablePubKeyEncryption con aes256, y luego a AddPubKeyRecipientCertificate una vez por certificado codificado en DER antes de BeginDoc. El helper construye un envelope RSAES-OAEP en proceso con valores THPDFRSAOAEPHash para el digest OAEP y el digest MGF1 (rohSHA256, rohSHA384 o rohSHA512), y cifra el contenido del envelope con AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // exactamente 20 bytes, también para AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // el tipo de clave por defecto es aes128
    // El revisor A puede imprimir; el revisor B solo puede leer y extraer
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Tres detalles de ese listado sostienen el edificio. Primero, la longitud de la semilla está fija en 20 bytes para cada tipo de clave, AES-256 incluido; EnablePubKeyEncryption lanza excepción ante cualquier otra longitud. Segundo, EnablePubKeyEncryption hace default a aes128, y ambos helpers de certificado se niegan a correr si el tipo de clave no es aes256, así que olvidar el segundo argumento le consigue la excepción «certificate envelopes require aes256». Los cifrados legacy (k40, k128, aes128) siguen funcionando, pero solo vía AddPubKeyRecipient con un envelope que usted haya construido en otro sitio. Tercero, el cifrado de clave pública AES-256 es una feature de PDF 2.0, así que HotPDF sube la versión del documento a 2.0 automáticamente. Con StrictVersionLock puesto en una versión inferior, EnablePubKeyEncryption devuelve sin activar nada, y el fallo solo aparece en la línea siguiente como «call EnablePubKeyEncryption first». Cambiar el cifrado durante una actualización incremental lanza EInvalidOpException de inmediato

Añadir recipients ECDH: P-256, P-384, P-521, X25519 y X448

Para certificados de curva elíptica, AddPubKeyAgreementRecipientWithSecret escribe un recipient CMS de key agreement (KeyAgreeRecipientInfo, la estructura KARI del RFC 5753, con el perfil X25519 y X448 del RFC 8418) y calcula el secreto compartido ECDH en proceso. La curva se elige con un valor THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 o pkasX448. El scheme tiene que coincidir con la clave del certificado, o la llamada lanza «Certificate key does not match the requested agreement scheme». Por debajo, cada envelope recibe un UKM aleatorio fresco de 32 bytes, una key-encryption key derivada con el KDF stdDH (SHA-256 para P-256 y X25519, SHA-384 para P-384, SHA-512 para P-521 y X448), y un key wrap AES-256 según RFC 3394. El secreto compartido en sí sale de código Pascal puro de curvas, sin ningún proveedor criptográfico de plataforma de por medio; el artículo de aritmética de curvas NIST en Pascal puro explica cómo se construyó y verificó esa capa. Para las curvas de Montgomery, el par de claves efímero entero puede generarse localmente:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Escalar efímero fresco por envelope; el clamping ocurre dentro de la ladder
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: solo tiene sentido para las curvas NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Las curvas NIST le exigen más a quien llama. HotPDF trae helpers de clave pública solo para X25519 y X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), así que para P-256, P-384 y P-521 genera el par efímero con su propia herramienta y pasa un escalar big-endian de exactamente el tamaño del cuerpo (32, 48 o 66 bytes) más el punto sin comprimir 0x04||X||Y correspondiente como OriginatorPublicKey. HotPDF valida el punto del destinatario contra la ecuación de la curva, pero no puede comprobar que su clave pública originator pertenezca de verdad a su escalar. Mitades que no casan producen aun así un envelope perfectamente bien formado que ningún destinatario puede abrir, que es la razón por la que una carga de ida y vuelta pertenece a su suite de tests, y no solo una comprobación de tamaño de archivo

Diagrama de agreement ECDH de HotPDF: AddPubKeyAgreementRecipientWithSecret deriva el secreto compartido con código Pascal puro de curvas, mezcla un UKM fresco de 32 bytes por el KDF stdDH con SHA-256 para P-256 y X25519, SHA-384 para P-384, SHA-512 para P-521 y X448, y luego envuelve la clave de contenido con el key wrap AES-256 del RFC 3394 para construir el envelope KeyAgreeRecipientInfo
El valor de scheme de pkasECDHP256 a pkasX448 debe coincidir con la clave del certificado, y unas mitades de escalar y punto público que no casan producen aun así un envelope bien formado que ningún destinatario puede abrir

¿Por qué importa el orden de /Recipients?

El orden de /Recipients importa porque la file key es un digest sobre la semilla y cada envelope en orden de array, así que writer y lector deben hashear los mismos bytes en la misma secuencia. HotPDF conserva los envelopes en el orden en que usted los añade y los escribe sin cambios, lo que significa que puede añadir destinatarios en el orden que quiera, pero nada aguas abajo puede reordenar, recodificar o «limpiar» ese array. La mayoría de los bugs reales de esta zona fueron una variación sobre ese tema, donde dos lados hasheaban bytes ligeramente distintos:

  • Guardar arrays dinámicos en un TList vía Add conserva solo un puntero crudo mientras el reference count se queda con la variable local. El siguiente SetLength libera el buffer y puede reutilizarlo, así que cada slot acababa apuntando al alias del último envelope y los archivos multi-destinatario derivaban una clave equivocada. El fix es guardar una copia propia con List.Add(Pointer(System.Copy(Bytes)))
  • Desenvolver el envelope parsea el DER in situ, y el paseo de recuperación de claves hasheaba originalmente esos mismos arrays vivos. El lector ahora saca snapshots prístinos de cada envelope antes de que ningún unwrap los toque, y el digest corre sobre los snapshots
  • DER binario pasado por un TStringList Unicode ve sus bytes de $80 en arriba recodificados por la code page, así que HotPDF guarda los envelopes internamente como texto hex
  • Las cadenas cifradas y binarias deben escribirse como hex strings. Una literal string está sujeta a normalización de fin de línea, donde CR, LF y CRLF se convierten todos en un único LF (ISO 32000-1 §7.3.4.2), y eso reescribe el ciphertext en silencio. HotPDF emite cada entrada de /Recipients como hex string y la exime del string encryption, porque todo lector necesita los envelopes antes de tener cualquier clave
  • El primer byte de un BIT STRING DER cuenta bits sin usar y debe ser cero para claves alineadas a byte. Dejarlo sin inicializar tras el SetLength escribía lo que hubiera en el stack, y un desenvolvedor estricto rechazaba la clave originator, así que un archivo podía fallar al abrirse de vez en cuando con la misma clave para la que se escribió
  • Cuando la misma clave sigue sin descifrar, compare capa por capa: la file key, luego el prefijo del ciphertext (el IV), luego la clave de objeto, luego el plaintext. El bug vive justo después de la primera capa que no cuadra

¿Cómo abrir un PDF cifrado con certificado usando una clave privada?

Para abrir un PDF cifrado con certificado, registre el material de claves privadas antes de llamar a LoadFromFile, porque HotPDF recupera la file key durante el paseo estructural. Asigne una clave RSA o EC parseada con HPDFParsePFX a PubSecKeyMaterial, añada más claves RSA con AddPubSecKeyMaterial, y registre escalares ECDH crudos con AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), usando las constantes HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 o HPDFOIDECP521. Las curvas NIST requieren el punto público sin comprimir del propio destinatario; las curvas de Montgomery lo ignoran

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Opcional: elegir el envelope directamente en vez de probarlos todos
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = probar cada envelope en orden
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Sin callback, HotPDF prueba cada envelope contra cada clave registrada: la clave primaria primero, luego cada clave RSA adicional, luego el material EC. PubSecRecipientQuery recibe el conteo de envelopes y devuelve un índice 0-based o -1, y un índice fuera del array lanza excepción en lugar de recortarse. Fíjese en que AddPubSecKeyMaterial acepta solo material RSA (insiste en un modulus y un exponente privado), así que las claves EC van en PubSecKeyMaterial o en AddPubSecAgreementKeyMaterial. Cuando ninguna clave desenvuelve ningún envelope, el paso de recuperación devuelve sin file key en lugar de lanzar, así que verifique que el contenido que espera se descifró de verdad en lugar de fiarse de que la llamada de carga devolviera

Diagrama de carga de claves privadas de HotPDF: PubSecKeyMaterial lleva la clave RSA o EC primaria de HPDFParsePFX, AddPubSecKeyMaterial añade solo claves RSA, AddPubSecAgreementKeyMaterial registra escalares ECDH crudos bajo los OIDs de curva de HPDFOIDX25519 a HPDFOIDP521, y en LoadFromFile el proveedor prueba la clave primaria, luego cada clave RSA adicional, luego el material EC contra cada envelope
Cuando ninguna clave desenvuelve ningún envelope, el paso de recuperación devuelve sin file key en lugar de lanzar, así que verifique que el contenido se descifró de verdad o fije el envelope vía PubSecRecipientQuery

Lo que HotPDF no garantiza

HotPDF garantiza que su propio writer y lector concuerdan byte a byte, y construye envelopes que siguen las estructuras CMS citadas arriba. No garantiza que todos los visores de PDF abran todas las combinaciones. El soporte de transporte de clave RSA-OAEP y de recipients X25519 o X448 varía entre lectores y versiones, y no hemos publicado resultados de compatibilidad para esas combinaciones. Si un documento debe abrirse en un visor concreto, cifre un archivo de prueba para un certificado de prueba del mismo tipo de clave y ábralo ahí antes de comprometerse con un scheme. Los permisos que viajan en el envelope siguen siendo una política que el software conforme honra, exactamente igual que bajo cifrado por contraseña. La calidad de la semilla también es cosa suya: AESGenerateRandomBytes está ahí para ese trabajo, y HotPDF borra su copia de la semilla una vez derivada la file key. Si además necesita que un string, un stream o un adjunto usen un crypt filter distinto, la guía de políticas de crypt filter para StmF, StrF y EFF muestra qué nombres de filtro acepta el handler de clave pública

El cifrado por certificado, los envelopes de recipients RSA-OAEP y ECDH, y la carga de claves privadas llegan todos en el componente PDF HotPDF para Delphi, junto al cifrado por contraseña, las firmas digitales y el resto del toolset ISO 32000 para Delphi y C++Builder