Si tuviéramos que elegir la confusión que más vulnerabilidades reales causa entre desarrolladores, sería esta: tratar la codificación (como Base64) o la ofuscación (como ROT13) como si fueran cifrado. Los tres transforman datos y los tres producen "texto raro" a simple vista, pero solo el cifrado ofrece una garantía de seguridad respaldada por una clave secreta. En esta lección estableceremos la diferencia con rigor, practicaremos con las codificaciones que verás constantemente (Base64, hexadecimal, URL-encoding, UTF-8) usando la librería estándar de Python, y analizaremos un caso de revisión de código en MediNube donde alguien "protegió" datos sanitarios con Base64. Dominar esta distinción es requisito para todo lo que viene.

Contenido

  1. Tres transformaciones que se confunden
  2. Codificar: representar información
  3. Texto y bytes: UTF-8, el primer paso siempre
  4. Base64 y hexadecimal en Python
  5. URL-encoding y Base64 URL-safe
  6. Ofuscar: dificultar sin clave
  7. Cifrar: proteger con una clave secreta
  8. Tabla comparativa y criterio de decisión
  9. Caso práctico: revisión de código en MediNube

Tres transformaciones que se confunden

Las tres operaciones transforman una entrada en una salida distinta, pero con propósitos radicalmente diferentes:

  • Codificar (encode): cambiar la representación de los datos para que puedan transportarse o almacenarse en un medio concreto. No hay secreto: cualquiera que conozca el formato (que es público y estándar) recupera el original. Ejemplos: UTF-8, Base64, hexadecimal, URL-encoding.
  • Ofuscar: dificultar la lectura casual, sin clave secreta. Quien conozca (o adivine) el método recupera el original sin esfuerzo. Ejemplos: ROT13, minificación de JavaScript, "invertir la cadena".
  • Cifrar (encrypt): transformar los datos con un algoritmo y una clave secreta, de modo que recuperar el original sin la clave sea computacionalmente inviable, aunque el atacante conozca perfectamente el algoritmo.
graph TD
    T[Transformación de datos] --> COD[Codificación]
    T --> OFU[Ofuscación]
    T --> CIF[Cifrado]
    COD --> COD1["Objetivo: representar/transportar<br/>Se invierte: conociendo el formato público"]
    OFU --> OFU1["Objetivo: dificultar lectura casual<br/>Se invierte: conociendo el método"]
    CIF --> CIF1["Objetivo: confidencialidad<br/>Se invierte: SOLO con la clave secreta"]

La pregunta discriminante es una sola: ¿qué necesita alguien para invertir la transformación? Si la respuesta es "nada secreto, solo conocer el formato o el método", no es cifrado. Punto.

Codificar: representar información

Los ordenadores solo manejan bytes. "Codificar" es acordar cómo representamos algo (texto, una imagen, una clave criptográfica) como bytes, o cómo representamos bytes en un medio que no los admite todos. Dos situaciones típicas:

  1. De texto a bytes: el texto "María" debe convertirse en bytes para guardarse o transmitirse → UTF-8.
  2. De bytes arbitrarios a texto seguro: una clave criptográfica son 32 bytes aleatorios que incluyen valores no imprimibles; para meterlos en un JSON, una URL o una variable de entorno necesitamos representarlos con caracteres "inocentes" → Base64 o hexadecimal.

Esta segunda situación explica por qué Base64 aparece por todas partes en criptografía: las salidas de cifrar, firmar o hashear son bytes arbitrarios, y casi todos los formatos de intercambio (JSON, HTTP, XML, certificados PEM, JWT) son textuales. Base64 es el "embalaje de transporte" de esos bytes. Ver Base64 cerca de criptografía es normal; creer que el Base64 es la criptografía es el error.

Texto y bytes: UTF-8, el primer paso siempre

En Python 3 la distinción entre str (texto) y bytes es estricta, y las funciones criptográficas siempre trabajan con bytes. La conversión se hace con UTF-8:

texto = "Historial de Ana Pérez — Clínica Sol"

datos = texto.encode("utf-8")   # str -> bytes
print(datos)
# b'Historial de Ana P\xc3\xa9rez \xe2\x80\x94 Cl\xc3\xadnica Sol'

