Artigo Técnico

Por que o Excel rejeita sua pasta de trabalho

Você escreve um workbook, encripta com uma senha, entrega o arquivo a um colega, e o colega abre no Excel. O Excel pede a senha. O colega digita, e o Excel aceita. Até aqui a encriptação parece correta. Aí o Excel exibe um diálogo dizendo que o arquivo está corrompido e não pode ser aberto, ou abre uma planilha de células sem sentido. A senha estava certa. O arquivo está quebrado mesmo assim. Este é o modo de falha mais desorientador da encriptação do Office, porque a parte que diz que a senha está certa e a parte que guarda seus dados são protegidas por duas operações diferentes, e acertar uma não garante nada sobre a outra

As duas armadilhas descritas aqui têm exatamente essa forma. Em cada caso o verifier passa e o corpo não, o que manda você caçar um problema de senha ou de derivação de chave que não existe. A falha real está a jusante, em como os bytes do pacote são transformados. As duas armadilhas são independentes, uma no caminho AES e outra no caminho RC4, mas compartilham o problema de diagnóstico, então vale ver por que um resultado meio correto é o mais difícil de interpretar

Por que uma senha correta não prova nada sobre o corpo

O formato que o XLSX encriptado moderno usa é a Standard Encryption do ECMA-376, e ele guarda duas coisas encriptadas lado a lado. Uma é o EncryptionVerifier: um bloco pequeno com um valor aleatório e o hash desse valor, encriptado com a chave derivada da senha. A outra é o EncryptedPackage: todo o contêiner zip do workbook, encriptado com a mesma chave. O verifier existe para o leitor poder confirmar a senha antes de gastar esforço com megabytes de corpo. Decripte o verifier, calcule o hash do valor aleatório, compare com o hash armazenado, e se baterem a senha está correta

A armadilha é que o verifier e o pacote são encriptados por chamadas separadas sobre buffers distintos. Uma chave derivada corretamente vai decriptar o verifier corretamente não importa o que aconteça com o pacote depois. Então, se sua derivação de chave está certa mas a transformação do pacote está errada, o Excel confirma a senha pelo verifier e depois falha no corpo. O sintoma se lê como "senha certa, arquivo quebrado", o que aponta a investigação para o caminho da senha, que é justamente a parte que nunca esteve quebrada. A mesma separação governa o caso legado do RC4: o hash do verifier é checado primeiro, e um corpo que dessincroniza deixa essa checagem intacta

Diagrama da Standard Encryption do ECMA-376 no HotXLS em Delphi: uma key derivada protege um verifier pequeno e todo o EncryptedPackage por chamadas separadas
Verifier e pacote são encriptados por chamadas separadas sobre buffers distintos, então uma senha correta não prova nada sobre o corpo

Armadilha um: AES em ECB, não CBC

[MS-OFFCRYPTO] §2.3.4.15 especifica que a Standard Encryption encripta o pacote com AES em Electronic Codebook. Cada bloco de 16 bytes do pacote com padding é encriptado de forma independente com a mesma chave. Não há chaining entre blocos e não há initialization vector. É uma escolha incomum para os padrões de hoje, em que o ECB normalmente é evitado, mas interop não é lugar para duvidar da especificação. O Excel decripta o pacote como ECB, então o produtor precisa encriptar como ECB ou os dois não vão concordar

O erro tentador é encriptar o pacote com AES em modo CBC usando um initialization vector todo zero. Eis por que isso quase funciona, e por que quase é o pior lugar para cair. No CBC, o primeiro bloco de plaintext é XORed com o IV antes da encriptação. Quando o IV é todo zero, esse XOR não muda nada, então o primeiro bloco de CBC-com-IV-zero produz exatamente o mesmo ciphertext que o ECB. Do segundo bloco em diante o CBC alimenta o ciphertext anterior no próximo, então todo bloco depois do primeiro diverge do ECB

