Artículo técnico

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

Un PDF cifrado con certificados lleva un único valor /P en su diccionario de cifrado sin importar para quién se escribió el archivo; en los archivos construidos más adelante en este artículo es -64. Tres destinatarios con tres conjuntos de permisos distintos comparten ese único número. El código que responde “¿puede este usuario imprimir?” desde /P está leyendo un placeholder, porque bajo el security handler de clave pública la palabra de permisos real viaja dentro del sobre cifrado de cada destinatario y solo aparece una vez que la clave privada de ese destinatario lo abrió

Ese diseño es la razón por la que el cifrado con certificados vale la molestia. Un paquete de auditoría trimestral va a tres revisores externos; legal quiere que todos lo lean, solo uno puede imprimirlo, y nadie quiere una contraseña sentada en el hilo de correo junto al adjunto. Una contraseña no puede expresar eso. Los certificados sí: cada destinatario desbloquea el archivo con una clave que ya tiene, y cada sobre lleva su propia palabra de permisos

¿En qué se diferencia el cifrado de PDF con certificados de una contraseña?

Un PDF cifrado con clave pública deriva su clave de archivo de una semilla aleatoria más los bytes exactos de cada sobre de destinatario, y nada que una persona teclee entra al cálculo. El handler está especificado en ISO 32000-1 §7.6.4 (§7.6.5 en ISO 32000-2), y los sobres son estructuras CMS EnvelopedData de RFC 5652. HotPDF escribe /Filter /Adobe.PubSec con /SubFilter /adbe.pkcs7.s5. Para AES-256 eso significa /V 5, una entrada /DefaultCryptFilter bajo /CF con /CFM /AESV3, y el arreglo /Recipients puesto dentro de ese crypt filter en lugar del diccionario de nivel superior

Cada sobre cifra 24 bytes: la semilla de 20 bytes seguida de la palabra de permisos de 32 bits de ese destinatario. Al cargar, un lector desenvuelve un sobre, recupera la semilla, y hashea la semilla junto con cada sobre en orden de /Recipients (SHA-256 para AES-256, SHA-1 para los cifradores más viejos) para reconstruir la clave de archivo. El revisor B nunca se entera de lo que el revisor A puede hacer, y aun así el lector de B hashea los bytes del sobre de A, así que el arreglo completo tiene que sobrevivir byte a byte. Si todavía está eligiendo entre este modelo y las contraseñas ordinarias, 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 sobre 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 sobre, recupera la semilla y la hashea con cada entrada de /Recipients en el orden del array para reconstruir la clave de archivo
El valor /P del diccionario de cifrado es solo un placeholder porque los permisos reales viajan dentro de cada sobre, y nada aguas abajo puede reordenar o recodificar el array sobre el que corre el digest

Escribir destinatarios RSA con EnablePubKeyEncryption

Para certificados RSA la secuencia es corta: EnablePubKeyEncryption con aes256, luego una llamada a AddPubKeyRecipientCertificate por cada certificado codificado en DER, todo antes de BeginDoc. El helper arma un sobre RSAES-OAEP en el propio proceso. Sus dos últimos parámetros eligen el digest de OAEP y el digest de MGF1 desde THPDFRSAOAEPHash (rohSHA256, rohSHA384 o rohSHA512), ambos con default rohSHA256, y el contenido del sobre se cifra 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, incluso 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 default 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 peso. La semilla es de 20 bytes para cada tipo de clave, AES-256 incluido, y EnablePubKeyEncryption levanta con cualquier otra longitud. El tipo de clave queda por defecto en aes128 mientras que ambos helpers de certificado insisten en aes256, así que soltar el segundo argumento hace que la primera llamada a AddPubKeyRecipientCertificate levante “certificate envelopes require aes256”. Los cifradores legados (k40, k128, aes128) siguen disponibles a través de AddPubKeyRecipient, que toma un sobre DER completo construido por otra herramienta

El tercer detalle es la versión. AES-256 bajo el handler de clave pública es una feature de PDF 2.0, y HotPDF sube la versión del documento a 2.0 por su cuenta. Con StrictVersionLock fijado sobre una versión menor ese bump no está permitido: EnablePubKeyEncryption devuelve sin encender nada, y el error emerge una línea más tarde cuando el helper de destinatario reporta “call EnablePubKeyEncryption first”. Ese mensaje manda a la gente a cazar un error de orden de llamadas. Revise el version lock en su lugar. Cambiar el cifrado durante una actualización incremental se rechaza de plano con EInvalidOpException

Agregar destinatarios ECDH en P-256, P-384, P-521, X25519 y X448

Los certificados de curva elíptica pasan por AddPubKeyAgreementRecipientWithSecret, que escribe un destinatario CMS de key agreement (KeyAgreeRecipientInfo de RFC 5753, con el perfil X25519 y X448 de RFC 8418) y computa el secreto compartido ECDH en el propio proceso. Un valor THPDFPubKeyAgreementScheme nombra la curva: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 o pkasX448. El esquema tiene que coincidir con el algoritmo de clave del certificado, la curva y la longitud de clave, o la llamada levanta “Certificate key does not match the requested agreement scheme”