recuperado = datos.decode("utf-8")  # bytes -> str
print(recuperado == texto)          # True

Observa el resultado:

  • El prefijo b'...' indica un objeto bytes.
  • Los caracteres ASCII ("Historial", espacios) ocupan 1 byte y se muestran tal cual.
  • La é se convierte en dos bytes (\xc3\xa9) y la raya en tres (\xe2\x80\x94): UTF-8 usa entre 1 y 4 bytes por carácter. Por eso "longitud del texto" y "longitud en bytes" no coinciden — detalle que importará al hablar de tamaños de claves y de mensajes.
  • .decode("utf-8") invierte la operación exactamente. No hay ningún secreto: UTF-8 es un estándar público.

Regla práctica para todo el curso: antes de cualquier operación criptográfica, texto → bytes con .encode("utf-8"); después de descifrar, bytes → texto con .decode("utf-8").

Base64 y hexadecimal en Python

Base64

Base64 representa bytes usando solo 64 caracteres seguros (A-Z, a-z, 0-9, +, /, más = como relleno). Cada 3 bytes de entrada se convierten en 4 caracteres de salida, así que el resultado ocupa un ~33 % más.

import base64

datos = "Paciente: Ana Pérez, alergia: penicilina".encode("utf-8")

codificado = base64.b64encode(datos)
print(codificado)
# b'UGFjaWVudGU6IEFuYSBQw6lyZXosIGFsZXJnaWE6IHBlbmljaWxpbmE='

decodificado = base64.b64decode(codificado)
print(decodificado.decode("utf-8"))
# Paciente: Ana Pérez, alergia: penicilina

Punto por punto:

  • b64encode recibe bytes y devuelve bytes (los caracteres Base64 en ASCII). Si necesitas un str para un JSON, añade .decode("ascii").
  • El = final es relleno (padding): como la entrada se procesa en grupos de 3 bytes, si el total no es múltiplo de 3 se completa la salida con = para que sea múltiplo de 4.
  • b64decode invierte la operación sin necesitar nada secreto. Cualquier persona, herramienta o atacante decodifica Base64 al instante; hasta los navegadores llevan una función para ello (atob).

¿Cómo reconocer Base64 a simple vista? Cadenas de letras mayúsculas/minúsculas y dígitos, quizá con +//, longitud múltiplo de 4 y a menudo uno o dos = al final. Reconocerlo es una habilidad útil en revisiones de seguridad.

Hexadecimal

El hexadecimal representa cada byte como dos caracteres 0-9a-f. Ocupa el doble que los bytes originales (peor que Base64) pero es más legible y muy común para hashes, claves e identificadores:

import binascii

datos = "Sol".encode("utf-8")

en_hex = binascii.hexlify(datos)
print(en_hex)                          # b'536f6c'  (S=0x53, o=0x6f, l=0x6c)
print(binascii.unhexlify(en_hex))      # b'Sol'

# Alternativa moderna sin importar nada:
print(datos.hex())                     # '536f6c'
print(bytes.fromhex("536f6c"))         # b'Sol'
  • hexlify/unhexlify (módulo binascii) y los métodos bytes.hex() / bytes.fromhex() hacen lo mismo; los segundos son más idiomáticos en Python moderno.
  • De nuevo: transformación pública, reversible por cualquiera, cero seguridad.
Codificación Alfabeto Sobrecoste de tamaño Uso típico
UTF-8 — (texto ↔ bytes) 1-4 bytes por carácter Todo texto
Base64 64 caracteres + = +33 % Transporte de bytes en JSON, HTTP, PEM, JWT
Hexadecimal 16 caracteres +100 % Hashes, claves, depuración
URL-encoding %XX Variable Datos dentro de URLs

URL-encoding y Base64 URL-safe

Las URLs solo admiten un subconjunto de caracteres. El URL-encoding (o percent-encoding) sustituye los problemáticos por % seguido de su valor hexadecimal:

from urllib.parse import quote, unquote

