Codificação, hashing e cifra são três coisas diferentes
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ção | Sim, por qualquer pessoa | Não | Fazer passar bytes por um canal que não os transporta em bruto |
| Hashing | Não | Não | Impressões digitais, verificações de integridade, chaves de pesquisa |
| Cifra | Sim, com a chave | Sim | Sigilo |
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.
| Contexto | Tratamento correto |
|---|---|
| Texto HTML | Entidades para &, < e > |
| Atributo entre aspas | Entidades 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 src | Codifique o valor em percentagem, faça-lhe o escape de atributo e depois verifique o esquema |
| Valor de CSS | Escape 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;.
Hashing: que algoritmo, e para quê
| Algoritmo | Digest | Estado |
|---|---|---|
| MD5 | 128 bits | Quebrado. As colisões são triviais |
| SHA-1 | 160 bits | Quebrado. As colisões com prefixo escolhido são práticas e baratas |
| SHA-256, SHA-512 | 256, 512 bits | Bons para integridade e assinaturas |
| bcrypt, scrypt, Argon2 | variável | Só 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
| Forma | Sinal | Exemplo |
|---|---|---|
| Hexadecimal | Só 0-9a-f, comprimento par. 32 carateres é MD5, 40 é SHA-1, 64 é SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | Maiúsculas e minúsculas com + e /, comprimento múltiplo de 4, pode acabar em = ou == | SGVsbG8sIHdvcmxkIQ== |
| Base64url | O mesmo com - e _, normalmente sem preenchimento | eyJzdWIiOiIxMjM0NSJ9 |
| Codificada em percentagem | % seguido de dois dígitos hexadecimais | a%20b%26c |
| JWT | Três blocos Base64url separados por pontos finais, a começar em eyJ | eyJhbGciOi... |
| bcrypt | Exatamente 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.