<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>University on tiago mendes</title>
    <link>https://tiago.mendes.im/tags/university/</link>
    <description>Recent content in University on tiago mendes</description>
    <generator>Hugo -- 0.147.0</generator>
    <language>en-us</language>
    <lastBuildDate>Wed, 28 Jan 2026 10:00:00 +0000</lastBuildDate>
    <atom:link href="https://tiago.mendes.im/tags/university/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Building a secure BLE tree network</title>
      <link>https://tiago.mendes.im/posts/secure-ble-tree-network/</link>
      <pubDate>Wed, 28 Jan 2026 10:00:00 +0000</pubDate>
      <guid>https://tiago.mendes.im/posts/secure-ble-tree-network/</guid>
      <description>A walkthrough of the final project for my security class, a secure ad-hoc Bluetooth network over BLE with a tree topology, mutual auth, ECDH session keys, and DTLS-style end-to-end encryption</description>
      <content:encoded><![CDATA[<p>Last semester I took the Computer and Network Security course in my degree. The final project was this group thing where we had to build a secure ad-hoc network over Bluetooth, in a tree topology, that routes messages between nodes through a central sink, with proper crypto all the way down. Ended up being a ton of fun to work on, mostly because it touched so many different things at once: wireless transport, ad-hoc routing, PKI, key exchange, message integrity, end-to-end encryption, and a GUI to watch it all happen. Writing about it because there&rsquo;s more than enough interesting pieces in there to fill a post.</p>
<h1 id="the-rough-idea">The rough idea</h1>
<p>The setup is basically a tree. There&rsquo;s one <strong>Sink</strong> at the root and a bunch of <strong>Nodes</strong> hanging off it in layers. Each node forwards messages upward to the Sink, the Sink routes them down to wherever they need to go, and end-to-end everything is encrypted so intermediate nodes can&rsquo;t actually read the payloads they&rsquo;re carrying. The whole thing runs over <strong>Bluetooth Low Energy</strong>, using the Linux BlueZ stack underneath, and the nodes form the tree automatically by picking the best parent they can find.</p>
<p>The interesting bit for a security course is the layered protection: everything between two nodes is mutually authenticated with certificates, every hop is HMAC-protected against tampering, and end-to-end traffic gets its own AES-256-GCM layer so even intermediate nodes on the path only see routing headers, never content. Kind of like DTLS, but cobbled together over BLE GATT characteristics.</p>
<h1 id="why-ble-and-why-a-tree">Why BLE and why a tree</h1>
<p>BLE mostly because the spec asked for it, but also because it&rsquo;s actually pretty convenient: discovery and pairing primitives are built in, you get small broadcasts for advertising, and GATT gives you a clean request/response channel once connected. Tree topology because it&rsquo;s about the simplest routing structure that still lets arbitrary nodes talk through a central authority (the Sink), and because it limits how much state any one node has to carry.</p>
<p>Nodes pick a parent by scanning around, reading the hop count each candidate advertises, and going with the lowest one they can find. So if a node hears a neighbor that&rsquo;s 2 hops from the Sink, it&rsquo;ll prefer that one over a neighbor that&rsquo;s 4 hops away, and its own hop count becomes 3. Enough to get a working tree without any central coordinator.</p>
<p><img alt="Tree topology with Sink at hop=0 and nodes fanning out by hop count" loading="lazy" src="/i1/ble_tree_topology.svg">
<em>The tree forms on its own as nodes pick the lowest-hop parent they can see.</em></p>
<h1 id="dual-role-ble-aka-a-node-is-both-a-client-and-a-server">Dual-role BLE (aka &ldquo;a node is both a client and a server&rdquo;)</h1>
<p>Each node has to accept connections from children below it while also maintaining its own uplink to its parent, which means it needs to be a <strong>GATT server</strong> and a <strong>GATT client</strong> at the same time. Python doesn&rsquo;t really have one library that does both cleanly, so we mixed two:</p>
<ul>
<li><code>bleak</code> for the client side (uplink towards the parent)</li>
<li><code>dbus-python</code> + GLib for the server side (downlink from children)</li>
</ul>
<p>The GUI runs on top of that in Tkinter, so at runtime each node has three concurrent contexts going: <strong>GLib</strong> for D-Bus/BlueZ callbacks, <strong>asyncio</strong> for the Bleak client, and <strong>Tkinter</strong> for the UI. Keeping those three cooperating takes a bit of care, especially around anything touching shared state.</p>
<p><img alt="Dual-role node: asyncio/bleak uplink, GLib/dbus-python downlink, Tkinter GUI, all coordinating through shared state" loading="lazy" src="/i1/dual_role_architecture.png">
<em>One process, three concurrent contexts, one block of shared state holding them together.</em></p>
<h1 id="the-security-side">The security side</h1>
<p>This is the part the course was actually about.</p>
<h2 id="provisioning-and-pki">Provisioning and PKI</h2>
<p>Before anything runs, every device gets provisioned with its own credentials:</p>
<ul>
<li>A <strong>P-521 ECC keypair</strong></li>
<li>An <strong>X.509 certificate</strong> signed by a shared CA</li>
<li>A copy of the CA cert so it can verify others</li>
</ul>
<p>The device identity (a 128-bit NID) is embedded into the Subject Alternative Name of its certificate, so you can&rsquo;t just present any valid cert, the NID in the cert has to match the one the device is advertising over BLE.</p>
<h2 id="mutual-auth-on-pairing">Mutual auth on pairing</h2>
<p>When two nodes pair, both read each other&rsquo;s certificate off a known GATT characteristic, verify the signature against the CA public key, and check that the NID in the cert matches the one being advertised. If either check fails, the pairing just doesn&rsquo;t happen. Pretty standard mutual auth, but wiring it into GATT read/write flows in both directions is its own little adventure.</p>
<h2 id="session-keys-via-ephemeral-ecdh">Session keys via ephemeral ECDH</h2>
<p>Once both sides trust each other, they do an <strong>ephemeral ECDH</strong> handshake. Each generates a fresh P-521 keypair, they exchange public halves, compute the shared secret, and run it through <strong>HKDF-SHA256</strong> to derive a session key. Because the keypairs are ephemeral, every connection has its own session key, so past traffic stays safe even if a long-term key leaks later. That&rsquo;s basically <strong>Perfect Forward Secrecy</strong>, at least for the link layer.</p>
<p><img alt="Sequence diagram of mutual authentication followed by the ephemeral ECDH handshake" loading="lazy" src="/i1/mutual_auth_ecdh.png">
<em>Certs first, then ephemeral key exchange. The session key is derived locally on both sides and never actually travels on the wire.</em></p>
<h2 id="hop-by-hop-integrity">Hop-by-hop integrity</h2>
<p>Every message on the wire gets prepended with a sequence counter and an <strong>HMAC-SHA256</strong> computed with the session key, then the routing headers, then the payload. The receiver verifies the HMAC, checks the sequence counter is strictly greater than the last one it saw from that sender (that&rsquo;s the <strong>replay protection</strong>), and only then processes the message. This layer is always on, including for end-to-end encrypted traffic.</p>
<h2 id="end-to-end-dtls-style">End-to-end, DTLS-style</h2>
<p>Routing headers have to be readable by intermediate nodes (otherwise the Sink can&rsquo;t route anything), but the payload shouldn&rsquo;t be. So we added a second layer: the Node and Sink do their own mini-handshake (ClientHello / ServerHello with nonces), derive a <strong>separate end-to-end key</strong> via HKDF, and encrypt the payload with <strong>AES-256-GCM</strong> before handing it off. Intermediate hops see the headers, forward the encrypted blob, and that&rsquo;s the extent of what they can read.</p>
<p><img alt="Message layout: seq + HMAC (hop-by-hop), routing headers (plain), AES-GCM payload (E2E)" loading="lazy" src="/i1/ble_message_layout.svg">
<em>Outer layer is re-HMACed at every hop; the inner AES-GCM payload is only readable by the Node and the Sink.</em></p>
<h2 id="heartbeats-and-recovery">Heartbeats and recovery</h2>
<p>The Sink signs a heartbeat every 5 seconds with an incrementing counter. Nodes verify the signature, update their state, and forward it down to their children. If a node misses 3 heartbeats in a row, it assumes its uplink is dead, sets its own hop count to <code>-1</code>, broadcasts that to its children, who then recursively disconnect their own subtrees and go back into scanning mode to find a new parent. That cascading disconnect was fiddly to get right but is probably the most satisfying piece to watch in the GUI.</p>
<p><img alt="Cascading disconnect: 3 missed heartbeats trigger a hop=-1 broadcast that propagates down the subtree" loading="lazy" src="/i1/cascading_disconnect.png">
<em>A single broken uplink takes the whole subtree back to scanning mode, layer by layer.</em></p>
<h1 id="what-i-took-from-it">What I took from it</h1>
<p>A few things. Writing <strong>crypto glue code</strong> (as opposed to crypto primitives, which the <code>cryptography</code> library handles for you) is where the subtle bugs hide: off-by-one on the sequence counter, forgetting to HKDF the shared secret, passing the wrong nonce into GCM, stuff like that. Second, designing a protocol that works across unreliable wireless links forces you to take state recovery seriously in a way purely wired setups don&rsquo;t.</p>
<p>And third, honestly the single biggest challenge of the whole project was just working with the <strong>Bluetooth stack on Linux</strong>, because BLE and BlueZ have their fair share of quirks that you only really find out about by running into them. The D-Bus API surface changes subtly between BlueZ versions, GATT MTU negotiation doesn&rsquo;t always give you the size you asked for (so big writes get silently fragmented or rejected, don&rsquo;t even ask about the headache this was), pairing state sometimes lingers across reboots in ways that make you question reality, and switching roles between central and peripheral on the same adapter can leave <code>hci0</code> in weird states that only <code>hciconfig reset</code> fixes. The error messages are also pretty cryptic, so you end up spending a lot of time reading <code>dbus-monitor</code> output and cross-referencing source comments in BlueZ itself to figure out what&rsquo;s actually going on</p>
<p>If you want to poke at the code, it&rsquo;s on GitHub at <a href="https://github.com/tfdmendes/SIC-final-project">tfdmendes/SIC-final-project</a>. Happy to answer questions if anyone else is running into the same BLE-on-Linux potholes 😵‍💫</p>
<p>Anyway, turns out teaching a bunch of BLE nodes to trust each other is mostly an exercise in not getting state wrong :)</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