consulta = "apellido=Pérez&clinica=Clínica Sol"
print(quote(consulta))
# apellido%3DP%C3%A9rez%26clinica%3DCl%C3%ADnica%20Sol
print(unquote(quote(consulta)) == consulta)  # True
  • quote codifica: = pasa a %3D, el espacio a %20, y la é a %C3%A9 (¡sus dos bytes UTF-8, cada uno con su %XX! — las codificaciones se apilan unas sobre otras).
  • unquote decodifica. Público y reversible, como siempre.

Base64 estándar usa + y /, que chocan con el URL-encoding. Por eso existe la variante Base64 URL-safe (usa - y _), omnipresente en tokens web y JWT (módulo 6):

import base64, os

token_bytes = os.urandom(16)  # 16 bytes aleatorios (lo veremos en 01-03)
print(base64.urlsafe_b64encode(token_bytes))
# p. ej. b'x3K9-fT_2mQlZ0aVb1cN4g=='  — apto para pegar en una URL

Ofuscar: dificultar sin clave

La ofuscación busca que el dato no se lea "de un vistazo", pero no hay clave secreta: conocer el método basta para invertirla. El ejemplo canónico es ROT13, un César con desplazamiento 13 (aplicarlo dos veces devuelve el original):

import codecs

mensaje = "Resultado reservado"
ofuscado = codecs.encode(mensaje, "rot13")
print(ofuscado)                                # Erfhygnqb erfreinqb
print(codecs.decode(ofuscado, "rot13"))        # Resultado reservado

Fíjate en el detalle delator: ROT13 está en el módulo codecs de Python, junto a las codificaciones. Ni el propio lenguaje lo considera seguridad. ¿Tiene usos legítimos la ofuscación? Sí, pero ninguno es de seguridad fuerte:

  • Evitar spoilers o destripes en foros (ROT13 clásico).
  • Minificar/ofuscar JavaScript: dificulta (un poco) copiar la lógica de negocio del frontend y reduce tamaño; cualquier herramienta de pretty-printing y algo de paciencia lo revierten.
  • Que un dato no aparezca en claro ante un grep casual.

La ofuscación puede sumar fricción sobre medidas reales; el problema es usarla en lugar de medidas reales. Retomaremos esta idea como "seguridad por oscuridad" en la lección 01-04.

Cifrar: proteger con una clave secreta

El cifrado es cualitativamente distinto: la transformación depende de una clave secreta, y la garantía es que sin ella, recuperar el texto en claro es computacionalmente inviable aunque el atacante tenga el texto cifrado, conozca el algoritmo al detalle y disponga de hardware masivo. La seguridad no reside en que el método sea desconocido, sino en que el espacio de claves es astronómico (lo cuantificaremos en 01-03) y el algoritmo no filtra patrones.

Todavía no vamos a cifrar de verdad — los algoritmos reales (AES, ChaCha20) son el tema del módulo 2, con la librería cryptography —, pero deja ya planteado el contrato:

cifrar(texto_en_claro, clave)  -> texto_cifrado      # ilegible sin la clave
descifrar(texto_cifrado, clave) -> texto_en_claro    # solo con LA MISMA garantía: la clave

Y una consecuencia práctica que cierra el círculo con Base64: el texto_cifrado son bytes arbitrarios, así que cuando veas cifrado real en APIs y ficheros, casi siempre viajará además codificado en Base64. Codificación y cifrado no compiten: se complementan, cada uno en su papel.

Tabla comparativa y criterio de decisión

Propiedad Codificación Ofuscación Cifrado
Propósito Representar / transportar Dificultar lectura casual Confidencialidad
¿Requiere clave secreta? No No
¿Quién puede invertirla? Cualquiera que conozca el formato (público) Cualquiera que conozca o deduzca el método Solo quien tiene la clave
¿Reversible? Sí, siempre y exactamente Sí (por diseño trivial) Sí, pero solo con la clave
Garantía de seguridad Ninguna Ninguna (solo fricción) Formal y cuantificable (bits de seguridad)
Ejemplos UTF-8, Base64, hex, URL-encoding ROT13, minificación AES, ChaCha20 (módulo 2)

