In 6.12, the version the ESP log was taken from, the Android code had nothing of the sort — no background time sync from the owner's phone. Time went out only as a side effect of an owner fire (phase 3), and even then it did not arrive: the ESP dropped the connection right after the relay fired.
6.13 added a separate background connection: on every passive scan of the ESP, the owner's phone opens its own GATT session and writes TIME. But the owner is still the only source of time.
Otherwise the picture is this: the owner is on holiday, the ESP loses power, the controller reboots with rtc=0 — and guests are locked out until the owner returns. That is no way to build it.
A guest holding a valid token should be allowed to sign TIME with their guest key.
The idea is simple: for a guest, time can only move forward. The owner is trusted and may move it either way without limits — needed if someone set the year to 2099 by mistake and it has to be rolled back.
The ESP keeps the last known time in NVS (time_ratchet). On a guest TIME write:
new_time < ratchet → reject it as a rollback.new_time ≥ ratchet → accept it, advance the ratchet and write to NVS.On an owner TIME write the ratchet is simply overwritten with the owner's value, in either direction.
After a reboot the ESP starts from rtc = ratchet rather than 0. The RTC counts on from there. A factory reset clears the ratchet too, but pairing immediately writes the current time into it.
The attacking guest wants to extend a token whose valid_until = X). What can they try?
Option 1 — wind time back (set the clock to yesterday so their token looks fresh):
→ the ratchet blocks it. Refused.
Option 2 — wind time forward (set the clock a year ahead):
→ the ESP accepts it, since time genuinely moved forward.
→ BUT the ESP now compares valid_until=X with now = a year ahead → X < now → and their own token has expired.
→ The attacker has hurt only themselves.
The only honest move for a guest is to set the real current time. Any forgery is either rejected or backfires.
The ESP should accept a TIME write signed with:
Two bytes are added to the TIME payload: bleId. The ESP then tries:
Without a legitimate guest bundle issued through the server, TIME cannot be signed — the attacker has no key.
If the last successful sync was less than an hour ago, the ESP treats its clock as fresh and sees no reason to update it out of the blue. Inside that window a TIME write is accepted only if the client performed a successful FIRE within the last 10 seconds — proving it holds a valid token with an unexpired TTL.
If it has been more than an hour the fire gate is off and TIME is accepted freely, still capped at +24 h for guests.
What this closes off: an attacker with a stolen but expired guest bundle can do nothing. Their FIRE is rejected on TTL, so a TIME write inside the fresh window is out of reach. Outside that window the shift is still capped at 24 hours, and the owner sets the right time on their next visit.
If the owner and three guests write different times at once, each write either advances the ratchet, if it is greater, or is rejected, if it is smaller. There is no contention and no race here.
ESP firmware:
TimeCallback: try verifying with owner_secret; if that fails, take the bleId from the payload, derive guest_key and try again.new_time ≥ time_ratchet.time_ratchet in NVS and update it on every accepted TIME.settimeofday(ratchet), g_rtcValid = (ratchet > 0).Android:
BleCrypto.buildTimeSync — add the bleId and a choice of key, owner_secret or guest_key.maybeSyncTime by analogy with the owner's, driven by passive scanning and no more than once every 30 minutes.After that:
If the ESP sits within Wi-Fi range, it is more robust to take BLE time sync off the critical path entirely and get the time straight from an NTP server.
Firmware implementation (NTP_ENABLED=1):
CHAR_WIFI — the owner writes a signed SSID and password there. It is stored in NVS.configTime() against pool.ntp.org, time.google.com or Cloudflare.NTP_RESYNC_INTERVAL_MS (6 hours by default).On an AA-powered device the ESP sleeps most of the time and cannot hold Wi-Fi up. The answer: ordinary wake-ups run BLE only, in a short window of about 2.5 s, while every DEEP_SLEEP_NTP_EVERY_N_WAKES-th wake-up stretches to 20 s and brings up Wi-Fi with SNTP. At wake=3 s and N=7200 an NTP sync happens roughly every 6 hours, and the energy budget stays acceptable.
Every parameter of the time policy, sleep and NTP lives in the CONFIG block at the top of the sketch. They can be tuned per site:
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 (mains powered), NTP_ENABLED = 1.NTP_ENABLED = 0, everything else BLE-only.