Artigo Técnico

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

Um PDF criptografado por certificado carrega um único valor /P no dicionário de criptografia, não importa para quem o arquivo foi escrito; nos arquivos construídos mais adiante neste artigo é -64. Três recipients com três conjuntos de permissões diferentes compartilham esse mesmo número. Código que responde "este usuário pode imprimir?" a partir do /P está lendo um placeholder, porque sob o security handler de chave pública a palavra de permissão real viaja dentro do envelope criptografado de cada recipient e só aparece quando a chave privada desse recipient o abre

Esse desenho é o motivo de a criptografia por certificado valer o trabalho. Um pacote de auditoria trimestral vai para três revisores externos; o jurídico quer que todos leiam, só um pode imprimir, e ninguém quer uma senha parada no thread de e-mail ao lado do anexo. Uma senha não expressa isso. Certificados expressam: cada recipient destrava o arquivo com uma chave que já tem, e cada envelope carrega a própria palavra de permissão

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, e nada que uma pessoa digita entra no cálculo. O handler é especificado na ISO 32000-1 §7.6.4 (§7.6.5 na ISO 32000-2), e os envelopes são estruturas CMS EnvelopedData da RFC 5652. O HotPDF grava /Filter /Adobe.PubSec com /SubFilter /adbe.pkcs7.s5. Para AES-256 isso significa /V 5, uma entrada /DefaultCryptFilter sob /CF com /CFM /AESV3, e o array /Recipients colocado dentro desse crypt filter em vez do dicionário de nível superior

Cada envelope criptografa 24 bytes: a seed de 20 bytes seguida da palavra de permissão de 32 bits daquele recipient. Na carga, um leitor 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. O revisor B nunca fica sabendo o que o revisor A pode fazer, mas o leitor de B mesmo assim hasseia os bytes do envelope de A, então o array inteiro precisa sobreviver byte a byte. Se você ainda está escolhendo entre esse modelo e senhas comuns, o guia de criptografia por senha AES-256 e flags de permissão cobre o outro lado dessa troca

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 a sequência é curta: EnablePubKeyEncryption com aes256, depois um AddPubKeyRecipientCertificate por certificado codificado em DER, tudo antes do BeginDoc. O helper monta um envelope RSAES-OAEP in-process. Os dois últimos parâmetros escolhem o digest OAEP e o digest MGF1 de THPDFRSAOAEPHash (rohSHA256, rohSHA384 ou rohSHA512), ambos com padrão rohSHA256, e o conteúdo do envelope é criptografado 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. A seed tem 20 bytes para todo key type, AES-256 incluído, e o EnablePubKeyEncryption levanta erro com qualquer outro comprimento. O key type tem padrão aes128, enquanto os dois helpers de certificado insistem em aes256, então deixar cair o segundo argumento faz a primeira chamada de AddPubKeyRecipientCertificate levantar "certificate envelopes require aes256". As cifras legadas (k40, k128, aes128) continuam disponíveis por meio do AddPubKeyRecipient, que recebe um envelope DER completo montado por outra ferramenta

O terceiro detalhe é a versão. AES-256 sob o handler de chave pública é um recurso do PDF 2.0, e o HotPDF sobe a versão do documento para 2.0 por conta própria. Com StrictVersionLock definido em uma versão menor, esse avanço não é permitido: o EnablePubKeyEncryption retorna sem ligar nada, e o erro aparece uma linha depois quando o helper de recipient reporta "call EnablePubKeyEncryption first". Essa mensagem manda as pessoas caçar um erro de ordem de chamada. Verifique o version lock em vez disso. Trocar a criptografia durante um update incremental é recusado de cara com EInvalidOpException

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

Certificados de curva elíptica passam pelo AddPubKeyAgreementRecipientWithSecret, que escreve um recipient de key agreement CMS (KeyAgreeRecipientInfo da RFC 5753, com o perfil X25519 e X448 da RFC 8418) e calcula o segredo compartilhado ECDH in-process. Um valor THPDFPubKeyAgreementScheme nomeia a curva: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 ou pkasX448. O scheme precisa casar com o algoritmo da chave do certificado, a curva e o comprimento da chave, ou a chamada levanta "Certificate key does not match the requested agreement scheme"

