Codificação, hashing e cifra são três coisas diferentes

Como a codificação, o hashing e a cifra diferem entre si, para que servem realmente o Base64, a codificação percentual e o escape de HTML, e como reconhecer à vista uma cadeia desconhecida.

Uma cadeia de carateres que parece baralhada não é uma cadeia protegida. Há três operações distintas que produzem saída ilegível, e é de as confundir que nascem falhas de segurança a sério.

Reversível?Precisa de chave?Para que serve
CodificaçãoSim, por qualquer pessoaNãoFazer passar bytes por um canal que não os transporta em bruto
HashingNãoNãoImpressões digitais, verificações de integridade, chaves de pesquisa
CifraSim, com a chaveSimSigilo

Codificar é mudar de alfabeto. O Base64, a codificação percentual, o hexadecimal e as entidades HTML escrevem a mesma informação de outra maneira para que um analisador mais à frente não se engasgue com ela. Não há segredo nenhum envolvido, por isso não há nada a guardar. Um deslocamento de César pertence aqui também: a ferramenta Caesar Cipher testa por força bruta os 25 deslocamentos de uma vez, um resumo justo da proteção que dá uma substituição fixa. O hashing mapeia qualquer entrada para um digest de comprimento fixo e não pode ser executado ao contrário. A cifra é a única das três que garante confidencialidade, e envolve sempre uma chave. Se não conseguir apontar para a chave, não está nada cifrado.

O Base64 e o que ele custa

O Base64 divide três bytes (24 bits) em quatro grupos de seis e escreve cada grupo como um caráter de um alfabeto de 64 carateres. Quatro carateres por cada três bytes quer dizer que a saída fica um terço maior. Isso conta sobretudo quando se incorporam ficheiros: a ferramenta File to Base64 produz um URL de dados, por isso uma imagem de 300 KB vai parar ao seu HTML com cerca de 400 KB, transferidos de novo a cada página.

Quando a entrada não é múltipla de três bytes, o último grupo é preenchido com =:

"abc"  ->  YWJj      (3 bytes, no padding)
"ab"   ->  YWI=      (2 bytes, one =)
"a"    ->  YQ==      (1 byte, two =)

O alfabeto padrão termina em + e /, o que é um problema num URL, porque + é descodificado como espaço em dados de formulário e / é um separador de caminho. Por isso existe um segundo alfabeto: o Base64url troca + por - e / por _, e normalmente dispensa o preenchimento. É essa a razão por que um token copiado de um URL às vezes falha num descodificador configurado para o alfabeto padrão. O Base64 Encoder trata dos dois e trata corretamente do Unicode, coisa que as implementações ingénuas muitas vezes não fazem: o texto tem de ser codificado em UTF-8 antes do passo de Base64.

Nada disto é uma medida de segurança. Um valor a que um produto chama identificador "cifrado" num URL é muitas vezes apenas o Base64 de um inteiro simples que qualquer pessoa lê, altera e volta a codificar em segundos. O mesmo vale para a carga útil de um JSON Web Token: o JWT Decoder lê-a sem chave nenhuma, porque não há ali nada para destrancar.

Codificação percentual, e que função usar

A codificação percentual substitui um byte por % e dois dígitos hexadecimais. Trabalha sobre bytes e não sobre carateres, por isso o texto não ASCII é primeiro codificado em UTF-8: é passa a %C3%A9.

Os carateres não reservados (A-Z, a-z, 0-9, -, ., _, ~) nunca precisam de codificação. Os carateres reservados (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) são os delimitadores que dão estrutura a um URL, e saber se um deles precisa de codificação depende de estar a ser usado como delimitador ou como dado. É essa a diferença entre as duas funções de JavaScript:

encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs   the & still splits the query

encodeURIComponent("cats & dogs")
// cats%20%26%20dogs                    safe as a single value

O encodeURI serve para um URL inteiro que se quer tornar válido, por isso deixa os delimitadores intactos. O encodeURIComponent serve para um valor que vai entrar num segmento de caminho ou num parâmetro de query, por isso codifica-os. Use o segundo para tudo o que estiver a inserir, e note que mesmo assim deixa !, ', (, ) e * em paz.

Depois há o sinal de mais. Os corpos de formulário enviados como application/x-www-form-urlencoded codificam um espaço como +, e a convenção alastrou às query strings, pelo que a maioria dos servidores descodifica um + numa query como espaço. Um sinal de mais literal tem de ser enviado como %2B, e é por isso que alex+billing@example.com chega tantas vezes como alex billing@example.com, com a etiqueta destruída. Num segmento de caminho, pelo contrário, + é apenas um mais e um espaço é %20. O URL Encoder torna isso visível quando um valor sobrevive a uma ida e volta e se parte na seguinte.

O escape depende de onde o valor vai parar

Não existe uma única forma de uma cadeia "com escape para a web". É o contexto que decide.

ContextoTratamento correto
Texto HTMLEntidades para &, < e >
Atributo entre aspasEntidades para & e para a aspa que o envolve, e ponha sempre o atributo entre aspas
Dentro de <script>Escape de cadeias de JavaScript, não entidades
URL em href ou srcCodifique o valor em percentagem, faça-lhe o escape de atributo e depois verifique o esquema
Valor de CSSEscape de CSS, e nunca construa url() a partir de entrada do utilizador

