Codificar, hashear y cifrar son tres cosas distintas

En qué se diferencian la codificación, el hasheo y el cifrado, para qué sirven de verdad Base64, la codificación porcentual y el escapado de HTML, y cómo reconocer una cadena desconocida de un vistazo.

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ónSí, por cualquieraNoHacer que unos bytes pasen por un canal que no puede transportarlos en bruto
HasheoNoNoHuellas, comprobaciones de integridad, claves de búsqueda
CifradoSí, con la claveSecreto

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.

ContextoTratamiento correcto
Texto HTMLEntidades para &, < y >
Atributo entrecomilladoEntidades 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 srcCodifica el valor en porcentaje, escápalo como atributo y después comprueba el esquema
Valor de CSSEscapado 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;amp;.

Hasheo: qué algoritmo y para qué

AlgoritmoResumenEstado
MD5128 bitsRoto. Las colisiones son triviales
SHA-1160 bitsRoto. Las colisiones de prefijo elegido son prácticas y asequibles
SHA-256, SHA-512256, 512 bitsVálidos para integridad y firmas
bcrypt, scrypt, Argon2variableSolo 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

FormaPistaEjemplo
HexadecimalSolo 0-9a-f, longitud par. 32 caracteres es MD5, 40 es SHA-1, 64 es SHA-2565d41402abc4b2a76b9719d911017c592
Base64Mayúsculas y minúsculas mezcladas con + y /, longitud múltiplo de 4, puede terminar en = o ==SGVsbG8sIHdvcmxkIQ==
Base64urlLo mismo pero con - y _, normalmente sin rellenoeyJzdWIiOiIxMjM0NSJ9
Codificado en porcentaje% seguido de dos dígitos hexadecimalesa%20b%26c
JWTTres bloques Base64url separados por puntos, que empiezan por eyJeyJhbGciOi...
bcryptExactamente 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.