Artigo Técnico

Encriptação por certificados PDF no Delphi: RSA-OAEP e ECDH

O HotPDF encripta um PDF para detentores de certificados específicos através do security handler de chave pública da ISO 32000: o EnablePubKeyEncryption recebe uma seed aleatória de 20 bytes, e cada destinatário recebe o seu próprio envelope CMS, construído pelo AddPubKeyRecipientCertificate para chaves RSA (transporte de chave RSA-OAEP) ou pelo AddPubKeyAgreementRecipientWithSecret para chaves de curvas elípticas (ECDH em P-256, P-384, P-521, X25519 ou X448). Ninguém partilha uma palavra-passe; quem tiver a chave privada correspondente abre o ficheiro

O caso de uso é sempre alguma versão da mesma história. Um pacote de auditoria trimestral vai para três revisores externos, a área jurídica quer que cada um o leia, só um deles pode imprimi-lo, e ninguém quer uma palavra-passe num fio de e-mail ao lado do anexo. A encriptação por palavra-passe não consegue expressar isso. A encriptação por certificados consegue, porque cada destinatário desbloqueia o documento com uma chave que já tem, e cada destinatário pode levar um conjunto de permissões diferente dentro do seu próprio envelope

Em que difere a encriptação PDF por certificados de uma palavra-passe?

Um PDF encriptado por chave pública deriva a sua chave de ficheiro de uma seed aleatória mais os bytes exactos de todos os envelopes de destinatários, e não de nada que uma pessoa escreva. O handler está descrito na ISO 32000-1 §7.6.4 (§7.6.5 na ISO 32000-2), e os envelopes são estruturas CMS EnvelopedData conforme definido no RFC 5652. O HotPDF escreve /Filter /Adobe.PubSec com /SubFilter /adbe.pkcs7.s5; para AES-256 isso significa /V 5 e uma entrada /DefaultCryptFilter sob /CF com /CFM /AESV3, e o array /Recipients vive dentro desse crypt filter. Cada envelope encripta 24 bytes: a seed de 20 bytes seguida da palavra de permissões de 32 bits desse destinatário. O valor /P no dicionário de encriptação é só um placeholder, porque as permissões reais viajam dentro de cada envelope. Em tempo de carga um leitor desembrulha um envelope, recupera a seed, e faz o hash da seed juntamente com todos os envelopes pela ordem de /Recipients (SHA-256 para AES-256, SHA-1 para as cifras mais velhas) para reconstruir a chave de ficheiro. Se ainda está a decidir entre este modelo e palavras-passe comuns, o guia de encriptação por palavra-passe AES-256 e flags de permissão cobre o outro lado desse trade-off

Diagrama da encriptação por chave pública no HotPDF: o EnablePubKeyEncryption fixa uma seed de 20 bytes, cada envelope CMS EnvelopedData encripta esses 20 bytes mais uma palavra de permissões de 32 bits dentro de /Filter /Adobe.PubSec com /SubFilter /adbe.pkcs7.s5 e /CFM /AESV3, e o leitor desembrulha um envelope, recupera a seed e faz o hash dela com cada entrada de /Recipients pela ordem do array para reconstruir a chave de ficheiro
O valor /P no dicionário de encriptação é só um placeholder porque as permissões reais viajam dentro de cada envelope, e nada a jusante pode reordenar ou recodificar o array sobre o qual o digest corre

Escrever destinatários RSA com EnablePubKeyEncryption

Para certificados RSA, chame o EnablePubKeyEncryption com aes256, e depois chame o AddPubKeyRecipientCertificate uma vez por certificado codificado em DER antes do BeginDoc. O helper constrói um envelope RSAES-OAEP no próprio processo com valores THPDFRSAOAEPHash para o digest OAEP e o digest MGF1 (rohSHA256, rohSHA384 ou rohSHA512), e encripta o conteúdo do envelope com 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);                      // exatamente 20 bytes, mesmo para AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // o tipo de chave por defeito é aes128
    // O revisor A pode imprimir; o revisor B só pode ler e extrair
    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;

Três detalhes nessa listagem são estruturais. Primeiro, o comprimento da seed está fixado em 20 bytes para todos os tipos de chave, AES-256 incluído; o EnablePubKeyEncryption levanta uma exceção com qualquer outro comprimento. Segundo, o EnablePubKeyEncryption tem como valor por defeito aes128, e ambos os helpers de certificados recusam-se a correr se o tipo de chave não for aes256, por isso esquecer o segundo argumento dá-lhe a exceção «certificate envelopes require aes256». As cifras legadas (k40, k128, aes128) continuam a funcionar, mas só através do AddPubKeyRecipient com um envelope que construa noutro sítio. Terceiro, a encriptação por chave pública AES-256 é uma funcionalidade do PDF 2.0, por isso o HotPDF sobe a versão do documento para 2.0 automaticamente. Com StrictVersionLock definido numa versão mais baixa, o EnablePubKeyEncryption devolve sem ativar nada, e a falha só aparece na linha seguinte como «call EnablePubKeyEncryption first». Trocar a encriptação durante uma atualização incremental levanta EInvalidOpException de imediato

