[{"data":1,"prerenderedAt":588},["ShallowReactive",2],{"post-\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-garage-door-in-a-browser-tab":3},{"id":4,"title":5,"body":6,"categories":564,"date":568,"description":569,"extension":570,"image":571,"meta":572,"navigation":573,"path":574,"seo":575,"stem":576,"tags":577,"__hash__":587},"blog\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-garage-door-in-a-browser-tab.md","Hoermoles BlueObscure – My Garage Door in a Browser Tab",{"type":7,"value":8,"toc":554},"minimark",[9,39,47,60,65,72,81,84,175,179,188,203,207,210,225,260,263,267,293,381,384,387,418,422,425,449,453,464,476,480,483,494,497,504,511,518,522,537,540,550],[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,46],"p",{},[15,43,45],{"href":44},"\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-reverse-engineering-hoermann-bluesecur","Last time"," I dug the tunnel: I pulled the BlueSecur BLE protocol out of a .NET assembly blob, rebuilt it in Python, and made my garage door move from a terminal. Lovely for a mole with a laptop. Useless for the Molewife standing in the rain.",[40,48,49,50,54,55,59],{},"The CLI was always the scaffolding, not the building. What I actually wanted was the thing at the top of this post: a little app, honestly named ",[51,52,53],"strong",{},"Hoermoles BlueObscure",", that opens the burrow from a phone – no app store, no cloud account, no 9,99 € per key. So this time I burrowed ",[56,57,58],"em",{},"upward",", into the browser.",[61,62,64],"h2",{"id":63},"the-idea-a-door-key-that-is-just-a-web-page","The Idea: A Door Key That Is Just a Web Page",[40,66,67,68,71],{},"The whole app is a single static page that talks to the drive over ",[51,69,70],{},"Web Bluetooth"," – the browser API that lets JavaScript speak GATT directly. No native app, no Play Store review, no server in the loop. You open a URL, it installs to the home screen as a PWA, and after the first load it works fully offline. A door key has no business phoning home, so it doesn't:",[40,73,74,75],{},"You can find it here: ",[15,76,80],{"href":77,"rel":78},"https:\u002F\u002Fthe78mole.github.io\u002Fhoermoles-ble\u002Fapp\u002F",[79],"nofollow","Hoermoles BlueObscure App",[40,82,83],{},"The stack is deliberately thin, because thin is what you want for something holding a house key:",[85,86,87,103],"table",{},[88,89,90],"thead",{},[91,92,93,97,100],"tr",{},[94,95,96],"th",{},"Layer",[94,98,99],{},"Choice",[94,101,102],{},"Why",[104,105,106,118,134,150,164],"tbody",{},[91,107,108,112,115],{},[109,110,111],"td",{},"Framework",[109,113,114],{},"Svelte 5 + TypeScript",[109,116,117],{},"Compiles away – the shipped bundle is ~29 kB gzipped",[91,119,120,123,131],{},[109,121,122],{},"Build \u002F PWA",[109,124,125,126,130],{},"Vite + ",[127,128,129],"code",{},"vite-plugin-pwa"," (Workbox)",[109,132,133],{},"Precached app shell, offline, auto-update",[91,135,136,139,144],{},[109,137,138],{},"Protocol",[109,140,141],{},[127,142,143],{},"hoermoles-ble-js",[109,145,146,147],{},"A dependency-free TS port of my ",[127,148,149],{},"protocol.py",[91,151,152,155,161],{},[109,153,154],{},"Crypto",[109,156,157,158],{},"WebCrypto + a little ",[127,159,160],{},"BigInt",[109,162,163],{},"HMAC-SHA256 native; RSA hand-rolled (see below)",[91,165,166,169,172],{},[109,167,168],{},"Hosting",[109,170,171],{},"GitHub Pages",[109,173,174],{},"Static, free, and it never sees your key",[61,176,178],{"id":177},"porting-the-protocol-without-letting-it-drift","Porting the Protocol Without Letting It Drift",[40,180,181,182,184,185,187],{},"Back in part one I wrote ",[127,183,149],{}," with zero BLE or crypto dependencies on purpose – pure byte logic – precisely so the port to another language would be mechanical. That bet paid off. ",[127,186,143],{}," is the same envelope, the same signed-frame builder, the same notification reassembler, just in TypeScript.",[40,189,190,191,194,195,198,199,202],{},"The interesting part isn't the port, it's making sure it can never quietly disagree with the Python original. So the pytest suite exports its test vectors – frames, HMACs, notification payloads, QR parsing, root-key derivation – into a single ",[127,192,193],{},"shared\u002Ftest-vectors.json",", and ",[51,196,197],{},"both"," test suites run against it. The TS port cannot drift from the reference without CI going red. Same trick for the 60-odd device menu settings: they're generated from Python into ",[127,200,201],{},"shared\u002Fmenu-tables.json",", and CI fails if the committed copy goes stale. 106 tests on the JS side, byte-identical to Python, and no second source of truth to keep in sync by hand.",[61,204,206],{"id":205},"the-one-thing-webcrypto-refuses-to-do","The One Thing WebCrypto Refuses to Do",[40,208,209],{},"Everyday door commands are just HMAC-SHA256, which WebCrypto does natively (and can even keep the key non-extractable – more on that shortly). Pairing is where it got awkward.",[40,211,212,213,216,217,220,221,224],{},"Registering a drive needs to RSA-encrypt a 32-byte random key with the drive's public key, using ",[51,214,215],{},"RSAES-PKCS#1 v1.5",". WebCrypto flatly does not offer it. It'll do RSA-",[56,218,219],{},"OAEP"," encryption and PKCS#1 v1.5 ",[56,222,223],{},"signing",", but not the one padding scheme the drive actually wants. So I hand-rolled it, and it's smaller than the sulking deserves:",[226,227,228,240,247],"ol",{},[229,230,231,232,235,236,239],"li",{},"Import the public key as OAEP just to borrow the browser's DER parser, then export it as JWK to read out the modulus ",[127,233,234],{},"n"," and exponent ",[127,237,238],{},"e",". No hand-written ASN.1 walker.",[229,241,242,243,246],{},"Build the type-2 padded block by hand: ",[127,244,245],{},"0x00 || 0x02 || random-nonzero-padding || 0x00 || message",".",[229,248,249,252,253,255,256,259],{},[127,250,251],{},"c = mᵉ mod n"," via square-and-multiply on ",[127,254,160],{},". With ",[127,257,258],{},"e = 65537"," that's all of 17 iterations.",[40,261,262],{},"All three steps are unit-tested against generated RSA key pairs, so the sketchy-sounding \"wrote my own RSA\" is actually the well-behaved part. In principle that lets the app register a drive entirely on its own – point the camera at the QR sticker, done, no CLI involved. In principle, because that's the one path I haven't yet walked against real hardware from the browser – more on that at the end.",[61,264,266],{"id":265},"moving-the-house-key-around-safely","Moving the House Key Around Safely",[40,268,269,270,273,274,277,278,281,282,285,286,289,290,246],{},"The web app can't read ",[127,271,272],{},"~\u002F.hoermoles",", so credentials travel as a versioned, language-neutral bundle – one spec, implemented identically in ",[127,275,276],{},"bundle.py"," and ",[127,279,280],{},"bundle.ts",". Plaintext form is ",[127,283,284],{},"HMOLES1:\u003Cbase64url(json)>","; the encrypted variant ",[127,287,288],{},"HMOLES1E:…"," is AES-256-GCM with a PBKDF2 passphrase, both native to WebCrypto and to Python's ",[127,291,292],{},"cryptography",[294,295,300],"pre",{"className":296,"code":297,"language":298,"meta":299,"style":299},"language-bash shiki shiki-themes github-dark","hoermoles-ble export            # a QR code in the terminal – scan it with the app\nhoermoles-ble export --encrypt  # passphrase-protected, for anything that leaves the machine\nhoermoles-ble export --out drive.json   # a file to load in the app instead\nhoermoles-ble import \u003Cfile|text|->      # the reverse: a phone-registered drive back to the CLI\n","bash","",[127,301,302,318,332,348],{"__ignoreMap":299},[36,303,306,310,314],{"class":304,"line":305},"line",1,[36,307,309],{"class":308},"svObZ","hoermoles-ble",[36,311,313],{"class":312},"sU2Wk"," export",[36,315,317],{"class":316},"sAwPA","            # a QR code in the terminal – scan it with the app\n",[36,319,321,323,325,329],{"class":304,"line":320},2,[36,322,309],{"class":308},[36,324,313],{"class":312},[36,326,328],{"class":327},"sDLfK"," --encrypt",[36,330,331],{"class":316},"  # passphrase-protected, for anything that leaves the machine\n",[36,333,335,337,339,342,345],{"class":304,"line":334},3,[36,336,309],{"class":308},[36,338,313],{"class":312},[36,340,341],{"class":327}," --out",[36,343,344],{"class":312}," drive.json",[36,346,347],{"class":316},"   # a file to load in the app instead\n",[36,349,351,353,356,360,363,366,369,371,374,378],{"class":304,"line":350},4,[36,352,309],{"class":308},[36,354,355],{"class":312}," import",[36,357,359],{"class":358},"snl16"," \u003C",[36,361,362],{"class":312},"file",[36,364,365],{"class":358},"|",[36,367,368],{"class":308},"text",[36,370,365],{"class":358},[36,372,373],{"class":308},"-",[36,375,377],{"class":376},"s95oV",">      ",[36,379,380],{"class":316},"# the reverse: a phone-registered drive back to the CLI\n",[40,382,383],{},"That reverse direction is the point: a drive registered on the phone stays usable from the CLI and, later, from Home Assistant.",[40,385,386],{},"A root key opens a physical door, so it's treated like one:",[388,389,390,401,404,415],"ul",{},[229,391,392,393,396,397,400],{},"After import it's stored as a ",[51,394,395],{},"non-extractable"," HMAC ",[127,398,399],{},"CryptoKey"," in IndexedDB. A later XSS gets at most a signing oracle – useless without Bluetooth range to the actual door – never the key bytes themselves.",[229,402,403],{},"Re-exporting a credential needs an extractable copy, so that's an explicit opt-in toggle, not the default.",[229,405,406,407,410,411,414],{},"A ",[127,408,409],{},"connect-src 'none'"," CSP means the app has ",[51,412,413],{},"zero"," network access after load. The exfiltration channel isn't guarded; it's removed.",[229,416,417],{},"Plaintext QR export prints a loud warning. Encrypted is the documented default for anything shared.",[61,419,421],{"id":420},"where-the-browser-draws-the-line","Where the Browser Draws the Line",[40,423,424],{},"I'd be a poor mole if I dug a tunnel and pretended it had no dead ends. Web Bluetooth is genuinely limited, and the honest version of this app says so on its own front page:",[388,426,427,433,443],{},[229,428,429,432],{},[51,430,431],{},"iOS and Firefox are out, full stop."," Safari has no Web Bluetooth at all, and Firefox has no plans. This works on Chrome and Edge – Android, Windows, macOS, ChromeOS. On Linux desktop it additionally needs an experimental flag. That's not something an app-store screenshot can fix; it's the platform.",[229,434,435,438,439,442],{},[51,436,437],{},"No advertisement data."," Reading the drive's broadcast – gate status, opening percentage – is flag-gated, so the web app has no equivalent of the CLI's ",[127,440,441],{},"scan"," output. It can command the door but not (yet) watch it.",[229,444,445,448],{},[51,446,447],{},"No remembered device."," Persisting a paired drive across app starts is flag-gated too, so you pick it from the browser's Bluetooth chooser once per session. While the PWA stays resident, \"impulse\" is one tap; after a cold start it's two. The app lights up the one-tap path when the flag is present and degrades quietly when it isn't.",[61,450,452],{"id":451},"one-drive-one-app-choose-your-camp","One Drive, One App – Choose Your Camp",[40,454,455,456,459,460,463],{},"One warning before you get any ideas, because it caught even me off guard at first: your drive will not run both apps at once. The BlueSecur reset that lets Hoermoles pair (menu 19\u002F02, from part one) wipes ",[51,457,458],{},"every"," stored key – including the one your phone's official ",[51,461,462],{},"Hörmann BlueSecur"," app is holding. After it, the official app is locked out until you pair it again.",[40,465,466,467,469,470,469,473,475],{},"So it's genuinely one or the other: ",[51,468,53],{}," ",[56,471,472],{},"or",[51,474,462],{},", never side by side on the same drive. Switching back means another 19\u002F02 and re-pairing with whichever app you want in charge – and each switch costs the other side its key. Pick your camp before you reset, and keep that QR sticker somewhere safe either way, because you'll need it every time you change your mind.",[61,477,479],{"id":478},"it-works-and-the-one-path-i-havent-walked","It Works – and the One Path I Haven't Walked",[40,481,482],{},"Time for the honest update, because part two earns a much better ending than I dared write for it.",[40,484,485,486,489,490,493],{},"The gremlin I feared most was the timing window from part one: the drive disconnects roughly 100–150 ms after the first chunk, as if it grants a fixed budget for the ",[56,487,488],{},"whole"," message rather than a per-packet rate limit. Web Bluetooth serialises every GATT operation through the browser process and is measurably slower than ",[127,491,492],{},"bleak"," talking to BlueZ directly, so I fully expected a browser client to miss that window and die in the driveway.",[40,495,496],{},"It didn't. I loaded a credential exported from the CLI, tapped impulse, and the door moved – first try, no tuning. Everyday commands work beautifully from the phone. And key-sharing works just as cleanly: show the credential as a QR code on one phone, scan it on another, and the second phone opens the door too. Everything I was nervous about simply wasn't a problem.",[40,498,499],{},[500,501],"img",{"alt":502,"src":503},"Hoermoles BlueObscure running on a phone in dark mode: a \"Garagentor\" drive card with a large Impulse button and Open, Close, Light and More actions below it","\u002Fimages\u002Fblog\u002F2026\u002F07\u002FBlueObscure_Screenshot.jpg",[40,505,506,507,510],{},"There is exactly ",[51,508,509],{},"one"," path I haven't exercised through the app itself: the very first handshake, where a fresh drive negotiates a brand-new root key – the RSA-PKCS#1-v1.5 registration from earlier. I did that step with the Python CLI and then handed the finished credential to the app by QR. So the in-app registration code exists, it's unit-tested against generated key pairs, and its bytes match the reference – but it has never actually done the pairing dance with real hardware from inside a browser.",[40,512,513,514,517],{},"That's the one dark corner of the tunnel still unlit, and it's a nice one for a fellow mole to poke a torch into. If you've got a Hörmann BlueSecur drive, a Chrome or Edge phone, and a drive you're willing to factory-reset (menu 19\u002F02 – see ",[15,515,516],{"href":44},"part one","), I'd genuinely love to know whether in-app registration works end to end. Everything downstream of it already does.",[61,519,521],{"id":520},"whats-next-the-burrow-should-open-itself","What's Next: The Burrow Should Open Itself",[40,523,524,525,528,529,532,533,536],{},"A phone in the pocket is fine, but a proper burrow doesn't wait to be tapped – it notices you're home and lifts the door itself. That means Home Assistant. The whole point of exporting credentials as a language-neutral bundle was to feed exactly this: a ",[127,526,527],{},"cover"," entity that signs the same Signed frames from an ESP32 or a Bluetooth-equipped HA host, no phone in the loop at all. There's already a placeholder package waiting for it, and the plan is to ship it as a proper ",[51,530,531],{},"HACS"," integration so installing it is three clicks, not a ",[127,534,535],{},"custom_components"," copy job.",[40,538,539],{},"So the next dig heads back underground – from the browser tab down into the smart home. If the protocol port taught me anything, it's that the hard part is already behind us; the root key is the same house key wherever it lives. See you in part three.",[40,541,542,543,549],{},"Code, the protocol port, and the app all live at ",[51,544,545],{},[15,546,548],{"href":17,"rel":547},[79],"github.com\u002Fthe78mole\u002Fhoermoles-ble"," – MIT-licensed. Issues and \"it opened my door\" reports equally welcome.",[551,552,553],"style",{},"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 .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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}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);}",{"title":299,"searchDepth":320,"depth":320,"links":555},[556,557,558,559,560,561,562,563],{"id":63,"depth":320,"text":64},{"id":177,"depth":320,"text":178},{"id":205,"depth":320,"text":206},{"id":265,"depth":320,"text":266},{"id":420,"depth":320,"text":421},{"id":451,"depth":320,"text":452},{"id":478,"depth":320,"text":479},{"id":520,"depth":320,"text":521},[565,566,567],"Embedded","Smart Home","DIY","2026-07-24","The sequel to the reverse-engineering dig – porting the Hörmann BlueSecur protocol to Web Bluetooth and shipping an installable, offline, no-app-store PWA that opens my burrow from a browser tab.","md","\u002Fimages\u002Fblog\u002F2026\u002F07\u002Fhoermoles_ble_icon.jpeg",{},true,"\u002Fblog\u002F2026\u002Fhoermoles-blueobscure-garage-door-in-a-browser-tab",{"title":5,"description":569},"blog\u002F2026\u002Fhoermoles-blueobscure-garage-door-in-a-browser-tab",[578,579,580,581,582,583,584,585,586],"web-bluetooth","BLE","PWA","svelte","typescript","hoermann","bluesecur","garage-door","webcrypto","Mg7R0TWP_JUSd6UlSnBV4G3Fg4ErUe_mOLzeDjjVsVQ",1785011988592]