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
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
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
TListviaAddguarda apenas um ponteiro em bruto enquanto a contagem de referências fica com a variável local. OSetLengthseguinte 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 comList.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
TStringListUnicode leva a que bytes de$80para 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
/Recipientscomo 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 STRINGDER conta bits não usados e tem de ser zero para chaves alinhadas ao byte. Deixá-lo por inicializar depois doSetLengthescrevia 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
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