Acrescentar destinatários ECDH: P-256, P-384, P-521, X25519 e X448

Para certificados de curvas elípticas, o AddPubKeyAgreementRecipientWithSecret escreve um destinatário CMS de key agreement (KeyAgreeRecipientInfo, a estrutura KARI do RFC 5753, com o perfil X25519 e X448 do RFC 8418) e calcula o segredo partilhado ECDH no próprio processo. A curva escolhe-se com um valor THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 ou pkasX448. O esquema tem de corresponder à chave do certificado, ou a chamada levanta «Certificate key does not match the requested agreement scheme». Por baixo do capô, cada envelope recebe um UKM aleatório fresco de 32 bytes, uma chave de encriptação de chaves derivada com o KDF stdDH (SHA-256 para P-256 e X25519, SHA-384 para P-384, SHA-512 para P-521 e X448), e um key wrap AES-256 conforme definido no RFC 3394. O segredo partilhado em si vem de código Pascal puro de curvas, sem fornecedor de criptografia da plataforma envolvido; o artigo sobre aritmética de curvas NIST em Pascal puro explica como essa camada foi construída e verificada. Para as curvas de Montgomery o par de chaves efémero inteiro pode ser gerado 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; o clamping acontece dentro da 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: só tem significado nas curvas NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

As curvas NIST pedem mais ao chamador. O HotPDF traz helpers de chave pública só para X25519 e X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), por isso para P-256, P-384 e P-521 gera o par de chaves efémero com as suas próprias ferramentas e passa um escalar big-endian do tamanho exacto do campo (32, 48 ou 66 bytes) mais o ponto não comprimido correspondente 0x04||X||Y como OriginatorPublicKey. O HotPDF valida o ponto do destinatário contra a equação da curva, mas não consegue verificar se a sua chave pública de originador pertence de facto ao seu escalar. Metades desfasadas produzem mesmo assim um envelope perfeitamente bem-formado que destinatário nenhum consegue abrir, e é por isso que uma carga de round trip pertence à sua suite de testes, e não só uma verificação de tamanho de ficheiro

Diagrama do acordo ECDH no HotPDF: o AddPubKeyAgreementRecipientWithSecret deriva o segredo partilhado com código Pascal puro de curvas, mistura um UKM fresco de 32 bytes pelo KDF stdDH com SHA-256 para P-256 e X25519, SHA-384 para P-384, SHA-512 para P-521 e X448, e depois embrulha a chave de conteúdo com o key wrap AES-256 do RFC 3394 para construir o envelope KeyAgreeRecipientInfo
O valor do esquema de pkasECDHP256 a pkasX448 tem de corresponder à chave do certificado, e metades de escalar e ponto público desfasadas produzem na mesma um envelope bem-formado que destinatário nenhum consegue abrir

Porque é que a ordem de /Recipients interessa?

A ordem de /Recipients interessa porque a chave de ficheiro é um digest sobre a seed e todos os envelopes pela ordem do array, por isso writer e leitor têm de fazer o hash dos mesmos bytes na mesma sequência. O HotPDF guarda os envelopes pela ordem em que os acrescenta e escreve-os sem alterações, o que significa que pode acrescentar destinatários em qualquer ordem, mas nada a jusante pode reordenar, recodificar ou «arrumar» esse array. A maioria dos bugs reais nesta área foi uma variação desse tema, em que dois lados faziam hash de bytes ligeiramente diferentes:

  • Guardar arrays dinâmicos numa TList via Add guarda apenas um ponteiro em bruto enquanto a contagem de referências fica com a variável local. O SetLength seguinte liberta o buffer e pode reutilizá-lo, por isso todos os slots acabavam a apontar para o último envelope e ficheiros com vários destinatários derivavam a chave errada. A correção é guardar uma cópia própria com List.Add(Pointer(System.Copy(Bytes)))
  • O desembrulho de envelopes faz o parse do DER no sítio, e a passagem de recuperação de chave originalmente fazia o hash desses mesmos arrays vivos. O leitor agora guarda cópias intatas de todos os envelopes antes de qualquer desembrulho os tocar, e o digest corre sobre essas cópias
  • DER binário passado por uma TStringList Unicode leva a que bytes de $80 para cima sejam recodificados pela code page, por isso o HotPDF guarda os envelopes como texto hex internamente
  • Strings encriptadas e binárias têm de ser escritas como hex strings. Uma string literal está sujeita à normalização de fins de linha, em que CR, LF e CRLF se tornam todos num único LF (ISO 32000-1 §7.3.4.2), e isso reescreve o ciphertext em silêncio. O HotPDF emite cada entrada de /Recipients como hex string e isenta-a da encriptação de strings, já que todos os leitores precisam dos envelopes antes de terem chave alguma
  • O primeiro byte de uma BIT STRING DER conta bits não usados e tem de ser zero para chaves alinhadas ao byte. Deixá-lo por inicializar depois do SetLength escrevia o que quer que estivesse na stack, e um desembrulhador estrito rejeitava a chave do originador, por isso um ficheiro podia ocasionalmente falhar a abrir com a própria chave para que foi escrito
  • Quando a mesma chave ainda não consegue desencriptar, compare camada a camada: a chave de ficheiro, depois o prefixo do ciphertext (o IV), depois a chave do objeto, depois o texto simples. O bug vive logo a seguir à primeira camada que discorda

