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

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)