Artigo Técnico

Assinatura de PDF com Certificado do Windows no HotPDF: Ordem de Bytes CNG vs CAPI

O HotPDF assina um PDF com um certificado já existente no Repositório de Certificados do Windows, entregando o digest ao próprio Windows, e o Windows conclui esse pedido através de um de dois back-ends de chave privada: CNG, que devolve a assinatura RSA em big-endian, ou o CSP CryptoAPI legado, que a devolve em little-endian. Trocar os dois faz com que a assinatura CMS que o HotPDF incorpora fique com os bytes invertidos consoante o back-end que efetivamente respondeu, pelo que um validador conforme reporta a assinatura como inválida mesmo que os bytes do documento nunca tenham sido alterados

Dois problemas sem relação entre si escondem-se por trás dessa única frase, e o signatário de certificado do sistema do HotPDF tem de resolver ambos antes sequer de assinar o que quer que seja. A discrepância na ordem de bytes é silenciosa: a chamada de assinatura continua a devolver True, o PDF continua a abrir, e a falha só aparece quando um visualizador percorre a estrutura CMS e a rejeita. O segundo problema é evidente e específico do C++Builder: meia dúzia de funções da crypt32 recusam-se a ligar, porque a biblioteca de importação que o RAD Studio distribui não as exporta. Nenhum destes problemas existe se apenas assinar com um ficheiro PFX, razão pela qual costuma apanhar de surpresa programadores que passam da assinatura numa só chamada baseada em PFX para um certificado já instalado por uma equipa de TI no perfil do utilizador

Selecionar um certificado do repositório

O HotPDF expõe este caminho através de HPDFSignPDFStreamWithSystemCertificate e HPDFSignPDFFileWithSystemCertificate, ambas controladas por um registo THPDFCertificateStoreSelector: Location (cslCurrentUser ou cslLocalMachine), StoreName ('MY', o repositório pessoal, por predefinição), um Thumbprint SHA-1, e uma flag AllowUI. A impressão digital (thumbprint) é normalizada internamente, pelo que hífenes ou espaços copiados diretamente da interface do Gestor de Certificados são removidos antes de se efetuar a comparação

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 à primeira vista, porque corresponde diretamente à flag CRYPT_ACQUIRE_SILENT_FLAG, e o Windows respeita-a literalmente: se a chave privada do certificado encontrado residir num cartão inteligente ou token que exige um PIN que o Windows ainda não tenha em cache, a função CryptAcquireCertificatePrivateKey falha em vez de abrir uma caixa de diálogo a partir do que poderia ser um processo de serviço. Essa falha é evidente, um EHPDFCMSError visível de imediato, mas é fácil interpretá-la mal como "certificado não encontrado" quando a causa real é um token à espera de um PIN que ninguém vai introduzir

Porque discordam o CNG e o CAPI quanto à ordem de bytes?

Qual dos back-ends responde não é uma suposição: CryptAcquireCertificatePrivateKey reporta-o diretamente através de um parâmetro de saída KeySpec, e é esse único valor que determina o ramo pelo qual o signatário do HotPDF segue. Uma chave de um CNG Key Storage Provider vem de volta com KeySpec definido para a sentinela CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); qualquer outro valor corresponde a uma chave CSP CryptoAPI tradicional. A maioria dos certificados pessoais emitidos ou importados numa instalação atual do Windows resolve-se para CNG, ainda que continue a existir um shim de CSP legado para compatibilidade, razão pela qual o HotPDF pede CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG em conjunto com CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG antes de verificar qual o valor devolvido

Os dois back-ends não se limitam a chamar funções diferentes, NCryptSignHash para uma chave CNG, CryptSignHashA para uma chave CSP; devolvem a assinatura RSA em bruto em ordem de bytes oposta. A saída do CNG já corresponde ao que o PKCS#1 espera: uma cadeia de octetos big-endian, com o byte mais significativo em primeiro lugar, exatamente o que a conversão I2OSP da RFC 8017 produz e o que um SignerInfo CMS (RFC 5652) exige no seu campo de assinatura ao abrigo da ISO 32000-1 §12.8.3. Já o CryptSignHash da CryptoAPI devolve a assinatura em little-endian, uma particularidade documentada que remonta à forma como os CSPs clássicos representavam números grandes internamente. Se se saltar a inversão no caminho CAPI, cada byte da assinatura fica na posição errada; a matemática RSA continua correta, mas a cadeia de octetos que um verificador lê não é a 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 signatário personalizado?