Cada sobre recibe un UKM aleatorio fresco de 32 bytes, una key-encryption key del 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 define RFC 3394. La aritmética de curvas es Pascal plano sin ningún proveedor criptográfico de plataforma por debajo; el artículo de aritmética de curvas NIST en Pascal puro recorre cómo se armó esa capa. En las curvas de Montgomery todo el par de claves efímero se puede generar localmente:

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

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Escalar efímero fresco por sobre; el clamping ocurre dentro del 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 piden más al llamador. HotPDF deriva claves públicas originadoras solo para X25519 y X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), así que para P-256, P-384 y P-521 el par efímero viene de su propia herramienta. Pase un escalar big-endian de exactamente el tamaño del campo (32, 48 o 66 bytes) y el punto sin comprimir 0x04||X||Y correspondiente (65, 97 o 133 bytes) como OriginatorPublicKey, y entregue el mismo punto a OwnPublicPoint, el parámetro reservado para los esquemas NIST. Las longitudes equivocadas levantan de inmediato. El punto del destinatario tomado del certificado se verifica contra la ecuación de la curva antes de que corra cualquier multiplicación

Lo que ninguna biblioteca puede verificar es si su clave pública originadora pertenece a su escalar. Mitades que no coinciden igual producen un sobre bien formado. El escritor deriva su clave de escalar por punto del destinatario, el destinatario deriva la suya de su clave privada por el punto originador equivocado, y la verificación de integridad RFC 3394 del lado del destinatario es lo primero que se entera, momento en el que el archivo ya se envió. Una carga de round trip contra una clave de prueba en la misma curva pertenece a su build; un EndDoc exitoso y un tamaño de archivo plausible no prueban nada aquí

Ese paso de emparejamiento es la razón por la que la elección de curva importa en la práctica. Si usted controla la emisión de certificados, X25519 es la curva más fácil de hacer bien desde Delphi: un escalar aleatorio de 32 bytes, una llamada a helper, ningún punto que mantener sincronizado a mano. Las curvas NIST ganan su lugar cuando los certificados de los destinatarios vienen de una PKI que solo emite P-256 o P-384, y en ese caso la carga de round trip deja de ser opcional

Diagrama de key agreement ECDH de HotPDF: AddPubKeyAgreementRecipientWithSecret deriva el secreto compartido con código de curvas en Pascal puro, 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 de RFC 3394 para armar el sobre KeyAgreeRecipientInfo
El valor de esquema desde pkasECDHP256 hasta pkasX448 debe coincidir con la clave del certificado, y un escalar y un punto público que no corresponden igual producen un sobre bien formado que ningún destinatario puede abrir

¿Por qué importa el orden de /Recipients?

El orden de /Recipients importa porque la clave de archivo es un digest sobre la semilla seguida de cada sobre en orden del arreglo, así que escritor y lector tienen que hashear bytes idénticos en una secuencia idéntica. HotPDF conserva los sobres en el orden en que usted los agrega y los escribe sin cambios, lo que lo deja libre de elegir cualquier orden. Lo que le pasa al arreglo después es la parte riesgosa. Las herramientas de post-procesamiento, los pipelines de firma y los constructores de sobres caseros son donde se rompe, y los fallos comparten una forma — dos lados hasheando bytes ligeramente distintos:

  • El TList sin tipo de Delphi guarda un puntero crudo. Recolectar sobres con List.Add(Pointer(Bytes)) deja el conteo de referencias con la variable local, así que el siguiente SetLength sobre esa variable libera o reutiliza el buffer y cada ranura puede terminar aliasando el último sobre. Un TList<TBytes> o un TArray<TBytes> simple mantiene las referencias contadas
  • Los parsers DER que trabajan in situ modifican el buffer que parsean. Hashee una copia prístina de cada sobre, tomada antes de que cualquier intento de unwrap lo toque
  • Un TStringList Unicode no es lugar para DER binario: los bytes en $80 o superiores se re-codifican por la code page en el camino de entrada. Conserve los sobres en TBytes, o hex-encódelos primero
  • El ciphertext pertenece a hex strings. Dentro de un string literal, CR, LF y CRLF todos se leen de vuelta como un único LF (ISO 32000-1 §7.3.4.2), así que un sobre que casualmente contenga $0D $0A pierde un byte. HotPDF escribe cada entrada de /Recipients como hex string y la deja fuera del cifrado de strings, ya que un lector necesita los sobres antes de tener alguna clave
  • El primer byte de contenido de un BIT STRING DER cuenta los bits sin usar y debe ser $00 para claves alineadas a byte. Un DER escrito a mano que suelte ese byte y ponga el punto 0x04||X||Y directo en el contenido desplaza toda la clave un byte, y un parser estricto rechaza la clave originadora de plano
  • Cuando una clave que debería funcionar igual no puede descifrar, compare capa por capa, en este orden: clave de archivo, el IV al frente del ciphertext, clave por objeto, plaintext. La falla se sienta justo después de la primera capa que discrepa

