miércoles, 19 de noviembre de 2025

Construyendo un Autenticador TOTP con ESP32

Autenticador TOTP con ESP32

Construyendo un Autenticador TOTP con ESP32

🎯 ¿Qué es TOTP?

TOTP (Time-based One-Time Password) es un algoritmo definido en el RFC 6238 que genera códigos de un solo uso basados en el tiempo actual. Es la tecnología detrás de la mayoría de sistemas de autenticación de dos factores (2FA).

¿Cómo funciona?

  1. Secret compartido: Cuando activas 2FA en un servicio, recibes un “secret” único (generalmente en formato Base32)
  2. Timestamp: Se usa la hora Unix actual dividida en intervalos de 30 segundos
  3. HMAC-SHA1: Se calcula un hash usando el secret y el timestamp
  4. Truncamiento dinámico: Se extrae un código de 6-8 dígitos del hash

El proceso matemático simplificado:

timestamp = unix_time / 30
hash = HMAC-SHA1(secret, timestamp)
code = hash[offset:offset+4] % 10^6

🔧 Hardware Utilizado

  • ESP32-WROVER-DEV (Freenove)
  • Cámara OV2640 (para futuras funcionalidades de escaneo QR)
  • Conexión WiFi 2.4GHz

📐 Arquitectura del Sistema

El proyecto está dividido en módulos bien definidos:

├── hardware/        # Gestión de WiFi, I2C, periféricos
├── network/         # Servidor HTTP y API REST
├── storage/         # Persistencia en NVS Flash
├── totp/           # Motor TOTP, parser, almacenamiento
└── utils/          # Base32, NTP, helpers

🚀 Implementación Paso a Paso

1. Sincronización de Tiempo con NTP

El componente más crítico de TOTP es tener la hora exacta. Un desfase de segundos hace que los códigos sean inválidos.

// utils/ntp.c
void ntp_sync(void) {
    ESP_LOGI(TAG, "Sincronizando con NTP");
    
    // Configurar modo de sincronización
    sntp_set_sync_mode(SNTP_SYNC_MODE_IMMED);
    
    // Servidor NTP
    esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
    esp_sntp_setservername(0, "pool.ntp.org");
    
    esp_sntp_init();
    
    // Esperar sincronización
    int retry = 0;
    while (sntp_get_sync_status() == SNTP_SYNC_STATUS_RESET && 
           retry < 20) {
        vTaskDelay(500 / portTICK_PERIOD_MS);
        retry++;
    }
    
    if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
        ESP_LOGI(TAG, "Tiempo sincronizado!");
        
        // Mostrar hora actual
        time_t now;
        time(&now);
        ESP_LOGI(TAG, "Hora actual: %s", ctime(&now));
    }
}

Importante: La sincronización NTP debe hacerse después de conectar al WiFi y antes de generar códigos TOTP.

2. Decodificador Base32

Los secrets TOTP vienen codificados en Base32. Necesitamos decodificarlos a bytes antes de usarlos.

// utils/base32.c
int base32_decode(const char *encoded, uint8_t *decoded, size_t decoded_len) {
    size_t encoded_len = strlen(encoded);
    size_t decoded_idx = 0;
    uint32_t buffer = 0;
    int bits_in_buffer = 0;

    for (size_t i = 0; i < encoded_len; i++) {
        char c = toupper(encoded[i]);
        
        // Skip padding y espacios
        if (c == '=' || c == ' ' || c == '\n') continue;

        int value = base32_char_to_value(c);
        if (value < 0) return -1;

        buffer = (buffer << 5) | value;
        bits_in_buffer += 5;

        if (bits_in_buffer >= 8) {
            decoded[decoded_idx++] = (buffer >> (bits_in_buffer - 8)) & 0xFF;
            bits_in_buffer -= 8;
        }
    }

    return decoded_idx;
}

3. Motor TOTP - El Corazón del Sistema

La implementación del algoritmo TOTP usando mbedtls para HMAC-SHA1:

// totp/totp_engine.c
esp_err_t totp_generate_code(const char *secret_b32, 
                             uint32_t time_step, 
                             uint8_t digits, 
                             uint32_t *code) {
    // 1. Decodificar secret Base32
    uint8_t secret[128];
    int secret_len = base32_decode(secret_b32, secret, sizeof(secret));
    
    // 2. Obtener contador de tiempo
    uint64_t timestamp = get_timestamp();
    uint64_t counter = timestamp / time_step;  // Intervalos de 30s
    
    // 3. Convertir contador a bytes big-endian
    uint8_t counter_bytes[8];
    for (int i = 7; i >= 0; i--) {
        counter_bytes[i] = counter & 0xFF;
        counter >>= 8;
    }
    
    // 4. Calcular HMAC-SHA1
    uint8_t hmac[20];
    const mbedtls_md_info_t *md_info = 
        mbedtls_md_info_from_type(MBEDTLS_MD_SHA1);
    mbedtls_md_hmac(md_info, secret, secret_len, 
                    counter_bytes, 8, hmac);
    
    // 5. Truncamiento dinámico (RFC 4226)
    int offset = hmac[19] & 0x0F;
    uint32_t binary = 
        ((hmac[offset] & 0x7F) << 24) |
        ((hmac[offset + 1] & 0xFF) << 16) |
        ((hmac[offset + 2] & 0xFF) << 8) |
        (hmac[offset + 3] & 0xFF);
    
    // 6. Generar código de N dígitos
    uint32_t modulo = 1;
    for (int i = 0; i < digits; i++) {
        modulo *= 10;
    }
    *code = binary % modulo;
    
    return ESP_OK;
}

4. Parser de URIs otpauth://

Los servicios 2FA generan URIs en formato estándar:

otpauth://totp/GitHub:user@email.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&digits=6&period=30

Nuestro parser extrae toda esta información:

// totp/totp_parser.c
esp_err_t totp_parse_uri(const char *uri, totp_service_t *service) {
    // Validar prefijo
    if (strncmp(uri, "otpauth://totp/", 15) != 0) {
        return ESP_ERR_INVALID_ARG;
    }
    
    // Extraer label (Issuer:account)
    const char *path = uri + 15;
    const char *query = strchr(path, '?');
    
    // Parsear label decodificando URL
    char decoded_label[128];
    url_decode(decoded_label, path);
    
    // Separar issuer y account si hay ":"
    char *colon = strchr(decoded_label, ':');
    if (colon != NULL) {
        *colon = '\0';
        strncpy(service->issuer, decoded_label, MAX_ISSUER_LEN - 1);
        strncpy(service->account, colon + 1, MAX_ACCOUNT_NAME_LEN - 1);
    }
    
    // Extraer parámetros: secret, issuer, digits, period
    get_query_param(query, "secret", service->secret, MAX_SECRET_LEN);
    get_query_param(query, "issuer", service->issuer, MAX_ISSUER_LEN);
    
    // Valores por defecto
    service->digits = 6;
    service->period = 30;
    
    return ESP_OK;
}

5. Almacenamiento Persistente en NVS

Los servicios se guardan en la partición NVS Flash del ESP32:

// totp/totp_storage.c
esp_err_t totp_storage_add(const totp_service_t *service) {
    // Agregar al cache en memoria
    memcpy(&services[service_count], service, sizeof(totp_service_t));
    service_count++;
    
    // Guardar en NVS
    nvs_handle_t handle;
    nvs_open("totp_storage", NVS_READWRITE, &handle);
    
    // Guardar contador
    nvs_set_u8(handle, "svc_count", service_count);
    
    // Guardar cada servicio
    char key[16];
    for (int i = 0; i < service_count; i++) {
        snprintf(key, sizeof(key), "svc_%d", i);
        nvs_set_blob(handle, key, &services[i], sizeof(totp_service_t));
    }
    
    nvs_commit(handle);
    nvs_close(handle);
    
    return ESP_OK;
}

6. Interfaz Web Moderna

La interfaz está construida como una SPA (Single Page Application) embebida en el ESP32:

<!-- network/www/index.html -->
<div class="container">
    <h1>🔐 ESP32 TOTP</h1>
    
    <!-- Formulario para agregar servicios -->
    <div class="card">
        <input id="uri-input" 
               placeholder="otpauth://totp/Service:user@email.com?secret=...">
        <button onclick="addService()">Agregar</button>
    </div>
    
    <!-- Lista de servicios -->
    <div id="services-list"></div>
    
    <!-- Vista de código -->
    <div class="code-display" id="code-value">------</div>
    <div class="progress-bar">
        <div class="progress-fill" id="progress"></div>
    </div>
