Files
Fundamentos-de-la-criptografia/Lab3_ECB_CBC_v2.py
2026-09-28 19:56:21 -03:00

516 lines
22 KiB
Python

"""
Lab 3: Modos de operacion AES - herramienta pedagogica
Pestana 1 ECB vs CBC ........ patron visual, entropia de Shannon e histograma
Pestana 2 Maleabilidad ...... bit-flipping sobre CBC, y por que GCM lo detiene
Pestana 3 Reuso de nonce .... dos imagenes con el mismo nonce en CTR
Ninguna de estas construcciones debe usarse en produccion: claves fijas,
IV predecibles y ausencia de autenticacion son parte de la demostracion.
"""
import math
import tkinter as tk
from collections import Counter
from tkinter import filedialog, messagebox, ttk
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad
from PIL import Image, ImageTk
# --- Parametros ajustables -------------------------------------------------
IMG_SIZE = 400 # lado de las imagenes de la pestana 1 (px)
SMALL_SIZE = 280 # lado de las imagenes de la pestana 3 (px)
HIST_H = 55 # alto del histograma (px)
BLOCK = AES.block_size # 16 bytes
STEP_BYTES = IMG_SIZE * 3 * 2 # avance por frame de animacion (2 filas RGB)
FRAME_DELAY = 10 # ms entre frames
KEY = b'Sixteen byte key' # clave ASCII de baja entropia: SOLO demostracion
IV_FIJO = b'Sixteen byte iv.'
# --- Metricas y utilidades -------------------------------------------------
def shannon_entropy(data: bytes) -> float:
"""Entropia de Shannon en bits/byte. Maximo teorico: 8.0"""
if not data:
return 0.0
n = len(data)
return -sum((c / n) * math.log2(c / n) for c in Counter(data).values())
def block_stats(data: bytes):
"""Total, unicos y repetidos de bloques de 16 bytes."""
usable = len(data) - (len(data) % BLOCK)
blocks = [data[i:i + BLOCK] for i in range(0, usable, BLOCK)]
total = len(blocks)
unique = len(set(blocks))
return total, unique, total - unique
def format_metrics(data: bytes) -> str:
h = shannon_entropy(data)
total, unique, repeated = block_stats(data)
pct = (repeated / total * 100) if total else 0.0
return (f"Entropia Shannon: {h:.4f} / 8.0 bits/byte\n"
f"Bloques 16B: {total:,} | unicos: {unique:,}\n"
f"Repetidos: {repeated:,} ({pct:.1f}%)")
def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def printable(data: bytes) -> str:
"""Bytes no imprimibles se muestran como punto medio."""
return ''.join(chr(c) if 32 <= c < 127 else '.' for c in data)
def draw_histogram(canvas, data, color="#2c6fbb"):
"""Histograma de los 256 valores de byte, dibujado en un Canvas."""
canvas.delete("all")
w = int(canvas.cget("width"))
h = int(canvas.cget("height"))
if not data:
return
counts = Counter(data)
max_c = max(counts.values())
bar_w = w / 256.0
for b in range(256):
c = counts.get(b, 0)
if not c:
continue
bh = (c / max_c) * (h - 2)
x0 = b * bar_w
canvas.create_rectangle(x0, h - bh, x0 + bar_w, h,
fill=color, outline="")
canvas.create_line(0, h - 1, w, h - 1, fill="#888")
# ===========================================================================
class AESLab:
def __init__(self, root):
self.root = root
self.root.title("Lab 3: Modos de Operacion AES")
self.root.geometry("1380x800")
nb = ttk.Notebook(root)
nb.pack(fill="both", expand=True, padx=8, pady=6)
self.tab_modes = tk.Frame(nb)
self.tab_flip = tk.Frame(nb)
self.tab_ctr = tk.Frame(nb)
nb.add(self.tab_modes, text=" 1. ECB vs CBC ")
nb.add(self.tab_flip, text=" 2. Maleabilidad de CBC ")
nb.add(self.tab_ctr, text=" 3. Reuso de nonce (CTR) ")
self.build_tab_modes()
self.build_tab_flip()
self.build_tab_ctr()
# =======================================================================
# PESTANA 1: ECB vs CBC
# =======================================================================
def build_tab_modes(self):
t = self.tab_modes
self.img_bytes_raw = None
self.original_img = None
self.busy = False
self.random_iv = tk.BooleanVar(value=False)
panels = tk.Frame(t)
panels.pack(pady=8)
self.p_orig = self.make_panel(panels, "Original (plaintext)", IMG_SIZE)
self.p_ecb = self.make_panel(panels, "ECB - INSEGURO", IMG_SIZE)
self.p_cbc = self.make_panel(panels, "CBC - sin patron visible", IMG_SIZE)
ctrl = tk.Frame(t)
ctrl.pack(pady=6)
self.btn_load = tk.Button(ctrl, text="1. Cargar Imagen",
command=self.load_image)
self.btn_load.pack(side=tk.LEFT, padx=8)
self.btn_ecb = tk.Button(ctrl, text="2. Ejecutar ECB",
command=self.run_ecb)
self.btn_ecb.pack(side=tk.LEFT, padx=8)
self.btn_cbc = tk.Button(ctrl, text="3. Ejecutar CBC",
command=self.run_cbc)
self.btn_cbc.pack(side=tk.LEFT, padx=8)
tk.Checkbutton(ctrl, text="IV aleatorio por ejecucion",
variable=self.random_iv).pack(side=tk.LEFT, padx=15)
info = tk.Frame(t)
info.pack(fill="x", padx=20, pady=4)
tk.Label(info, font=("Courier", 9), anchor="w",
text=f"Llave (Hex): {KEY.hex().upper()} "
f"[ASCII de baja entropia: solo demostracion]"
).pack(anchor="w")
self.lbl_status = tk.Label(info, text="Estado: esperando imagen...",
font=("Arial", 10, "bold"), fg="blue",
anchor="w")
self.lbl_status.pack(anchor="w")
tk.Label(info, justify="left", fg="#444", font=("Arial", 9), anchor="w",
text=("La entropia y el histograma de ECB y CBC son practicamente "
"identicos: NO sirven para detectar el modo inseguro.\n"
"El indicador valido es 'Repetidos': ECB conserva los bloques "
"duplicados del original, CBC los elimina por encadenamiento.")
).pack(anchor="w")
def make_panel(self, parent, title, size, with_hist=True):
f = tk.Frame(parent)
f.pack(side=tk.LEFT, padx=10)
tk.Label(f, text=title, font=("Arial", 10, "bold")).pack()
# Contenedor de tamano fijo EN PIXELES: un Label sin imagen mide su
# width en caracteres, no en pixeles.
holder = tk.Frame(f, width=size, height=size, bg="#ccc")
holder.pack()
holder.pack_propagate(False)
img_lbl = tk.Label(holder, bg="#ccc", borderwidth=0)
img_lbl.pack(fill="both", expand=True)
stats_lbl = tk.Label(f, text="Entropia: -\nBloques: -\nRepetidos: -",
font=("Courier", 9), justify="left",
anchor="w", width=42)
stats_lbl.pack(pady=(4, 0))
hist = None
if with_hist:
hist = tk.Canvas(f, width=size, height=HIST_H, bg="#f7f7f7",
highlightthickness=1, highlightbackground="#ddd")
hist.pack()
tk.Label(f, text="histograma de bytes 0 ... 255",
font=("Arial", 7), fg="#777").pack()
return {"img": img_lbl, "stats": stats_lbl, "hist": hist}
def update_panel_metrics(self, panel, data, color="#2c6fbb"):
panel["stats"].config(text=format_metrics(data))
if panel["hist"] is not None:
draw_histogram(panel["hist"], data, color)
def clear_panel(self, panel):
panel["img"].config(image='', bg="#ccc")
panel["img"].image = None
panel["stats"].config(text="Entropia: -\nBloques: -\nRepetidos: -")
if panel["hist"] is not None:
panel["hist"].delete("all")
def set_busy(self, busy):
self.busy = busy
state = tk.DISABLED if busy else tk.NORMAL
for b in (self.btn_load, self.btn_ecb, self.btn_cbc):
b.config(state=state)
def load_image(self):
path = filedialog.askopenfilename(
initialdir=".",
filetypes=[("Images", ("*.png", "*.jpg", "*.jpeg", "*.bmp")),
("All Files", "*.*")])
if not path:
return
try:
img = (Image.open(path)
.resize((IMG_SIZE, IMG_SIZE), Image.LANCZOS)
.convert('RGB'))
self.img_bytes_raw = img.tobytes()
self.original_img = img
self.show(self.p_orig, img)
self.update_panel_metrics(self.p_orig, self.img_bytes_raw, "#4a7c3f")
self.clear_panel(self.p_ecb)
self.clear_panel(self.p_cbc)
total = len(self.img_bytes_raw) // BLOCK
self.lbl_status.config(
text=f"Imagen cargada. Bytes: {len(self.img_bytes_raw):,} | "
f"Bloques de 16B: {total:,}")
except Exception as e:
messagebox.showerror("Error", f"No se pudo cargar la imagen: {e}")
def check_ready(self):
if self.busy:
return False
if self.img_bytes_raw is None:
messagebox.showwarning("Aviso", "Primero carga una imagen.")
return False
return True
def run_ecb(self):
if not self.check_ready():
return
ct = AES.new(KEY, AES.MODE_ECB).encrypt(pad(self.img_bytes_raw, BLOCK))
self.lbl_status.config(text="Iniciando cifrado ECB...")
self.set_busy(True)
self.animate(self.p_ecb, ct[:len(self.img_bytes_raw)],
self.img_bytes_raw, mode_name="ECB", color="#b03030")
def run_cbc(self):
if not self.check_ready():
return
iv = get_random_bytes(BLOCK) if self.random_iv.get() else IV_FIJO
ct = AES.new(KEY, AES.MODE_CBC, iv).encrypt(pad(self.img_bytes_raw, BLOCK))
self.lbl_status.config(
text=f"Iniciando cifrado CBC... IV = {iv.hex().upper()}")
self.set_busy(True)
self.animate(self.p_cbc, ct[:len(self.img_bytes_raw)],
self.img_bytes_raw, mode_name="CBC", color="#2c6fbb")
def animate(self, panel, target, original, current_idx=0,
mode_name="", color="#2c6fbb"):
if current_idx >= len(original):
self.show_bytes(panel, target)
self.update_panel_metrics(panel, target, color)
self.lbl_status.config(text=f"Cifrado {mode_name} completado.")
self.set_busy(False)
return
self.show_bytes(panel, target[:current_idx] + original[current_idx:])
self.lbl_status.config(
text=f"Cifrando {mode_name}... bloque {current_idx // BLOCK:,}"
f" / {len(original) // BLOCK:,}")
self.root.after(FRAME_DELAY,
lambda: self.animate(panel, target, original,
current_idx + STEP_BYTES,
mode_name, color))
def show(self, panel, img_obj):
tk_img = ImageTk.PhotoImage(img_obj)
panel["img"].config(image=tk_img)
panel["img"].image = tk_img
def show_bytes(self, panel, data, size=IMG_SIZE):
try:
self.show(panel, Image.frombytes('RGB', (size, size), data))
except Exception as e:
print(f"Error mostrando bytes: {e}")
# =======================================================================
# PESTANA 2: maleabilidad de CBC (bit-flipping)
# =======================================================================
def build_tab_flip(self):
t = self.tab_flip
self.flip_iv = None
self.flip_ct = None
self.flip_blocks = ["usuario=esteban;", "rol=invitado;;;;",
"saldo=0000001000"]
tk.Label(t, font=("Arial", 10), justify="left", anchor="w",
text=("CBC entrega confidencialidad, NO integridad. Un atacante que no conoce la clave "
"puede alterar el bloque de texto claro N modificando el bloque de cifrado N-1.\n"
"El precio es que el bloque N-1 queda destruido; si ese bloque es relleno o poco "
"revisado, el ataque pasa desapercibido.")
).pack(anchor="w", padx=20, pady=(12, 8))
pt_frame = tk.LabelFrame(t, text="Mensaje original (3 bloques de 16 bytes, sin relleno)",
padx=10, pady=8)
pt_frame.pack(fill="x", padx=20)
for i, b in enumerate(self.flip_blocks):
tk.Label(pt_frame, font=("Courier", 11), anchor="w",
text=f" Bloque {i}: |{b}|").pack(anchor="w")
atk = tk.LabelFrame(t, text="Ataque", padx=10, pady=8)
atk.pack(fill="x", padx=20, pady=10)
tk.Label(atk, text="El atacante quiere que el bloque 1 diga "
"(exactamente 16 caracteres):",
font=("Arial", 10)).pack(anchor="w")
self.flip_target = tk.Entry(atk, font=("Courier", 11), width=24)
self.flip_target.insert(0, "rol=admin;;;;;;;")
self.flip_target.pack(anchor="w", pady=4)
btns = tk.Frame(atk)
btns.pack(anchor="w", pady=4)
tk.Button(btns, text="1. Cifrar con CBC",
command=self.flip_encrypt).pack(side=tk.LEFT, padx=6)
tk.Button(btns, text="2. Manipular el ciphertext",
command=self.flip_attack).pack(side=tk.LEFT, padx=6)
tk.Button(btns, text="3. Repetir el ataque contra AES-GCM",
command=self.flip_gcm).pack(side=tk.LEFT, padx=6)
self.flip_out = tk.Text(t, height=16, font=("Courier", 10),
bg="#1e1e1e", fg="#dcdcdc",
insertbackground="#dcdcdc", wrap="none")
self.flip_out.pack(fill="both", expand=True, padx=20, pady=(0, 14))
self.flip_log("Pulsa '1. Cifrar con CBC' para comenzar.")
def flip_log(self, text, clear=False):
if clear:
self.flip_out.delete("1.0", tk.END)
self.flip_out.insert(tk.END, text + "\n")
self.flip_out.see(tk.END)
def flip_plaintext(self) -> bytes:
return "".join(self.flip_blocks).encode()
def flip_encrypt(self):
self.flip_iv = get_random_bytes(BLOCK)
self.flip_ct = AES.new(KEY, AES.MODE_CBC,
self.flip_iv).encrypt(self.flip_plaintext())
self.flip_log("=== CIFRADO LEGITIMO (CBC) ===", clear=True)
self.flip_log(f"IV : {self.flip_iv.hex().upper()}")
for i in range(3):
c = self.flip_ct[i * BLOCK:(i + 1) * BLOCK]
self.flip_log(f"C{i} : {c.hex().upper()}")
self.flip_log("")
self.flip_log("El atacante intercepta el ciphertext. No conoce la clave.")
def flip_attack(self):
if self.flip_ct is None:
messagebox.showwarning("Aviso", "Primero cifra el mensaje (paso 1).")
return
target = self.flip_target.get().encode()
if len(target) != BLOCK:
messagebox.showerror(
"Error",
f"El texto objetivo debe medir exactamente {BLOCK} caracteres "
f"(actualmente mide {len(target)}).")
return
# P1' = D(C1) xor C0' => C0' = C0 xor P1 xor P1'
delta = xor_bytes(self.flip_blocks[1].encode(), target)
forged = bytearray(self.flip_ct)
for j in range(BLOCK):
forged[j] ^= delta[j]
forged = bytes(forged)
recovered = AES.new(KEY, AES.MODE_CBC,
self.flip_iv).decrypt(forged)
self.flip_log("")
self.flip_log("=== ATAQUE DE BIT-FLIPPING ===")
self.flip_log(f"Delta aplicado a C0 : {delta.hex().upper()}")
self.flip_log(f"C0 manipulado : {forged[:BLOCK].hex().upper()}")
self.flip_log("")
self.flip_log("Descifrado en el servidor (la clave nunca se toco):")
for i in range(3):
b = recovered[i * BLOCK:(i + 1) * BLOCK]
mark = " <-- DESTRUIDO" if i == 0 else (" <-- CONTROLADO POR EL ATACANTE"
if i == 1 else "")
self.flip_log(f" Bloque {i}: |{printable(b)}|{mark}")
self.flip_log("")
self.flip_log("El atacante escribio el bloque 1 a voluntad sin conocer la clave.")
self.flip_log("CBC sin MAC no detecta nada: descifra y entrega el resultado.")
def flip_gcm(self):
pt = self.flip_plaintext()
nonce = get_random_bytes(12)
ct, tag = AES.new(KEY, AES.MODE_GCM, nonce=nonce).encrypt_and_digest(pt)
target = self.flip_target.get().encode()
if len(target) != BLOCK:
target = b"rol=admin;;;;;;;"
# En CTR/GCM el flip es aun mas directo: se altera el bloque objetivo.
delta = xor_bytes(self.flip_blocks[1].encode(), target)
forged = bytearray(ct)
for j in range(BLOCK):
forged[BLOCK + j] ^= delta[j]
self.flip_log("")
self.flip_log("=== MISMO ATAQUE CONTRA AES-GCM ===")
self.flip_log(f"Nonce: {nonce.hex().upper()}")
self.flip_log(f"Tag : {tag.hex().upper()}")
try:
rec = AES.new(KEY, AES.MODE_GCM,
nonce=nonce).decrypt_and_verify(bytes(forged), tag)
self.flip_log(f"Descifrado: {printable(rec)}")
except ValueError:
self.flip_log("")
self.flip_log(" MAC INVALIDO -> el mensaje se rechaza por completo.")
self.flip_log(" GCM verifica la integridad ANTES de entregar texto claro.")
self.flip_log("")
self.flip_log("Conclusion: el cifrado autenticado (AEAD) no es un extra,")
self.flip_log("es el requisito minimo para cualquier canal real.")
# =======================================================================
# PESTANA 3: reuso de nonce en CTR
# =======================================================================
def build_tab_ctr(self):
t = self.tab_ctr
self.ctr_a = None
self.ctr_b = None
tk.Label(t, font=("Arial", 10), justify="left", anchor="w",
text=("CTR convierte AES en un cifrado de flujo: C = P xor keystream. "
"Si dos mensajes usan el mismo nonce comparten keystream, y entonces\n"
" C_A xor C_B = P_A xor P_B ... el keystream se cancela y el "
"atacante ve la relacion entre ambos textos claros.")
).pack(anchor="w", padx=20, pady=(12, 8))
panels = tk.Frame(t)
panels.pack(pady=4)
self.p_a = self.make_panel(panels, "Imagen A", SMALL_SIZE, with_hist=False)
self.p_b = self.make_panel(panels, "Imagen B", SMALL_SIZE, with_hist=False)
self.p_xor_same = self.make_panel(panels, "C_A xor C_B (MISMO nonce)",
SMALL_SIZE, with_hist=False)
self.p_xor_diff = self.make_panel(panels, "C_A xor C_B (nonces distintos)",
SMALL_SIZE, with_hist=False)
ctrl = tk.Frame(t)
ctrl.pack(pady=8)
tk.Button(ctrl, text="Cargar Imagen A",
command=lambda: self.ctr_load('a')).pack(side=tk.LEFT, padx=8)
tk.Button(ctrl, text="Cargar Imagen B",
command=lambda: self.ctr_load('b')).pack(side=tk.LEFT, padx=8)
tk.Button(ctrl, text="Cifrar y comparar",
command=self.ctr_run).pack(side=tk.LEFT, padx=8)
self.ctr_status = tk.Label(t, text="Carga dos imagenes distintas.",
font=("Arial", 10, "bold"), fg="blue")
self.ctr_status.pack(pady=4)
def ctr_load(self, which):
path = filedialog.askopenfilename(
initialdir=".",
filetypes=[("Images", ("*.png", "*.jpg", "*.jpeg", "*.bmp")),
("All Files", "*.*")])
if not path:
return
try:
img = (Image.open(path)
.resize((SMALL_SIZE, SMALL_SIZE), Image.LANCZOS)
.convert('RGB'))
data = img.tobytes()
if which == 'a':
self.ctr_a = data
self.show(self.p_a, img)
self.p_a["stats"].config(text=format_metrics(data))
else:
self.ctr_b = data
self.show(self.p_b, img)
self.p_b["stats"].config(text=format_metrics(data))
self.ctr_status.config(text="Imagen cargada.")
except Exception as e:
messagebox.showerror("Error", f"No se pudo cargar la imagen: {e}")
def ctr_run(self):
if self.ctr_a is None or self.ctr_b is None:
messagebox.showwarning("Aviso", "Carga las dos imagenes primero.")
return
nonce = get_random_bytes(8)
ca = AES.new(KEY, AES.MODE_CTR, nonce=nonce).encrypt(self.ctr_a)
cb = AES.new(KEY, AES.MODE_CTR, nonce=nonce).encrypt(self.ctr_b)
xor_same = xor_bytes(ca, cb)
c2a = AES.new(KEY, AES.MODE_CTR,
nonce=get_random_bytes(8)).encrypt(self.ctr_a)
c2b = AES.new(KEY, AES.MODE_CTR,
nonce=get_random_bytes(8)).encrypt(self.ctr_b)
xor_diff = xor_bytes(c2a, c2b)
self.show_bytes(self.p_xor_same, xor_same, SMALL_SIZE)
self.p_xor_same["stats"].config(text=format_metrics(xor_same))
self.show_bytes(self.p_xor_diff, xor_diff, SMALL_SIZE)
self.p_xor_diff["stats"].config(text=format_metrics(xor_diff))
self.ctr_status.config(
text=f"Nonce reutilizado: {nonce.hex().upper()} -> el XOR de los "
f"ciphertexts es exactamente P_A xor P_B (ambas siluetas visibles). "
f"Con nonces distintos, solo ruido.")
if __name__ == "__main__":
root = tk.Tk()
app = AESLab(root)
root.mainloop()