Artigo Técnico

HotPDF: Assinatura via Repositório de Certificados — CNG vs CAPI e a Ordem dos Bytes

O HotPDF assina um PDF usando um certificado que já está no Repositório de Certificados do Windows entregando o digest diretamente ao próprio Windows, que conclui essa requisição por meio de um de dois back-ends de chave privada: o CNG, que devolve a assinatura RSA em big-endian, ou o CSP legado do CryptoAPI, que a devolve em little-endian. Confundir os dois faz com que a assinatura CMS embutida pelo HotPDF fique com os bytes invertidos para qualquer que seja o back-end que efetivamente respondeu, de modo que um validador em conformidade acusa a assinatura como inválida mesmo que os bytes do documento nunca tenham sido tocados

Dois problemas sem relação entre si se escondem atrás dessa única frase, e o assinador de certificado do sistema do HotPDF precisa resolver ambos antes de assinar qualquer coisa. A divergência de ordem de bytes é silenciosa: a chamada de assinatura ainda retorna True, o PDF ainda abre, e a falha só aparece quando um visualizador percorre a estrutura CMS e a rejeita. O segundo problema é barulhento e específico do C++Builder: meia dúzia de funções da crypt32 se recusam a linkar, porque a biblioteca de importação que o RAD Studio distribui não as exporta. Nenhum dos dois problemas existe se você só assina com um arquivo PFX, motivo pelo qual costuma pegar de surpresa desenvolvedores que migram da assinatura em uma chamada baseada em PFX para um certificado que o setor de TI já instalou no perfil do usuário

Selecionando um certificado do repositório

O HotPDF expõe esse caminho como HPDFSignPDFStreamWithSystemCertificate e HPDFSignPDFFileWithSystemCertificate, ambos controlados por um registro THPDFCertificateStoreSelector: Location (cslCurrentUser ou cslLocalMachine), StoreName ('MY', o repositório pessoal, por padrão), uma Thumbprint SHA-1 e um flag AllowUI. O thumbprint é normalizado internamente, de modo que hífens ou espaços copiados diretamente da interface do Gerenciador de Certificados são removidos antes de a comparação ser executada

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False importa mais do que parece, porque mapeia diretamente para CRYPT_ACQUIRE_SILENT_FLAG, e o Windows respeita isso ao pé da letra: se a chave privada do certificado correspondente estiver em um smart card ou token que exige um PIN ainda não armazenado em cache pelo Windows, CryptAcquireCertificatePrivateKey falha em vez de exibir uma caixa de diálogo a partir do que pode ser um processo de serviço. Essa falha é barulhenta, um EHPDFCMSError que aparece imediatamente, mas é fácil de interpretar erroneamente como "certificado não encontrado" quando a causa real é um token esperando por um PIN que ninguém vai digitar

Por que CNG e CAPI divergem quanto à ordem de bytes?

Qual back-end responde não é um palpite: CryptAcquireCertificatePrivateKey informa isso diretamente por meio de um parâmetro de saída KeySpec, e é esse único valor que determina o ramo de decisão do assinador do HotPDF. Uma chave de um CNG Key Storage Provider volta com KeySpec definido para a sentinela CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); qualquer outro valor é uma chave CSP tradicional do CryptoAPI. A maioria dos certificados pessoais emitidos ou importados em uma instalação atual do Windows resolve para CNG, mesmo que ainda exista um shim de CSP legado por compatibilidade, motivo pelo qual o HotPDF solicita CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG junto com CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG antes mesmo de olhar para qual valor voltou

Os dois back-ends não apenas chamam funções diferentes — NCryptSignHash para uma chave CNG, CryptSignHashA para uma chave CSP — eles também devolvem a assinatura RSA bruta em ordens de bytes opostas. A saída do CNG já corresponde ao que o PKCS#1 espera: uma octet string big-endian, byte mais significativo primeiro, exatamente o que a conversão I2OSP da RFC 8017 produz e o que um SignerInfo do CMS (RFC 5652) precisa em seu campo de assinatura sob a ISO 32000-1 §12.8.3. Já o CryptSignHash do CryptoAPI devolve a assinatura em little-endian, uma peculiaridade documentada que remonta a como os CSPs clássicos representavam números grandes internamente. Pule a inversão no caminho do CAPI e cada byte da assinatura fica no lugar errado; a matemática do RSA continua correta, mas a octet string que um verificador lê não é aquela que o PKCS#1 define

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

E quanto a um callback de assinador personalizado?

Quem contorna o assinador de repositório de certificados embutido no HotPDF herda a mesma regra de ordem de bytes. HPDFCMSSignPDFStreamWithExternalSigner recebe um THPDFCMSSignDigestCallback, um closure do tipo reference to function(const SignedAttributesSHA256: TBytes): TBytes, para assinar por meio de um HSM, uma pilha de middleware de smart card, ou qualquer outra coisa que não seja um certificado para o qual o repositório do Windows possa lhe entregar um handle de chave. Seja qual for o back-end por trás desse callback, os bytes que ele devolve precisam estar em ordem big-endian antes de o HotPDF incorporá-los à estrutura CMS

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