Cada envelope ganha um UKM aleatório fresco de 32 bytes, uma key-encryption key do 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. A aritmética de curvas é Pascal puro sem provedor de criptografia de plataforma por baixo; o artigo sobre aritmética de curvas NIST em Pascal puro percorre como essa camada é montada. Nas 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 deriva chaves públicas de originator apenas para X25519 e X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), então para P-256, P-384 e P-521 o par efêmero vem da sua própria toolchain. Passe um scalar big-endian de exatamente o tamanho do campo (32, 48 ou 66 bytes) e o ponto não comprimido 0x04||X||Y correspondente (65, 97 ou 133 bytes) como OriginatorPublicKey, e entregue o mesmo ponto ao OwnPublicPoint, o parâmetro reservado para os schemes NIST. Comprimentos errados levantam erro imediatamente. O ponto do recipient tirado do certificado é checado contra a equação da curva antes de qualquer multiplicação rodar

O que nenhuma biblioteca consegue checar é se a sua chave pública de originator pertence ao seu scalar. Metades que não combinam ainda produzem um envelope perfeitamente bem formado. O escritor deriva a chave dele de scalar vezes ponto do recipient, o recipient deriva a chave dele da chave privada dele vezes o ponto de originator errado, e a checagem de integridade da RFC 3394 no lado do recipient é a primeira coisa a perceber, momento em que o arquivo já foi distribuído. Um load de round trip contra uma chave de teste na mesma curva pertence ao seu build; um EndDoc bem-sucedido e um tamanho de arquivo plausível não provam nada aqui

Esse passo de pareamento é o motivo de a escolha da curva importar na prática. Se você controla a emissão de certificados, X25519 é a curva mais fácil de acertar a partir do Delphi: um scalar aleatório de 32 bytes, uma chamada de helper, nenhum ponto para manter em sincronia à mão. As curvas NIST merecem o lugar delas quando os certificados dos recipients vêm de uma PKI que só emite P-256 ou P-384, e nesse caso o load de round trip deixa de ser opcional

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 seguida de todo envelope na ordem do array, então escritor e leitor precisam hassear bytes idênticos em uma sequência idêntica. O HotPDF mantém os envelopes na ordem em que você os adiciona e os grava inalterados, o que deixa você livre para escolher qualquer ordem. O que acontece com o array depois é a parte arriscada. Ferramentas de pós-processamento, pipelines de assinatura e builders de envelope caseiros são onde isso quebra, e as falhas compartilham uma forma — dois lados hasseando bytes ligeiramente diferentes:

  • O TList sem tipo do Delphi guarda um ponteiro cru. Coletar envelopes com List.Add(Pointer(Bytes)) deixa o reference count com a variável local, então o próximo SetLength nessa variável libera ou reutiliza o buffer e todo slot pode acabar fazendo aliasing do último envelope. Um TList<TBytes> ou um TArray<TBytes> simples mantém as referências contadas
  • Parsers DER que trabalham no lugar modificam o buffer que interpretam. Hasseie uma cópia pristine de cada envelope, tirada antes de qualquer tentativa de unwrap tocá-lo
  • Um TStringList Unicode não é lugar para DER binário: bytes em $80 ou acima são re-encodados pela code page na entrada. Guarde envelopes em TBytes, ou hex-encode-os primeiro
  • Ciphertext pertence a hex strings. Dentro de uma literal string, CR, LF e CRLF são todos lidos de volta como um único LF (ISO 32000-1 §7.3.4.2), então um envelope que por acaso contenha $0D $0A perde um byte. O HotPDF grava toda entrada de /Recipients como hex string e a deixa fora da criptografia de strings, já que um leitor precisa dos envelopes antes de ter qualquer chave
  • O primeiro byte de conteúdo de uma BIT STRING DER conta bits não usados e precisa ser $00 para chaves alinhadas a byte. DER escrito à mão que descarta esse byte e põe o ponto 0x04||X||Y direto no conteúdo desloca a chave inteira em um byte, e um parser estrito rejeita a chave de originator de cara
  • Quando uma chave que deveria funcionar ainda não consegue decifrar, compare camada por camada, nesta ordem: file key, o IV no início do ciphertext, chave por objeto, plaintext. A falha mora logo depois da primeira camada que discorda