Agora sobreponha isso à estrutura. O layout do pacote coloca um prefixo de comprimento de 8 bytes little-endian bem no início, então as partes do arquivo que o Excel checa mais cedo ficam no primeiro ou segundo bloco. Um primeiro bloco que por acaso bate significa que a validação mais cedo passa enquanto todo bloco posterior decripta para ruído. A cura não é sutil depois que o modo é nomeado: encripte cada bloco de 16 bytes com ECB e não faça chaining. No engine, o XlsEncryptStdPackage percorre o buffer com padding em passos de 16 bytes e chama AESEncryptECB128Block em cada um, a mesma primitiva já usada nos blocos do verifier. O source traz um comentário no loop que declara a regra sem rodeios: CBC com IV zero só bate com ECB no primeiro bloco, então o resto do pacote decriptaria para lixo e o Excel o rejeitaria

Diagrama do HotXLS de AES ECB versus CBC com IV zero nos blocos de 16 bytes de um pacote XLSX encriptado, em que só o primeiro bloco bate e todos os seguintes divergem
CBC com IV zero reproduz o ECB só no primeiro bloco, que guarda o prefixo de comprimento que o Excel valida primeiro, enquanto todo bloco seguinte decripta para ruído
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('report.xlsx');
    // O SaveAsEncrypted serializa o workbook e depois roda o
    // pipeline de Standard Encryption do ECMA-376: AES-128 ECB sobre o
    // pacote conforme [MS-OFFCRYPTO] 2.3.4.15. Retorna 1 em caso de sucesso.
    if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
      raise Exception.Create('Encryption failed');
  finally
    Book.Free;
  end;
end;

Armadilha dois: o re-key do RC4 dessincroniza

O caminho legado de .xls usa o esquema RC4 CryptoAPI, e a regra dele é diferente em natureza. [MS-OFFCRYPTO] §2.3.6 especifica que a cifra é re-keyed a cada fronteira de bloco de 1024 bytes. O stream é dividido em blocos de 1024 bytes, uma nova chave RC4 é derivada para o bloco 0, 1, 2 e assim por diante, e dentro de cada bloco o keystream é consumido continuamente de byte em byte. Dois invariantes precisam valer juntos: re-key em cada fronteira, e consumir o keystream sem buracos dentro do bloco. RC4 é uma stream cipher, então o keystream dela é uma única sequência ordenada; o n-ésimo byte que você tira é determinado por quantos bytes você tirou antes dele. A decriptação é o mesmo XOR contra a mesma sequência, o que significa que producer e consumer precisam tirar exatamente os mesmos bytes nas exatamente mesmas posições

Essa é toda a dificuldade. Uma stream cipher não tem ressincronização. Se você desperdiça um byte de keystream, todo byte depois dele é XORed contra o byte errado do keystream, e o erro nunca se corrige; ele se propaga em cascata até o fim do bloco e, uma vez que a posição corrente fica errada, para todo bloco seguinte. Um pequeno deslize de contabilidade faz exatamente isso. Imagine um contador de blocos que começa de um valor sentinela de menos um, e uma rotina de skip que assume que o contador já bate com o bloco atual. Partindo dessa sentinela, ela re-keya e queima um bloco inteiro de 1024 bytes de keystream que nunca deveria ter sido consumido, e no processo deixa a contagem restante negativa. Daí em diante o decrypter está um bloco inteiro fora de fase. O verifier, checado antes de tudo isso, continua passando, então a senha parece certa enquanto toda célula de dados sai como lixo

No HotXLS a lógica mora em TXLSDecrypterRC4. Tanto Skip quanto Decrypt compartilham um único loop: re-key só quando a posição corrente cruza para um bloco novo, em que o índice do bloco é a posição dividida por REKEY_BLOCK_SIZE (1024), e então consome até o resto do bloco atual e nada mais. O MakeKey é chamado com o índice do bloco, nunca com um índice velho ou sentinela, e a posição avança pelo número exato de bytes processados para que Skip e Decrypt fiquem em fase com o producer. A lição fica na menor unidade: um único byte desperdiçado não é um erro pequeno numa stream cipher, é a perda total de tudo que vem depois