</div>

<script>
async function addService() {
    const uri = document.getElementById('uri-input').value;
    
    const response = await fetch('/api/services', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uri: uri })
    });
    
    if (response.ok) {
        loadServices();
    }
}

async function updateCode() {
    const response = await fetch(`/api/code/${currentIndex}`);
    const data = await response.json();
    
    document.getElementById('code-value').textContent = 
        data.code.toString().padStart(6, '0');
    
    const progress = (data.remaining / 30) * 100;
    document.getElementById('progress').style.width = progress + '%';
}

setInterval(updateCode, 1000);
</script>

7. API REST

El servidor HTTP expone endpoints RESTful:

// network/server.c

// GET /api/services - Listar servicios
static esp_err_t api_services_get_handler(httpd_req_t *req) {
    char *json = totp_storage_list_json();
    httpd_resp_set_type(req, "application/json");
    httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
    free(json);
    return ESP_OK;
}

// POST /api/services - Agregar servicio
static esp_err_t api_services_post_handler(httpd_req_t *req) {
    char *buf = malloc(512);
    httpd_req_recv(req, buf, 512);
    
    cJSON *root = cJSON_Parse(buf);
    const char *uri = cJSON_GetObjectItem(root, "uri")->valuestring;
    
    totp_service_t service;
    totp_parse_uri(uri, &service);
    totp_storage_add(&service);
    
    cJSON_Delete(root);
    free(buf);
    
    httpd_resp_send(req, "{\"success\":true}", HTTPD_RESP_USE_STRLEN);
    return ESP_OK;
}

// GET /api/code/{index} - Obtener código TOTP
static esp_err_t api_code_get_handler(httpd_req_t *req) {
    int index = atoi(strrchr(req->uri, '/') + 1);
    
    totp_service_t service;
    totp_storage_get(index, &service);
    
    uint32_t code;
    totp_get_code(service.secret, service.digits, &code);
    uint32_t remaining = totp_get_remaining_seconds(service.period);
    
    char response[256];
    snprintf(response, sizeof(response),
        "{\"code\":%lu,\"remaining\":%lu,\"service\":\"%s\"}", 
        code, remaining, service.issuer);
    
    httpd_resp_send(req, response, HTTPD_RESP_USE_STRLEN);
    return ESP_OK;
}

🔒 Seguridad y Consideraciones

Ventajas

  • ✅ Genera códigos idénticos a Google Authenticator
  • Persistencia - los servicios sobreviven a reinicios
  • Código abierto - puedes auditar toda la seguridad

Limitaciones

  • ⚠️ Secrets guardados en NVS sin cifrado adicional
  • ⚠️ Red WiFi debe ser segura
  • ⚠️ No tiene autenticación en la interfaz web
  • ⚠️ Reloj depende de sincronización NTP

Mejoras Futuras

  • 🔄 Cifrado de secrets usando secure boot
  • 🔄 Autenticación web con login/password
  • 🔄 Backup/restore de servicios
  • 🔄 Escaneo de códigos QR con cámara
  • 🔄 Display físico para uso sin WiFi

📊 Rendimiento

  • Generación de código: ~5ms
  • Uso de RAM: ~40KB
  • Flash ocupado: ~830KB
  • Capacidad: hasta 20 servicios

🧪 Probándolo

Para probar con un servicio real:

  1. Ve a GitHub → Settings → Security → 2FA
  2. Selecciona “Configurar manualmente”
  3. Copia la URI completa: otpauth://totp/GitHub:user@...
  4. Pégala en la interfaz del ESP32
  5. Compara el código generado con Google Authenticator

¡Deberían ser idénticos!

💻 Código Fuente

El proyecto completo está disponible en GitHub:

👉 https://github.com/ggenzone/maker/esp32/05-TOTP

🎓 Lo Aprendido

Este proyecto me enseñó sobre:

  • Criptografía práctica: HMAC, hashing, códigos de un solo uso
  • Protocolos estándar: RFC 6238, RFC 4226
  • Tiempo real en embedded: Sincronización NTP, timestamps
  • Persistencia en flash: Uso de NVS en ESP32
  • Desarrollo web embebido: Server HTTP, APIs REST, SPAs
  • Debugging complejo: Códigos incorrectos por timezone o desfase de reloj

Referencias