onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

provisioning.md (16503B)


      1 # Provisioning: the web/BLE setup flow
      2 
      3 How a blank badge gets a handle. On first boot (or any time you force it) the badge
      4 comes up as a **BLE GATT peripheral**, a browser talks to it over **Web Bluetooth**,
      5 and you type the badge's name + a couple of optional extras. The write lands in NVS,
      6 the badge reboots, and from then on it lives in discovery mode broadcasting that
      7 handle.
      8 
      9 Source of truth:
     10 [`firmware/src/main.cpp`](../firmware/src/main.cpp) (the `PROVISIONING MODE` block +
     11 `setup()`), [`firmware/src/config.h`](../firmware/src/config.h) (UUIDs + clamps), and
     12 the companion app [`provisioning/index.html`](../provisioning/index.html). This doc is
     13 the *why*; the code is the *what*.
     14 
     15 > One radio, never shared. The ESP32 has a single 2.4 GHz radio. Provisioning runs
     16 > **BLE**; discovery runs **WiFi/ESP-NOW**. The firmware picks exactly one at boot so
     17 > the two never have to coexist. You provision, the badge reboots, and only then does
     18 > the ESP-NOW beacon start.
     19 
     20 ---
     21 
     22 ## The two modes, chosen at boot
     23 
     24 ```
     25   BOOT ─┬─ (no handle stored) OR (BOOT held) OR (one-shot "provreq" flag) ─► PROVISIONING (BLE)
     26         │        browser writes pairing code → handle (+ clock, + pet) → NVS → reboot
     27         └─ (handle stored, no override) ──────────────────────────────────► DISCOVERY (ESP-NOW)
     28 ```
     29 
     30 `setup()` resolves the mode. There are **three ways** to land in provisioning — a
     31 fresh badge, the BOOT button, or a one-shot NVS flag set from the on-device menu
     32 (`Options → Badge Settings → Developer Options → Provisioning Mode`) so you can re-seed
     33 without holding a button through a power cycle:
     34 
     35 ```cpp
     36 // firmware/src/main.cpp — setup()
     37 bool force_provision = (digitalRead(BUTTON_PIN) == LOW) ||       // BOOT held at power-on
     38                        (prefs.getUChar("provreq", 0) == 1);      // menu → Provisioning Mode
     39 prefs.putUChar("provreq", 0);                                    // consume the one-shot
     40 
     41 String stored   = prefs.getString("handle", "");
     42 bool have_handle = stored.length() > 0 && !force_provision;
     43 
     44 if (!have_handle) { mode = MODE_PROVISION; start_provisioning(); }
     45 else              { mode = MODE_DISCOVER;  start_discovery();    }
     46 ```
     47 
     48 ---
     49 
     50 ## The GATT layout
     51 
     52 One custom service, five characteristics — four **write**, plus one **read/notify**
     53 metrics stream. The UUIDs are shared between firmware and browser —
     54 they **must** match `config.h` and `index.html` or the browser can't find them.
     55 
     56 ```
     57   service  6e8f0001-…            "badge-setup" provisioning service
     58     ├─ 0006  CODE   (write)      3-char pairing code   ── GATES everything below
     59     ├─ 0002  HANDLE (write)      the name your crew sees
     60     ├─ 0003  TIME   (write)      epoch secs, u32 LE — seed the soft-clock
     61     ├─ 0004  PET    (write)      custom-avatar blob (name + 5 faces)
     62     └─ 0005  METRICS(read/notify) framed CSV/TSV dump — pairing-code gated (live, v1.7)
     63 ```
     64 
     65 ```cpp
     66 // firmware/src/config.h
     67 #define PROV_SERVICE_UUID    "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"
     68 #define PROV_CHAR_UUID       "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"  // handle (write)
     69 #define PROV_TIME_CHAR_UUID  "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"  // epoch secs, u32 LE (write)
     70 #define PROV_PET_CHAR_UUID   "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"  // custom pet blob (write)
     71 #define PROV_CODE_CHAR_UUID  "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"  // 3-char code — GATES the others
     72 ```
     73 
     74 `start_provisioning()` stands the whole thing up with NimBLE — create the server,
     75 create the service, attach a callback object per characteristic:
     76 
     77 ```cpp
     78 // firmware/src/main.cpp — start_provisioning()
     79 NimBLEServer*  server = NimBLEDevice::createServer();
     80 server->setCallbacks(new ServerCB());
     81 NimBLEService* svc = server->createService(PROV_SERVICE_UUID);
     82 
     83 svc->createCharacteristic(PROV_CODE_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new CodeWriteCB());
     84 svc->createCharacteristic(PROV_CHAR_UUID,      NIMBLE_PROPERTY::WRITE)->setCallbacks(new HandleWriteCB());
     85 svc->createCharacteristic(PROV_TIME_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new TimeWriteCB());
     86 svc->createCharacteristic(PROV_PET_CHAR_UUID,  NIMBLE_PROPERTY::WRITE)->setCallbacks(new PetWriteCB());
     87 svc->start();
     88 ```
     89 
     90 ---
     91 
     92 ## The pairing-code gate (physical presence, not crypto)
     93 
     94 BLE writes are cleartext, so anyone in range could otherwise scribble a handle onto
     95 any badge in setup mode. The defense is a **fresh 3-char code** shown on the badge's
     96 own screen each session. The browser must write that code first; until it matches,
     97 every other write is dropped on the floor.
     98 
     99 ```
    100    ┌─────────────┐        code shown on OLED         ┌──────────┐
    101    │  badge OLED  │  ── "code: K7Q" ────────────────►│  human   │
    102    │  SETUP MODE  │                                   │  eyeball │
    103    └─────────────┘                                   └────┬─────┘
    104                                                           │ retypes K7Q
    105                                                           ▼
    106    handle / time / pet writes  ◄── unlocked only after ── CODE write matches
    107 ```
    108 
    109 The code is drawn from an **unambiguous uppercase alphabet** (no `0/O/1/I/L`) so it's
    110 easy to read off a tiny mono OLED and retype:
    111 
    112 ```cpp
    113 // firmware/src/main.cpp — a fresh code per provisioning session
    114 static void gen_prov_code(char out[4]) {
    115   static const char A[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";   // 30 glyphs
    116   for (int i = 0; i < 3; i++) out[i] = A[esp_random() % (sizeof(A) - 1)];
    117   out[3] = '\0';
    118 }
    119 ```
    120 
    121 The gate is a single `volatile bool`. Only an exact (case-insensitive) match flips it:
    122 
    123 ```cpp
    124 // firmware/src/main.cpp — CodeWriteCB::onWrite
    125 char in[4] = {0};
    126 for (uint8_t i = 0; i < v.length() && i < 3; i++) {
    127   char ch = (char)v.data()[i];
    128   in[i] = (ch >= 'a' && ch <= 'z') ? (char)(ch - 32) : ch;   // uppercase
    129 }
    130 g_prov_authed = (strcmp(in, g_prov_code) == 0);
    131 ```
    132 
    133 Every other callback opens with the same guard, so an unauthorized write is a no-op:
    134 
    135 ```cpp
    136 // firmware/src/main.cpp — HandleWriteCB::onWrite (TimeWriteCB / PetWriteCB are identical)
    137 void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override {
    138   if (!g_prov_authed) { Serial.println("[PROV] handle write ignored — pairing code not accepted"); return; }
    139   NimBLEAttValue v = c->getValue();
    140   char clean[HANDLE_MAX_LEN + 1];
    141   uint8_t len = sanitize_handle(v.data(), v.length(), clean);
    142   if (len == 0) return;                       // ignore empty/garbage writes
    143   prefs.putString("handle", clean);
    144   g_saved = true;                             // loop() will reboot us
    145 }
    146 ```
    147 
    148 And the auth flag resets on every disconnect, so a new connection must re-send the
    149 code — you can't authorize once and let a second client ride the same session:
    150 
    151 ```cpp
    152 // firmware/src/main.cpp — ServerCB
    153 void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override {
    154   g_prov_authed = false;                      // each new connection must re-send the code
    155   NimBLEDevice::startAdvertising();           // allow repeated attempts
    156 }
    157 ```
    158 
    159 > **What it is / isn't.** The code proves *line-of-sight to the screen* — a
    160 > physical-presence gate against drive-by BLE provisioning. It is **not** crypto:
    161 > BLE writes are still cleartext, so someone who can *see your screen* (or shoulder-surf
    162 > the code) could provision your badge. That's an acceptable threat model for a con
    163 > badge — the real beacon-authenticity guarantees live in the HMAC layer
    164 > ([`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md)), not here.
    165 >
    166 > **What the code protects.** The read/notify metrics stream (above) is behind the same
    167 > code, so a matched code also dumps the owner's **encounter log**, not just the ability to
    168 > rename the badge. Guessing is bounded: repeated wrong codes latch the gate shut until a
    169 > reboot, which needs physical access. The 3-char code (27,000 keyspace) proves
    170 > *presence*, not secrecy.
    171 
    172 ---
    173 
    174 ## Advertising: why the name lives in the scan response
    175 
    176 The badge advertises as `badge-setup-<full 6-byte MAC>` (e.g.
    177 `badge-setup-14335C591858`) so multiple badges in setup mode are distinguishable in
    178 the browser's device picker and map 1:1 to the bench roster (which dedups by MAC).
    179 
    180 That 24-char name is the catch. A legacy BLE advertisement is only 31 bytes, and the
    181 128-bit service UUID (18 B) + flags (3 B) already eat 21 of them — the name won't
    182 fit alongside. So the firmware builds **both packets explicitly**: the primary
    183 advertisement carries flags + the service UUID (so the browser's service filter can
    184 find it), and the **scan response** carries the full name *alone* in its own 31 B:
    185 
    186 ```cpp
    187 // firmware/src/main.cpp — start_provisioning()
    188 NimBLEAdvertisementData advData;                 // PRIMARY: flags + service filter
    189 advData.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP);
    190 advData.addServiceUUID(PROV_SERVICE_UUID);
    191 adv->setAdvertisementData(advData);
    192 
    193 NimBLEAdvertisementData scanData;                // SCAN RESPONSE: full name, alone
    194 scanData.setName(prov_name);
    195 adv->setScanResponseData(scanData);
    196 adv->enableScanResponse(true);
    197 adv->start();
    198 ```
    199 
    200 Letting NimBLE auto-route the name truncated it to an 11-char *Shortened Local Name*
    201 (`badge-setup`), which killed the per-badge MAC suffix — hence the manual split.
    202 
    203 ---
    204 
    205 ## The browser side (Web Bluetooth)
    206 
    207 The companion app is a single static [`provisioning/index.html`](../provisioning/index.html)
    208 — no build step, no dependencies. It needs **Chrome or Edge** (Web Bluetooth isn't
    209 in Firefox/Safari) served over **https or localhost** (Web Bluetooth is a secure
    210 context). Its UUID constants mirror `config.h`:
    211 
    212 ```js
    213 // provisioning/index.html — must match firmware/src/config.h
    214 const SERVICE_UUID = "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e";
    215 const CHAR_UUID    = "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e";   // handle
    216 const TIME_UUID    = "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e";   // clock sync
    217 const PET_UUID     = "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e";   // custom pet
    218 const CODE_UUID    = "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e";   // pairing code (gates the others)
    219 ```
    220 
    221 **Connect** — `requestDevice` pops the OS chooser, filtered to our service and
    222 name prefix, then resolves the primary service:
    223 
    224 ```js
    225 // provisioning/index.html — connect()
    226 const device = await navigator.bluetooth.requestDevice({
    227   filters: [{ services: [SERVICE_UUID] }, { namePrefix: DEVICE_NAME }],
    228   optionalServices: [SERVICE_UUID]
    229 });
    230 const server = await device.gatt.connect();
    231 return { device, service: await server.getPrimaryService(SERVICE_UUID) };
    232 ```
    233 
    234 **Code first, every time** — because the badge resets its auth flag on each new
    235 connection, the app writes the code immediately after connecting for *every* action
    236 (save / sync / pet):
    237 
    238 ```js
    239 // provisioning/index.html — writeCode()
    240 const code = ($("code").value || "").trim().toUpperCase();
    241 if (code.length !== 3) throw new Error("enter the 3-char pairing code shown on the badge");
    242 const cch = await service.getCharacteristic(CODE_UUID);
    243 await cch.writeValue(new TextEncoder().encode(code));
    244 ```
    245 
    246 **The main flow** — connect → code → handle → (best-effort) clock → disconnect:
    247 
    248 ```js
    249 // provisioning/index.html — provision()
    250 const { device, service } = await connect();
    251 await writeCode(service);                                    // unlock
    252 const ch = await service.getCharacteristic(CHAR_UUID);
    253 await ch.writeValue(new TextEncoder().encode(handle).slice(0, MAX_LEN));
    254 try { await writeTime(service); } catch (_) { /* time char optional */ }
    255 device.gatt.disconnect();
    256 ```
    257 
    258 The handle input is clamped to printable ASCII and 24 bytes **in the browser**, and
    259 the firmware re-clamps with `sanitize_handle()` on arrival — never trust the client.
    260 
    261 ---
    262 
    263 ## The optional writes
    264 
    265 **Clock sync** — the phone/laptop knows real wall-clock time; the badge doesn't. The
    266 app writes the current epoch as a little-endian `u32` so the con report can show real
    267 timestamps. There's also a **`sync clock only`** button that skips the handle write
    268 entirely (re-sync after a battery swap without renaming):
    269 
    270 ```js
    271 // provisioning/index.html — writeTime()
    272 const tb = new Uint8Array(4);
    273 new DataView(tb.buffer).setUint32(0, Math.floor(Date.now() / 1000), true);   // LE u32 epoch
    274 await (await service.getCharacteristic(TIME_UUID)).writeValue(tb);
    275 ```
    276 
    277 ```cpp
    278 // firmware/src/main.cpp — TimeWriteCB::onWrite (guard omitted)
    279 uint32_t epoch = (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
    280                  ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
    281 g_clock.set(epoch);
    282 // re-sync on an already-named badge reboots to home; first-time setup waits for the handle write
    283 if (prefs.getString("handle", "").length() > 0) g_saved = true;
    284 ```
    285 
    286 **Custom pet** — an optional blob of `name` + 5 state faces (idle / contact /
    287 milestone / lonely / low-batt), newline-separated. The browser joins the fields; the
    288 firmware splits on `\n`, clamps each field to printable ASCII + its max length, stores
    289 it to NVS slot `PET_BUILTIN_N`, and auto-selects it. The pet model itself is in
    290 `firmware/src/pet.h`.
    291 
    292 ```js
    293 // provisioning/index.html — writePet()
    294 const blob = [f("pet_name"), f("pet_idle"), f("pet_contact"),
    295               f("pet_milestone"), f("pet_lonely"), f("pet_lowbatt")].join("\n");
    296 await (await service.getCharacteristic(PET_UUID)).writeValue(new TextEncoder().encode(blob));
    297 ```
    298 
    299 ---
    300 
    301 ## Commit: the reboot handshake
    302 
    303 Callbacks run on the BLE stack task; they don't reboot inline. Instead the write that
    304 completes provisioning sets a `volatile bool g_saved`, and the main `loop()` — which
    305 in provisioning mode does nothing but poll that flag — performs the restart. That
    306 guarantees the NVS write is flushed and the BLE client sees its write-response before
    307 the radio drops:
    308 
    309 ```cpp
    310 // firmware/src/main.cpp — loop()
    311 if (mode == MODE_PROVISION) {
    312   if (g_saved) {
    313     display_boot("saved — rebooting to home");
    314     delay(800);
    315     ESP.restart();                    // comes back up in DISCOVERY with the new handle
    316   }
    317   delay(50);
    318   return;
    319 }
    320 ```
    321 
    322 Full round-trip:
    323 
    324 ```
    325   browser                         badge (BLE peripheral)
    326   ───────                         ──────────────────────
    327   requestDevice / connect  ──────►  advertising as badge-setup-<MAC>
    328   write CODE "K7Q"         ──────►  CodeWriteCB → g_prov_authed = true
    329   write HANDLE "crash…"    ──────►  HandleWriteCB → sanitize → NVS → g_saved = true
    330   write TIME <epoch>       ──────►  TimeWriteCB → g_clock.set()
    331   gatt.disconnect()        ──────►  ServerCB::onDisconnect → g_prov_authed = false
    332                                     loop() sees g_saved → ESP.restart()
    333                                     ↳ reboots into DISCOVERY, beaconing "crash…"
    334 ```
    335 
    336 ---
    337 
    338 ## Onboard metrics export
    339 
    340 The fifth characteristic (`PROV_METRICS_CHAR_UUID`, read/notify) streams a framed
    341 CSV/TSV dump to the browser: the **encounter log** (`/encounters.log`) + **points
    342 history** (`/points_history.csv`) + the **best-friends ledger** (`/best_friends.csv`),
    343 plus the Test-Mode CSVs (`/test_batt.csv`, `/test_pts.csv`) when Debug is on. It is
    344 **gated by the pairing code** — the badge won't stream until the code matches — chunked
    345 to the negotiated MTU, and the page reassembles the notify chunks up to an
    346 `==== EOF ====` sentinel, then the badge auto-reboots to discovery a few seconds later.
    347 
    348 Firmware: `stream_metrics()` in `main.cpp` (runs from `loop()`, never a BLE callback);
    349 served by the characteristic created in `start_provisioning()`. Browser:
    350 `fetchMetricsOverBle()` in `index.html` renders inline-SVG charts (friends ranking ·
    351 best friends · points over time) and offers **PDF** (print-to-PDF) and re-importable
    352 **JSON** export, all from one in-memory `g_data`.
    353 
    354 > **Export regularly over a long con:** the encounter log is size-capped
    355 > (`ENC_LOG_MAX_BYTES` in `config.h`) and trims its oldest entries once it's full, so a
    356 > daily JSON export keeps the whole history.
    357 
    358 ---
    359 
    360 ## Related docs
    361 
    362 - [`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md) — beacon authenticity in discovery mode (the actual crypto)
    363 - `firmware/src/clock.h` — the soft-clock the TIME write seeds
    364 - `firmware/src/pet.h` — the custom-pet model the PET write feeds