""" 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()