En la versión 6.12, de la que se tomó el registro del controlador, el código de Android no tenía nada parecido: no había sincronización de hora en segundo plano desde el teléfono del propietario. La hora salía solo como efecto secundario de un fire del propietario (fase 3), y ni así llegaba: el controlador cortaba la conexión justo después de disparar el relé.
En la 6.13 apareció una conexión de fondo aparte: en cada detección pasiva del controlador, el teléfono del propietario abre su propia sesión GATT y escribe TIME. Pero la única fuente de hora sigue siendo el propietario.
Si no, el cuadro es este: el propietario está de vacaciones, el controlador se queda sin corriente, arranca con rtc=0 y los invitados quedan fuera hasta que vuelva. Así no se construye.
A un invitado con un token válido hay que permitirle firmar TIME con su clave de invitado.
La idea es sencilla: para un invitado la hora solo puede avanzar. Del propietario nos fiamos: la mueve en cualquier sentido y sin límites, algo necesario si alguien puso por error el año 2099 y hay que revertirlo.
El controlador guarda la última hora conocida en la NVS (time_ratchet). Ante una escritura TIME de invitado:
new_time < ratchet → se rechaza como retroceso.new_time ≥ ratchet → se acepta, se avanza el trinquete y se escribe en la NVS.Ante una escritura TIME del propietario, el trinquete se sobrescribe sin más con su valor, en cualquier sentido.
Tras reiniciarse, el controlador arranca desde rtc = ratchet y no desde 0. El RTC sigue contando desde ahí. Un reinicio de fábrica borra también el trinquete, pero el emparejamiento le escribe enseguida la hora actual.
El invitado atacante quiere prolongar un token cuyo valid_until = X). ¿Qué puede intentar?
Opción 1: atrasar la hora (poner el reloj en ayer para que su token parezca fresco):
→ el trinquete no lo permite. Rechazado.
Opción 2: adelantar la hora (adelantar el reloj un año):
→ el controlador lo acepta, porque la hora avanzó de verdad.
→ PERO ahora el controlador compara valid_until=X con now = a year ahead → X < now → y su propio token ha caducado.
→ El atacante solo se ha perjudicado a sí mismo.
El único movimiento honesto de un invitado es poner la hora real. Cualquier falsificación se rechaza o se vuelve en su contra.
El controlador debe aceptar una escritura TIME firmada con:
A la carga útil de TIME se añaden dos bytes: bleId. Luego el controlador prueba:
Sin un paquete de invitado legítimo emitido por el servidor no se puede firmar TIME: el atacante no tiene la clave.
Si la última sincronización correcta fue hace menos de una hora, el controlador considera fresco su reloj y no ve motivo para actualizarlo porque sí. Dentro de esa ventana una escritura TIME se acepta solo si el cliente hizo un FIRE correcto en los últimos 10 segundos: prueba de que tiene un token válido y no caducado.
Si han pasado más de una hora la puerta del fire se desactiva y TIME se acepta sin más, siempre con el tope de +24 h para invitados.
Qué cierra esto: un atacante con un paquete robado pero caducado de invitado no puede hacer nada. Su FIRE se rechaza por caducidad, así que la escritura TIME dentro de la ventana fresca queda fuera de su alcance. Fuera de la ventana el desplazamiento sigue limitado a 24 horas y el propietario pone la hora correcta en su siguiente visita.
Si el propietario y tres invitados escriben horas distintas a la vez, cada escritura avanza el trinquete si es mayor o se rechaza si es menor. Aquí no hay disputa ni carrera.
Firmware del controlador:
TimeCallback: probar la verificación con owner_secret; si falla, tomar el bleId de la carga útil, derivar guest_key e intentarlo de nuevo.new_time ≥ time_ratchet.time_ratchet en la NVS y actualizarlo con cada TIME aceptado.settimeofday(ratchet), g_rtcValid = (ratchet > 0).Android:
BleCrypto.buildTimeSync — añadir el bleId y la elección de clave: owner_secret o guest_key.maybeSyncTime por analogía con la del propietario, disparada por el escaneo pasivo y no más de una vez cada 30 minutos.Después de eso:
Si el controlador está dentro del alcance del Wi-Fi, es más robusto sacar del todo la sincronización de hora por BLE de la ruta crítica y tomar la hora directamente de un servidor NTP.
Implementación en la firmware (NTP_ENABLED=1):
CHAR_WIFI — el propietario escribe ahí el SSID y la contraseña firmados. Se guardan en la NVS.configTime() contra pool.ntp.org, time.google.com o Cloudflare.NTP_RESYNC_INTERVAL_MS (6 horas por defecto).Con pilas, el controlador duerme casi todo el tiempo y no puede mantener el Wi-Fi. La solución: los despertares normales trabajan solo por BLE, en una ventana corta de unos 2,5 s, mientras que cada DEEP_SLEEP_NTP_EVERY_N_WAKESdespertar se alarga a 20 s y levanta el Wi-Fi con SNTP. Con wake=3 s y N=7200 la sincronización NTP ocurre cada 6 horas aproximadamente y el consumo sigue siendo aceptable.
Todos los parámetros de la política de hora, el sueño y NTP están en el bloque CONFIG al principio del sketch. Se pueden ajustar por instalación:
FRESH_SYNC_WINDOW = 1 h, GUEST_FWD_CAP = 24 h, DEEP_SLEEP = on, NTP_EVERY_N = 7200.FRESH_SYNC_WINDOW = 5 min, GUEST_FWD_CAP = 15 min, DEEP_SLEEP = off (alimentado de la red), NTP_ENABLED = 1.NTP_ENABLED = 0, todo lo demás solo por BLE.