Chismes del Lince 33: El mensaje verificado
La noche en Sierra Bermeja estaba tranquila hasta que la radio de la cueva hizo clic. En la pantalla apareció una línea: «Lince, ven ya al collado. Trae el portátil. Es urgente. — Montesa».
El Lince de la Noche se levantó de un salto, cogió el portátil, se tropezó con la mochila, recogió el portátil otra vez y estaba a punto de salir corriendo cuando oyó un bostezo desde el fondo de la cueva.
—Yo no he escrito eso —dijo la Gata Montesa, sin abrir los ojos del todo—. Llevo dos horas aquí dormida.
—Pero pone tu nombre.
—Mi nombre lo puede escribir cualquiera. ¿Cómo sabes que el mensaje es mío?
El Lince se quedó pensando. No era la primera vez que alguien se hacía pasar por otro por radio, y no sería la última. Propuso lo primero que se le ocurrió:
—Podemos escribir los mensajes en clave, como Julio César. Así nadie los entiende.
—Eso esconde el mensaje —replicó la Gata, ya despierta del todo—. Pero no demuestra que lo he escrito yo. Si alguien entiende la clave, puede escribir lo que quiera con mi nombre debajo. Lo que necesitamos es un sello: algo que solo podamos hacer tú y yo, y que cambie si alguien toca el mensaje por el camino.
El Lince soltó el portátil. Por una vez, la Gata tenía toda la razón, y además ya sabía dónde estaban las dos placas LoRa.
Introducción
En este chisme construimos un sistema de mensajes verificados con dos placas Heltec LoRa 32. Escribes un mensaje en el móvil; la primera placa lo cifra con el cifrado César, le añade una firma HMAC en la ESP32 (una función matemática que el ESP32 trae incorporada) y lo envía por radio LoRa. La segunda placa lo recibe, comprueba la firma y marca el mensaje como AUTÉNTICO, FALSO o REPETIDO. Solo si es auténtico lo descifra y lo muestra en su pantalla.
Es un buen ejemplo de HMAC ESP32: no hay que instalar ninguna librería para firmar, porque la biblioteca de criptografía (mbedtls) viene con la placa. Y además se puede sabotear a propósito: cambiar una letra, hacerse pasar por otro o reenviar un mensaje viejo. Con cada ataque veremos qué protege el cifrado, qué protege la firma y qué no protege ninguno de los dos.
Por qué este chisme
Cuando una información llega por el móvil, por una radio o por una red social, casi nunca nos preguntamos si es real. Nos fijamos en lo que dice, o en el nombre que lleva debajo, que es justo lo más fácil de falsificar.
Aquí aprendemos dos ideas que mucha gente mezcla:
- Cifrar responde a la pregunta «¿quién puede leer esto?». Esconde el contenido de quien no tiene la clave.
- Autenticar responde a «¿quién lo ha mandado y es exactamente lo que mandó?». Demuestra el origen y que nadie lo ha tocado.
Son cosas distintas. Un mensaje puede estar cifrado y ser falso, o estar en abierto y ser perfectamente auténtico. Y lo importante para la ciencia, y para la vida, es que la verdad de un dato se puede comprobar: no hace falta creer, se puede verificar.
Cómo funciona el HMAC en ESP32
El camino del mensaje tiene cuatro pasos, y cada uno responde a una pregunta del Lince y de la Gata.

La receptora hace las comprobaciones en este orden: si la firma no cuadra, el mensaje es FALSO y ni se descifra; si cuadra pero el número ya se había visto, es REPETIDO; solo si pasa las dos, se descifra y se muestra.
¿Cómo llega de una placa a otra sin cables ni Internet? Con LoRa, una radio de largo alcance y muy poca velocidad: manda paquetes pequeños, pero llega lejos con muy poca energía. En Europa se usa la banda de 868 MHz. Ojo: cualquiera con un receptor LoRa configurado igual puede captar nuestros paquetes. Una radio abierta se puede escuchar.
¿Cómo escondemos el texto? Con el cifrado César, el más antiguo que se conoce: cada letra se sustituye por la que está k posiciones más allá en el alfabeto. Con k = 3, la A se convierte en D. Según el historiador Suetonio, Julio César usaba justo ese desplazamiento de 3. La clave es ese número k: quien la conoce, descifra; quien no, ve un galimatías. Solo hay 25 claves posibles, así que es un cifrado muy débil, pero perfecto para entender qué es una clave.
¿Cómo demostramos que el mensaje es real? Con una firma HMAC-SHA256. SHA-256 es una función que convierte cualquier texto en una huella de 256 bits: si cambias una sola letra del texto, la huella cambia por completo. El HMAC la combina con un secreto compartido que solo conocen las dos placas. Sin el secreto es imposible calcular la huella correcta; con él, la receptora recalcula la huella del mensaje que le ha llegado y la compara con la que viene en el paquete. Si coinciden, el mensaje no ha cambiado y lo ha firmado alguien que conoce el secreto. Aquí nos quedamos con los 8 primeros bytes de la huella para que el paquete sea corto y se pueda leer en pantalla; para una demostración basta, para un sistema serio se usaría la firma entera.
¿Cómo evitamos que alguien reenvíe un mensaje bueno de ayer? Con un contador. Cada paquete lleva un número que sube en uno cada vez, y ese número forma parte de lo que se firma. La receptora recuerda el último que aceptó: si llega uno igual o menor, es una repetición.
El paquete que viaja por el aire es un texto como este (con clave del César 5 y el mensaje «Hola Lince»):
V1|22137|Mtqf Qnshj|537a409cc29f738f
Son cuatro campos separados por barras: versión, contador, texto cifrado y firma. Y fíjate en el orden de las comprobaciones en la receptora, que es la idea clave del chisme: primero se comprueba la verdad y después se lee. La firma se calcula sobre el texto ya cifrado, de modo que la receptora puede rechazar un mensaje falso sin descifrarlo siquiera.
| Qué llega | Firma | Contador | Veredicto |
|---|---|---|---|
| Mensaje normal | Correcta | Nuevo | AUTÉNTICO: se descifra y se lee |
| Alguien cambió una letra por el camino | No cuadra | — | FALSO |
| Un impostor sin el secreto | No cuadra | — | FALSO |
| Se reenvía un paquete bueno de antes | Correcta | Igual o menor | REPETIDO |
Qué necesitamos
| Material | Para qué sirve |
|---|---|
| 2 placas Heltec / TTGO LoRa32 con ESP32 | Una es la emisora y la otra la receptora; cada una trae radio LoRa y pantalla OLED |
| 2 antenas LoRa | La radio no debe encenderse sin antena: puede dañarse |
| 2 cables USB | Para programar y alimentar las placas |
| Un móvil o portátil con Chrome o Edge | Para escribir el mensaje por Bluetooth (la página del chisme) |
| Arduino IDE | Para cargar los programas |
| Librerías LoRa (Sandeep Mistry), Adafruit SSD1306 y Adafruit GFX | Radio y pantalla (las dos últimas ya las usaste en el Chisme 25) |
No hay que cablear nada ni usar sensores. El HMAC lo calcula la biblioteca mbedtls, que ya viene dentro del paquete del ESP32 para Arduino.


Construcción
- En el Arduino IDE, abre el Gestor de bibliotecas e instala LoRa (de Sandeep Mistry), Adafruit SSD1306 y Adafruit GFX.
- Pon la antena LoRa en las dos placas antes de conectarlas al USB.
- Elige la placa ESP32 Dev Module. Si al compilar sale «Sketch too big», ve a Herramientas → Partition Scheme y elige Huge APP (3MB No OTA/1MB SPIFFS).
- Carga
chisme-33-emisora.inoen una placa ychisme-33-receptora.inoen la otra. - Comprueba que en los dos programas coinciden
BANDA,SYNC_WORDyCLAVE_HMAC. Mira la etiqueta de tu placa: si pone 433 o 915 MHz en vez de 868, cambiaBANDAen las dos. - Abre la página del chisme con Chrome y conecta con la emisora, como en los chismes anteriores.
Los pines de la pantalla (SDA 21, SCL 22, RST 16) y de la radio LoRa son los mismos que usamos en el Chisme 28. Si tu placa es una Heltec V2, la pantalla suele estar en los pines 4, 15 y 16: cámbialos en los #define del principio.