Quem contorna o signatário de certificado do repositório incorporado 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 através de um HSM, uma pilha de middleware de cartão inteligente, ou qualquer outra coisa que não seja um certificado para o qual o repositório do Windows possa entregar um handle de chave. Seja qual for o back-end por trás desse callback, os bytes que ele devolve têm de ficar em ordem big-endian antes de o HotPDF os incorporar na 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 a pena tornar explícito um limite aqui: os dois caminhos de assinatura incorporados no HotPDF, CNG através de NCryptSignHash com padding PKCS#1, e CAPI através de CryptSignHashA, visam ambos chaves RSA a assinar um digest SHA-256 de 32 bytes. Nenhum deles negoceia um formato de assinatura ECDSA. Um certificado cuja chave privada seja baseada em curva elíptica (EC) precisa de um signatário escrito por si próprio contra HPDFCMSSignPDFStreamWithExternalSigner, codificando a assinatura ECDSA da forma que o CMS espera em vez de assumir uma cadeia de bytes RSA de comprimento fixo, pelo que não deve esperar que o signatário de repositório de certificados incorporado faça o correto para um token provisionado com um certificado EC

Porque falha o C++Builder ao ligar o CertOpenStore?

Porque a biblioteca de importação predefinida do C++Builder no RAD Studio, import32.lib, não exporta CertOpenStore, nem cinco funções vizinhas: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, e CryptAcquireCertificatePrivateKey. As compilações em Delphi nunca veem este problema, porque o dcc32/dcc64 resolve uma importação estática external 'crypt32.dll' diretamente para a tabela de importação do PE. O C++Builder é diferente: o compilador Delphi emite um .obj OMF para a compilação do pacote, o ilink32 liga-o, e nesse ponto a mesma declaração external não passa de um símbolo por resolver à espera de uma biblioteca de importação na linha de comandos. Apontar o linker para a pasta psdk do Windows SDK, onde a crypt32.lib completa exporta de facto os seis símbolos, também não resolve o problema: o ilink32 só liga as bibliotecas de importação efetivamente indicadas na sua linha de comandos, import32.lib cp32mt.lib por predefinição, e adicionar um caminho de pesquisa não faz com que retire mais nada desse caminho. Executar o tdump sobre a import32.lib confirma diretamente a lacuna, zero resultados para CertOpenStore, contra seis resultados limpos na crypt32.lib do SDK

O HotPDF resolve isto da mesma forma que já trata a enumeração de certificados noutros pontos da biblioteca: em vez de pedir estes símbolos ao linker, carrega-os em tempo de execução. Um registo interno THPDFCryptoProcs transporta 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 uma única vez, no início de HPDFSignPDFStreamWithSystemCertificate, levantando de imediato EHPDFCMSError se faltar algo, 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 ocorre uma vez por chamada, em vez de ser feito 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 tem de permanecer viva durante todo o fluxo de assinatura, incluindo o callback para HPDFCMSSignPDFStreamWithExternalSigner; ambos os handles de DLL são libertados no bloco finally mais externo assim que a assinatura termina ou levanta uma exceção. Nada disto afeta a superfície pública: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, e THPDFCertificateStoreSelector mantêm exatamente as mesmas assinaturas que tinham antes, pelo que adotar a correção passa por uma recompilação para o código existente, e não por uma alteração de código

O que isto não cobre

Acertar a ordem de bytes e a ligação no C++Builder produz um SignerInfo CMS que um validador consegue analisar e uma assinatura que consegue verificar aritmeticamente; não diz nada sobre se esse validador deve confiar no certificado por trás dela, uma vez que a construção da cadeia, a verificação de revogação, e a política de carimbo temporal são preocupações separadas, sobrepostas através das opções CMS, e não algo que a correção da ordem de bytes proporcione de graça. Dois pormenores de manutenção importam tanto quanto a criptografia: o PCCERT_CONTEXT devolvido pela pesquisa de certificado tem de ser libertado com CertFreeCertificateContext antes de o repositório fechar, e um handle de chave CNG ou CSP adquirido, quando a API reporta que o chamador é o proprietário, tem de ser libertado através da chamada própria do back-end correspondente, nunca do outro. Se o resultado svValid que obtém depois de tudo isto se revelar mais limitado do que esperava, o artigo sobre verificação de assinaturas digitais em PDF explica exatamente o que essa flag promete e o que não promete. Como o certificado permanece sob custódia do Windows durante todo este processo, a assinatura a partir do repositório de certificados evita toda uma superfície de ataque: não há ficheiro PKCS#12 para analisar nem ASN.1 para percorrer manualmente, o que é precisamente o problema que o reforço de segurança do PKCS#12 e ASN.1 do HotPDF resolve para o caminho de assinatura por ficheiro PFX

A assinatura por repositório de certificados, a assinatura por PFX, e os callbacks de signatário externo são três portas de entrada para o mesmo pipeline CMS/PKCS#7 dentro do componente PDF HotPDF para Delphi e C++Builder, e escolher a porta certa resume-se, em grande medida, a quem tem permissão para guardar a chave privada: o seu processo, um ficheiro PFX, ou o próprio Windows