Um contrato é assinado, ganha mais uma atualização, e um script de auditoria funde essa atualização na revisão assinada ou passa a página reescrita como material de validação. Nada disso acontece quando uma revisão termina onde o startxref nomeia uma seção que a cadeia /Prev alcançou e um objeto reescrito mantém a classe anterior dele — as duas regras por trás do revision map do PDFlibPas
Por que %%EOF é o lugar errado para cortar um arquivo
Toda atualização incremental é terminada pelo próprio marcador de fim de arquivo dela (ISO 32000-1 §7.5.6), então partir o arquivo em cada %%EOF parece o caminho óbvio para achar revisões. O marcador são só cinco bytes, porém, e nada impede que esses bytes apareçam dentro de um content stream, um embedded file ou um programa de fonte. Arquivos linearizados acrescentam um segundo problema: o Anexo F (§F.3.4) deixa o trailer da primeira página terminar com startxref e %%EOF próprios perto do começo do arquivo, bem antes da tabela de cross-reference principal. Partir em cada marcador inventa revisões que nenhuma seção de cross-reference sustenta. Adivinhar quais marcadores são falsos é pior — esse palpite é exatamente o que o falsificador pode moldar. O revision map do PDFlibPas funciona ao contrário. Ele segue a cadeia /Prev, registra o offset em bytes de cada seção de cross-reference que a cadeia de fato leu e só então procura sequências startxref N … %%EOF, mantendo uma fronteira apenas quando N é um dos offsets registrados. Um marcador perdido dentro de um stream precisaria de um startxref na frente nomeando uma seção real para contar alguma coisa
Os bytes entre esses três tokens são menos arrumados do que §7.5.5 sugere. A norma desenha startxref, o offset e o marcador em linhas consecutivas, mas os marcadores de fim de linha e os demais white-space characters de §7.2.2 (Tabela 1) podem se repetir, e nenhuma regra limita quantos. Perder um marcador com padding e duas revisões se fundem. A assinada agora parece terminar depois de bytes que ninguém assinou. O revision map do PDFlibPas segue a sequência da keyword ao operando ao marcador por mais white space que os separe e onde quer que esse padding caia no arquivo, e conta cada keyword uma única vez. A keyword trailer tem uma junta frouxa parecida: << é um delimitador (§7.2.2, Tabela 2) e §7.5.5 nunca exige quebra de linha depois de trailer, então trailer<< em uma linha é sintaxe conforme. Lida linha a linha, essa linha parece um header de subseção de cross-reference — e a cadeia para no meio
Percorrendo o revision map com o PDFlibPas
Os acessores de revisão são declarados no TPDFlib e implementados na edição Small Business: crie um TPDFlibSmallBusiness a partir da unit PDFlibSmallBusiness (a Enterprise desce dela e carrega as mesmas chamadas). Em um TPDFlib simples eles retornam o valor de falha deles (0, 7 ou -1) e põem o LastErrorCode em PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. As revisões são numeradas a partir de 0, o documento original. Cada chamada recebe um nome de arquivo e uma senha e constrói o map a partir dos bytes no disco, então as respostas descrevem o arquivo como armazenado, nunca um documento que você por acaso tenha carregado e editado. O GetDocumentRevisionKind reporta a mudança de maior alcance em uma revisão. Uma atualização que acrescenta uma resposta OCSP e também reescreve uma página é reportada como a reescrita de página:
| Kind | Significado | Fonte típica |
|---|---|---|
| 0 | Só housekeeping: objetos que não mudam nada que um reader veja | Cross-reference stream, um novo container de object stream |
| 1 | Material de validação | Document security store, VRI, certificados, CRLs, respostas OCSP |
| 2 | Document time stamp | Uma assinatura /DocTimeStamp e o field que a carrega |
| 3 | Assinatura | Um signature field existente recebendo o valor dele |
| 4 | Form field | Um valor de field ou a appearance dele |
| 5 | Annotation | Qualquer annotation que não seja um widget |
| 6 | Conteúdo do documento | Tudo que pode mudar o que o documento mostra |
| 7 | Não pôde ser determinado | Algo na revisão não pôde ser lido; trate como insegura |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// uma linha 'objnum,change,class,detail' por objeto
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // itens contam a partir de 1
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
O GetDocumentRevisionsComplete é a chamada a ler antes de confiar em qualquer outra. Ele retorna 1 só quando a cadeia foi seguida até o fim, de volta à seção original; um /Prev quebrado ou cíclico, ou uma cadeia mais funda que 256 elos, deixa as atualizações mais antigas invisíveis, e toda contagem e índice viram então um limite inferior. Uma profundidade de 256 deixa espaço para a história de save-and-sign de arquivos reais, e uma cadeia que aponta para si mesma ainda termina quase de imediato. Cuidado com a indexação no loop. O GetStringListItem conta a partir de 1 enquanto as revisões contam a partir de 0, e começar o loop interno em 0 descarta o primeiro objeto alterado de cada revisão sem erro algum
Como uma atualização forjada se passa por material de validação
A validação de longo prazo PAdES legitimamente acrescenta atualizações depois da última assinatura: um document security store (/Type /DSS), dicionários /VRI por assinatura, streams de certificado e revogação, document time stamps. Os níveis de certificação os deixam passar, o que faz do material de validação o rótulo mais valioso que uma atualização pode vestir. E também é o mais barato de falsificar. O /Type é o que a atualização escolheu escrever. Um viewer desenhando uma página nunca olha o /Type de um content stream, um resource dictionary ou uma fonte, então uma atualização pode reescrever o content stream da página 3, acrescentar /Type /DSS ao dicionário dela, e todo renderer continuará pintando o texto novo enquanto um classificador que acredita no /Type vê housekeeping. /VRI, /Sig e /DocTimeStamp funcionam do mesmo jeito
O revision map do PDFlibPas ignora o rótulo que uma atualização escreve em um objeto que ela reescreve. Um objeto reescrito conta como material de validação, um time stamp ou uma assinatura apenas quando a versão anterior desse mesmo objeto já era uma. Streams de certificado, CRL e resposta OCSP não carregam tipo próprio, então são reconhecidos pelo document security store que aponta para eles — e só uma store escrita na revisão sendo julgada ou em uma mais antiga conta. Uma store acrescentada depois não pode alcançar para trás e atestar um objeto que a revisão assinada escreveu. Mudanças na estrutura do documento contam como conteúdo sempre que mudam o que um reader resolveria:
- substituir um object stream existente, porque todo objeto comprimido dentro dele agora resolve para o substituto (um container novinho fica como housekeeping, já que cada membro ganha a própria entrada dele e é julgado lá)
- acrescentar um catalog ou uma página
- um trailer cujo
/Rootnomeia um catalog diferente, mesmo quando a atualização não redefine nada que a assinatura cobria, já que trocar a raiz substitui o documento inteiro sem tocar um único objeto assinado - um stream reescrito cujo dicionário é byte a byte idêntico ao antigo: um
/Lengthindireto ou um corpo substituto com padding até o mesmo tamanho mantém o dicionário inalterado enquanto o desenho muda, então só objetos sem corpo de stream são tratados como inalterados com essa evidência
Membros de object stream não têm header para remover
Desde o PDF 1.5 uma atualização pode armazenar objetos reescritos dentro de um object stream, e §7.5.7 é direto quanto ao formato: só os valores de objeto são armazenados, e as keywords obj e endobj não devem ser usadas. Código que lê um objeto na posição dele e remove o header procurando pela string obj não encontra nada na frente de um membro e continua procurando dentro do corpo dele. Dê a um dicionário de página reescrito depois da assinatura uma string de aparência inocente como (obj<</Type/DSS>>) e essa busca para dentro da string. O decoder recebe <</Type/DSS>> — um dicionário de store que a revisão nunca escreveu. O revision map do PDFlibPas faz o parse de N G obj da frente de um objeto descomprimido, confere que os números nomeiam o objeto esperado e trata um header ausente ou divergente como ilegível em vez de caçar mais longe
Cross-reference streams carregam uma ambiguidade de nível de field própria. Sob §7.5.8.3 (Tabela 18), só uma entrada type 1 guarda um offset em bytes. O segundo campo de uma entrada type 2 é o número de objeto do object stream contenedor e o terceiro é o índice dentro dele, enquanto o segundo campo de uma entrada type 0 é o próximo número de objeto livre. Ler esse número de objeto como um offset e uma entrada comprimida cai na revisão que cobrir aquelas posições pequenas de byte — quase sempre o documento original, o que esconde a reescrita dentro dos bytes assinados. O revision map posiciona cada entrada pela seção de cross-reference que a declarou. A mesma cláusula diz que um tipo de entrada diferente de 0, 1 ou 2 é uma referência ao objeto null, então essas entradas não acrescentam nem liberam nada e ficam fora do map
Quando uma auditoria de assinaturas deve se recusar a responder?
Sempre que o arquivo esconde parte da própria história. O revision map reporta isso de três formas: o GetDocumentRevisionsComplete retorna 0, o GetDocumentRevisionKind retorna 7 para alguma revisão depois da assinatura, ou o GetSignatureRevisionIndex retorna -1. Esse último valor cobre um ByteRange que não termina em uma fronteira de revisão — bytes dentro da faixa que a assinatura diz cobrir — e igualmente um field que não existe, não carrega valor de assinatura ou mora em um arquivo que não pôde ser lido. Mande os três para revisão manual com o índice de revisão e as linhas do GetDocumentRevisionChanges anexadas, para que quem abrir o arquivo comece dos números de objeto
// Confere as atualizações depois de uma assinatura contra uma política estilo DocMDP.
// Os Kinds são ordenados por severidade, então cada nível de política é um teto:
// P 1 tolera housekeeping, material de validação e time stamps (0..2),
// P 2 acrescenta assinar e preencher formulários (..4), P 3 acrescenta annotations (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // parte da história nunca foi vista
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // fora de fronteira de revisão, sem field
// assinado assim, ou o arquivo não pôde ser lido
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 fica acima de todo teto; explícito para o leitor
end;
Result := True;
end;
Os tetos seguem a ISO 32000-2 §12.8.2.2 (Tabela 257): P 1 não permite mudança nenhuma, 2 permite preencher formulários e assinar, 3 acrescenta annotations, e atualizações incrementais que só acrescentam dados DSS ou document time stamps não contam como mudança em nível algum. A ISO 32000-1 §12.8.2.2 define os mesmos três valores sem essa isenção. Escreva o loop você mesmo quando a política for sua e não do certificador, por exemplo uma regra de entrada que rejeita contratos anotados independentemente do P que o arquivo carregue. Quando a política deve vir do arquivo, o GetSignatureModificationVerdict da mesma classe lê o nível de certificação, aplica os field locks por campo descritos em detectar edições em form fields travados por FieldMDP e retorna 5 para o caso fora da fronteira. Ambos respondem a uma pergunta diferente de classificar mudanças contra uma cópia do documento que você guardou: o revision map não precisa de nada além do arquivo à frente dele
Anexar segue a mesma cadeia. O trailer de uma atualização incremental deve carregar uma entrada /Prev dando a localização da seção de cross-reference anterior (§7.5.6), e um documento que só poderia ser aberto reconstruindo a tabela de cross-reference a partir de um scan completo não tem essa localização para dar. O AppendToFile retorna 0 para ele e deixa o arquivo alvo intocado. Salve-o por inteiro e assine a cópia limpa em vez disso — uma reescrita completa não leva assinaturas existentes junto
Para um acervo de contratos assinados, rode o UpdatesWithinPolicy uma vez por signature field antes que algo novo seja anexado. Um arquivo cuja revisão posterior lê como kind 7 deve chegar a uma pessoa primeiro, antes de qualquer etapa de assinatura ou validação de longo prazo do PDFlibPas que enterraria a revisão ilegível sob outra atualização