El código de la emisora
Guarda este programa como chisme-33-emisora.ino. Solo hay un valor que debes tocar si tu placa es de otra banda: BANDA. El secreto de la firma (CLAVE_HMAC) debe ser exactamente igual que en la receptora.
/*
Chisme 33 · El mensaje verificado · LA EMISORA (placa A)
Recibe un mensaje del movil por Bluetooth, lo CIFRA con el cifrado Cesar,
lo FIRMA con un HMAC (SHA-256) y lo manda por LoRa a la placa receptora.
Dos cosas distintas:
- CIFRAR (Cesar): esconde el texto. Solo lo lee quien conoce la clave.
- FIRMAR (HMAC): demuestra que el mensaje lo ha mandado alguien que conoce
la clave secreta y que nadie lo ha tocado por el camino.
Paquete LoRa (texto): V1|contador|texto cifrado|firma
contador: numero que sube con cada mensaje (sirve para detectar repeticiones)
firma: los 8 primeros bytes (16 letras hexadecimales) del HMAC-SHA256 de
"contador|texto cifrado", calculado con CLAVE_HMAC
Librerias: "LoRa" (Sandeep Mistry), Adafruit SSD1306 y Adafruit GFX.
El HMAC usa mbedtls, que ya viene con el ESP32: no hay que instalar nada.
Placa en el Arduino IDE: "ESP32 Dev Module".
Si al compilar dice "Sketch too big":
Herramientas -> Partition Scheme -> "Huge APP (3MB No OTA/1MB SPIFFS)".
¡PON LA ANTENA LORA ANTES DE ENCENDER LA PLACA!
Ordenes que llegan del movil (una linea cada una, acabada en salto de linea):
M:hola mensaje normal: cifrar, firmar y enviar
K:5 cambia la clave del Cesar (1 a 25)
X:hola enviar ALTERADO: despues de firmar, alguien cambia una letra
I:hola enviar como IMPOSTOR: firmado con una clave secreta equivocada
R REPETIR el ultimo paquete tal cual (ataque de repeticion)
Lo que cuenta la placa al movil (una linea cada una):
S:3,N paquete numero 3; modo N normal, A alterado, I impostor, R repetido
O:hola el mensaje original
C:krod el mensaje cifrado
T:9f2a... la firma que se ha mandado
E fin
K:5 confirma la clave del Cesar
*/
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include "mbedtls/md.h"
#define BANDA 868E6 // la misma en las DOS placas (mira la etiqueta: 433E6, 868E6 o 915E6)
#define SYNC_WORD 0x33 // para no mezclarnos con los Chismes 28 u otros
#define CLAVE_HMAC "lince-de-la-noche" // secreto compartido: ¡debe ser IGUAL en las dos placas!
#define CLAVE_FALSA "doctor-mente" // la clave del impostor
#define MAX_MSG 40 // maximo de caracteres del mensaje
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
#define LORA_SCK 5
#define LORA_MISO 19
#define LORA_MOSI 27
#define LORA_SS 18
#define LORA_RST 14
#define LORA_DIO0 26
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e" // el movil escribe por aqui
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e" // la placa envia por aqui
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
BLECharacteristic *tx = nullptr;
volatile bool conectado = false;
int claveCesar = 3; // desplazamiento del Cesar
unsigned long contador; // numero del siguiente paquete
String ultimoPaquete = ""; // para el ataque de repeticion
String ultOriginal = "", ultCifrado = "", ultEstado = "Esperando mensaje";
String rxLinea = ""; // linea que se esta recibiendo del movil
String cola[4]; // lineas completas pendientes de procesar
volatile int nCola = 0;
/* ---------- Bluetooth ---------- */
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
class AlEscribir : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) {
auto v = c->getValue(); // sirve con las dos versiones del paquete ESP32 para Arduino
for (size_t i = 0; i < v.length(); i++) {
char ch = (char)v[i];
if (ch == '\n') {
if (rxLinea.length() && nCola < 4) cola[nCola++] = rxLinea; // se procesa luego, en loop()
rxLinea = "";
} else if (ch != '\r' && rxLinea.length() < 80) {
rxLinea += ch;
}
}
}
};
void enviar(const String &m) { // trozos de 20 bytes: la pagina los junta hasta el salto de linea
if (!conectado || !tx) return;
String s = m + "\n";
for (size_t i = 0; i < s.length(); i += 20) {
String t = s.substring(i, i + 20);
tx->setValue((uint8_t *)t.c_str(), t.length());
tx->notify();
delay(10);
}
}
/* ---------- Cifrado Cesar ---------- */
// Desplaza cada LETRA k posiciones en el alfabeto. Lo demas (numeros, signos, espacios) no cambia.
String cesar(const String &t, int k) {
String r = t;
for (size_t i = 0; i < r.length(); i++) {
char c = r[i];
if (c >= 'a' && c <= 'z') r[i] = 'a' + ((c - 'a' + k) % 26 + 26) % 26;
else if (c >= 'A' && c <= 'Z') r[i] = 'A' + ((c - 'A' + k) % 26 + 26) % 26;
}
return r;
}
/* ---------- Firma HMAC-SHA256 ---------- */
// Devuelve los 8 primeros bytes del HMAC como 16 letras hexadecimales
String firma(const String &datos, const char *clave) {
unsigned char h[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1); // 1 = modo HMAC
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)clave, strlen(clave));
mbedtls_md_hmac_update(&ctx, (const unsigned char *)datos.c_str(), datos.length());
mbedtls_md_hmac_finish(&ctx, h);
mbedtls_md_free(&ctx);
String s = "";
const char *hex = "0123456789abcdef";
for (int i = 0; i < 8; i++) { s += hex[h[i] >> 4]; s += hex[h[i] & 15]; }
return s;
}
/* ---------- Mensaje limpio: solo ASCII, sin '|' ---------- */
String limpiar(const String &t) {
String r = "";
for (size_t i = 0; i < t.length() && r.length() < MAX_MSG; i++) {
char c = t[i];
if (c == '|') c = ' ';
if (c >= 32 && c <= 126) r += c;
}
return r;
}
/* ---------- Pantalla ---------- */
void pintar() {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "EMISORA BT:ok k=" : "EMISORA BT:-- k=");
display.print(claveCesar);
display.setCursor(0, 10); display.print("Original:");
display.setCursor(0, 18); display.print(ultOriginal.substring(0, 42));
display.setCursor(0, 34); display.print("Cifrado:");
display.setCursor(0, 42); display.print(ultCifrado.substring(0, 42));
display.setCursor(0, 56); display.print(ultEstado);
display.display();
}
/* ---------- Enviar un mensaje ---------- */
// modo: 'N' normal, 'A' alterado, 'I' impostor
void mandar(const String &texto, char modo) {
String original = limpiar(texto);
if (original.length() == 0) return;
String cifrado = cesar(original, claveCesar);
contador++;
String base = String(contador) + "|" + cifrado;
String f = firma(base, modo == 'I' ? CLAVE_FALSA : CLAVE_HMAC); // se firma ANTES de alterar
if (modo == 'A') { // un "intruso" cambia una letra del texto cifrado, ya firmado
for (size_t i = 0; i < cifrado.length(); i++) {
char c = cifrado[i];
if (c >= 'a' && c <= 'z') { cifrado[i] = (c == 'z') ? 'a' : c + 1; break; }
if (c >= 'A' && c <= 'Z') { cifrado[i] = (c == 'Z') ? 'A' : c + 1; break; }
if (i == cifrado.length() - 1) cifrado[0] = '#';
}
}
String paquete = "V1|" + String(contador) + "|" + cifrado + "|" + f;
LoRa.beginPacket();
LoRa.print(paquete);
LoRa.endPacket();
ultimoPaquete = paquete;
ultOriginal = original; ultCifrado = cifrado;
ultEstado = "Enviado #" + String(contador) + (modo == 'A' ? " ALT" : modo == 'I' ? " IMP" : "");
Serial.println("Enviado: " + paquete);
enviar("S:" + String(contador) + "," + String(modo));
enviar("O:" + original);
enviar("C:" + cifrado);
enviar("T:" + f);
enviar("E");
pintar();
}
void repetir() { // vuelve a mandar EXACTAMENTE el ultimo paquete
if (ultimoPaquete == "") return;
LoRa.beginPacket();
LoRa.print(ultimoPaquete);
LoRa.endPacket();
int p2 = ultimoPaquete.indexOf('|', 3), p3 = ultimoPaquete.lastIndexOf('|');
String num = ultimoPaquete.substring(3, p2);
ultEstado = "Repetido #" + num;
Serial.println("Repetido: " + ultimoPaquete);
enviar("S:" + num + ",R");
enviar("O:" + ultOriginal);
enviar("C:" + ultimoPaquete.substring(p2 + 1, p3));
enviar("T:" + ultimoPaquete.substring(p3 + 1));
enviar("E");
pintar();
}
void procesar(const String &l) {
if (l.startsWith("M:")) mandar(l.substring(2), 'N');
else if (l.startsWith("X:")) mandar(l.substring(2), 'A');
else if (l.startsWith("I:")) mandar(l.substring(2), 'I');
else if (l.startsWith("K:")) {
int k = l.substring(2).toInt();
if (k >= 1 && k <= 25) { claveCesar = k; enviar("K:" + String(k)); pintar(); }
} else if (l.startsWith("R")) repetir();
}
/* ---------- Arranque ---------- */
void setup() {
Serial.begin(115200);
contador = esp_random() & 0xFFFF; // empieza en un numero al azar: no se repite entre arranques
Wire.begin(OLED_SDA, OLED_SCL);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) Serial.println("No se encuentra la OLED");
display.setTextColor(SSD1306_WHITE);
pintar();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(BANDA)) { Serial.println("No arranca la radio LoRa"); while (true) delay(1000); }
LoRa.setSyncWord(SYNC_WORD);
LoRa.setTxPower(14);
BLEDevice::init("Heltec-Emisora");
BLEServer *servidor = BLEDevice::createServer();
servidor->setCallbacks(new AlConectar());
BLEService *svc = servidor->createService(SERVICE_UUID);
tx = svc->createCharacteristic(UUID_TX, BLECharacteristic::PROPERTY_NOTIFY);
tx->addDescriptor(new BLE2902());
BLECharacteristic *rx = svc->createCharacteristic(UUID_RX,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rx->setCallbacks(new AlEscribir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("Chisme 33 - Emisora lista");
}
void loop() {
while (nCola > 0) { // procesa las ordenes del movil, una a una
String l = cola[0];
for (int i = 1; i < nCola; i++) cola[i - 1] = cola[i];
nCola--;
procesar(l);
}
static bool antes = false;
if (conectado != antes) { antes = conectado; pintar(); }
delay(5);
}
Qué hace cada parte
cesar() desplaza solo las letras; los números, los signos y los espacios pasan sin cambiar. Descifrar es lo mismo con el desplazamiento negativo.
firma() calcula el HMAC-SHA256 con las funciones mbedtls_md_hmac_* y devuelve los 8 primeros bytes como 16 letras hexadecimales. La firma se calcula sobre contador|texto cifrado.
mandar() hace el orden correcto: limpia el mensaje, lo cifra, sube el contador, firma y, solo después, si el modo es «alterado», cambia una letra del texto ya firmado. Así el ataque de verdad ocurre «por el camino». En modo «impostor», firma con otro secreto (CLAVE_FALSA).
repetir() vuelve a emitir exactamente el último paquete, con su contador y su firma originales: un ataque de repetición.
El contador arranca en un número al azar de 16 bits (esp_random()), para que dos arranques seguidos no empiecen siempre en el mismo número.
La cola de órdenes. Cuando el móvil escribe por Bluetooth, la función onWrite no envía nada: solo guarda la línea en una cola y es loop() quien la procesa. Así el programa no se bloquea esperando a la radio dentro de la rutina del Bluetooth.
El código de la receptora
Guarda este programa como chisme-33-receptora.ino y cárgalo en la segunda placa. BANDA, SYNC_WORD y CLAVE_HMAC deben ser iguales que en la emisora.
/*
Chisme 33 · El mensaje verificado · LA RECEPTORA (placa B)
Recibe por LoRa el paquete de la emisora y hace TRES comprobaciones:
1) ¿La FIRMA es correcta? -> si no, el mensaje es FALSO (alterado o de un impostor).
2) ¿El CONTADOR es nuevo? -> si no, es REPETIDO (alguien reenvia un mensaje antiguo).
3) Solo si es AUTENTICO, lo DESCIFRA con la clave del Cesar.
Primero se comprueba la verdad; despues se lee. Nunca se descifra lo que no es de fiar.
En la pantalla OLED se ve el texto tal como LLEGA (cifrado), el veredicto y el texto descifrado.
Si hay un movil conectado por Bluetooth, tambien se lo cuenta.
Paquete LoRa (texto): V1|contador|texto cifrado|firma
Librerias: "LoRa" (Sandeep Mistry), Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
Si al compilar dice "Sketch too big":
Herramientas -> Partition Scheme -> "Huge APP (3MB No OTA/1MB SPIFFS)".
¡PON LA ANTENA LORA ANTES DE ENCENDER LA PLACA!
IMPORTANTE: BANDA, SYNC_WORD y CLAVE_HMAC deben ser IGUALES que en la emisora.
Si reinicias la emisora, reinicia tambien la receptora (el contador vuelve a empezar).
Orden que puede llegar del movil:
K:5 cambia la clave del Cesar de la receptora (1 a 25)
Lo que cuenta la placa al movil (una linea cada una):
Q:3,A,-62 paquete 3; A autentico, F falso, R repetido; senal en dBm
C:krod el texto cifrado tal como llego
D:hola el texto descifrado (solo si es autentico)
E fin
K:5 confirma la clave del Cesar
*/
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include "mbedtls/md.h"
#define BANDA 868E6 // la misma en las DOS placas
#define SYNC_WORD 0x33
#define CLAVE_HMAC "lince-de-la-noche" // secreto compartido: ¡debe ser IGUAL que en la emisora!
#define CLAVE_CESAR_INICIAL 3 // la clave del Cesar que conoce esta placa
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
#define LORA_SCK 5
#define LORA_MISO 19
#define LORA_MOSI 27
#define LORA_SS 18
#define LORA_RST 14
#define LORA_DIO0 26
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
BLECharacteristic *tx = nullptr;
volatile bool conectado = false;
int claveCesar = CLAVE_CESAR_INICIAL;
unsigned long ultimoContador = 0; // el contador mas alto aceptado hasta ahora
String vCifrado = "", vDescifrado = "", vEstado = "Esperando mensaje";
unsigned long vNumero = 0;
String rxLinea = "";
String cola[4];
volatile int nCola = 0;
/* ---------- Bluetooth ---------- */
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
class AlEscribir : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) {
auto v = c->getValue();
for (size_t i = 0; i < v.length(); i++) {
char ch = (char)v[i];
if (ch == '\n') {
if (rxLinea.length() && nCola < 4) cola[nCola++] = rxLinea;
rxLinea = "";
} else if (ch != '\r' && rxLinea.length() < 80) {
rxLinea += ch;
}
}
}
};
void enviar(const String &m) {
if (!conectado || !tx) return;
String s = m + "\n";
for (size_t i = 0; i < s.length(); i += 20) {
String t = s.substring(i, i + 20);
tx->setValue((uint8_t *)t.c_str(), t.length());
tx->notify();
delay(10);
}
}
/* ---------- Cifrado Cesar ---------- */
String cesar(const String &t, int k) {
String r = t;
for (size_t i = 0; i < r.length(); i++) {
char c = r[i];
if (c >= 'a' && c <= 'z') r[i] = 'a' + ((c - 'a' + k) % 26 + 26) % 26;
else if (c >= 'A' && c <= 'Z') r[i] = 'A' + ((c - 'A' + k) % 26 + 26) % 26;
}
return r;
}
/* ---------- Firma HMAC-SHA256 (igual que en la emisora) ---------- */
String firma(const String &datos, const char *clave) {
unsigned char h[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)clave, strlen(clave));
mbedtls_md_hmac_update(&ctx, (const unsigned char *)datos.c_str(), datos.length());
mbedtls_md_hmac_finish(&ctx, h);
mbedtls_md_free(&ctx);
String s = "";
const char *hex = "0123456789abcdef";
for (int i = 0; i < 8; i++) { s += hex[h[i] >> 4]; s += hex[h[i] & 15]; }
return s;
}
// Compara dos firmas SIEMPRE tardando lo mismo (asi nadie adivina la firma midiendo el tiempo)
bool iguales(const String &a, const String &b) {
if (a.length() != b.length()) return false;
unsigned char dif = 0;
for (size_t i = 0; i < a.length(); i++) dif |= (unsigned char)(a[i] ^ b[i]);
return dif == 0;
}
/* ---------- Pantalla ---------- */
void pintar() {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "RECEPTORA BT:ok k=" : "RECEPTORA BT:-- k=");
display.print(claveCesar);
display.setCursor(0, 10); display.print("Llego (cifrado):");
display.setCursor(0, 18); display.print(vCifrado.substring(0, 42));
display.setCursor(0, 34); display.print(vEstado);
display.setCursor(0, 44); display.print("Descifrado:");
display.setCursor(0, 52); display.print(vDescifrado.substring(0, 21));
display.display();
}
/* ---------- Un paquete LoRa ---------- */
void recibir(const String &m, int rssi) {
int p2 = m.indexOf('|', 3), p3 = m.lastIndexOf('|');
if (!m.startsWith("V1|") || p2 < 0 || p3 <= p2) { // no es de los nuestros
Serial.println("Paquete raro, ignorado: " + m);
return;
}
unsigned long num = m.substring(3, p2).toInt();
String cifrado = m.substring(p2 + 1, p3);
String tag = m.substring(p3 + 1);
// 1) ¿La firma es la que deberia ser? (se recalcula con NUESTRA copia del secreto)
bool firmaOk = iguales(tag, firma(String(num) + "|" + cifrado, CLAVE_HMAC));
char veredicto;
String descifrado = "";
if (!firmaOk) { // alterado o de un impostor
veredicto = 'F';
vEstado = "X FALSO #" + String(num);
descifrado = "(no se lee)";
} else if (ultimoContador != 0 && num <= ultimoContador) { // firma buena, pero ya la habiamos visto
veredicto = 'R';
vEstado = "~ REPETIDO #" + String(num);
descifrado = "(ya lo lei)";
} else { // 3) solo ahora se descifra
veredicto = 'A';
ultimoContador = num;
descifrado = cesar(cifrado, -claveCesar);
vEstado = "OK AUTENTICO #" + String(num);
}
vCifrado = cifrado; vDescifrado = descifrado; vNumero = num;
Serial.println("Paquete " + String(num) + " | " + String(veredicto) + " | cifrado: " + cifrado +
" | descifrado: " + descifrado + " | RSSI " + String(rssi));
enviar("Q:" + String(num) + "," + String(veredicto) + "," + String(rssi));
enviar("C:" + cifrado);
if (veredicto == 'A') enviar("D:" + descifrado);
enviar("E");
pintar();
}
void procesar(const String &l) {
if (l.startsWith("K:")) {
int k = l.substring(2).toInt();
if (k >= 1 && k <= 25) { claveCesar = k; enviar("K:" + String(k)); pintar(); }
}
}
/* ---------- Arranque ---------- */
void setup() {
Serial.begin(115200);
Wire.begin(OLED_SDA, OLED_SCL);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) Serial.println("No se encuentra la OLED");
display.setTextColor(SSD1306_WHITE);
pintar();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(BANDA)) { Serial.println("No arranca la radio LoRa"); while (true) delay(1000); }
LoRa.setSyncWord(SYNC_WORD);
BLEDevice::init("Heltec-Receptora");
BLEServer *servidor = BLEDevice::createServer();
servidor->setCallbacks(new AlConectar());
BLEService *svc = servidor->createService(SERVICE_UUID);
tx = svc->createCharacteristic(UUID_TX, BLECharacteristic::PROPERTY_NOTIFY);
tx->addDescriptor(new BLE2902());
BLECharacteristic *rx = svc->createCharacteristic(UUID_RX,
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rx->setCallbacks(new AlEscribir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("Chisme 33 - Receptora lista");
}
void loop() {
if (LoRa.parsePacket() > 0) {
String m = "";
while (LoRa.available()) m += (char)LoRa.read();
recibir(m, LoRa.packetRssi());
}
while (nCola > 0) {
String l = cola[0];
for (int i = 1; i < nCola; i++) cola[i - 1] = cola[i];
nCola--;
procesar(l);
}
static bool antes = false;
if (conectado != antes) { antes = conectado; pintar(); }
}
Qué hace cada parte
recibir() es el corazón del chisme. Separa el paquete en contador, texto cifrado y firma; recalcula la firma con su propia copia del secreto; y la compara con la que llegó. Solo si la firma es correcta y el contador es nuevo llama a cesar() para descifrar. Si no, el texto cifrado ni se toca.
iguales() compara las dos firmas de forma que siempre tarda lo mismo, sin parar en la primera letra distinta. Una comparación normal tardaría un poquito más cuanto más letras acertara el atacante, y esa diferencia de tiempo le podría delatar la firma correcta letra a letra. En una radio de juguete es un detalle, pero es el tipo de hábito que se aprende desde el principio.
ultimoContador es la memoria de la receptora: guarda el número más alto aceptado. Un paquete con firma correcta pero con número igual o menor se marca como REPETIDO.
La página web
La página es el mando de la emisora: escribes el mensaje, eliges la clave del César y pulsas los botones de sabotaje. Opcionalmente también se conecta a la receptora para ver el resultado con más detalle y para cambiar su clave. Guarda este archivo como chisme-33-mensaje-verificado.html y ábrelo con Chrome desde una dirección https://.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Chisme 33 · El mensaje verificado (Heltec)</title>
<style>
:root{
--bg:#eef2ef; --ink:#10201c; --soft:#4d625b; --card:#ffffff; --line:#cfdad5;
--pine:#1f5b4a; --slow:#2563a8; --ok:#1f7a4d; --norte:#c8321f; --oro:#9a6f12;
--okbg:#e3f3ea; --norbg:#fbe7e3; --orobg:#fbf0d6;
box-sizing:border-box;
padding-top:env(safe-area-inset-top,0px); padding-bottom:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#0f1a17; --ink:#e8f1ed; --soft:#9db3aa; --card:#172622; --line:#2a3d37;
--pine:#5fc4a2; --slow:#7db3ee; --ok:#6fd89f; --norte:#ff6a55; --oro:#e0b45a;
--okbg:#14352a; --norbg:#3b1d19; --orobg:#35290f;}}
:root[data-theme="dark"]{--bg:#0f1a17; --ink:#e8f1ed; --soft:#9db3aa; --card:#172622; --line:#2a3d37;
--pine:#5fc4a2; --slow:#7db3ee; --ok:#6fd89f; --norte:#ff6a55; --oro:#e0b45a;
--okbg:#14352a; --norbg:#3b1d19; --orobg:#35290f;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:18px/1.45 "Trebuchet MS","Segoe UI",system-ui,sans-serif}
.tira{height:.55rem;background:linear-gradient(90deg,#b8892b,var(--norte),var(--pine))}
main{max-width:36rem;margin:0 auto;padding:1rem 1rem 2rem}
h1{font-size:1.6rem;margin:.2rem 0 .1rem;text-align:center}
h2{font-size:1.1rem;margin:0 0 .3rem}
h3{font-size:.95rem;margin:1rem 0 .3rem}
.sub{color:var(--soft);margin:0 0 .9rem;font-size:.95rem;text-align:center}
section.panel{background:var(--card);border:1px solid var(--line);border-radius:1rem;padding:.9rem 1rem 1rem;margin-top:1rem}
button{font:inherit;font-weight:700;border:2px solid var(--pine);background:var(--pine);color:var(--bg);
border-radius:.9rem;padding:.6rem 1rem;min-height:2.9rem;cursor:pointer}
button.sec{background:transparent;color:var(--pine)}
button.mal{border-color:var(--norte);color:var(--norte);background:transparent}
button:disabled{opacity:.5;cursor:not-allowed}
button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid var(--slow);outline-offset:2px}
.estado{margin:.6rem 0 .1rem;color:var(--soft);min-height:1.5em;font-size:.95rem}
.ayuda{margin:0 0 .4rem;color:var(--soft);font-size:.85rem;min-height:1.1em}
.fila{display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap;align-items:center}
.fila > input[type=text]{flex:1;min-width:10rem}
label{font-size:.85rem;color:var(--soft)}
input,textarea{font:inherit;border:2px solid var(--line);background:var(--bg);color:var(--ink);border-radius:.7rem;padding:.5rem .7rem;min-width:0}
input[type=number]{width:5rem}
.nota{font-size:.82rem;color:var(--soft);margin:.5rem 0 0}
.mono{font-family:ui-monospace,"Cascadia Mono",Consolas,monospace;word-break:break-all}
.lista{margin-top:.7rem;display:flex;flex-direction:column;gap:.5rem}
.msg{border:1px solid var(--line);border-radius:.8rem;padding:.55rem .75rem;background:var(--bg);font-size:.92rem}
.msg .cab{display:flex;justify-content:space-between;gap:.5rem;font-size:.8rem;color:var(--soft);margin-bottom:.2rem}
.msg .l{margin:.1rem 0}
.msg .l b{display:inline-block;min-width:5.3rem;color:var(--soft);font-weight:700;font-size:.8rem}
.ins{display:inline-block;font-weight:700;font-size:.82rem;border-radius:.5rem;padding:.05rem .5rem}
.ins.A{background:var(--okbg);color:var(--ok)}
.ins.F{background:var(--norbg);color:var(--norte)}
.ins.R{background:var(--orobg);color:var(--oro)}
.ins.N{background:var(--line);color:var(--soft)}
table{width:100%;border-collapse:collapse;margin-top:.5rem;font-size:.9rem}
th,td{border-bottom:1px solid var(--line);padding:.3rem .3rem;text-align:left}
th{color:var(--soft);font-size:.8rem}
.comp td:first-child{font-weight:700;width:6rem}
.guia{background:var(--card);border:1px solid var(--line);border-radius:.8rem;padding:.2rem .9rem;margin-top:1rem}
.guia summary{font-weight:700;cursor:pointer;padding:.6rem 0}
.guia ol,.guia ul{padding-left:1.3rem;margin:.2rem 0 .6rem}
.guia li{margin:.4rem 0}
.priv{font-size:.82rem;color:var(--soft);margin-top:1rem}
.scroll{overflow-x:auto}
</style>
</head>
<body>
<div class="tira"></div>
<main>
<h1>El mensaje verificado ✉️</h1>
<p class="sub">Un mensaje que viaja cifrado y firmado de una placa a otra por LoRa.</p>
<!-- ===================== EMISORA ===================== -->
<section class="panel" aria-labelledby="t-em">
<h2 id="t-em">1 · La emisora (aquí escribes)</h2>
<button id="em-conectar">Conectar emisora</button>
<div class="estado" id="em-estado" role="status">Sin conectar.</div>
<div class="ayuda" id="em-ayuda"></div>
<div class="fila">
<input type="text" id="msg" maxlength="40" placeholder="Escribe tu mensaje (máx. 40 letras)" aria-label="Mensaje" disabled>
</div>
<div class="fila">
<label for="k">Clave del César (1–25)</label>
<input type="number" id="k" min="1" max="25" value="3" disabled>
<button id="enviar" disabled>Enviar por LoRa</button>
</div>
<h3>Sabotajes para probar la verificación</h3>
<div class="fila">
<button id="alterado" class="mal" disabled>Enviar alterado</button>
<button id="impostor" class="mal" disabled>Enviar como impostor</button>
<button id="repetir" class="mal" disabled>Repetir el último</button>
</div>
<p class="nota">Alterado: alguien cambia una letra después de firmar. Impostor: lo firma con una clave secreta equivocada. Repetir: se reenvía un mensaje auténtico de antes.</p>
<div class="lista" id="em-lista" aria-live="polite"></div>
</section>
<!-- ===================== RECEPTORA ===================== -->
<section class="panel" aria-labelledby="t-rx">
<h2 id="t-rx">2 · La receptora (aquí llega)</h2>
<p class="nota" style="margin-top:0">Es opcional: la receptora ya enseña el resultado en su pantalla OLED. Si conectas también esta página a ella, verás aquí lo mismo con más detalle.</p>
<button id="rx-conectar">Conectar receptora</button>
<div class="estado" id="rx-estado" role="status">Sin conectar.</div>
<div class="ayuda" id="rx-ayuda"></div>
<div class="fila">
<label for="rk">Clave del César de la receptora</label>
<input type="number" id="rk" min="1" max="25" value="3" disabled>
<button id="rk-poner" class="sec" disabled>Cambiar</button>
</div>
<p class="nota">Pruébalo: pon en la receptora una clave distinta a la de la emisora. La firma seguirá siendo correcta, pero el mensaje saldrá ilegible. La firma demuestra quién lo envió; la clave del César es lo que permite leerlo.</p>
<div class="lista" id="rx-lista" aria-live="polite"></div>
</section>
<!-- ===================== ROMPER EL CESAR ===================== -->
<section class="panel" aria-labelledby="t-rompe">
<h2 id="t-rompe">3 · Rompe el César</h2>
<p class="nota" style="margin-top:0">Si no conoces la clave, no pasa nada: solo hay 25 posibles. Pega un texto cifrado y prueba todas. Busca la línea que se lee bien.</p>
<div class="fila">
<input type="text" id="cif" placeholder="Texto cifrado" aria-label="Texto cifrado">
<button id="probar" class="sec">Probar las 25 claves</button>
</div>
<div class="scroll"><table id="rompe" aria-label="Resultado de cada clave"><tbody></tbody></table></div>
</section>
<details class="guia">
<summary>Cifrar no es lo mismo que autenticar</summary>
<div class="scroll">
<table class="comp">
<thead><tr><th></th><th>Cifrar (César)</th><th>Firmar (HMAC)</th></tr></thead>
<tbody>
<tr><td>Responde a</td><td>¿Quién puede leerlo?</td><td>¿Quién lo mandó y es el mismo mensaje?</td></tr>
<tr><td>Necesita</td><td>La clave del César</td><td>El secreto compartido</td></tr>
<tr><td>Si lo tocan</td><td>Se descifra mal, sin que nadie avise</td><td>La firma deja de cuadrar: FALSO</td></tr>
<tr><td>Si lo reenvían</td><td>No se entera</td><td>El contador lo detecta: REPETIDO</td></tr>
</tbody>
</table>
</div>
<p class="nota">El César es muy débil: 25 claves se prueban en un minuto. Se usa aquí solo para entender qué es una clave. La firma, en cambio, es HMAC-SHA256, la misma que protege muchas comunicaciones reales.</p>
</details>
<details class="guia">
<summary>Guía de uso</summary>
<ol>
<li>Carga <b>chisme-33-emisora.ino</b> en una placa y <b>chisme-33-receptora.ino</b> en la otra. Pon la antena LoRa <b>antes</b> de encenderlas.</li>
<li>Abre esta página con <b>Chrome</b> y pulsa <b>Conectar emisora</b> (elige <b>Heltec-Emisora</b>).</li>
<li>Escribe un mensaje y pulsa <b>Enviar por LoRa</b>. En la pantalla de la receptora verás el texto cifrado que llega, el veredicto (AUTÉNTICO, FALSO o REPETIDO) y el mensaje descifrado.</li>
<li>Prueba los tres sabotajes. Después cambia la clave del César solo en la emisora y mira qué pasa.</li>
<li>Si reinicias una placa, reinicia también la otra (el contador de mensajes vuelve a empezar).</li>
</ol>
</details>
<p class="priv"><b>Privacidad:</b> los mensajes solo viajan entre tus placas y tu móvil; esta página no envía nada a Internet y no guarda nada: si la cierras, el historial desaparece. Aviso: el secreto de la firma está escrito en el programa de la placa. En un sistema real nunca se haría así; es solo un prototipo educativo, no un sistema de seguridad.</p>
</main>
<script>
const UART_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UART_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; // el móvil escribe aquí
const UART_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // la placa envía por aquí
const $ = id => document.getElementById(id);
const espera = ms => new Promise(r => setTimeout(r, ms));
/* ---------- César (igual que en las placas) ---------- */
function cesar(t, k){
return t.replace(/[a-z]/gi, c => {
const base = c <= "Z" ? 65 : 97;
return String.fromCharCode(((c.charCodeAt(0) - base + k) % 26 + 26) % 26 + base);
});
}
// La placa solo trabaja con ASCII y sin "|": quitamos acentos y signos raros antes de enviar
const limpiar = t => t.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\|/g, " ").replace(/[^\x20-\x7e]/g, "").slice(0, 40);
/* ---------- Una conexión Bluetooth por placa ---------- */
function crearEnlace(pref, nombreEsperado, alLinea, alConectar, alDesconectar){
const E = { dev: null, rx: null, buffer: "", conectando: false, reconectando: false, paso: "", diag: "" };
const estado = (t, ayuda) => { if (!/^Error/.test(t)) E.paso = t; $(pref + "-estado").textContent = t; $(pref + "-ayuda").textContent = ayuda || ""; };
const props = c => ["read", "write", "writeWithoutResponse", "notify", "indicate"].filter(k => c.properties[k]).join(", ");
function explicar(e){
const n = e && e.name ? e.name : "Error", m = e && e.message ? e.message : String(e);
let a = "";
if (n === "NotFoundError" && /cancel/i.test(m)) a = "No has elegido ningún dispositivo. Pulsa Conectar y elige " + nombreEsperado + ".";
else if (n === "NotFoundError") a = "La placa no tiene el servicio UART. Comprueba que has cargado el programa del Chisme 33.";
else if (n === "NotSupportedError") a = "No se pudo activar el envío de datos. Olvida la placa en Ajustes → Bluetooth y reinicia el móvil y la placa.";
else if (n === "NetworkError") a = "No se pudo abrir la conexión. Comprueba que no haya otra app conectada y reinicia la placa (botón RST).";
else if (n === "SecurityError") a = "La página debe abrirse con https://.";
else if (n === "NotAllowedError") a = "Permiso denegado. Permite el Bluetooth y los dispositivos cercanos para el navegador.";
estado("Error (" + n + "): " + m, "Falló en el paso: «" + E.paso + "». " + a + (E.diag ? " Características: " + E.diag : ""));
}
async function conectarGatt(dev){
let ultimo;
for (let k = 1; k <= 4; k++){
try {
estado("Conectando… (intento " + k + " de 4)");
const srv = dev.gatt.connected ? dev.gatt : await dev.gatt.connect();
await espera(600);
estado("Buscando el servicio UART…");
return await srv.getPrimaryService(UART_SERVICE);
} catch (e){
ultimo = e;
if (e.name === "NotFoundError" || e.name === "SecurityError") break;
try { if (dev.gatt.connected) dev.gatt.disconnect(); } catch (_){}
await espera(800);
}
}
throw ultimo;
}
function recibir(ev){
E.buffer += new TextDecoder().decode(ev.target.value);
let i;
while ((i = E.buffer.indexOf("\n")) >= 0){ alLinea(E.buffer.slice(0, i).trim()); E.buffer = E.buffer.slice(i + 1); }
}
async function enlazar(dev){
const svc = await conectarGatt(dev);
E.buffer = ""; E.diag = "";
estado("Leyendo las características del servicio…");
const chars = await svc.getCharacteristics();
E.diag = chars.map(c => c.uuid.slice(0, 8) + " [" + props(c) + "]").join(" · ");
E.rx = chars.find(c => c.uuid === UART_RX) || null;
const cand = chars.filter(c => c.properties.notify || c.properties.indicate)
.sort((a, b) => (b.uuid === UART_TX) - (a.uuid === UART_TX));
if (!cand.length) throw new Error("El servicio UART no tiene ninguna característica que envíe datos.");
let ch = null, ultimoErr;
for (const c of cand){
c.addEventListener("characteristicvaluechanged", recibir);
for (let i = 1; i <= 2 && !ch; i++){
try { estado("Activando notificaciones en " + c.uuid.slice(0, 8) + "… (intento " + i + ")"); await c.startNotifications(); ch = c; }
catch (e){ ultimoErr = e; await espera(1000); }
}
if (ch) break;
c.removeEventListener("characteristicvaluechanged", recibir);
}
if (!ch) throw ultimoErr;
if (!E.rx) throw new Error("La placa no tiene la característica para recibir órdenes (UUID …0002). ¿Has cargado el programa del Chisme 33?");
estado("Conectada a " + (dev.name || "la placa") + ".");
alConectar();
}
async function reconectar(){
if (E.reconectando) return;
E.reconectando = true; alDesconectar();
for (let i = 1; i <= 20; i++){
estado("Conexión perdida. Reintentando… (" + i + " de 20)", "Mantén la placa cerca y encendida.");
await espera(3000);
try { await enlazar(E.dev); E.reconectando = false; return; } catch (_) {}
}
E.reconectando = false;
estado("No se pudo reconectar.", "Pulsa Conectar para volver a empezar.");
}
$(pref + "-conectar").onclick = async () => {
if (E.conectando || E.reconectando) return;
if (!navigator.bluetooth){ estado("Este navegador no permite Bluetooth.", "Usa Chrome o Edge (en ordenador o en Android)."); return; }
E.conectando = true; $(pref + "-conectar").disabled = true;
try {
estado("Buscando la placa…");
const dev = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [UART_SERVICE] });
E.dev = dev;
dev.addEventListener("gattserverdisconnected", reconectar);
await enlazar(dev);
if (dev.name && dev.name.indexOf(nombreEsperado) !== 0)
$(pref + "-ayuda").textContent = "Ojo: esta placa se llama «" + dev.name + "» y aquí esperábamos «" + nombreEsperado + "».";
} catch (e){ explicar(e); }
finally { E.conectando = false; $(pref + "-conectar").disabled = false; }
};
// Envía una línea a la placa, en trozos de 20 bytes
E.escribir = async function(texto){
if (!E.rx) throw new Error("Sin conexión");
const bytes = new TextEncoder().encode(texto + "\n");
for (let i = 0; i < bytes.length; i += 20){
const trozo = bytes.slice(i, i + 20);
if (E.rx.writeValueWithResponse) await E.rx.writeValueWithResponse(trozo);
else await E.rx.writeValue(trozo);
}
};
return E;
}
/* ---------- Historial ---------- */
function tarjeta(lista, cab, inscripcion, clase, lineas){
const d = document.createElement("div"); d.className = "msg";
const c = document.createElement("div"); c.className = "cab";
const a = document.createElement("span"); a.textContent = cab;
const b = document.createElement("span"); b.className = "ins " + clase; b.textContent = inscripcion;
c.appendChild(a); c.appendChild(b); d.appendChild(c);
lineas.forEach(([nombre, valor]) => {
const l = document.createElement("div"); l.className = "l";
const n = document.createElement("b"); n.textContent = nombre;
const v = document.createElement("span"); v.className = "mono"; v.textContent = valor;
l.appendChild(n); l.appendChild(v); d.appendChild(l);
});
lista.insertBefore(d, lista.firstChild);
}
function ponerCifrado(t){ $("cif").value = t; }
/* ---------- Emisora ---------- */
const MODOS = { N: ["Normal", "N"], A: ["Alterado", "F"], I: ["Impostor", "F"], R: ["Repetido", "R"] };
let evE = null;
function lineaEmisora(t){
let m;
if ((m = /^S:(\d+),([NAIR])$/.exec(t))) { evE = { num: m[1], modo: m[2], o: "", c: "", t: "" }; return; }
if ((m = /^K:(\d+)$/.exec(t))) { $("k").value = m[1]; return; }
if (!evE) return;
if (t.startsWith("O:")) evE.o = t.slice(2);
else if (t.startsWith("C:")) evE.c = t.slice(2);
else if (t.startsWith("T:")) evE.t = t.slice(2);
else if (t === "E") {
const nombre = { N: "Mensaje normal", A: "Alterado a propósito", I: "Firmado por un impostor", R: "Reenviado (repetición)" }[evE.modo];
tarjeta($("em-lista"), "Paquete #" + evE.num, nombre, "N", [["Original", evE.o], ["Cifrado", evE.c], ["Firma", evE.t]]);
ponerCifrado(evE.c);
evE = null;
}
}
const em = crearEnlace("em", "Heltec-Emisora", lineaEmisora,
() => { ["msg", "k", "enviar", "alterado", "impostor", "repetir"].forEach(i => $(i).disabled = false); em.escribir("K:" + $("k").value).catch(() => {}); },
() => { ["msg", "k", "enviar", "alterado", "impostor", "repetir"].forEach(i => $(i).disabled = true); });
async function mandar(prefijo){
const t = limpiar($("msg").value);
if (!t.trim()){ $("em-ayuda").textContent = "Escribe un mensaje primero."; return; }
$("msg").value = t;
try { await em.escribir(prefijo + t); $("em-ayuda").textContent = ""; }
catch (e){ $("em-ayuda").textContent = "No se pudo enviar: " + e.message; }
}
$("enviar").onclick = () => mandar("M:");
$("alterado").onclick = () => mandar("X:");
$("impostor").onclick = () => mandar("I:");
$("repetir").onclick = async () => { try { await em.escribir("R"); } catch (e){ $("em-ayuda").textContent = "No se pudo enviar: " + e.message; } };
$("msg").addEventListener("keydown", e => { if (e.key === "Enter") mandar("M:"); });
$("k").onchange = async () => {
let k = Math.max(1, Math.min(25, parseInt($("k").value, 10) || 3));
$("k").value = k;
try { await em.escribir("K:" + k); } catch (e){}
};
/* ---------- Receptora ---------- */
let evR = null;
function lineaReceptora(t){
let m;
if ((m = /^Q:(\d+),([AFR]),(-?\d+)$/.exec(t))) { evR = { num: m[1], v: m[2], rssi: m[3], c: "", d: "" }; return; }
if ((m = /^K:(\d+)$/.exec(t))) { $("rk").value = m[1]; return; }
if (!evR) return;
if (t.startsWith("C:")) evR.c = t.slice(2);
else if (t.startsWith("D:")) evR.d = t.slice(2);
else if (t === "E") {
const txt = { A: "✔ AUTÉNTICO", F: "✖ FALSO", R: "↻ REPETIDO" }[evR.v];
const lineas = [["Llegó cifrado", evR.c]];
if (evR.v === "A") lineas.push(["Descifrado", evR.d]);
else if (evR.v === "F") lineas.push(["Descifrado", "(no se descifra: la firma no cuadra)"]);
else lineas.push(["Descifrado", "(ya se había leído)"]);
lineas.push(["Señal LoRa", evR.rssi + " dBm"]);
tarjeta($("rx-lista"), "Paquete #" + evR.num, txt, evR.v, lineas);
ponerCifrado(evR.c);
evR = null;
}
}
const rx = crearEnlace("rx", "Heltec-Receptora", lineaReceptora,
() => { $("rk").disabled = false; $("rk-poner").disabled = false; },
() => { $("rk").disabled = true; $("rk-poner").disabled = true; });
$("rk-poner").onclick = async () => {
let k = Math.max(1, Math.min(25, parseInt($("rk").value, 10) || 3));
$("rk").value = k;
try { await rx.escribir("K:" + k); } catch (e){ $("rx-ayuda").textContent = "No se pudo cambiar: " + e.message; }
};
/* ---------- Romper el César por fuerza bruta ---------- */
$("probar").onclick = () => {
const t = $("cif").value;
const tb = $("rompe").querySelector("tbody");
tb.innerHTML = "";
for (let k = 1; k <= 25; k++){
const tr = document.createElement("tr");
const a = document.createElement("td"); a.textContent = "Clave " + k;
const b = document.createElement("td"); b.className = "mono"; b.textContent = cesar(t, -k);
tr.appendChild(a); tr.appendChild(b); tb.appendChild(tr);
}
};
</script>
</body>
</html>
Qué hace cada parte
Dos conexiones independientes. La función crearEnlace() se usa dos veces, una por placa. Cada una tiene su botón, su estado y su reconexión automática. Así puedes usar solo la emisora (lo más sencillo) o conectar las dos desde el mismo móvil.
El mensaje antes de salir. limpiar() quita acentos, eñes y signos que la placa no maneja (solo trabaja con ASCII) y sustituye la barra |, que usamos como separador del paquete. Por eso «¡Hola, Lince!» viaja como «Hola, Lince!».
Cada línea que cuenta la placa. La emisora responde con S: (número y modo), O: (original), C: (cifrado), T: (firma) y E (fin); la receptora, con Q: (número, veredicto y señal), C:, D: (descifrado) y E. Las funciones lineaEmisora() y lineaReceptora() van montando cada paquete y, al llegar la E, dibujan una tarjeta del historial.
Rompe el César. El botón prueba las 25 claves sobre el último texto cifrado y escribe cada resultado. La clave correcta es la única línea que se lee como español.
Cómo probarlo
- Enciende las dos placas con sus antenas puestas. En las pantallas verás «EMISORA» y «RECEPTORA».
- Abre la página, pulsa Conectar emisora y elige Heltec-Emisora. Deja la clave del César en 3.
- Escribe
Hola Lincey pulsa Enviar por LoRa. En la emisora verás el original y el cifrado (Krod Olqfh). En la receptora, el texto que llega cifrado, el veredicto AUTENTICO y el mensaje descifrado. - Pulsa Enviar alterado. La receptora debe marcarlo FALSO y no mostrar ningún texto descifrado: alguien cambió una letra después de firmar.
- Pulsa Enviar como impostor. También FALSO: el texto es el mismo que uno bueno, pero la firma se calculó sin el secreto correcto.
- Envía un mensaje normal y, a continuación, pulsa Repetir el último. La firma es correcta (es el mismo paquete), pero el contador no es nuevo: REPETIDO. Ojo: si el último paquete fue falso, repetirlo también será falso.
- Cambia la clave del César de la emisora a 7 y envía otro mensaje. La receptora sigue con la clave 3, así que la firma es correcta (AUTÉNTICO) pero el texto descifrado sale ilegible. Esta es la prueba de que autenticar no es lo mismo que poder leer.
- Copia un texto cifrado en el bloque Rompe el César y prueba las 25 claves.
| Prueba | Qué debe pasar |
|---|---|
| Mensaje normal | AUTÉNTICO y se lee |
| Alterado | FALSO, no se descifra |
| Impostor | FALSO, no se descifra |
| Repetir el último bueno | REPETIDO |
| Claves del César distintas | AUTÉNTICO, pero ilegible |
Qué estamos midiendo realmente
Aquí no medimos una magnitud física, sino una garantía. Y conviene saber exactamente qué garantiza y qué no.
Qué demuestra una firma correcta. Que quien mandó el paquete conocía el secreto, y que el texto cifrado y el contador son exactamente los que se firmaron. Si alguien cambia una sola letra, la firma deja de cuadrar. Como solo guardamos 8 bytes (64 bits) de la huella, adivinarla a ciegas tiene una probabilidad de 1 entre 1,8·10¹⁹ por intento, y cada intento por LoRa tarda al menos varias decenas de milisegundos. Para una demostración es más que suficiente; para un sistema serio se usaría la firma completa.
Qué no demuestra.
- Que el mensaje sea verdad. Una emisora auténtica puede mandar una mentira. La firma certifica el origen, no el contenido. Es la diferencia entre dato verificado y dato cierto.
- Que el secreto siga siendo secreto. Está escrito en el programa de las placas: cualquiera que lea el código (por ejemplo, este artículo) puede firmar como si fuera la emisora. En un sistema real se guarda en memoria protegida, no en el código.
- Quién de los dos lo firmó. El HMAC usa un secreto compartido: las dos placas pueden producir firmas válidas, así que no sirve para saber cuál de ellas lo hizo.
- Que el mensaje sea reciente. El contador detecta repeticiones mientras la receptora esté encendida, pero lo olvida al reiniciarse: un paquete antiguo reenviado después de un reinicio se aceptaría. Los sistemas reales guardan el contador o añaden la hora.
Qué protege el César. Casi nada. Solo hay 25 claves, y una persona puede probarlas todas en un minuto. Además solo cambia las letras: la longitud, los espacios y los números se ven tal cual, y un texto largo se rompe contando qué letra aparece más (en español, la E). Hoy se usan cifrados como AES, que además ofrecen modos que cifran y autentican a la vez.
Y la radio es abierta. El sync word de LoRa que usamos (0x33) sirve para no mezclarnos con otras redes, no es una medida de seguridad: cualquiera con un receptor LoRa en la misma banda puede captar los paquetes, y por eso la firma y el cifrado viajan dentro del mensaje.
Por último, la receptora también muestra la señal con la que llegó cada paquete (RSSI, en dBm). Igual que en el Chisme 30, es un valor del chip, no calibrado: sirve para comparar sitios con la misma placa, no para dar valores absolutos.
Para el aula
Preguntas para empezar. ¿Qué es una clave? ¿Cuántas claves tiene el César? ¿Qué diferencia hay entre que nadie pueda leer un mensaje y que nadie pueda falsificarlo? ¿Puede un mensaje firmado ser mentira?
Actividad 1: rompe el mensaje de tu compañero. Cada pareja escribe una frase, la cifra con una clave que no revela y se la pasa a otra pareja. Esa pareja debe descifrarla probando las 25 claves y medir cuánto tarda. Después se repite con un mensaje largo y se comprueba que contar letras también sirve.
Actividad 2: ¿cuánto más difícil sería? Si en vez de desplazar el alfabeto se sustituye cada letra por otra distinta (un alfabeto desordenado), hay 26! ≈ 4·10²⁶ claves. ¿Por qué sigue siendo fácil de romper con un texto largo? Es una buena forma de entender que el número de claves no lo es todo.
Actividad 3: diseña el experimento. Hipótesis: «toda alteración de un solo carácter se detecta». Se envían 10 mensajes normales, 10 alterados, 10 de impostor y 10 repetidos, y se rellena una tabla con el veredicto de cada uno. ¿Se cumple siempre? ¿Hay mensajes que no llegan, ni como verdaderos ni como falsos? Un mensaje perdido es un dato distinto: «no se recibió» no es lo mismo que «se recibió y es falso».
Actividad 4: el atacante. Un grupo hace de atacante y solo conoce los paquetes que ve en la pantalla. Su objetivo: producir un paquete que la receptora marque como AUTÉNTICO sin saber el secreto. Después se les permite leer el código de la placa. ¿Qué cambia? ¿Dónde habría que guardar el secreto?
Actividad 5: verdad y origen. Se reparten tarjetas con frases de dos tipos: las que podrían llegar firmadas por una fuente fiable y las que no. El grupo debe clasificar cada una en tres categorías: origen comprobado y contenido cierto, origen comprobado pero contenido falso, y origen desconocido. Es una forma de hablar de bulos y de por qué comprobar de dónde viene un dato es solo la mitad del trabajo.
Actividad 6: alcance de la radio. Con la emisora fija, se camina con la receptora por el centro (aula, pasillo, patio) y se apunta, para cada punto, si el mensaje llega y la señal en dBm. ¿A qué distancia deja de llegar? ¿Influyen las paredes, como pasaba con el wifi en el Chisme 30?
Conexión con ciencia ciudadana. Si en un proyecto de ciencia ciudadana muchos dispositivos mandan datos de sensores, la firma sirve para saber que los datos que llegan vienen de dispositivos autorizados y no han sido manipulados. Es el primer paso para tener datos en los que se pueda confiar: sensor → dato → dato verificado → conjunto de datos → análisis.
Retos
Reto 1: haz que funcione. Envía un mensaje, comprueba que la receptora lo marca AUTÉNTICO y prueba los tres sabotajes.
Reto 2: modifica algo. Cambia CLAVE_HMAC en las dos placas por un secreto vuestro. Después cámbialo solo en una y observa qué veredicto sale para todos los mensajes. ¿Por qué?
Reto 3: cambia el cifrado. Sustituye el César por un alfabeto desordenado (una tabla de 26 letras) o por un cifrado de Vigenère con una palabra clave. ¿Cuánto cuesta romperlo ahora? Si te atreves, investiga el cifrado AES de mbedtls.
Reto 4: añade un sensor. Haz que la emisora no mande texto, sino una lectura firmada: la temperatura del DHT22 del Chisme 26 o el valor del sensor táctil del Chisme 29. La receptora debe mostrar solo los datos verificados.
Reto 5: guarda los datos. Añade a la página un botón que descargue el historial en un archivo CSV con las columnas número, veredicto, señal y texto.
Reto 6: represéntalos. Con el CSV de varias pruebas, dibuja en una hoja de cálculo la señal (dBm) de cada paquete según la distancia entre las placas, marcando cuáles llegaron.
Reto 7: automatízalo. Haz que la emisora mande sola un mensaje de latido cada minuto y que la receptora avise si pasan dos minutos sin recibir uno válido.
Reto 8: haz que otro dispositivo participe. Prepara una tercera placa como «atacante» que capture un paquete y lo reenvíe más tarde. ¿Detecta la receptora la repetición después de reiniciarse? Si no, ¿cómo lo arreglarías guardando el contador?
Reto 9: conviértelo en una pequeña investigación. Elige una pregunta, por ejemplo «¿hasta qué distancia llegan los mensajes con el patio entre las dos placas?». Escribe la hipótesis, diseña el experimento con al menos 10 mensajes por punto, analiza los datos y redacta una conclusión que diga también qué no puedes afirmar.
Qué hemos aprendido
Hemos construido un sistema de HMAC en ESP32 que comprueba si un mensaje es auténtico antes de leerlo. Hemos aprendido que cifrar y autenticar son cosas distintas: el César esconde el texto (y se rompe probando 25 claves), y la firma HMAC demuestra el origen y que nadie lo tocó. También hemos visto qué detecta cada sabotaje (alterar, suplantar y repetir) y qué no detecta ninguna firma: que el contenido sea cierto, o que el secreto no se haya filtrado. Y sobre todo, que la verdad de un dato se puede comprobar: primero se verifica y después se lee.
El Lince de la Noche tardó un rato en volver a la cueva con el portátil. Cuando leyó por fin en la pantalla de la receptora el mensaje del principio, el que ponía «Montesa» y lo llamaba urgentemente al collado, vio una sola palabra: FALSO.
—Alguien sabe imitar mi letra —murmuró la Gata Montesa, mirando la pantalla—, pero no mi sello.
—¿Y quién ha sido?
—Eso, Lince, ya no lo dice la firma. La firma solo dice que no fui yo. Lo demás tendremos que averiguarlo con más datos.
Para seguir investigando
Este chisme combina piezas de los anteriores. Si quieres repasar de dónde viene cada una:
- Chisme 25: El letrero del Lince: la pantalla OLED y el primer enlace Bluetooth con la Heltec.
- Chisme 28: Lince seguro (página de la app): dos placas Heltec que se hablan por LoRa.
- Chisme 26: Termómetro Bluetooth con Heltec y DHT22: el sensor que puedes firmar en el Reto 4.
- Todos los Chismes del Lince: la colección completa.
Para profundizar en la criptografía de este chisme:
- RFC 2104: HMAC, keyed-hashing for message authentication: el documento original que define el HMAC.
- Documentación de mbedTLS en ESP-IDF: la biblioteca de criptografía que usa el ESP32.
- Librería arduino-LoRa de Sandeep Mistry: la librería de la radio.



