[{"data":1,"prerenderedAt":501},["ShallowReactive",2],{"post-\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-reverse-engineering-hoermann-bluesecur":3},{"id":4,"title":5,"body":6,"categories":477,"date":481,"description":482,"extension":483,"image":484,"meta":485,"navigation":486,"path":487,"seo":488,"stem":489,"tags":490,"__hash__":500},"blog\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-reverse-engineering-hoermann-bluesecur.md","Hoermoles BlueObscure – Digging a Second Tunnel Into My Own Garage",{"type":7,"value":8,"toc":470},"minimark",[9,39,48,51,54,59,127,138,161,171,175,178,206,213,222,225,239,243,246,256,266,277,284,290,293,320,323,327,337,429,436,439,443,453,456,466],[10,11,13,14],"div",{"style":12},"display: flex; justify-content: flex-end; margin: 0 0 1rem;","\n  ",[15,16,23,24,23,35,13],"a",{"href":17,"target":18,"rel":19,"style":22},"https:\u002F\u002Fgithub.com\u002Fthe78mole\u002Fhoermoles-ble","_blank",[20,21],"noopener","noreferrer","display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; color: inherit; font-size: 0.9rem; padding: 0.35rem 0.7rem; border: 1px solid #d0d7de; border-radius: 0.5rem;","\n    ",[25,26,31],"svg",{"height":27,"width":27,"viewBox":28,"fill":29,"ariaHidden":30},"18","0 0 16 16","currentColor","true",[32,33],"path",{"d":34},"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z",[36,37,38],"span",{},"the78mole\u002Fhoermoles-ble",[40,41,42,43,47],"p",{},"Every mole needs a proper entrance to the burrow, and mine got an upgrade: a Hörmann Supramatic Serie 4 mole hole cover drive with Bluetooth. Lovely bit of hardware. It came with an app called ",[44,45,46],"strong",{},"BlueSecur",", a QR-code sticker, and a business model.",[40,49,50],{},"The first key – the admin key that pairs your phone with the drive – is free. Every additional \"virtual access key\" after that costs 9,99 €. And the whole architecture assumes there will always be a phone with an app installed somewhere in the tunnel. My burrow does not work that way. My burrow runs Home Assistant, and eventually it should run a fingerprint reader next to the door, because typing a PIN with muddy paws is a design flaw.",[40,52,53],{},"So I did what moles do. I started digging. One evening later, the door was moving on my command.",[55,56,58],"h2",{"id":57},"the-stack-i-had-to-get-through","The Stack I Had to Get Through",[60,61,62,75],"table",{},[63,64,65],"thead",{},[66,67,68,72],"tr",{},[69,70,71],"th",{},"Layer",[69,73,74],{},"What I found",[76,77,78,91,103,111,119],"tbody",{},[66,79,80,84],{},[81,82,83],"td",{},"APK",[81,85,86,90],{},[87,88,89],"code",{},".xapk"," split bundle, version 26.1.3",[66,92,93,96],{},[81,94,95],{},"Runtime",[81,97,98,99,102],{},".NET MAUI \u002F Xamarin – so the code is ",[44,100,101],{},"not"," in the Smali",[66,104,105,108],{},[81,106,107],{},"Payload",[81,109,110],{},"428 .NET assemblies in one undocumented blob",[66,112,113,116],{},[81,114,115],{},"Transport",[81,117,118],{},"BLE GATT, three sub-protocols on one characteristic",[66,120,121,124],{},[81,122,123],{},"Crypto",[81,125,126],{},"RSA-2048 for pairing, HMAC-SHA256 for everyday commands",[40,128,129,130,133,134,137],{},"The first surprise came early. I ran ",[87,131,132],{},"apktool"," over the APK, got a nice pile of Smali, and found… almost nothing. No protocol, no crypto, barely a hint of Bluetooth. That is because the app is .NET, and .NET-for-Android stuffs every assembly into a single file called ",[87,135,136],{},"libassemblies.arm64-v8a.blob.so",".",[40,139,140,141,144,145,148,149,152,153,156,157,160],{},"That file is an \"Assembly Store v2\" container. It is internal, undocumented, and starts with the magic bytes ",[87,142,143],{},"XABA",". I found the actual layout in Microsoft's own ",[87,146,147],{},"dotnet\u002Fandroid"," repository – ",[87,150,151],{},"AssemblyStoreGenerator.cs"," describes header, index and descriptor arrays precisely enough to write a parser. Descriptor table said one thing, the header's ",[87,154,155],{},"index_size"," said another, so I brute-forced the offset backwards from a name table I could already validate. Out came 428 LZ4-compressed assemblies. ",[87,158,159],{},"ilspycmd"," turned the interesting ones back into readable C#.",[40,162,163,166,167,170],{},[87,164,165],{},"2_SAL.dll"," was the treasure chamber: the entire ",[87,168,169],{},"SAL.BlueConnect.*"," namespace. The complete Bluetooth protocol, in C#, with the original class and method names intact.",[55,172,174],{"id":173},"the-protocol-briefly","The Protocol, Briefly",[40,176,177],{},"One GATT service, one write characteristic, one notify characteristic. Three different protocols share that single write channel, distinguished by a routing byte at the front of every raw message:",[179,180,185],"pre",{"className":181,"code":182,"language":183,"meta":184,"style":184},"language-txt shiki shiki-themes github-dark","0x01  Signed        everyday commands, HMAC-signed\n0x02  Encrypted     one-time registration, RSA\n0x03  BlueControl   a different device profile, unused here\n","txt","",[87,186,187,194,200],{"__ignoreMap":184},[36,188,191],{"class":189,"line":190},"line",1,[36,192,193],{},"0x01  Signed        everyday commands, HMAC-signed\n",[36,195,197],{"class":189,"line":196},2,[36,198,199],{},"0x02  Encrypted     one-time registration, RSA\n",[36,201,203],{"class":189,"line":202},3,[36,204,205],{},"0x03  BlueControl   a different device profile, unused here\n",[40,207,208,209,212],{},"Everything is wrapped in ",[87,210,211],{},"[routing byte][length, LE16][payload]"," and chunked into fixed 20-byte pieces – hardcoded, regardless of the negotiated MTU. An everyday command like \"move the door\" is just:",[179,214,216],{"className":181,"code":215,"language":183,"meta":184,"style":184},"[RootId (2)] [Command-ID (2)] [Length (2)] [payload] [HMAC-SHA256 (32)]\n",[87,217,218],{"__ignoreMap":184},[36,219,220],{"class":189,"line":190},[36,221,215],{},[40,223,224],{},"where the signature covers the frame plus an 8-byte challenge the drive hands out with every notification. Channel 1 is the classic impulse toggle, channel 2 the light, 3 partial opening, 4 and 5 direct open\u002Fclose, 6 ventilation position. The mapping lives in a per-product table in the app and is identical across the whole Supramatic\u002FRollmatic\u002FSilentDrive family.",[40,226,227,228,230,231,234,235,238],{},"The QR code on the sticker turned out ",[44,229,101],{}," to be an X.509 certificate, despite what everyone (me included) assumed at first glance. It is a bare RSA-2048 public key in SPKI-DER format, Base64-encoded, with a 31-character numeric prefix in front carrying version, product class, serial number and customer ID. Pairing then works like this: I generate 32 random bytes, encrypt them with that public key (PKCS#1 v1.5, ",[232,233,101],"em",{}," OAEP), and send them over. The drive answers with 32 bytes of its own, and the real root key is the ",[44,236,237],{},"XOR of both",". A neat little one-time-pad handshake where my random bytes only ever served as transport protection for the device's.",[55,240,242],{"id":241},"where-i-wasted-the-most-time","Where I Wasted the Most Time",[40,244,245],{},"Two places, and neither was the protocol.",[40,247,248,251,252,255],{},[44,249,250],{},"Timing."," My first registration attempts died mid-transfer, so I did the obvious thing and slowed the transmission down. With a 150 ms pause between chunks the connection dropped after the ",[232,253,254],{},"first"," chunk. With 20 ms it survived about four. With no pause at all, all 16 chunks arrived cleanly. It is not a rate limit – it is a fixed time window of maybe 100–150 ms in which the entire message has to land. Sending politely was the problem.",[40,257,258,261,262,265],{},[44,259,260],{},"The actual wall."," For most of the evening the drive accepted all 13 packets of my pairing message and then said absolutely nothing. No ack, no error code, just a quiet disconnect. I reviewed the entire decompiled call chain from ViewModel down to the byte writes and found ",[232,263,264],{},"zero"," discrepancies with my implementation. The protocol had been correct the whole time.",[40,267,268,269,272,273,276],{},"The answer was hiding in ",[87,270,271],{},"DevicesService.TeachNewDeviceAsync",". Before every pairing attempt, the real app checks an ",[87,274,275],{},"AdminTeached"," bit that the drive broadcasts in its BLE advertisement, and refuses to even try if it is set. My tool had no such guard – it was cheerfully sending pairing requests to a drive that already had an admin. Namely my own phone, with the official app, still working perfectly. The drive had been ignoring me correctly and silently the entire time.",[40,278,279,280,283],{},"Cue menu 19, parameter 02 – which clears all Bluetooth pairings and nothing else. The drive keeps its limit positions, its forces, its whole personality; it just forgets every key it ever handed out, including my phone's, and goes back to \"first one to pair becomes admin\". Which failed twice, because the PRG button has to be ",[44,281,282],{},"held for five seconds"," to confirm. Press it briefly and the menu hops back to 19 looking exactly like success, having cleared precisely nothing. Third time, held until the fast blinking started, and it actually took.",[40,285,286,289],{},[44,287,288],{},"The next pairing attempt worked on the first try."," A real root key, issued by real hardware, stored in a real (and thoroughly gitignored) JSON file.",[40,291,292],{},"Then I typed:",[179,294,298],{"className":295,"code":296,"language":297,"meta":184,"style":184},"language-bash shiki shiki-themes github-dark","hoermoles-ble exec --address XX:XX:XX:XX:XX:XX impulse\n","bash",[87,299,300],{"__ignoreMap":184},[36,301,302,306,310,314,317],{"class":189,"line":190},[36,303,305],{"class":304},"svObZ","hoermoles-ble",[36,307,309],{"class":308},"sU2Wk"," exec",[36,311,313],{"class":312},"sDLfK"," --address",[36,315,316],{"class":308}," XX:XX:XX:XX:XX:XX",[36,318,319],{"class":308}," impulse\n",[40,321,322],{},"…and from the other side of the wall, my garage door started moving.",[55,324,326],{"id":325},"what-exists-now","What Exists Now",[40,328,329,330,336],{},"The result is ",[15,331,334],{"href":17,"rel":332},[333],"nofollow",[87,335,305],{},", MIT-licensed: a Python library plus a CLI wrapper.",[179,338,340],{"className":295,"code":339,"language":297,"meta":184,"style":184},"hoermoles-ble scan\nhoermoles-ble save-qr \"\u003Cqr code content>\"\nhoermoles-ble register --address \u003Cmac>\nhoermoles-ble exec --address \u003Cmac> open|close|impulse|light|partial|ventilation\n",[87,341,342,349,359,382],{"__ignoreMap":184},[36,343,344,346],{"class":189,"line":190},[36,345,305],{"class":304},[36,347,348],{"class":308}," scan\n",[36,350,351,353,356],{"class":189,"line":196},[36,352,305],{"class":304},[36,354,355],{"class":308}," save-qr",[36,357,358],{"class":308}," \"\u003Cqr code content>\"\n",[36,360,361,363,366,368,372,375,379],{"class":189,"line":202},[36,362,305],{"class":304},[36,364,365],{"class":308}," register",[36,367,313],{"class":312},[36,369,371],{"class":370},"snl16"," \u003C",[36,373,374],{"class":308},"ma",[36,376,378],{"class":377},"s95oV","c",[36,380,381],{"class":370},">\n",[36,383,385,387,389,391,393,395,397,400,403,406,409,411,414,416,419,421,424,426],{"class":189,"line":384},4,[36,386,305],{"class":304},[36,388,309],{"class":308},[36,390,313],{"class":312},[36,392,371],{"class":370},[36,394,374],{"class":308},[36,396,378],{"class":377},[36,398,399],{"class":370},">",[36,401,402],{"class":308}," open",[36,404,405],{"class":370},"|",[36,407,408],{"class":304},"close",[36,410,405],{"class":370},[36,412,413],{"class":304},"impulse",[36,415,405],{"class":370},[36,417,418],{"class":304},"light",[36,420,405],{"class":370},[36,422,423],{"class":304},"partial",[36,425,405],{"class":370},[36,427,428],{"class":304},"ventilation\n",[40,430,431,432,435],{},"The core module ",[87,433,434],{},"protocol.py"," is deliberately dependency-free – pure byte logic, no crypto library, no BLE stack. It is meant as a template for ports to C, C++ and TypeScript, because the fingerprint reader at the burrow entrance is not going to run Python.",[40,437,438],{},"One deliberate boundary: this project is about interoperability with hardware I own, not about getting around anyone's price list. The key-sharing paywall is enforced entirely client-side against in-app purchases, and the cloud key exchange needs no account at all – both of which I found and then walked away from. A second admin key can be derived locally over BLE without ever touching Hörmann's servers, and if I ever need one, that is the route I will take.",[55,440,442],{"id":441},"whats-next","What's Next",[40,444,445,446,449,450,452],{},"The obvious gap is the interface. A CLI is fine for a mole with a terminal, but not for the Molewife standing in the rain. So the next dig is ",[44,447,448],{},"Hoermoles BlueObscure"," – a single-page web app, honestly named after the thing it clones, doing over Web Bluetooth what the original does over a store listing. After that: the Home Assistant integration, and eventually those ports of ",[87,451,434],{}," down to bare metal.",[40,454,455],{},"The tunnel is dug. Now it needs a door handle.",[40,457,458,459,465],{},"Code, protocol notes and the CLI live at ",[44,460,461],{},[15,462,464],{"href":17,"rel":463},[333],"github.com\u002Fthe78mole\u002Fhoermoles-ble"," – MIT-licensed, pull requests and fellow diggers welcome.",[467,468,469],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}",{"title":184,"searchDepth":196,"depth":196,"links":471},[472,473,474,475,476],{"id":57,"depth":196,"text":58},{"id":173,"depth":196,"text":174},{"id":241,"depth":196,"text":242},{"id":325,"depth":196,"text":326},{"id":441,"depth":196,"text":442},[478,479,480],"Embedded","Smart Home","DIY","2026-07-23","How I reverse-engineered the Hörmann BlueSecur BLE protocol out of a .NET MAUI app and ended up opening my garage door from a laptop terminal.","md","\u002Fimages\u002Fblog\u002F2026\u002F07\u002FHoermann_SupraMatic_E4.jpg",{},true,"\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-reverse-engineering-hoermann-bluesecur",{"title":5,"description":482},"blog\u002F2026\u002Fhoermoles-blueobscure-reverse-engineering-hoermann-bluesecur",[491,492,493,494,495,496,497,498,499],"bluetooth","BLE","reverse-engineering","hoermann","bluesecur","garage-door","python","home-assistant","bleak","LjNcVqK75QJ41vdTfuX-himV9FgsAjyJXKvdE3EkYio",1784849119075]