Cargar un PDF cifrado con certificados usando claves privadas

Las claves privadas tienen que registrarse antes de que LoadFromFile corra, porque HotPDF recupera la clave de archivo durante la pasada estructural. Las identidades RSA parseadas con HPDFParsePFX van a PubSecKeyMaterial, y más claves RSA a AddPubSecKeyMaterial, que rechaza material sin módulo y exponente privado. Ambas ranuras de clave RSA se emparejan por módulo RSA y exponente privado, así que las claves EC, las basadas en PFX incluidas, toman una tercera ruta: AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), con una de las constantes HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 o HPDFOIDECP521. Las curvas NIST exigen ahí el punto público sin comprimir del propio destinatario; las curvas de Montgomery lo ignoran

Una identidad EC que llega como archivo PFX encaja en la misma llamada. HPDFParsePFX la reporta con KeyAlgorithm = pfxkaECDSA (identidades X25519 y X448 incluidas) y llena CurveOID, el escalar privado en PrivateKey y el punto del certificado en PublicKey, que mapean directo a los tres parámetros

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

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
  EcKey: THPDFPFXKeyMaterial;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    // La identidad RSA se vuelve la clave primaria
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // Identidad EC desde un PFX: curva, escalar y punto van a la lista de agreement
    EcKey := HPDFParsePFX(TFile.ReadAllBytes('reviewer-c-p256.pfx'), 'pfx-password');
    if EcKey.KeyAlgorithm = pfxkaECDSA then
      Reader.AddPubSecAgreementKeyMaterial(EcKey.CurveOID,
        EcKey.PrivateKey, EcKey.PublicKey);
    // Escalar X25519 crudo que sostiene el equipo legal; no se necesita punto
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Dejado solo, HotPDF prueba cada sobre contra cada clave registrada: la clave RSA primaria, luego cada clave RSA adicional, luego el material EC. PubSecRecipientQuery acota eso a un solo sobre. El callback recibe el conteo de sobres y debe devolver un índice zero-based dentro del arreglo; cualquier otro valor, -1 incluido, levanta durante la carga. Para probar cada sobre, deje la propiedad sin asignar. Bajo Delphi el tipo es una referencia a función, así que un método anónimo funciona, mientras que bajo FPC es un puntero a función plano cuyo argumento Context viene de PubSecRecipientQueryContext

Un comportamiento merece un test propio. Cuando ninguna clave registrada desenvuelve ningún sobre, la recuperación de claves termina sin una clave de archivo y no levanta por esa razón. Afirme sobre contenido que usted sabe que el documento tiene, como un string de texto o un valor de metadatos; una llamada a LoadFromFile que devuelve normalmente solo muestra que la estructura del archivo se parseó

Diagrama de carga de claves privadas de HotPDF: PubSecKeyMaterial lleva la clave primaria RSA de HPDFParsePFX, AddPubSecKeyMaterial agrega solo claves RSA, AddPubSecAgreementKeyMaterial registra escalares ECDH crudos bajo los OIDs de curva de HPDFOIDX25519 a HPDFOIDECP521, y en LoadFromFile el proveedor prueba la clave primaria, luego cada clave RSA adicional, luego el material EC contra cada sobre
Cuando ninguna clave desenvuelve ningún sobre, el paso de recuperación regresa sin clave de archivo en lugar de lanzar una excepción, así que verifique que el contenido realmente se descifró o fije el sobre mediante PubSecRecipientQuery

Qué conviene verificar en el visor de destino

El escritor y el lector de HotPDF coinciden byte a byte, y los sobres siguen las estructuras CMS citadas arriba. Los visores de terceros son otra pregunta: el soporte de transporte de claves RSA-OAEP, y más aún el de destinatarios X25519 o X448, varía entre lectores y entre versiones del mismo lector. Si el documento tiene que 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 esquema. Cuando el visor está fuera de su control, su lista de soporte elige el esquema, y eso supera el argumento de conveniencia de X25519 hecho antes

Los permisos de los sobres siguen siendo política que el software conforme honra, exactamente como bajo cifrado con contraseña. La calidad de la semilla es cosa suya: AESGenerateRandomBytes existe para ese trabajo, y HotPDF borra su propia copia de la semilla una vez que la clave de archivo se derivó. Si un string, un stream o un adjunto necesita otro crypt filter, la guía de políticas de crypt filter para StmF, StrF y EFF muestra cómo asignarlo; el handler de clave pública acepta DefaultCryptFilter o Identity

Los sobres por destinatario para certificados RSA y de curva elíptica, junto con la carga de claves en el camino de vuelta, son parte del componente PDF HotPDF para Delphi y C++Builder, al lado del cifrado con contraseña y las firmas digitales