Como abrir um PDF encriptado por certificados com uma chave privada?

Para abrir um PDF encriptado por certificados, registre o material de chaves privadas antes de chamar o LoadFromFile, porque o HotPDF recupera a chave de ficheiro durante a passagem estrutural. Atribua uma chave RSA ou EC analisada com HPDFParsePFX ao PubSecKeyMaterial, acrescente mais chaves RSA com AddPubSecKeyMaterial, e registre escalares ECDH em bruto com AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), usando as constantes HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 ou HPDFOIDECP521. As curvas NIST exigem o ponto público não comprimido do próprio destinatário; as curvas de Montgomery ignoram-no

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: escolher o envelope diretamente em vez de os tentar todos
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = tentar todos os envelopes pela ordem
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Sem callback, o HotPDF tenta todos os envelopes contra todas as chaves registadas: a chave primária primeiro, depois cada chave RSA adicional, depois o material EC. O PubSecRecipientQuery recebe a contagem de envelopes e devolve um índice de base zero ou -1, e um índice fora do array levanta uma exceção em vez de ser aferido. Note que o AddPubSecKeyMaterial só aceita material RSA (insiste num módulo e num expoente privado), por isso as chaves EC pertencem ao PubSecKeyMaterial ou ao AddPubSecAgreementKeyMaterial. Quando nenhuma chave desembrulha envelope algum, o passo de recuperação devolve sem chave de ficheiro em vez de levantar exceção, por isso verifique que o conteúdo que espera ficou realmente desencriptado em vez de confiar no simples facto de a chamada de carga ter devolvido

Diagrama do carregamento de chaves privadas no HotPDF: o PubSecKeyMaterial transporta a chave primária RSA ou EC vinda do HPDFParsePFX, o AddPubSecKeyMaterial só acrescenta chaves RSA, o AddPubSecAgreementKeyMaterial registra escalares ECDH em bruto sob os OIDs de curva HPDFOIDX25519 a HPDFOIDP521, e no LoadFromFile o fornecedor tenta a chave primária, depois cada chave RSA adicional, depois o material EC, contra todos os envelopes
Quando nenhuma chave desembrulha envelope algum o passo de recuperação devolve sem chave de ficheiro em vez de levantar exceção, por isso verifique que o conteúdo ficou realmente desencriptado ou fixe o envelope através do PubSecRecipientQuery

O que o HotPDF não garante

O HotPDF garante que o seu próprio writer e leitor concordam byte a byte, e constrói envelopes que seguem as estruturas CMS citadas acima. Não garante que todos os visualizadores de PDF abrem todas as combinações. O suporte para transporte de chave RSA-OAEP e para destinatários X25519 ou X448 varia entre leitores e versões, e não publicámos resultados de compatibilidade para essas combinações. Se um documento tem de abrir num visualizador específico, encripte um ficheiro de teste para um certificado de teste do mesmo tipo de chave e abra-o lá antes de se comprometer com um esquema. As permissões transportadas no envelope continuam a ser política que software em conformidade honra, exatamente como sob encriptação por palavra-passe. A qualidade da seed também é da sua responsabilidade: o AESGenerateRandomBytes está ali para esse trabalho, e o HotPDF apaga a sua cópia da seed assim que a chave de ficheiro foi derivada. Se também precisar de uma string, um stream ou um anexo a usar um crypt filter diferente, o guia de políticas de crypt filter para StmF, StrF e EFF mostra que nomes de filtros o handler de chave pública aceita

A encriptação por certificados, os envelopes de destinatários RSA-OAEP e ECDH, e o carregamento de chaves privadas saem todos no componente PDF HotPDF para Delphi, ao lado da encriptação por palavra-passe, das assinaturas digitais e do resto do toolset ISO 32000 para Delphi e C++Builder