O caso do script apanha muita gente. O conteúdo de um script não passa por descodificação de entidades, por isso uma entidade escrita lá dentro fica literal, e o analisador de HTML fecha o bloco na sequência </script onde quer que ela apareça, esteja ou não dentro de uma cadeia entre aspas:

<script>var s = "</script>";</script>   <!-- block ends early -->
<script>var s = "<\/script>";</script>  <!-- correct -->

Para os URL o escape também não chega. Um valor que começa por javascript: executa quando alguém clica, por muito cuidadosa que tenha sido a codificação, por isso valide o esquema contra uma lista de esquemas permitidos. A ferramenta HTML Entities cobre os casos de texto e de atributo e também descodifica entidades de volta, que é a necessidade habitual quando uma API fez escape a dobrar e transformou alguma coisa em &amp;amp;.

Hashing: que algoritmo, e para quê

AlgoritmoDigestEstado
MD5128 bitsQuebrado. As colisões são triviais
SHA-1160 bitsQuebrado. As colisões com prefixo escolhido são práticas e baratas
SHA-256, SHA-512256, 512 bitsBons para integridade e assinaturas
bcrypt, scrypt, Argon2variávelSó palavras-passe

O MD5 e o SHA-1 falham na resistência a colisões, ou seja, um atacante consegue construir duas entradas diferentes com o mesmo digest. Em qualquer sítio onde um hash faça as vezes de um documento são inutilizáveis: assinaturas, certificados, endereçamento por conteúdo, eliminação de duplicados de fosse o que fosse fornecido por um atacante. Nenhum deles está quebrado para pré-imagens, por isso uma soma de verificação MD5 antiga ainda apanha corrupção acidental, mas nenhum tem lugar em trabalho novo. Use SHA-256, que o Hash Generator calcula tanto para ficheiros como para texto.

As palavras-passe são outro problema, e um hash rápido é a ferramenta errada precisamente por ser rápido. Hardware comum faz milhares de milhões de operações SHA-256 por segundo, por isso uma tabela vazada de hashes SHA-256 de palavras-passe é uma tabela vazada de palavras-passe. O bcrypt, o scrypt e o Argon2id são deliberadamente lentos e ajustáveis, com um fator de trabalho (e, nos dois últimos, um custo de memória) que se aumenta à medida que o hardware melhora, e cada um guarda na sua saída um sal aleatório único.

Um hash também não é uma assinatura. Para provar que uma mensagem veio de alguém que detém uma chave, use HMAC-SHA-256 em vez de fazer o hash do segredo colado à mensagem, prática vulnerável à extensão de comprimento.

O que um hash não consegue fazer

Um hash não se inverte por cálculo. Inverte-se por procura sempre que o conjunto de entradas possíveis é pequeno o suficiente para ser enumerado: faça o hash de todos os PIN de quatro dígitos e tem os dez mil digests num instante, e o mesmo vale para todos os códigos postais, números de telefone ou moradas de uma lista vazada. Fazer o hash de um identificador não o torna anónimo.

A salga, um valor aleatório único guardado com cada registo e misturado na entrada, não torna um alvo isolado mais difícil de adivinhar, mas obriga um atacante a atacar cada registo em separado e torna inúteis as tabelas pré-calculadas. Para valores de baixa entropia, o que ajuda mesmo é um pepper, uma chave secreta mantida fora da base de dados.

Reconhecer uma cadeia desconhecida

FormaSinalExemplo
Hexadecimal0-9a-f, comprimento par. 32 carateres é MD5, 40 é SHA-1, 64 é SHA-2565d41402abc4b2a76b9719d911017c592
Base64Maiúsculas e minúsculas com + e /, comprimento múltiplo de 4, pode acabar em = ou ==SGVsbG8sIHdvcmxkIQ==
Base64urlO mesmo com - e _, normalmente sem preenchimentoeyJzdWIiOiIxMjM0NSJ9
Codificada em percentagem% seguido de dois dígitos hexadecimaisa%20b%26c
JWTTrês blocos Base64url separados por pontos finais, a começar em eyJeyJhbGciOi...
bcryptExatamente 60 carateres, a começar em $2a$, $2b$ ou $2y$ e um custo$2b$12$...
Argon2$argon2id$v=19$m=...,t=...,p=...$salt$hash

Vale a pena decorar eyJ: é o Base64 de {", por isso qualquer bloco que comece assim é um objeto JSON codificado, seja ele um token ou não.

Descodificar com sucesso não prova que o palpite estava certo, porque o Base64 transforma qualquer entrada em bytes. Verifique se o resultado é texto plausível, ou se começa por uma assinatura de ficheiro conhecida: Base64 começado por iVBORw0KGgo é um PNG, /9j/ é um JPEG, JVBERi0 é um PDF e UEsDB é um zip. Se os bytes não se parecerem com nada, passe a saída pela ferramenta Text to Binary para ler os códigos diretamente, ou verifique se o valor foi codificado em percentagem antes de ser codificado em Base64, um duplo embrulho frequente.