Um painel de trabalho que encadeia a validação de conformidade com a assinatura digital tem de coordenar quatro etapas, nesta ordem, e mantê-las vinculadas a um único conjunto de bytes durante todo o processo. Executa uma verificação prévia (preflight) PDF/A ou PDF/UA. Aplica as correções exigidas pelas inconformidades detetadas e grava uma revisão corrigida. Assina essa revisão exata. Em seguida, lê novamente o ficheiro assinado e confirma se a assinatura realmente o cobre. A ordem não é apenas cosmética. Se ignorar a leitura de validação, estará a confiar no seu próprio fluxo de escrita; se executar o preflight no documento incorreto, o seu relatório de conformidade descreverá um ficheiro que nunca chegou a enviar.
A parte em que a maioria dos fluxos internos falha é na transição entre a validação e a assinatura. Execute-as como duas ferramentas distintas com uma etapa de correção pelo meio e passará a ter pelo menos três revisões diferentes do ficheiro, cada uma com os seus próprios bytes. O relatório de preflight que entrega a um auditor descreve uma delas. A assinatura congela outra. Nada no ficheiro atesta que se trata da mesma revisão e, frequentemente, não é. O PDFlibPas, a biblioteca de desenvolvimento de PDF da losLab para Delphi e C++Builder, coloca o preflight e a assinatura PAdES sob uma única classe de fachada, permitindo que toda a sequência decorra num único processo que nunca perde o rasto de quais os bytes em questão. Cada chamada apresentada abaixo já existe na biblioteca atual, tal como cada uma das armadilhas assinaladas.
Três revisões de um documento, e como a divergência surge
Conte as gravações. O original chega de uma etapa anterior. A etapa de correção carrega-o, ativa um modo de conformidade e grava uma revisão corrigida. A etapa de assinatura anexa uma assinatura como uma atualização incremental, o que constitui uma terceira gravação. Três gravações, três layouts de bytes, e um relatório de preflight não significa nada a menos que especifique qual das três revisões cobre. Um hash SHA-256 do ficheiro, registado ao lado de cada execução de preflight e de cada assinatura, é a âncora simples que lhe permite provar que a revisão validada é a mesma que assinou.
Um determinado comportamento da biblioteca reforça ainda mais esta disciplina. As correções de conformidade solicitadas através de SetPDFAMode ou SetPDFUAMode não produzem efeito imediato ao serem chamadas. Elas são aplicadas apenas durante a gravação. As correções automáticas, como forçar sinalizações de impressão em anotações ou atribuir uma ordem de tabulação PDF/UA, afetam o ficheiro de saída e nada mais, de modo que uma verificação executada no documento que acabou de "corrigir" em memória nada lhe diz sobre os bytes que serão enviados ao assinante. Grave primeiro, depois execute o preflight no ficheiro gravado. O estado em memória é apenas um rascunho; apenas o ficheiro no disco é real.
Preflight a partir do disco, e o zero que significa duas coisas
O ponto de entrada plano do preflight é CheckFileCompliance(FileName, Password, ComplianceTest, Options). O teste 1 seleciona PDF/A (ISO 19005) e o teste 2 seleciona PDF/UA (ISO 14289). Abre o ficheiro através do leitor de streaming da biblioteca, pelo que não há necessidade de chamar LoadFromFile primeiro, e devolve o handle de uma lista de strings contendo uma ocorrência por entrada:
var
PDF: TPDFlib;
ListID, I: Integer;
begin
PDF := TPDFlib.Create;
try
ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0); // 1 = PDF/A
if ListID = 0 then
begin
if PDF.LastErrorCode <> 0 then
raise Exception.Create('Preflight could not read the file')
else
Writeln('No PDF/A findings');
end
else
begin
for I := 0 to PDF.GetStringListCount(ListID) - 1 do
Writeln(PDF.GetStringListItem(ListID, I));
PDF.ReleaseStringList(ListID);
end;
finally
PDF.Free;
end;
end;
A armadilha reside no valor de retorno, sendo do tipo que passa em qualquer teste de fluxo ideal. Zero significa "sem ocorrências". Zero também significa "o ficheiro não pôde ser aberto", uma vez que a implementação devolve 0 sempre que a lista de resultados regressa vazia, incluindo em caso de falha de leitura. Um painel que interprete o 0 como sinal verde aprovará alegremente um ficheiro que outro processo tenha bloqueado. Associar a chamada ao LastErrorCode, como demonstrado acima, permite distinguir os dois casos. O verificador abre também o ficheiro com o modo de partilha deny-write, por isso, se a sua etapa de correção ainda mantiver um handle de escrita ativo, o preflight falhará por um motivo que nada tem a ver com a conformidade e tudo a ver com um fluxo que se esqueceu de libertar.
Quando é uma pessoa e não um sistema automatizado a necessitar de ler as ocorrências, o método CreatePreflightReport formata-as como um relatório legível. ComparePreflightReports compara duas execuções, o que é uma forma simples de demonstrar que a correção resolveu as inconformidades originais sem introduzir novas silenciosamente.
Assinar a revisão verificada com um SignProcess
Assim que a revisão gravada passar no preflight e o seu hash estiver registado, assine esse ficheiro exato e nenhum outro. A API SignProcess funciona de forma semelhante a um builder. Abre um handle de processo, configura-o linha a linha, finaliza e, em seguida, lê o código de resultado de volta.
ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached'); // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2); // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192); // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
Duas linhas nessa sequência têm mais importância do que aparentam. A instrução SetSignProcessCustomSubFilter com ETSI.CAdES.detached escolhe uma assinatura PAdES conforme delineada na norma ETSI EN 319 142-1, em vez da família legada adbe.pkcs7.detached, o que dita a diferença entre uma assinatura aceite por um validador europeu ou uma assinalada com erro. Por sua vez, SetSignProcessReserveContentsBytes preenche o marcador /Contents com bytes reservados. O tamanho aqui escolhido determina o futuro: se mais tarde pretender adicionar um carimbo de data/hora à assinatura, o CMS expandido terá de caber no espaço que reservar agora, já que o marcador não pode crescer depois sem assinar novamente todo o documento. Se reservar com generosidade, desperdiçará alguns kilobytes. Se reservar muito à justa, a etapa do carimbo de data/hora falhará daqui a meses devido a um estouro de memória (overflow) que terá dificuldade em associar a esta linha.
O método GetSignProcessResult responde com um código e não com um booleano, e convém registar esses códigos. 1 é sucesso. 4 é senha do PDF incorreta, 7 é senha do certificado incorreta, 9 indica um PFX sem chave privada e 11 representa falha ao aplicar a assinatura. Se reduzir isto a um simples verdadeiro/falso, perderá a única informação que lhe permite distinguir uma situação de suporte por senha incorreta de outra em que a chave não possui a parte privada. Registe o valor inteiro.
Validação de leitura (read-back): auditar o ficheiro que acabou de gerar
Nenhum painel deve confiar cegamente no processo de escrita do ficheiro que está prestes a certificar. A classe de auditoria TPDFlibSignDoc reabre o ficheiro de saída assinado e lê as entradas do dicionário de assinaturas diretamente do disco:
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// Capture the size before Open: the audit object holds a share lock on the file
FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
SourceSize := FS.Size;
FS.Free;
Doc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // > 0 means the field is signed
begin
RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
GapStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
TailStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
TailLen := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
Writeln(Names[I], ': signature covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
Os argumentos de ValueKey mapeiam as entradas do dicionário. A chave 0 devolve o CMS em bruto de /Contents, as chaves 2 e 3 os nomes de /Filter e /SubFilter, e de 11 a 14 os quatro números do ByteRange. Os valores de texto são obtidos por GetSignatureTextValueByName: a chave 0 corresponde ao momento declarado da assinatura e a chave 5 distingue uma assinatura Sig comum de um DocTimeStamp, o que é importante quando o documento contém ambos.
A captura do tamanho do ficheiro no início do exemplo acima é crucial e não mero detalhe. O método TPDFlibSignDoc.Open mantém o ficheiro sob um bloqueio de partilha (share lock) restritivo durante todo o seu ciclo de vida, de modo que qualquer operação que necessite dos bytes em bruto (cálculo de hash do intervalo assinado, nova computação do resumo CMS) deve ler o ficheiro antes de chamar Open. A própria demonstração SigningWorkbench da biblioteca lê primeiro a totalidade do ficheiro para a memória por este motivo exato; um painel de trabalho que ignore esta ordem falhará de forma intermitente, dependendo de qual processo ganhar a corrida na máquina.
Aritmética de ByteRange que comprova a cobertura
Um ficheiro saudável com assinatura única apresenta um ByteRange do tipo [0 a b c]: a cobertura inicia-se no desvio (offset) 0, ignora o marcador hexadecimal /Contents entre a e b, e é retomada ao longo do byte b+c. Quando b+c é igual ao tamanho do ficheiro, a assinatura cobre tudo até ao final do ficheiro (EOF), que é o resultado pretendido. Se ficar aquém, significa que alguém anexou uma atualização incremental após a assinatura ter sido aplicada. Isto é perfeitamente legítimo nos termos da norma ISO 32000-1 §12.8, já que preenchimentos de formulários posteriores, uma segunda assinatura ou um dicionário DSS chegam ao ficheiro desta forma. Também constitui precisamente o facto que um registo de auditoria deve registar no momento da assinatura, em vez de o tentar reconstruir sob pressão durante um litígio.
Atenção à largura do inteiro ao fazer esta aritmética. O método GetSignProcessByteRange da API plana devolve um inteiro de 32 bits, mas os valores subjacentes são Int64, pelo que, num ficheiro com mais de 2 GB, o acessor plano trunca o valor silenciosamente. Utilize o método da camada de classe TPDFlibSigner.GetByteRange, que devolve Int64, ou extraia os valores de GetSignatureValueByName da forma mostrada no código de auditoria acima.
O que a biblioteca deixa ao seu encargo
É preferível conhecer duas limitações na fase de desenho da solução do que na fase final de entrega. A API plana TPDFlib não inclui nenhuma rotina de verificação de assinatura. A validação criptográfica reside um nível abaixo, em TPDFlibSignatureVerifier, cujo método VerifySignature responde se é válida, inválida ou desconhecida. Também não existe um cliente HTTP integrado para autoridades de carimbo de data/hora RFC 3161. A biblioteca calcula o hash a submeter e reincorporpora o CMS aumentado assim que obtém a resposta, mas a comunicação de rede com o servidor de carimbo (TSA) fica ao encargo do seu código. Ambas as funcionalidades são fáceis de encapsular, mas constituem uma surpresa desagradável se detetadas em falta perto do lançamento, pelo que devem ser planeadas desde o primeiro esboço.
Convém esclarecer uma questão sobre conformidade, pois ela decide onde colocar o último filtro: adicionar uma assinatura invalida o formato PDF/A? Por si só, não. A assinatura é inserida como uma atualização incremental e a norma ISO 19005-2 e posteriores autorizam explicitamente documentos assinados. A questão reside no aspeto visual da assinatura, que segue as mesmas regras que qualquer outro conteúdo de página: inclusão de tipos de letra incorporados e ausência de cores dependentes do dispositivo. Assim, o filtro final no seu painel é mais uma execução de preflight, desta vez aplicada ao ficheiro final assinado. Utilize o CheckFileCompliance como uma validação rápida no fluxo de processamento e continue a validar as versões finais com uma ferramenta independente como o veraPDF, dado que os validadores aplicam regras sobrepostas mas não idênticas; em caso de divergência, o texto da ocorrência geralmente especifica a cláusula correspondente na norma.
Deste processo resulta um ponto de sequenciação importante. A assinatura e a aposição do carimbo de data/hora não ocorrem no mesmo passo: primeiro grava-se a assinatura base e depois um processo separado de carimbo complementa o CMS dentro do espaço /Contents reservado, motivo pelo qual a reserva prévia de bytes assumia tanta importância. Para as camadas de carimbo de data/hora e validação a longo prazo desenvolvidas a partir deste painel, o manual de assinatura e validação PAdES acompanha a assinatura desde a base até ao nível B-LT, e a parte do preflight é detalhada no guia de preflight PDF/A e PDF/UA. A documentação completa da API e as transferências de avaliação estão disponíveis na página do produto PDFlibPas.