Artigo Técnico

Criptografar PDF por certificado no Delphi: RSA-OAEP e ECDH

O HotPDF criptografa um PDF para detentores de certificado específicos por meio do security handler de chave pública da ISO 32000: o EnablePubKeyEncryption recebe uma seed aleatória de 20 bytes, e cada recipient ganha o envelope CMS próprio dele, montado pelo AddPubKeyRecipientCertificate para chaves RSA (key transport RSA-OAEP) ou pelo AddPubKeyAgreementRecipientWithSecret para chaves de curva elíptica (ECDH em P-256, P-384, P-521, X25519 ou X448). Ninguém compartilha senha; quem tiver a chave privada correspondente abre o arquivo

O caso de uso é sempre alguma versão da mesma história. Um pacote de auditoria trimestral vai para três revisores externos, o jurídico quer que cada um leia, só um deles pode imprimir, e ninguém quer uma senha parada num thread de e-mail ao lado do anexo. Criptografia por senha não expressa isso. Criptografia por certificado expressa, porque cada recipient destrava o documento com uma chave que já tem, e cada recipient pode carregar um conjunto de permissões diferente dentro do envelope dele

Como a criptografia de PDF por certificado difere de uma senha?

Um PDF criptografado por chave pública deriva a file key dele de uma seed aleatória mais os bytes exatos de todo envelope de recipient, não de nada que uma pessoa digite. O handler é descrito na ISO 32000-1 §7.6.4 (§7.6.5 na ISO 32000-2), e os envelopes são estruturas CMS EnvelopedData conforme a 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 mora dentro desse crypt filter. Cada envelope criptografa 24 bytes: a seed de 20 bytes seguida da palavra de permissão de 32 bits daquele recipient. O valor /P no dicionário de criptografia é só um placeholder, porque as permissões de verdade viajam dentro de cada envelope. No load, um reader abre um envelope, recupera a seed, e faz o hash da seed junto com todo envelope na ordem do /Recipients (SHA-256 para AES-256, SHA-1 para as cifras mais antigas) para reconstruir a file key. Se você ainda está decidindo entre esse modelo e senhas comuns, o guia de criptografia por senha AES-256 e flags de permissão cobre o outro lado desse trade-off

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

Escrevendo recipients RSA com EnablePubKeyEncryption

Para certificados RSA, chame o EnablePubKeyEncryption com aes256 e depois o AddPubKeyRecipientCertificate uma vez por certificado codificado em DER antes do BeginDoc. O helper monta um envelope RSAES-OAEP in-process com valores THPDFRSAOAEPHash para o digest OAEP e o digest MGF1 (rohSHA256, rohSHA384 ou rohSHA512), e criptografa 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 key type default é 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 dessa listagem sustentam tudo. Primeiro, o comprimento da seed é fixo em 20 bytes para todo key type, AES-256 incluído; o EnablePubKeyEncryption levanta exceção com qualquer outro comprimento. Segundo, o EnablePubKeyEncryption tem default aes128, e ambos os helpers de certificado se recusam a rodar a menos que o key type seja aes256, então esquecer o segundo argumento te dá a exceção "certificate envelopes require aes256". As cifras legadas (k40, k128, aes128) ainda funcionam, mas só via AddPubKeyRecipient com um envelope que você montou em outro lugar. Terceiro, criptografia de chave pública AES-256 é um recurso do PDF 2.0, então o HotPDF sobe a versão do documento para 2.0 automaticamente. Com StrictVersionLock setado numa versão menor, o EnablePubKeyEncryption retorna sem habilitar nada, e a falha só aparece na linha seguinte como "call EnablePubKeyEncryption first". Trocar a criptografia durante um update incremental levanta EInvalidOpException de cara

Adicionando recipients ECDH: P-256, P-384, P-521, X25519 e X448

Para certificados de curva elíptica, o AddPubKeyAgreementRecipientWithSecret escreve um recipient de key agreement CMS (KeyAgreeRecipientInfo, a estrutura KARI da RFC 5753, com o perfil X25519 e X448 da RFC 8418) e calcula o segredo compartilhado ECDH in-process. Você escolhe a curva com um valor THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 ou pkasX448. O scheme tem que casar com a chave do certificado, ou a chamada levanta "Certificate key does not match the requested agreement scheme". Por baixo dos panos, cada envelope ganha um UKM aleatório fresco de 32 bytes, uma key-encryption key 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 a RFC 3394. O segredo compartilhado em si vem de código de curvas em Pascal puro, sem provedor de criptografia de 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
  // Scalar 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 para as curvas NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

As curvas NIST pedem mais de quem chama. O HotPDF distribui helpers de chave pública só para X25519 e X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), então para P-256, P-384 e P-521 você gera o par de chaves efêmero com a toolchain própria e passa um scalar big-endian exatamente do tamanho do campo (32, 48 ou 66 bytes) mais o ponto não comprimido 0x04||X||Y correspondente como OriginatorPublicKey. O HotPDF valida o ponto do recipient contra a equação da curva, mas não consegue checar que a sua chave pública de originator realmente pertence ao seu scalar. Metades que não combinam ainda produzem um envelope perfeitamente bem-formado que nenhum recipient consegue abrir, e é por isso que um load de round trip pertence à sua suíte de testes, não só uma checagem de tamanho de arquivo

Diagrama do key agreement ECDH do HotPDF: o AddPubKeyAgreementRecipientWithSecret deriva o segredo compartilhado com código de curvas em Pascal puro, 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, então embrulha a content key com o key wrap AES-256 da RFC 3394 para montar o envelope KeyAgreeRecipientInfo
O valor de scheme de pkasECDHP256 a pkasX448 tem que casar com a chave do certificado, e metades de scalar e ponto público que não combinam ainda produzem um envelope bem-formado que nenhum recipient consegue abrir