Criterio de decisión en una frase: si el requisito es que alguien no autorizado no pueda leer el dato, la única opción es cifrar. Codifica para transportar; ofusca, si acaso, para molestar; cifra para proteger.

Caso práctico: revisión de código en MediNube

Primera semana en MediNube. Durante una revisión de código encuentras este pull request, que responde al requisito "los DNI de los pacientes no deben guardarse en claro en la base de datos":

import base64

def proteger_dni(dni: str) -> str:
    """Protege el DNI del paciente antes de guardarlo en la base de datos."""
    return base64.b64encode(dni.encode("utf-8")).decode("ascii")

def recuperar_dni(dni_protegido: str) -> str:
    return base64.b64decode(dni_protegido).decode("utf-8")

# Guardado en la tabla pacientes:
print(proteger_dni("12345678Z"))   # MTIzNDU2Nzha

El autor argumenta: "ya no se ve el DNI en la base de datos". Analicemos como revisores:

  1. ¿Qué hace realmente? Codifica en Base64. La columna mostrará MTIzNDU2Nzha en lugar de 12345678Z. A simple vista parece "protegido".
  2. ¿Qué necesita un atacante para revertirlo? Nada. Si roba un volcado de la base de datos, una línea (base64.b64decode(...)) o cualquier web de "base64 decode" le devuelve todos los DNI. No hay clave; no hay seguridad. Es equivalente a guardar el dato en claro con un disfraz.
  3. ¿Es incluso peor que nada? En cierto sentido, sí: el nombre proteger_dni documenta una protección que no existe. En una auditoría, alguien podría marcar el requisito como cumplido. La falsa seguridad desactiva las alarmas.
  4. ¿Qué debería hacerse? Cifrado real con clave gestionada fuera de la base de datos — exactamente lo que construiremos en el módulo 2 (cifrado autenticado) y el módulo 6 (cifrado en reposo y gestión de claves). El comentario de revisión correcto es: "Base64 es codificación, no cifrado: no cumple el requisito de confidencialidad. Bloqueo el PR; lo abordamos con cifrado autenticado y una clave fuera de la BD."

Nota de cumplimiento: el DNI es un dato personal y, asociado a un contexto sanitario, el conjunto es especialmente sensible bajo el RGPD. En un sistema real, decisiones como esta deben pasar por profesionales de seguridad y compliance; este caso es una simplificación didáctica.

Este patrón (Base64 presentado como protección) aparece en sistemas reales con una frecuencia deprimente: en cabeceras "secretas", en configuraciones, en tokens caseros. A partir de hoy lo detectarás en segundos.

Errores Comunes y Consejos

  • "Está en Base64, así que está cifrado". El error estrella. Base64 se decodifica sin clave, siempre, por cualquiera. Si puedes revertirlo sin un secreto, no es cifrado.
  • Confundir el vocabulario inglés: encode/decode (codificar) vs encrypt/decrypt (cifrar). En muchas APIs conviven, y leer encode pensando "encrypt" causa malentendidos reales.
  • Olvidar que las funciones cripto quieren bytes. El TypeError: Unicode-objects must be encoded... te perseguirá si no interiorizas str.encode("utf-8") → operar → bytes.decode("utf-8").
  • Usar Base64 estándar dentro de URLs. Los caracteres +, / y = se corrompen o requieren escapado; usa base64.urlsafe_b64encode para tokens que viajen en URLs.
  • Descartar la codificación como "irrelevante para seguridad". Los errores de codificación sí causan vulnerabilidades (dobles decodificaciones, confusiones UTF-8) y los formatos criptográficos reales (PEM, JWT) son capas de Base64 sobre bytes; entender el embalaje evita confundirlo con el contenido.
  • Consejo: en cualquier revisión de código, cuando veas una función llamada proteger*, encriptar* u ocultar*, mira dentro. Si no hay clave secreta involucrada, no protege nada.

Ejercicios

Ejercicio 1. Clasifica cada elemento como codificación, ofuscación o cifrado, y justifica en una línea: (a) guardar la contraseña del panel de administración de MediNube en el código fuente como cGFzc3dvcmQxMjM=; (b) el texto de un aviso de spoiler transformado con ROT13; (c) un fichero de historial que solo puede abrirse con una clave de 256 bits generada aleatoriamente; (d) los bytes \xc3\xa9 representando la letra é; (e) JavaScript del frontend de MediNube pasado por un minificador.

