Codificar, hashear y cifrar son tres cosas distintas
Una cadena que parece revuelta no es una cadena protegida. Hay tres operaciones distintas que producen una salida ilegible, y confundirlas es de donde salen los fallos de seguridad de verdad.
| ¿Reversible? | ¿Necesita clave? | Para qué sirve | |
|---|---|---|---|
| Codificación | Sí, por cualquiera | No | Hacer que unos bytes pasen por un canal que no puede transportarlos en bruto |
| Hasheo | No | No | Huellas, comprobaciones de integridad, claves de búsqueda |
| Cifrado | Sí, con la clave | Sí | Secreto |
Codificar es cambiar de alfabeto. Base64, la codificación porcentual, el hexadecimal y las entidades HTML escriben la misma información de otra forma para que un analizador posterior no se atragante con ella. No interviene ningún secreto, así que no hay nada que guardar. El desplazamiento de César también entra aquí: la herramienta de cifrado César prueba por fuerza bruta los 25 desplazamientos a la vez, lo que resume bastante bien cuánto protege una sustitución fija. El hasheo transforma cualquier entrada en un resumen de longitud fija y no se puede ejecutar al revés. El cifrado es el único de los tres que aporta confidencialidad, y siempre implica una clave. Si no puedes señalar la clave, no se está cifrando nada.
Base64 y lo que cuesta
Base64 parte tres bytes (24 bits) en cuatro grupos de seis y escribe cada grupo como un carácter de un alfabeto de 64. Cuatro caracteres por cada tres bytes significa que la salida es un tercio más grande. Eso importa sobre todo al incrustar archivos: la herramienta de archivo a Base64 produce una URL de datos, así que una imagen de 300 KB acaba en tu HTML ocupando unos 400 KB, que se vuelven a descargar con cada página.
Cuando la entrada no es múltiplo de tres bytes, el último grupo se rellena con =:
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
El alfabeto estándar termina en + y /, lo cual es un problema dentro de una URL, porque + se decodifica como espacio en los datos de formulario y / es un separador de rutas. Por eso existe un segundo alfabeto: Base64url cambia + por - y / por _, y normalmente prescinde del relleno. Esa es la razón por la que un token copiado de una URL falla a veces en un decodificador configurado con el alfabeto estándar. El codificador Base64 acepta los dos y trata Unicode correctamente, cosa que las implementaciones ingenuas a menudo no hacen: el texto tiene que codificarse en UTF-8 antes del paso de Base64.
Nada de esto es una medida de seguridad. Un valor que un producto llama identificador «cifrado» dentro de una URL es muy a menudo el Base64 de un entero corriente que cualquiera puede leer, cambiar y volver a codificar en segundos. Lo mismo vale para la carga útil de un JSON Web Token: el decodificador JWT la lee sin clave, porque no hay nada que desbloquear.
La codificación porcentual y qué función usar
La codificación porcentual sustituye un byte por % y dos dígitos hexadecimales. Trabaja sobre bytes, no sobre caracteres, así que el texto que no es ASCII se codifica antes en UTF-8: é se convierte en %C3%A9.
Los caracteres no reservados (A-Z, a-z, 0-9, -, ., _, ~) no hay que codificarlos nunca. Los caracteres reservados (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) son los delimitadores que dan estructura a una URL, y que uno haya que codificarlo o no depende de si se está usando como delimitador o como dato. Esa es la diferencia entre las dos funciones 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
encodeURI es para una URL entera que quieres dejar válida, así que respeta los delimitadores. encodeURIComponent es para un único valor que va dentro de un segmento de ruta o de un parámetro de consulta, así que los codifica. Usa la segunda para todo lo que estés insertando, y ten en cuenta que aun así deja intactos !, ', (, ) y *.
Y luego está el signo más. Los cuerpos de formulario enviados como application/x-www-form-urlencoded codifican un espacio como +, y la convención se coló en las cadenas de consulta, así que la mayoría de servidores decodifican un + de una consulta como un espacio. Un más literal hay que enviarlo como %2B, y por eso alex+billing@example.com llega tantas veces como alex billing@example.com, con la etiqueta destruida. En un segmento de ruta, en cambio, + es simplemente un más y un espacio es %20. El codificador de URL lo hace visible cuando un valor sobrevive a una ida y vuelta y se rompe en la siguiente.
El escapado depende de dónde acabe el valor
No existe una única forma «escapada para la web» de una cadena. Decide el contexto.
| Contexto | Tratamiento correcto |
|---|---|
| Texto HTML | Entidades para &, < y > |
| Atributo entrecomillado | Entidades para & y para la comilla que lo envuelve, y entrecomilla siempre el atributo |
Dentro de <script> | Escapado de cadenas de JavaScript, no entidades |
URL en href o src | Codifica el valor en porcentaje, escápalo como atributo y después comprueba el esquema |
| Valor de CSS | Escapado de CSS, y nunca construyas url() a partir de entrada del usuario |
El caso del script pilla a mucha gente. El contenido de un script no se decodifica como entidades, así que una entidad escrita ahí se queda literal, y el analizador de HTML cierra el bloque en la secuencia </script dondequiera que aparezca, esté dentro de una cadena entrecomillada o no:
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
Para las URL el escapado por sí solo tampoco basta. Un valor que empieza por javascript: se ejecuta al hacer clic por muy cuidadosamente que se haya codificado, así que valida el esquema contra una lista de permitidos. La herramienta de entidades HTML cubre los casos de texto y de atributo y también decodifica entidades, que es lo que suele hacer falta cuando una API ha escapado algo dos veces y lo ha dejado en &amp;.
Hasheo: qué algoritmo y para qué
| Algoritmo | Resumen | Estado |
|---|---|---|
| MD5 | 128 bits | Roto. Las colisiones son triviales |
| SHA-1 | 160 bits | Roto. Las colisiones de prefijo elegido son prácticas y asequibles |
| SHA-256, SHA-512 | 256, 512 bits | Válidos para integridad y firmas |
| bcrypt, scrypt, Argon2 | variable | Solo contraseñas |
MD5 y SHA-1 fallan en resistencia a colisiones, es decir, que un atacante puede construir dos entradas distintas con el mismo resumen. Allí donde un hash hace las veces de un documento son inservibles: firmas, certificados, direccionamiento por contenido, deduplicación de cualquier cosa que aporte un atacante. Ninguno de los dos está roto frente a preimágenes, así que una suma de comprobación MD5 antigua todavía detecta una corrupción accidental, pero ninguno tiene sitio en trabajo nuevo. Usa SHA-256, que el generador de hashes calcula tanto para archivos como para texto.
Las contraseñas son otro problema, y un hash rápido es la herramienta equivocada precisamente por ser rápido. El hardware de consumo hace miles de millones de operaciones SHA-256 por segundo, así que una tabla filtrada de hashes SHA-256 de contraseñas es una tabla filtrada de contraseñas. bcrypt, scrypt y Argon2id son deliberadamente lentos y ajustables, con un factor de trabajo (y, en los dos últimos, un coste de memoria) que vas subiendo a medida que mejora el hardware, y cada uno guarda una sal aleatoria única dentro de su salida.
Un hash tampoco es una firma. Para demostrar que un mensaje viene de alguien que tiene una clave, usa HMAC-SHA-256 en lugar de hashear el secreto y el mensaje pegados, que es vulnerable a la extensión de longitud.
Lo que un hash no puede hacer
Un hash no se puede revertir mediante cálculo. Sí se puede revertir por búsqueda siempre que el conjunto de entradas posibles sea lo bastante pequeño como para enumerarlo: hashea todos los PIN de cuatro dígitos y tendrás los diez mil resúmenes al instante, y lo mismo vale para todos los códigos postales, números de teléfono o direcciones de una lista filtrada. Hashear un identificador no lo anonimiza.
La sal, un valor aleatorio único que se guarda con cada registro y se mezcla con la entrada, no hace que un objetivo concreto sea más difícil de adivinar, pero obliga al atacante a atacar cada registro por separado y deja sin valor las tablas precalculadas. Para valores de baja entropía, la pimienta (una clave secreta guardada fuera de la base de datos) es la parte que ayuda de verdad.
Reconocer una cadena desconocida
| Forma | Pista | Ejemplo |
|---|---|---|
| Hexadecimal | Solo 0-9a-f, longitud par. 32 caracteres es MD5, 40 es SHA-1, 64 es SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | Mayúsculas y minúsculas mezcladas con + y /, longitud múltiplo de 4, puede terminar en = o == | SGVsbG8sIHdvcmxkIQ== |
| Base64url | Lo mismo pero con - y _, normalmente sin relleno | eyJzdWIiOiIxMjM0NSJ9 |
| Codificado en porcentaje | % seguido de dos dígitos hexadecimales | a%20b%26c |
| JWT | Tres bloques Base64url separados por puntos, que empiezan por eyJ | eyJhbGciOi... |
| bcrypt | Exactamente 60 caracteres, que empiezan por $2a$, $2b$ o $2y$ y un coste | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
Vale la pena memorizar eyJ: es el Base64 de {", así que cualquier bloque que empiece así es un objeto JSON codificado, sea un token o no.
Que la decodificación funcione no demuestra que la conjetura fuera correcta, porque Base64 convierte cualquier entrada en bytes. Comprueba que el resultado sea texto plausible o que empiece con una firma de archivo conocida: un Base64 que empieza por iVBORw0KGgo es un PNG, /9j/ es un JPEG, JVBERi0 un PDF y UEsDB un zip. Si los bytes no parecen nada, pasa la salida por la herramienta de texto a binario para leer los códigos directamente, o comprueba si el valor se codificó en porcentaje antes de codificarse en Base64, una doble envoltura habitual.