Por que a ordem do /Recipients importa?

A ordem do /Recipients importa porque a file key é um digest sobre a seed e todo envelope na ordem do array, então writer e reader precisam fazer o hash dos mesmos bytes na mesma sequência. O HotPDF mantém os envelopes na ordem em que você os adiciona e os escreve inalterados, o que significa que você pode adicionar recipients em qualquer ordem que quiser, mas nada a jusante pode reordenar, recodificar ou "limpar" esse array. A maioria dos bugs reais nessa área foi uma variação desse tema, em que os dois lados fizeram o hash de bytes ligeiramente diferentes:

  • Guardar arrays dinâmicos num TList via Add mantém só um ponteiro cru enquanto o reference count fica com a variável local. O próximo SetLength libera o buffer e pode reutilizá-lo, então todo slot acabava fazendo aliasing do último envelope e arquivos multi-recipient derivavam a chave errada. A correção é guardar uma cópia própria com List.Add(Pointer(System.Copy(Bytes)))
  • A abertura de envelopes parseia o DER in place, e a passagem de recuperação de chave originalmente fazia o hash desses mesmos arrays vivos. O reader agora tira snapshots de cópias íntegras de todo envelope antes de qualquer unwrap tocá-los, e o digest roda sobre os snapshots
  • DER binário passado por um TStringList Unicode tem bytes em $80 ou acima recodificados pela code page, então o HotPDF guarda os envelopes como texto hex internamente
  • Strings criptografadas e binárias precisam ser escritas como hex strings. Uma literal string está sujeita à normalização de fim de linha, em que CR, LF e CRLF viram todos um único LF (ISO 32000-1 §7.3.4.2), e isso reescreve o ciphertext em silêncio. O HotPDF emite toda entrada de /Recipients como hex string e a isenta da criptografia de strings, já que todo reader precisa dos envelopes antes de ter qualquer chave
  • O primeiro byte de um BIT STRING DER conta bits não usados e precisa ser zero para chaves alinhadas a byte. Deixá-lo sem inicializar depois do SetLength escrevia o que quer que estivesse na stack, e um unwrapper estrito rejeitava a chave do originator, então um arquivo podia ocasionalmente falhar ao abrir com a própria chave para a qual foi escrito
  • Quando a mesma chave ainda não consegue decifrar, compare camada por camada: a file key, depois o prefixo do ciphertext (o IV), depois a object key, depois o plaintext. O bug mora logo depois da primeira camada que discorda

Como abrir um PDF criptografado por certificado com uma chave privada?

Para abrir um PDF criptografado por certificado, registre o material de chave privada antes de chamar o LoadFromFile, porque o HotPDF recupera a file key durante a passagem estrutural. Atribua uma chave RSA ou EC parseada com HPDFParsePFX ao PubSecKeyMaterial, adicione outras chaves RSA com AddPubSecKeyMaterial, e registre scalars ECDH crus 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 recipient; as curvas de Montgomery o ignoram

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

Sem callback, o HotPDF tenta todo envelope contra toda chave registrada: a chave primária primeiro, depois cada chave RSA adicional, depois o material EC. O PubSecRecipientQuery recebe a contagem de envelopes e retorna um índice zero-based ou -1, e um índice fora do array levanta exceção em vez de ser clampado. Note que o AddPubSecKeyMaterial aceita só material RSA (ele exige um modulus e um expoente privado), então chaves EC pertencem ao PubSecKeyMaterial ou ao AddPubSecAgreementKeyMaterial. Quando nenhuma chave abre nenhum envelope, o passo de recuperação retorna sem file key em vez de levantar exceção, então verifique que o conteúdo que você espera realmente foi decifrado em vez de confiar que a chamada de load retornou

Diagrama de carregamento de chave privada do HotPDF: o PubSecKeyMaterial carrega a chave primária RSA ou EC do HPDFParsePFX, o AddPubSecKeyMaterial adiciona só chaves RSA, o AddPubSecAgreementKeyMaterial registra scalars ECDH crus sob os OIDs de curva HPDFOIDX25519 a HPDFOIDP521, e no LoadFromFile o provedor tenta a chave primária, depois cada chave RSA adicional, depois o material EC contra todo envelope
Quando nenhuma chave abre nenhum envelope, o passo de recuperação retorna sem file key em vez de levantar exceção, então verifique que o conteúdo realmente foi decifrado ou fixe o envelope via PubSecRecipientQuery

O que o HotPDF não garante

O HotPDF garante que o writer e o reader dele concordam byte a byte, e monta envelopes que seguem as estruturas CMS citadas acima. Ele não garante que todo viewer de PDF abra toda combinação. O suporte a key transport RSA-OAEP e a recipients X25519 ou X448 varia entre readers e versões, e nós não publicamos resultados de compatibilidade para essas combinações. Se um documento precisa abrir num viewer específico, criptografe um arquivo de teste para um certificado de teste do mesmo key type e abra-o lá antes de se comprometer com um scheme. As permissões carregadas no envelope continuam sendo política que software em conformidade honra, exatamente como sob criptografia por senha. A qualidade da seed também é sua responsabilidade: o AESGenerateRandomBytes existe para esse trabalho, e o HotPDF apaga a cópia dele da seed assim que a file key é derivada. Se você também precisa que uma string, stream ou anexo use um crypt filter diferente, o guia de políticas de crypt filter para StmF, StrF e EFF mostra quais nomes de filtro o handler de chave pública aceita

Criptografia por certificado, envelopes de recipient RSA-OAEP e ECDH, e carregamento de chave privada entram todos no componente HotPDF PDF para Delphi, junto com criptografia por senha, assinaturas digitais e o resto do toolset ISO 32000 para Delphi e C++Builder