Ejercicio 2. Interceptas (en un entorno de pruebas de MediNube) esta cadena enviada por una app antigua: eyJ1c3VhcmlvIjogImFuYS5wZXJleiIsICJyb2wiOiAicGFjaWVudGUifQ==. (a) Escribe el código Python que la decodifica. (b) ¿Qué contiene? (c) ¿Qué problema de seguridad implica que la app use esto como "token de sesión" tal cual?

Ejercicio 3. Escribe una función bytes_para_json(datos: bytes) -> str que prepare bytes arbitrarios (por ejemplo, la salida de un futuro cifrado) para incluirlos en un campo JSON, y su inversa json_para_bytes(campo: str) -> bytes. Compruébalo con os.urandom(32). ¿Por qué no puedes meter los bytes directamente en el JSON?

Soluciones

Solución 1. (a) Codificación (Base64 de password123): la contraseña está en claro a efectos prácticos — doble error: credencial en el código y "protección" inexistente. (b) Ofuscación: evita la lectura accidental, invertible por cualquiera que conozca ROT13. (c) Cifrado: hay clave secreta y sin ella el contenido es inaccesible. (d) Codificación (UTF-8): pura representación de texto como bytes. (e) Ofuscación: dificulta la lectura, pero toda la lógica sigue ahí y es recuperable.

Solución 2.

import base64, json

token = "eyJ1c3VhcmlvIjogImFuYS5wZXJleiIsICJyb2wiOiAicGFjaWVudGUifQ=="
decodificado = base64.b64decode(token).decode("utf-8")
print(decodificado)
# {"usuario": "ana.perez", "rol": "paciente"}
datos = json.loads(decodificado)

(b) Un JSON con el usuario y su rol. La pista clásica: las cadenas Base64 que empiezan por eyJ son casi siempre un JSON codificado ({" codifica a eyJ), algo omnipresente en JWT (módulo 6). (c) El "token" es simplemente los datos codificados, sin clave ni verificación: cualquiera puede fabricar un token cambiando "rol": "paciente" por "rol": "medico" y recodificando en Base64. No hay autenticidad ni integridad. La solución real son tokens firmados, que construiremos en el módulo 6 tras aprender HMAC (módulo 3) y firmas (módulo 4).

Solución 3.

import base64, os, json

def bytes_para_json(datos: bytes) -> str:
    return base64.b64encode(datos).decode("ascii")

def json_para_bytes(campo: str) -> bytes:
    return base64.b64decode(campo)

clave_futura = os.urandom(32)
documento = json.dumps({"material": bytes_para_json(clave_futura)})
recuperados = json_para_bytes(json.loads(documento)["material"])
assert recuperados == clave_futura

No puedes meter los bytes directamente porque JSON solo admite texto Unicode en sus cadenas, y 32 bytes aleatorios casi seguro contienen secuencias que no son UTF-8 válido (además, json.dumps rechaza objetos bytes directamente). Base64 es exactamente el puente estándar para esto — y por eso lo verás envolviendo todo el material criptográfico del curso.

Conclusión

Ya tienes el criterio que separa las tres transformaciones: codificar representa (UTF-8, Base64, hex, URL-encoding — públicas y reversibles por cualquiera), ofuscar solo molesta (ROT13, minificación — sin clave, sin garantía), y cifrar protege con una clave secreta y una garantía computacional. Has practicado con base64, binascii y urllib.parse, sabes por qué Base64 envuelve casi todo material criptográfico sin ser criptografía, y has bloqueado tu primer PR en MediNube por confundir codificación con cifrado.

Ahora bien, hemos dicho que toda la fuerza del cifrado reside en la clave secreta... pero ¿de dónde sale una buena clave? ¿Qué hace que un valor sea impredecible de verdad? Esa es la base de absolutamente todo lo demás, y es el tema de la siguiente lección: aleatoriedad y entropía.

© Copyright 2026. Todos los derechos reservados