hmac-beacon-auth.md (11225B)
1 # Beacon authentication: the HMAC system 2 3 How the badge proves a beacon came from a crew member and not from a rando or a 4 spoofer in the (very hostile) DefCon RF soup. This is the load-bearing security 5 mechanism — everything else (peer table, finder sort, encounter log, points) 6 trusts that a beacon which *reaches* it has already been authenticated here. 7 8 Source of truth: [`firmware/src/beacon.h`](../firmware/src/beacon.h) (wire format 9 + sign/verify) and [`firmware/src/config.h`](../firmware/src/config.h) (the group 10 key + handle clamp). This doc explains the *why*; the code is the *what*. 11 12 > Threat model in one line: **friends vs. the con.** We are not defending against 13 > a nation-state with the group key — we are keeping outsiders and impersonators 14 > who *don't* have the key off everyone's screen. Pick that frame up before 15 > reading "what it doesn't stop" below. 16 17 --- 18 19 ## Why HMAC (and not a plain hash, or encryption) 20 21 A beacon is a tiny public announcement: *"handle `cereal_killer` is here."* The handle 22 isn't secret — it's literally meant to show up on other people's screens. So we 23 don't want **confidentiality**. We want **authenticity + integrity**: a receiver 24 must be able to tell that this beacon was minted by *a holder of the shared group 25 key* and wasn't altered in flight. 26 27 ``` 28 plain hash SHA256(message) ← anyone can compute it → anyone can forge 29 encryption AES(key, message) ← hides the handle we WANT public; wrong tool 30 keyed MAC HMAC-SHA256(key, message) ← unforgeable without the key ✓ this one 31 ``` 32 33 An **HMAC** (Hash-based Message Authentication Code) folds a secret key into the 34 hash so that only key-holders can produce a tag that verifies. Without the key you 35 can't compute the right tag for a message, and you can't tweak a signed message 36 without invalidating its tag. That's exactly "came from the crew, unmodified." 37 38 We use **HMAC-SHA256** because mbedTLS ships inside the ESP32 Arduino core 39 (`mbedtls/md.h`) — no extra dependency, hardware-accelerated SHA on the ESP32. 40 41 --- 42 43 ## The wire format 44 45 A `Beacon` is a fixed-size, `#pragma pack(1)` struct broadcast over ESP-NOW ~1×/sec 46 to the broadcast address on a fixed channel. **64 bytes** total (wire **v2**, which added 47 `pet` + `pet_face`; v1 badges won't verify a v2 beacon and vice-versa): 48 49 ``` 50 offset field size notes 51 ────── ─────────── ──── ───────────────────────────────────────────── 52 0 magic 1 0xB1 — cheap pre-filter, not security 53 1 version 1 0x02 — wire-format version (BEACON_VERSION) 54 2 counter 4 uint32, monotonic-ish freshness hint 55 6 handle_len 1 1..24 (HANDLE_MAX_LEN) 56 7 handle 24 zero-padded; the padding is signed too 57 31 pet 1 sender's pet index (==PET_BUILTIN_N means custom) 58 32 pet_face 16 sender's pet idle face, zero-padded (PET_FACE_MAX) 59 ┌───────────────────────────────────────────────┐ 60 │ signed region = bytes [0 .. 48) = 48 bytes │ ← offsetof(Beacon, tag) 61 └───────────────────────────────────────────────┘ 62 48 tag 16 HMAC-SHA256(PSK, signed_region) truncated to 128 bits 63 ────── ─── 64 64 65 ``` 66 67 The **signed region is everything before `tag`** (`beacon_signed_len()` = 68 `offsetof(Beacon, tag)` = 48 bytes). The tag is computed *over* those 48 bytes and 69 then appended. So `magic`, `version`, `counter`, `handle_len`, `pet`, `pet_face`, 70 **and the zero-padding of `handle`** are all authenticated — you can't tamper with any 71 of them without breaking the tag. 72 73 ESP-NOW's payload limit is 250 bytes; at 64 bytes the beacon has enormous 74 headroom (relevant to the Ed25519 upgrade path below). 75 76 --- 77 78 ## Signing (transmit path) 79 80 `beacon_sign()` ([beacon.h:49](../firmware/src/beacon.h)) — caller zero-pads 81 `handle` first, then: 82 83 ``` 84 full[32] = HMAC-SHA256(GROUP_PSK, beacon[0..48)) // mbedtls_md_hmac 85 tag[16] = full[0..16) // truncate to 128 bits 86 ``` 87 88 We keep the **first 128 bits** of the 256-bit HMAC. 128 bits of MAC is comfortably 89 beyond brute-forceable (an attacker gets one online guess per broadcast; there's no 90 offline oracle), and halving the tag saves 16 bytes on every packet. 91 92 ## Verifying (receive path) 93 94 `beacon_verify()` ([beacon.h:63](../firmware/src/beacon.h)) runs **cheap structural 95 checks first, the expensive crypto last**, and a receiver drops anything that fails 96 *before* it ever touches the peer table: 97 98 ``` 99 incoming bytes 100 │ 101 ├─ len < sizeof(Beacon)? → drop (runt / truncated) 102 ├─ magic != 0xB1? → drop (not ours / noise) ┐ cheap 103 ├─ version != 2? → drop (wrong wire version) │ pre- 104 ├─ handle_len ∉ [1..24]? → drop (malformed) ┘ filters 105 │ 106 ├─ full = HMAC-SHA256(PSK, bytes[0..48)) ← the work 107 └─ ct_equal(full[0..16), tag, 16)? → NO → drop (forgery / wrong key) 108 YES → accept → peer table 109 ``` 110 111 Ordering matters: the magic/version/length gates are one-byte comparisons that 112 reject the overwhelming majority of con-floor garbage for almost no CPU, so we only 113 pay for an HMAC computation on packets that are at least shaped like ours. The HMAC 114 is the actual trust boundary; the pre-filters are just there to make the common 115 "not even ours" case free. 116 117 ### Constant-time comparison 118 119 The tag compare is `ct_equal()` ([beacon.h:56](../firmware/src/beacon.h)), which 120 XOR-accumulates all 16 bytes and checks the result once at the end: 121 122 ```c 123 uint8_t diff = 0; 124 for (i = 0; i < n; i++) diff |= a[i] ^ b[i]; 125 return diff == 0; 126 ``` 127 128 A naive `memcmp` returns early on the first mismatching byte, so its runtime leaks 129 *how many leading bytes matched*. An attacker who can measure that timing could 130 forge a valid tag byte-by-byte (a classic MAC timing attack). The XOR-accumulate 131 form always touches all 16 bytes, so the timing is independent of where the 132 mismatch is. It's cheap insurance and the correct default for any MAC/tag compare. 133 134 --- 135 136 ## What this stops — and what it doesn't 137 138 **Stops (the friends-vs-the-con threat model):** 139 - **Outsiders / randos** — no group key → can't produce a verifying tag → silently 140 dropped, never rendered, never logged. 141 - **Spoofers** — can't forge "I'm `acid_burn`" without the key; can't replay a 142 captured beacon with the handle swapped (that breaks the tag). 143 - **Tampering / corruption** — any bit flip in the signed region fails verify. 144 - **Outsider floods** — unsigned garbage gets rejected at the cheap pre-filters or the 145 HMAC, before it can consume a slot in the bounded peer table (`MAX_PEERS`). *Caveat:* 146 this stops *keyless* floods only. A **key holder** can still fill the 32-slot table 147 with valid, signed beacons carrying distinct handles — the HMAC proves "a crew member 148 sent this," not that the handles are real. That's the same trust boundary as 149 friend-impersonates-friend. Received handles are length-checked in `beacon_verify` but 150 not charset-filtered, so a key holder can send arbitrary handle bytes. 151 152 **Does NOT stop (by design, accepted for v1):** 153 - **Friend-impersonates-friend.** The key is a *shared symmetric group* secret, so 154 *any* key-holder can mint a valid beacon for *any* handle. Authenticity is "a 155 crew member sent this," not "*this specific* crew member sent this." Fix = the 156 Ed25519 upgrade below. 157 - **A lost / stolen badge.** The PSK lives in flash (no secure-boot / flash- 158 encryption in v1), so a recovered badge yields the group key → the whole crew is 159 compromised until you **rekey everyone**. There's no per-badge revocation with a 160 shared key. 161 - **Replay, strictly.** `beacon_verify()` does **not** gate on `counter` — a 162 captured-and-rebroadcast beacon still verifies (it's a real, signed beacon). The 163 `counter` is a *freshness hint* — **not** a cryptographic anti-replay nonce. Note it 164 is currently **stored but not read**: `peers.upsert()` copies it into the peer slot 165 (`peers.h:43`) and nothing consumes it for freshness, dedup, or ordering. So it neither 166 defends against replay nor is wired into any peer-table logic today; it's a reserved 167 field the Ed25519 upgrade (or a real anti-replay scheme) could put to work. True 168 anti-replay (per-peer monotonic counters or timestamp binding enforced in verify) is a 169 noted non-goal for v1. For a presence beacon that re-broadcasts every second anyway, 170 replaying one is low-value. 171 172 This is the right amount of security for the job: it makes the screen trustworthy 173 against the con, without pretending to be a PKI. 174 175 --- 176 177 ## Upgrade path: per-badge Ed25519 + allowlist 178 179 When "friends can't impersonate *each other*" and "revoke a lost badge without 180 rekeying the crew" become worth the complexity, swap the symmetric PSK for 181 **asymmetric identities**: 182 183 ``` 184 each badge: Ed25519 keypair (private key stays on the badge) 185 each badge: carries an ALLOWLIST of the crew's public keys 186 beacon: { …, pubkey(32), Ed25519_signature(64) } 187 verify: pubkey ∈ allowlist? AND sig valid over the signed region? 188 ``` 189 190 - **Non-impersonation** — only the holder of `cereal_killer`'s private key can sign as 191 `cereal_killer`; another crew member's key signs as *them*. 192 - **Revocation** — drop a compromised badge's pubkey from everyone's allowlist; no 193 group rekey. 194 - **Payload budget** — a 64-byte sig + 32-byte pubkey + the 48-byte body ≈ 127 195 bytes, still well under ESP-NOW's 250-byte limit. 196 197 Cost: key distribution/management, slower verify (asymmetric vs. a single HMAC), 198 and bigger packets. Parked until the threat model demands it. 199 200 --- 201 202 ## Where to change things 203 204 - **The group key** — `GROUP_PSK[]` in [`config.h`](../firmware/src/config.h). 205 ⚠️ The repo ships a throwaway key; generate a real one (`openssl rand -hex 32`) 206 and keep it out of git before anything public. 207 - **Tag length** — `TAG_LEN` in [`beacon.h`](../firmware/src/beacon.h) (currently 208 16 = 128 bits). Both sign and verify read it, so changing it is one edit — but 209 every badge must agree. 210 - **Hash / MAC algorithm** — `MBEDTLS_MD_SHA256` in `beacon_hmac()`. Same "all 211 badges must agree" rule. 212 - **Handle clamp** — `HANDLE_MAX_LEN` (config.h); also enforced on the web 213 provisioning side before a handle is ever stored. Untrusted input is length- and 214 charset-clamped on both sides before it's signed or rendered. 215 216 --- 217 218 ## References 219 220 - [`firmware/src/beacon.h`](../firmware/src/beacon.h) — wire format, `beacon_sign`, 221 `beacon_verify`, `ct_equal` 222 - [`firmware/src/config.h`](../firmware/src/config.h) — `GROUP_PSK`, `HANDLE_MAX_LEN` 223 - [`README.md`](../README.md) — "Security model" summary (this doc is its expansion)