Vale deixar explícito um limite aqui: os dois caminhos de assinatura embutidos do HotPDF, o CNG por meio de NCryptSignHash com padding PKCS#1 e o CAPI por meio de CryptSignHashA, ambos visam chaves RSA assinando um digest SHA-256 de 32 bytes. Nenhum dos dois negocia um formato de assinatura ECDSA. Um certificado cuja chave privada é baseada em curva elíptica (EC) precisa de um assinador que você mesmo escreva contra HPDFCMSSignPDFStreamWithExternalSigner, codificando a assinatura ECDSA da forma como o CMS espera em vez de assumir uma string de bytes RSA de tamanho fixo; portanto, não espere que o assinador embutido de repositório de certificados faça a coisa certa para um token provisionado com um certificado EC

Por que o C++Builder falha ao linkar CertOpenStore?

Porque a biblioteca de importação padrão do C++Builder no RAD Studio, import32.lib, não exporta CertOpenStore, nem cinco de suas vizinhas: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore e CryptAcquireCertificatePrivateKey. Builds em Delphi nunca veem esse problema, porque dcc32/dcc64 resolvem uma importação estática external 'crypt32.dll' diretamente na tabela de importação do PE. O C++Builder é diferente: o compilador Delphi emite um .obj OMF para o build do pacote, o ilink32 o linka, e nesse ponto a mesma declaração external é apenas um símbolo não resolvido esperando por uma biblioteca de importação na linha de comando. Apontar o linker para o diretório psdk do Windows SDK, onde o crypt32.lib completo exporta os seis símbolos, também não resolve: o ilink32 só linka as bibliotecas de importação de fato nomeadas em sua linha de comando, import32.lib cp32mt.lib por padrão, e adicionar um caminho de busca não faz com que ele puxe nada extra desse caminho. Rodar tdump contra import32.lib confirma a lacuna diretamente: zero ocorrências para CertOpenStore, contra seis ocorrências limpas no crypt32.lib do SDK

O HotPDF resolve isso da mesma forma como já trata a enumeração de certificados em outras partes da biblioteca: em vez de pedir esses símbolos ao linker, ele os carrega em tempo de execução. Um registro interno THPDFCryptoProcs carrega um handle de crypt32.dll, um handle de advapi32.dll e onze campos de ponteiro de função; LoadCryptoProcs carrega ambas as DLLs e resolve cada ponto de entrada com GetProcAddress exatamente uma vez, no início de HPDFSignPDFStreamWithSystemCertificate, levantando EHPDFCMSError imediatamente caso algo esteja faltando, em vez de falhar mais tarde com uma violação de acesso no meio do fluxo de assinatura

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

O carregamento acontece uma vez por chamada, em vez de de forma preguiçosa dentro de cada auxiliar, porque o closure que escolhe entre CNG e CAPI captura a tabela de funções carregada por valor e precisa permanecer viva durante todo o fluxo de assinatura, incluindo o callback para HPDFCMSSignPDFStreamWithExternalSigner; ambos os handles de DLL são liberados no bloco finally mais externo assim que a assinatura termina ou levanta uma exceção. Nada disso toca a superfície pública: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate e THPDFCertificateStoreSelector mantêm exatamente as assinaturas que tinham antes, de modo que aplicar a correção é apenas um rebuild para quem já usa a API, não uma mudança de código

O que isto não cobre

Acertar a ordem de bytes e o link no C++Builder produz um SignerInfo CMS que um validador consegue analisar e uma assinatura que ele consegue verificar aritmeticamente; isso nada diz sobre se esse validador deveria confiar no certificado por trás dela, já que a construção de cadeia, a verificação de revogação e a política de carimbo de tempo são preocupações separadas, camadas adicionais por meio das opções de CMS, não algo que a correção de ordem de bytes traz de graça. Dois detalhes de manutenção importam tanto quanto a criptografia: o PCCERT_CONTEXT devolvido pela busca de certificado precisa ser liberado com CertFreeCertificateContext antes de o repositório fechar, e um handle de chave CNG ou CSP adquirido, quando a API informa que quem chamou é o dono, precisa ser liberado pela chamada correspondente do próprio back-end, nunca pela do outro. Se o resultado svValid que você obtém depois de tudo isso acabar sendo mais restrito do que você esperava, o artigo sobre verificação de assinaturas digitais em PDF explica exatamente o que essa flag promete e o que ela não promete. Como o certificado permanece sob custódia do Windows o tempo todo aqui, a assinatura via repositório de certificados contorna toda uma superfície de ataque: não há arquivo PKCS#12 para analisar nem ASN.1 para percorrer manualmente, que é justamente o problema que o endurecimento de PKCS#12 e ASN.1 do HotPDF resolve para o caminho de assinatura via arquivo PFX

Assinatura via repositório de certificados, assinatura via PFX e callbacks de assinador externo são três portas de entrada para o mesmo pipeline CMS/PKCS#7 dentro do componente HotPDF PDF para Delphi e C++Builder, e escolher a porta certa se resume, em grande parte, a quem tem permissão para deter a chave privada: o seu processo, um arquivo PFX ou o próprio Windows