Artículo técnico

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

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

Ese diseño es la razón por la que el cifrado con certificado merece 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 fichero con una clave que ya tiene, y cada envelope lleva su propia palabra de permisos

¿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, y nada de lo que una persona teclea entra en el cálculo. El handler está especificado en la ISO 32000-1 §7.6.4 (§7.6.5 en la ISO 32000-2), y los envelopes son estructuras CMS EnvelopedData del 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 array /Recipients colocado dentro de ese crypt filter en lugar del diccionario de nivel superior

Cada envelope cifra 24 bytes: la semilla de 20 bytes seguida de la palabra de permisos de 32 bits de ese destinatario. 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. El revisor B nunca aprende qué puede hacer el revisor A, y aun así el lector de B hashea los bytes del envelope de A, así que el array entero tiene que sobrevivir byte a byte. Si sigue 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 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 la secuencia es corta: EnablePubKeyEncryption con aes256, y luego una llamada a AddPubKeyRecipientCertificate por cada certificado codificado en DER, todo antes de BeginDoc. El helper construye un envelope RSAES-OAEP en proceso. Sus dos últimos parámetros eligen el digest OAEP y el digest MGF1 de entre THPDFRSAOAEPHash (rohSHA256, rohSHA384 o rohSHA512), ambos con rohSHA256 por defecto, y el contenido del envelope 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, 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 llevan peso. La semilla es de 20 bytes para cada tipo de clave, AES-256 incluido, y EnablePubKeyEncryption lanza una excepción ante cualquier otra longitud. El tipo de clave por defecto es aes128 mientras que ambos helpers de certificado insisten en aes256, así que omitir el segundo argumento hace que la primera llamada a AddPubKeyRecipientCertificate lance "certificate envelopes require aes256". Los cifrados heredados (k40, k128, aes128) siguen disponibles a través de AddPubKeyRecipient, que toma un envelope DER completo construido por otras herramientas

El tercer detalle es la versión. AES-256 bajo el handler de clave pública es una funcionalidad de PDF 2.0, y HotPDF sube la versión del documento a 2.0 por su cuenta. Con StrictVersionLock fijado en una versión inferior ese salto no está permitido: EnablePubKeyEncryption devuelve sin activar nada, y el error aparece una línea más tarde cuando el helper de destinatarios reporta "call EnablePubKeyEncryption first". Ese mensaje manda a la gente a buscar un error de orden de llamadas. Compruebe el bloqueo de versión en su lugar. Cambiar el cifrado durante una actualización incremental se rechaza sin más con EInvalidOpException

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

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

Cada envelope 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 el RFC 3394. La aritmética de curvas es Pascal puro 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 montó esa capa. En las curvas de Montgomery el par 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 piden más al llamante. HotPDF deriva claves públicas originator 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 cuerpo (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 erróneas lanzan inmediatamente. El punto del destinatario tomado del certificado se comprueba contra la ecuación de la curva antes de que se ejecute cualquier multiplicación

Lo que ninguna librería puede comprobar es si su clave pública originator pertenece a su escalar. Mitades que no coinciden producen igualmente un envelope bien formado. El escritor deriva su clave de escalar × punto del destinatario, el destinatario deriva la suya de su clave privada × el punto originator equivocado, y la comprobación de integridad del RFC 3394 en el lado del destinatario es lo primero que lo nota, momento en el que el fichero ya se ha enviado. Una carga de ida y vuelta contra una clave de prueba en la misma curva pertenece a su build; un EndDoc con éxito y un tamaño de fichero plausible no prueban nada aquí

Ese paso de emparejamiento es por lo 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 un helper, ningún punto que mantener sincronizado a mano. Las curvas NIST se ganan su sitio 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 ida y vuelta deja de ser opcional

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 seguida de cada envelope en orden de array, así que escritor y lector tienen que hashear bytes idénticos en una secuencia idéntica. HotPDF conserva los envelopes en el orden en que usted los añade y los escribe sin cambios, lo que le deja libre para elegir cualquier orden. Lo que pasa con el array después es la parte arriesgada. Las herramientas de postproceso, las pipelines de firma y los constructores de envelopes 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. Recoger envelopes con List.Add(Pointer(Bytes)) deja el recuento de referencias en la variable local, así que el siguiente SetLength sobre esa variable libera o reutiliza el buffer y cada hueco puede acabar haciendo aliasing del último envelope. 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 envelope, tomada antes de que cualquier intento de unwrap la toque
  • Un TStringList Unicode no es sitio para DER binario: los bytes de $80 en adelante los re-codifica la code page al entrar. Guarde los envelopes en TBytes, o codifíquelos primero en hexadecimal
  • El ciphertext pertenece a cadenas hex. Dentro de una cadena literal, CR, LF y CRLF se leen de vuelta todos como un único LF (ISO 32000-1 §7.3.4.2), así que un envelope que contenga $0D $0A pierde un byte. HotPDF escribe cada entrada de /Recipients como cadena hex y la deja fuera del cifrado de cadenas, ya que un lector necesita los envelopes antes de tener cualquier clave
  • El primer byte de contenido de un BIT STRING DER cuenta bits sin usar y debe ser $00 para claves alineadas a byte. Un DER escrito a mano que suelte ese byte y meta el punto 0x04||X||Y directamente en el contenido desplaza la clave entera un byte, y un parser estricto rechaza la clave originator sin más
  • Cuando una clave que debería funcionar sigue sin descifrar, compare capa por capa, en este orden: file key, el IV al frente del ciphertext, clave por objeto, plaintext. El fallo está justo después de la primera capa que discrepa

Cargar un PDF cifrado con certificado con claves privadas

Las claves privadas tienen que registrarse antes de que LoadFromFile se ejecute, porque HotPDF recupera la file key 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. Ambos huecos de clave RSA se emparejan por módulo 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 fichero PFX encaja en la misma llamada. HPDFParsePFX la reporta con KeyAlgorithm = pfxkaECDSA (identidades X25519 y X448 incluidas) y rellena CurveOID, el escalar privado en PrivateKey y el punto del certificado en PublicKey, que se mapean directamente 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 convierte en 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 guardado por el equipo legal; no hace falta punto
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Dejado a su aire, HotPDF prueba cada envelope contra cada clave registrada: la clave RSA primaria, luego cada clave RSA adicional, luego el material EC. PubSecRecipientQuery lo reduce a un único envelope. El callback recibe el recuento de envelopes y debe devolver un índice de base cero dentro del array; cualquier otro valor, -1 incluido, lanza durante la carga. Para probar cada envelope, 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 envelope, la recuperación de claves termina sin file key y no lanza por esa razón. Haga assert sobre contenido que sepa que el documento lleva, como una cadena de texto o un valor de metadatos; una llamada a LoadFromFile que devuelve con normalidad solo muestra que la estructura del fichero se parseó

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

¿Qué debe comprobar en el visor de destino?

El escritor y el lector de HotPDF concuerdan byte a byte, y los envelopes siguen las estructuras CMS citadas arriba. Los visores de terceros son otra pregunta: el soporte de transporte de clave RSA-OAEP, y más aún el de recipients 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 fichero 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 soportes elige el esquema, y eso supera el argumento de conveniencia de X25519 de antes

Los permisos de envelope siguen siendo una política que el software conforme honra, exactamente como bajo el cifrado por 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 derivada la file key. Si una cadena, un stream o un adjunto necesita un crypt filter distinto, 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 envelopes por destinatario para certificados RSA y de curva elíptica, junto con la carga de claves en el camino de vuelta, forman parte del componente PDF HotPDF para Delphi y C++Builder, al lado del cifrado por contraseña y las firmas digitales