Diagrama do drift de re-key do RC4 na encriptação legado do Excel lida do Delphi: o producer re-keya a cada 1024 bytes enquanto um consumer que começa em sentinela queima um bloco de keystream e fica fora de fase
Uma stream cipher não tem ressincronização, então um bloco de keystream de 1024 bytes queimado deixa toda célula decriptada como lixo enquanto o verifier continua passando
var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create;
  try
    // Um .xls legado protegido com RC4 ou RC4 CryptoAPI é decriptado dentro
    // do Open: passe a senha e o decrypter de stream mantém a cadência
    // de re-key alinhada com o producer.
    Book.Open('legacy.xls', 'S3cret!');
    // leia as células aqui
  finally
    Book.Free;
  end;
end;

Interop com especificação congelada é bater byte a byte

As duas armadilhas se reduzem ao mesmo princípio de raiz, e vale enunciá-lo por conta própria porque ele muda como você pesa escolhas de design. Quando o consumer do seu output é um programa externo fixo que você não pode mudar, o modo da cifra e a cadência de re-key não são detalhes de implementação que você pode otimizar ou simplificar. Eles fazem parte do contrato binário. O Excel vai decriptar com ECB e re-keyar nas fronteiras de 1024 bytes quer essas escolhas lhe agradem ou não, e seu único trabalho é produzir bytes que decriptem para o original sob esse procedimento exato. Um modo mais moderno, um IV que parece inofensivo, um contador que começa onde parece natural; qualquer um desses é um defeito no instante em que diverge do que o leitor espera. Interop contra uma especificação congelada não é aproximado. É byte-exato ou está quebrado

É também por isso que o verifier sozinho é um smoke test ruim. Ele diz que a derivação de chave funciona, o que é necessário mas de longe suficiente. Um teste que só abre um arquivo encriptado e confirma que a senha passa vai reportar sucesso com o corpo ilegível. Um teste de verdade decripta o pacote e compara os bytes recuperados com o input original, ou faz round trip de um workbook por encriptar e decriptar e lê as células de volta. O verifier prova a senha; só o corpo prova a encriptação

O jeito suportado de ler e escrever workbooks protegidos

A superfície pública é pequena. Para escrever um workbook moderno protegido por senha, popule ou abra um TXLSXWorkbook e chame SaveAsEncrypted com um nome de arquivo e uma senha; ele serializa o workbook e roda o pipeline de Standard Encryption descrito acima, retornando 1 em caso de sucesso. O SaveAsEncryptedAgile recebe os mesmos argumentos e escreve o esquema Agile que o Excel 2010 e posteriores usam por padrão, com derivação de chave SHA-512 e encriptação de pacote AES-256. Para ler, chame CanReadEncrypted para testar se o arquivo é um contêiner Compound File, e então bifurque: o OpenEncrypted decripta pacotes Standard e Agile e cai no Open para arquivos simples, e levanta EXlsxEncryptionNotImplemented quando a senha está errada ou o contêiner usa outro esquema. Um arquivo .xls legado também é um Compound File, então ele vai para o TXLSWorkbook.Open com a senha. O tratamento de modo e o loop de re-key descritos acima ficam por baixo dessas chamadas; você fornece a senha e o nome do arquivo e o engine bate com a especificação por você

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly.xlsx');
    Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
    // Reabre no lado do consumer
    Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
  finally
    Book.Free;
  end;
end;

A forma do output protegido, o stream EncryptionInfo, os blocos do verifier e o layout do pacote estão cobertos em nosso passo a passo de output XLSX protegido por AES. Para a pergunta separada de travamento no nível de planilha e de como a proteção interage com page setup e impressão, veja o artigo sobre proteção, page setup e impressão. Ambos se apoiam no caminho de encriptação descrito aqui, que vem como parte do componente de planilhas HotXLS para Delphi e C++Builder junto com as APIs de leitura, escrita e renderização cobertas em outros posts deste blog