Carregando um PDF criptografado por certificado com chaves privadas

Chaves privadas precisam estar registradas antes de o LoadFromFile rodar, porque o HotPDF recupera a file key durante a passada estrutural. Identidades RSA interpretadas com HPDFParsePFX vão para o PubSecKeyMaterial, e outras chaves RSA para o AddPubSecKeyMaterial, que recusa material sem modulus e sem expoente privado. Os dois slots de chave RSA são casados pelo modulus RSA e pelo expoente privado, então chaves EC, inclusive as baseadas em PFX, seguem uma terceira rota: AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), com uma das constantes HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 ou HPDFOIDECP521. As curvas NIST exigem ali o ponto público não comprimido do próprio recipient; as curvas de Montgomery o ignoram

Uma identidade EC que chega como arquivo PFX cabe na mesma chamada. O HPDFParsePFX a reporta com KeyAlgorithm = pfxkaECDSA (identidades X25519 e X448 incluídas) e preenche CurveOID, o scalar privado em PrivateKey e o ponto do certificado em PublicKey, que mapeiam direto nos três 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;
    // identidade RSA vira a chave primária
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    // identidade EC de um PFX: curva, scalar e ponto vão para a 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);
    // scalar X25519 cru guardado pelo time jurídico; nenhum ponto é preciso
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Deixado sozinho, o HotPDF tenta todo envelope contra toda chave registrada: a chave RSA primária, depois cada chave RSA adicional, depois o material EC. O PubSecRecipientQuery estreita isso para um único envelope. O callback recebe a contagem de envelopes e precisa devolver um índice zero-based dentro do array; qualquer outro valor, -1 incluído, levanta erro durante a carga. Para tentar todo envelope, deixe a propriedade sem atribuição. Sob Delphi o tipo é uma reference to a function, então um anonymous method funciona, enquanto sob FPC é um ponteiro de função simples cujo argumento Context vem de PubSecRecipientQueryContext

Um comportamento merece um teste próprio. Quando nenhuma chave registrada abre nenhum envelope, a recuperação de chave termina sem file key e não levanta erro por isso. Faça assert sobre conteúdo que você sabe que o documento guarda, como uma string de texto ou um valor de metadados; uma chamada de LoadFromFile que retorna normalmente só mostra que a estrutura do arquivo foi interpretada

Diagrama de carregamento de chave privada do HotPDF: o PubSecKeyMaterial carrega a chave primária RSA do HPDFParsePFX, o AddPubSecKeyMaterial adiciona só chaves RSA, o AddPubSecAgreementKeyMaterial registra scalars ECDH crus sob os OIDs de curva HPDFOIDX25519 a HPDFOIDECP521, 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 erro, então verifique que o conteúdo realmente foi decifrado ou fixe o envelope via PubSecRecipientQuery

O que o HotPDF não garante

O escritor e o leitor do HotPDF concordam byte a byte, e os envelopes seguem as estruturas CMS citadas acima. Viewers de terceiros são outra pergunta: o suporte a key transport RSA-OAEP, e ainda mais a recipients X25519 ou X448, varia entre leitores e entre versões do mesmo leitor. Se o documento precisa abrir em um 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. Quando o viewer está fora do seu controle, a lista de suporte dele escolhe o scheme, e isso se sobrepõe ao argumento de conveniência do X25519 feito antes

As permissões do envelope continuam sendo política que software em conformidade honra, exatamente como sob criptografia por senha. A qualidade da seed é responsabilidade sua: o AESGenerateRandomBytes existe para esse trabalho, e o HotPDF apaga a própria cópia da seed assim que a file key é derivada. Se uma string, um stream ou um anexo precisa de um crypt filter diferente, o guia de políticas de crypt filter para StmF, StrF e EFF mostra como atribuir um; o handler de chave pública aceita DefaultCryptFilter ou Identity

Envelopes por recipient para certificados RSA e de curva elíptica, junto com a carga de chaves no caminho de volta, fazem parte do HotPDF PDF component para Delphi e C++Builder, ao lado da criptografia por senha e das assinaturas digitais