-
One fingerprint key, two computers
08/04/2026 at 17:17 • 0 commentsThe most common thing people told us after the first firmware shipped was simple: they have two computers. A work Mac and a personal one. A Mac on the desk, a Linux laptop in the bag. Nobody wants to buy and enroll a second key for the second machine.
So we added dual-host. One key, paired to two computers, switched with a fingerprint. It shipped in firmware 1.7.0. Here’s how it works.
Why one key can’t just talk to both
immurok connects as a bonded Bluetooth device. That bond is what lets the key sit in your pocket and reconnect the moment you sit down, with nothing to approve. It’s also 1-to-1: one bond, one computer. It’s the same reason your phone sometimes can’t decide which speaker to join.
We could have made the key answer to anyone, but that’s the wrong default for an authenticator. Its whole job is proving you’re there. So each computer’s trust had to stay separate.
Two slots, two identities
The trick is that the key presents itself as two different devices. Inside there are two slots, each with its own Bluetooth address and its own bond. When it’s serving the first computer, it shows one identity. Switch to the second, and it shows another. Each computer just sees “my immurok key” and reconnects. Neither one knows the other is there.
![]()
The macOS app’s Two Hosts panel: both slots bound, each its own identity, with a Switch Host fingerprint sitting next to your regular ones.
Switching with a fingerprint
Something has to flip between the two. We didn’t want a button combo or a toggle buried in an app, so switching is just a fingerprint. You enroll a switch finger, and pressing it hands the device to the other computer, which reconnects on its own. Same gesture as everything else, different finger.
Adding the second computer, safely
This was the hard part. Adding a host to a key that lives in your pocket is sensitive. If the key gets lost or borrowed for five minutes, pairing a new computer to it must not be something a stranger can do.
We first tried a temporary PIN, shown on one computer and typed into the other. Then we threw it out. It added a screen, a code to copy, and it was visible to anyone looking over your shoulder.
What we shipped needs two physical steps and nothing to type. First you touch a fingerprint the key already knows, to prove you’re the owner. Then you press the button on the device, to prove you’re holding it right now. Both happen in your hand. A found key does nothing without both.
Leaving without losing the other machine
Say you sell the personal Mac but keep the work one. You want that host gone for good, so the sold machine can never quietly reconnect, without touching the computer you’re keeping.
Removing a host clears its slot and gives it a fresh Bluetooth address. The old computer, even with its saved bond, simply never sees the key again. The slot you kept stays exactly as it was: same address, same keys, no re-pairing. And it doesn’t wipe the whole key. Being done with one computer shouldn’t cost you the other one or your fingerprints.
The limits, honestly
Two hosts, not ten. That’s on purpose. Two covers the common case (work and personal, desktop and laptop, Mac and Linux) and keeps the security easy to reason about. As with everything on the device, your fingerprint never leaves the key. Both computers only ever get a yes or no.
Try it
Dual-host is live in firmware 1.7.0, on both the macOS app and the Linux companion.
![]()
The Linux companion carries the same model into the terminal: a Hosts panel under
H, and the switch finger on the fingerprint row.It’s all on GitHub if you want to read the code. One key, two computers, one finger to move between them.
-
The trial production of 50 units is ready to commence
07/15/2026 at 04:07 • 0 commentsFinally, we started the trail production.
![]()
-
One touch, three paths: how immurok authenticates on macOS
07/08/2026 at 14:20 • 0 commentsmacOS has no single API you can plug a fingerprint into. immurok meets each authentication surface with the right mechanism — PAM, credential injection, or a GUI bridge — and keeps a hard security boundary around each. Here's the map, and an honest risk analysis.
There is no one place in macOS where you can say “a fingerprint touch counts as the password.”sudogoes through PAM. The App Store shows its own sheet. Screen unlock is a special world the system guards tightly. “Sign in with Apple” is rendered by a separate helper process. Each surface expects a different kind of proof.So immurok doesn’t have one trick — it has three, and it picks the right one per surface. The unifying principle underneath is simple: the device only ever proves that a real, authenticated touch happened; the host decides what that touch is allowed to unlock. (How the device proves it — ECDH pairing, HMAC-signed events, biometrics that never leave the sensor — is the subject of the immurok security model. This post is about the host side.)
Path 1 — PAM + BLE: the device proves it
sudo, SSH key signing, and the system’s GUI authorization prompts all run a PAM stack. immurok ships a tiny module,pam_immurok.so, that sits at the front of the relevant/etc/pam.dservices. When one of them authenticates, the module opens a Unix socket to the companion app, which asks your paired device for a challenge-response over BLE. Touch the sensor, the device returns a signed OK, PAM succeeds.The important part: no password is involved anywhere. The credential is the cryptographically signed touch. There is nothing to store and nothing to type.
Path 2 — Credential injection: the host fills the field
The App Store, the Passwords app, and Safari’s “Sign in with Apple” sheet don’t go through PAM. They put a real secure text field on screen and wait for you to type. immurok watches the system’s focused accessibility element; when a whitelisted app focuses an
AXSecureTextField, immurok writes the stored secret straight into it through the accessibility API and presses the sheet’s confirm button.Two subtleties make this safe and possible:
- It writes the value (
kAXValue), it doesn’t synthesize keystrokes — so macOS’s Secure Event Input, which blocks fake typing into password fields, doesn’t apply. - The secret it writes lives in the Keychain (your login password, or your Apple ID password), never on the device and never over BLE.
Path 3 — The GUI bridge: host submits, PAM proves
Some GUI dialogs (the standalone SecurityAgent window) do run PAM, but only after a human clicks a button. immurok bridges the gap: on a touch it arms a short pre-authorization window scoped to the
authorizationservice, then submits the field so the PAM chain runs and the pre-auth answers it. Screen unlock is the extreme case — PAM can’t dismiss a locked screen at all — so there the device simply types your password over its BLE HID keyboard.Where the credential lives
The three paths differ most in one thing that matters for security: whether a real secret sits on your Mac at all.
The PAM path stores nothing — that’s its great property. The injection path buys convenience on surfaces PAM can’t reach, at the cost of keeping an actual password on the machine. immurok narrows that cost deliberately: secrets go in the Keychain, are captured only when you enable the feature, and are wiped the moment you turn it off.
Honest risk analysis
- Injection leans on an OS behavior Apple could restrict. Writing a value into a secure field via accessibility is allowed today; a future macOS could lock it down. That’s why the device keeps a keyboard: HID typing is the fallback, and the pure-PAM paths don’t depend on it at all.
- The code-signature check is the anti-spoofing anchor. Matching bundle id alone would be forgeable; requiring
anchor appleon the target’s running process is what stops a malicious app from posing as a trusted one to harvest an injected secret. - A stored secret is a new local attack surface. The injection paths trade the PAM path’s “nothing on disk” property for reach. Keychain storage, on-demand capture, and clear-on-disable shrink the window, but a compromised host with the feature on can recover those secrets — a pure-PAM setup can’t.
- The pre-auth window is a small race surface. Scoping it to one service, making it single-use, timing it out in seconds, and requiring a focused password field keep it tight, but it is the one moment where a same-UID process could try to slip in. We keep it as narrow as the UX allows.
The takeaway is the same principle we started with, now with teeth: prove where macOS lets us, fill where it doesn’t, and never hand out authority without explicit, scoped, signed intent.
- It writes the value (
-
How immurok verifies its own firmware: encryption, signatures, and OTA over BLE
07/04/2026 at 09:47 • 0 commentsA fingerprint key that accepts forged firmware is worthless, so the OTA pipeline is where immurok's security model either holds or collapses. This is how ours works: the .imfw package format, AES-CTR encryption, on-device verification, the migration from HMAC to ECDSA P-256 signatures, anti-rollback — and what it takes to run P-256 on a chip with 26KB of RAM and a 512-byte stack.
The immurok key is a sealed device. There’s no USB port, no debug header a user can reach, no recovery button — after it leaves the factory, the only way firmware gets onto it is over Bluetooth LE, pushed by the companion app. That’s convenient. It’s also the single most dangerous interface the device has.
immurok’s entire value is that the device tells the truth: a real finger touched me, and here’s a cryptographic statement to prove it. If an attacker can load their own firmware, every other guarantee — on-device fingerprint matching, keys that never leave the hardware, authenticated notifications — evaporates. Firmware update is where the security model either holds or collapses.
So this post is about how our OTA actually works: what’s in a firmware package, what the device checks before it agrees to run new code, how we migrated the signing scheme on devices already in the field, and what it costs to do real public-key cryptography on a $1 microcontroller.
The .imfw package
Firmware never travels as a raw binary. The build machine packages it into a .imfw file: a fixed 128-byte header followed by the firmware body encrypted with AES-128-CTR.
The header is where the security lives. In the current (v2) format it carries:
- magic, format version, and a hardware ID — so a package for a different board revision is rejected before anything is written;
- the plaintext firmware size and the AES-CTR IV;
- a security version number (SVN) — a monotonic counter, separate from the human-readable semver, used purely for anti-rollback;
- the SHA-256 of the plaintext firmware;
- an ECDSA P-256 signature over the first 64 bytes of the header.
That last field does more than it looks. Because the signed region includes the firmware hash, the signature transitively covers the entire firmware body: you can’t swap the payload without breaking the hash, and you can’t fix the hash without breaking the signature. One 64-byte signature chains the whole package.
Signing happens on the build machine; the private key never ships. The device holds only the corresponding public key, which is enough to answer the one question it cares about: did this package come from us? The encryption layer, to be clear about roles, is not what makes forgery hard — that’s the signature’s job, and the scheme is published in our security model precisely because none of it depends on secrecy. Encryption keeps the plaintext image from being trivially harvested off the air or from update files, and that’s all we claim for it.
What the device checks, and when
The push protocol is a short command sequence over an authenticated GATT session: an info handshake, an erase of the staging image, the header, a stream of 240-byte data chunks, and an end command. The device uses an A/B flash layout — the running firmware (Image A, 216KB) is never touched during transfer; everything lands in Image B.
The ordering of checks is deliberate:
The header is judged first, before a single firmware byte is transferred. Format version, hardware ID, ECDSA signature, and the SVN against the device’s persisted floor. A forged, mismatched, or rollback package dies in the first second, not after a two-minute transfer. Each failure returns a distinct error code — signature invalid, version rollback, bad format, low battery — because “update failed” with no reason is how you end up unable to debug field reports.
The body is decrypted and hashed in flight. The device streams AES-CTR decryption and a running SHA-256 as chunks arrive, writing plaintext to Image B as it goes. There’s no “download, then verify” phase for a simple reason: the image is 216KB and the chip has 26KB of RAM. Everything on this device is streaming or it doesn’t happen.
The end command verifies what’s actually in flash. Not the bytes we received — the bytes that landed. The device reads Image B back and compares the SHA-256 against the header’s value, catching flash write errors as well as transfer corruption. Only after that match does it set the image flag that tells the bootloader to copy B over A on the next boot.
An interrupted or failed update at any stage leaves Image A untouched and the flag unset. The worst case of a botched OTA is a wasted minute and roughly 0.2% of the battery — not a bricked key. The device also hard-refuses to start an update below 5% battery, independent of the app’s own softer 30% gate: the app gate is UX, the firmware gate is the safety boundary.
The migration: from HMAC to ECDSA, over the air
The v2 format above is not what we shipped first, and the migration is the part most worth writing down.
Early firmware (v1 packages) authenticated updates with HMAC-SHA256 — a symmetric scheme. It’s fast, tiny, and easy to get right on a constrained chip, which is why we started there. But symmetric authentication has a structural flaw for a fleet: the same secret that verifies a package can create one, and that secret has to live inside every device. Extract it from one unit and you can sign firmware for all of them. For a hobby gadget, maybe acceptable. For a device whose whole job is security, it was a debt we always intended to pay before it mattered.
ECDSA fixes the asymmetry: devices carry only a public key, and no amount of hardware extraction on a shipped unit yields the ability to forge. The problem is the fleet in the field. Devices running v1 firmware verify HMAC and know nothing about ECDSA — you can’t just start publishing v2 packages, because old devices would reject them.
The answer is a bridge release — firmware 1.6.0, the hinge between eras. It is the last release delivered as a v1 package, so old devices accept it — and the first whose own verifier accepts only v2. Installing the bridge closes the door behind itself: from that moment the device demands ECDSA, and rejecting v1 outright doubles as the guard against being downgraded back into the symmetric era.
Two more pieces make the migration stick:
- The SVN floor. The device persists the highest security version it has ever run in its data flash, in a dedicated page that survives updates. Any package with a lower SVN is rejected at the header stage — so even a genuine, correctly-signed old v2 release can’t be replayed onto a device once a security fix has raised the floor. Semver is for humans; the SVN exists so that “downgrade to a vulnerable version” is not an attack.
- Two-hop orchestration in the apps. A device on old firmware now needs two sequential updates — bridge first, then latest. Both the macOS and Linux apps reduce this to one pure planner function (up-to-date / direct / bridge-only / two-hops) and present it as a single continuous update with one progress bar, resumable across the mid-flight reboot. The app-side plumbing had its own crop of bugs — lying four-segment version strings, resume state persisted too early, a progress bar that jumped backwards — but they’re a story for another post.
P-256 on 26KB of RAM and a 512-byte stack
The reason we didn’t start with ECDSA is worth being honest about: on this class of hardware, it hurts.
The CH592F gives us 26KB of RAM, of which the stack is 512 bytes, and no crypto acceleration for elliptic curves. A single P-256 signature verification with a software implementation (we use micro-ECC) takes on the order of two seconds at the chip’s clock. Two seconds is an eternity in embedded land, and it stepped on two rakes immediately:
- The watchdog. Our window watchdog times out in ~559ms. The first ECDSA verify test didn’t fail — it rebooted the device, which is a much more confusing symptom. micro-ECC provides a hook exactly for this, and the fix is to register a callback that feeds the watchdog from inside the computation.
- The BLE stack. Two seconds of not servicing the radio flirts with the connection supervision timeout. The tempting fix — pumping the BLE event loop from inside the crypto callback — is a trap: it nests an event dispatch on top of a deep call stack that’s already brushing against those 512 bytes, mid-computation. We feed only the watchdog from the callback and instead rely on connection parameters generous enough to absorb the stall. Getting that wrong doesn’t fail loudly; it corrupts state and fails somewhere else, later.
Add the usual embedded miseries — an endianness mismatch between micro-ECC’s native little-endian word order and the big-endian convention of every crypto standard ever written, buffers declared static because a 64-byte stack array is a meaningful fraction of your entire stack — and “just verify a signature” becomes a respectable engineering line item. It’s still worth it. Every one of these costs is paid once, at update time, on the device — and in exchange, the ability to sign firmware lives nowhere except the build machine.
The shape of the whole thing
Zoomed out, the pipeline is: sign and encrypt on the build machine → distribute as .imfw → app plans the hop(s) and streams the package over an authenticated BLE session → device judges the header, streams decrypt-and-hash into the staging image, verifies the flash readback, and only then commits → bootloader swaps on reboot → app confirms by reconnecting and reading the new version, because “push succeeded” and “device now runs the new firmware” are different claims.
Nothing in that chain is exotic, and that’s the point. The scheme is documented, the packaging and update tooling and both companion apps are on GitHub, and the security rests on exactly one secret: a private key that has never been inside any device we’ve shipped.
Bring your own firmware
One last thing, because the verification chain above locks the OTA path to our signing key — but it doesn’t lock you out of hardware you own.
The board exposes serial flashing test points. Over those you can write your own firmware image directly — including your own OTA public key, generated with the same generate_ota_keys.py tooling we use. From that point on the official companion apps will happily push .imfw packages you built and signed: the device verifies them against your key instead of ours, and the whole pipeline — header checks, streaming decrypt, SVN floor, flash readback — works exactly as described above, just under your ownership.
For anyone going down that road, here’s the flash map your firmware has to live in:
![]()
A sealed update path and a hackable device are not in conflict. The signature check keeps firmware we didn’t sign from arriving silently over the air; the test points keep the hardware yours.
-
Eight PCB revisions for a tiny wireless fingerprint key
06/30/2026 at 09:02 • 0 commentsNotes from building immurok: a wrong MCU choice, a sensor swap, a tamper switch that only worked while powered, and a long fight to get standby current down to 40 uA.
![Eight immurok PCB revisions arranged as a hardware timeline]()
immurok is a small wireless fingerprint key: touch the sensor, approve the action, get back to work. It took eight PCB revisions to get there. These are the notes — the boards we scrapped, the parts that looked right until they met a battery, and the mechanical choices that worked in CAD and failed in plastic.
Four things drove most of the rework: keep the radio connected without draining the battery, make the sensor usable without it glowing like a toy, build an enclosure that survives assembly, and make the tamper switch actually do something.
Picking the MCU: ESP32 was too power-hungry
The first prototypes used ESP32-S3 and ESP32-C3. Familiar, well documented, easy to bring up.
Then we looked at the power budget. immurok sits on a desk for weeks, wakes on touch, and does nothing the rest of the time. The number that matters is idle current, not peak current during BLE traffic. The ESP32 direction projected over 200 uA static before the design was even finished. For a battery-powered key, that drains the battery while the user is doing nothing.
So we moved to CH592F. Tighter RAM, less convenient, but it gave us the sleep budget a desk key needs. The shipping design idles around 40 uA, after a lot of measurement and firmware tuning — BLE intervals, sensor rails, pull resistors, LEDs, wake paths, and every GPIO default.
Takeaway: for a device that’s supposed to disappear into the desk, the MCU choice is the product, not an implementation detail.
Choosing a fingerprint sensor
Our first working path used ZW3021-class modules. They worked: enroll a finger, match locally, report over UART. The problem was the built-in lighting — blue rings, green glows, decorative LEDs that make sense on an access-control panel and look wrong on a desk. The light also costs power and complicates the enclosure.
For a while we had a promising module shaped like a ping-pong paddle. Mechanically it fit a small handheld object well, and it skipped the built-in-light problem. Then the vendor told us they couldn’t support the volume we’d need for a real run. “Can I buy five?” and “Can I buy five thousand, repeatedly, without redesigning the product?” are different questions.
We settled on R559S: no light, on-module matching, fits the enclosure. It has one catch — after every power cycle, firmware must explicitly enable the touch interrupt switch. Most modules we tested do this automatically. On R559S, if firmware doesn’t send the setup command after power is restored, touch no longer wakes the system.
This matters in the failure path. If firmware crashes before re-enabling the touch interrupt, the user can touch the sensor forever and nothing happens. So we treat the command as part of the power system, not a feature toggle: bring up the sensor early, and re-send the touch-interrupt command after every power transition.
Why the board shape kept changing
![A pile of failed 3D-printed enclosure prototypes for immurok]()
The PCB revisions were half the story. The other half was a pile of failed enclosures: sensor holes a millimeter off, screw posts colliding with components, shapes that looked fine in CAD and felt wrong in the hand.
Each failed case pushed back on the board. The sensor opening set how far the module could sit from the edge. The screw posts set where copper couldn’t go. The USB connector needed room for a cable and fingers. The antenna needed air, not a wall of plastic and metal. Button and LED positions changed once the enclosure became something you could actually press.
- VER0 — proving the parts could talk: MCU, sensor, buttons, debug UART, BLE. A lab board, not a product.
- VER1–2 — real mechanical constraints: USB connector placement, sensor flex routing, room for a switch, antenna clearance, how the device sits in the hand.
- VER3–4 — consolidation. Components moved off the edges, power routing got cleaner, LED and button choices settled. Started to look like something that could survive assembly.
- VER5 — added the tamper switch. The firmware already had a security model for radio attackers; the hardware needed an answer for someone holding the device.
The first version of that answer was incomplete.
The tamper switch only worked while powered
VER5 could detect a case-open event while the device was powered and running. That sounds like the whole feature until you ask: what if the attacker turns the device off first?
With no power, the MCU never runs the tamper code. No flash erase, no red LED. A switch on a GPIO is useless if the chip isn’t alive to read it.
![The VER6 anti-tamper power bypass circuit]()
The VER6 fix: opening the case has to power the MCU long enough to enter the wipe path, not just signal it. The tamper switch now drives a small power-bypass path through a MOSFET arrangement — opening the enclosure forces the supply on even if the device was off. The MCU sees
ANTI_OPENand boots straight into the tamper handler.VER5 asked “can firmware notice the case opened?” VER6 asks “can opening the case create the electrical conditions firmware needs to wipe secrets?” Security features fail in the gap between those two questions.
Chasing standby current
Once the architecture settled, the rest was current measurement. We bought a microamp meter for this, because the LED being off and the logs being quiet tells you nothing — a pull-up, a sensor rail, or a BLE parameter can quietly eat the battery.
Status LED. Early boards used a WS2812-style single-wire RGB LED: cheap, one data pin, any color. Measured, the internal controller drew ~0.5–1 mA even when visually off — more than the rest of the sleeping system. We dropped it for plain LED channels driven straight from GPIO. Costs a few pins, removes an always-on controller.
Fingerprint UART. Leaving the port configured while the module slept kept current paths alive through the IO pins. Turning the sensor off wasn’t enough; the host-side UART pins also had to be parked so they didn’t back-power or bias the module. On a low-power board, serial ports are part of the power design.
Prompt behavior. My first
sudoprompt design flashed the LED, powered the fingerprint module, and waited for a touch. Wasteful — the user might hesitate or ignore the prompt while the sensor DSP is already awake. The current design keeps the heavy DSP unpowered during the prompt window; only the touch-detect path stays alive. We power the module only when the user actually touches it. That adds ~50 ms of wake latency but roughly halves the energy spent waiting for a finger.The full checklist, applied every revision:
- Turn the fingerprint module fully off when not needed.
- Keep the touch-detect path alive without powering the whole sensor stack.
- Park the fingerprint UART pins in sleep so they don’t leak current.
- Replace smart RGB LEDs with GPIO-driven LEDs.
- Increase BLE slave latency where possible, within Apple’s connection rules.
- Re-check every revision — a pin that was safe in VER3 may be expensive in VER5.
The target was the whole product — battery, regulator, sensor, radio, GPIOs, board leakage — not a heroic number on a bare MCU. Around 40 uA standby is where it stopped asking to be charged.
BLE connection parameters
BLE looked easy in the prototype: advertise, connect, expose a custom GATT service, send signed match events.
Shipping a battery-powered BLE device is fussier. Set interval, latency, and supervision timeout wrong and either the device stays too chatty and burns power, or the host decides the peripheral is misbehaving and drops it.
immurok presents as a BLE HID keyboard but keeps auth traffic on a private GATT service. HID gives the OS a reason to hold the connection; the custom GATT service handles pairing, challenge-response, and signed touch events without putting secrets in the keyboard channel.
Increasing slave latency is one of the most effective ways to cut current — the peripheral skips connection events and the radio sleeps longer. But Apple’s rule has to hold: supervision timeout > max interval × (latency + 1) × safety factor. Too much latency and the connection gets fragile; too little and the battery pays for needless radio wakeups. It never shows up in a product render, but it’s the difference between “there when I touch it” and “why is my key asleep again?”
Takeaways
- ESP32-S3/C3 — familiar chips can be wrong for the battery.
- ZW3021-class modules — a sensor can work and still feel wrong.
- The ping-pong-paddle sensor — supply chain is a design constraint.
- R559S — one power-cycle quirk can reshape firmware boot order.
- VER5 — a tamper switch isn’t a tamper response unless the MCU is powered to act.
- The microamp meter — don’t trust your feeling on power.
Eight boards later: small because the boards got smaller, quiet because the power budget became real, dark because the sensor choice mattered, and able to wipe itself because case-open is now an electrical event, not a firmware wish.
-
Why desktop fingerprint authentication is still awkward on Mac and Linux
06/30/2026 at 08:59 • 0 commentsTouch ID is excellent when it is built into the machine or keyboard. The awkward part starts everywhere else: Mac mini, Mac Studio, closed-lid MacBooks, external keyboards, Linux desktops, and terminal-heavy workflows.
immurok is my attempt to build a small wireless fingerprint key for those setups.
It is not meant to be “Apple Touch ID, but external.” Touch ID is Apple’s own secure platform, deeply fused into the Secure Enclave and the OS. immurok is something more modest and more honest about its scope: a local-first desktop authentication device with clear limits.
Where built-in fingerprint auth runs out
If you use a laptop with the sensor under your thumb, none of this is your problem. But a lot of desktop computing doesn’t look like that:
- Mac mini and Mac Studio ship with no fingerprint reader at all. Apple’s only first-party answer is a $199 Magic Keyboard with Touch ID.
- A MacBook in clamshell mode, driving an external display, can’t reach its own sensor — the lid is closed.
- External keyboards — and especially mechanical keyboards, which a lot of developers prefer — have no biometric story on macOS unless you buy Apple’s specific keyboard.
- Linux desktops can do fingerprint auth through
fprintd, but good, well-supported hardware is genuinely scarce, and setup is fiddly. - Terminal-heavy workflows are the worst case: you
sudoa dozen times an hour, sign Git commits, SSH into boxes — and every one of those is a password prompt.
The common thread is that biometric auth is welded to specific hardware. The moment your setup steps outside that hardware, you’re back to typing passwords.
What immurok actually is
A small wireless key with a capacitive fingerprint sensor that pairs to your computer over Bluetooth LE. Concretely, its scope is:
- Linux
sudoand system authentication through PAM. This is the cleanest integration — see below for why it matters. - A Linux CLI/TUI app for pairing, status, and local control — no GUI required, built for people who live in the terminal.
- macOS
sudo/ system-prompt support through PAM integration, for the auth prompts that do consult PAM. - Mac desk-setup workflows — Mac mini, Studio, clamshell, external keyboards — where Touch ID is missing or impractical.
- Fingerprint matching on the device. Your fingerprint template is enrolled and matched on the key itself; it never travels over Bluetooth, never lands on your disk, and there is no cloud to send it to.
- Open-source firmware and software. The macOS app, the PAM module, the Linux app, and the hardware design are open; the firmware will be opened up before the end of the year.
It is deliberately a single-purpose device. No screen, no account, no app store — a key that proves a fingerprint touch happened, and lets your OS act on it.
![immurok features overview]()
The distinction that matters: PAM, not a typed password
Here is the part I most want to be clear about, because it’s the easiest thing to get wrong when you build something like this.
For
sudoand system authentication, immurok is not just typing a stored password for you. A lot of “fingerprint unlock” gadgets are really just a biometric trigger wired to a password autotyper: they keep your password somewhere, and when you touch the sensor they replay it into the prompt. That works, but it means your password is sitting in storage, and anything that can see the keystrokes sees your password.On Linux, immurok integrates through PAM — the same authentication framework
sudo, login, andpolkitalready use. When you runsudo, the PAM stack asks immurok’s module, the module checks with your paired device over an authenticated channel, you touch the sensor, and PAM gets back a real success/failure result. There’s no stored password being replayed — the authentication decision flows through the OS’s own auth pipeline, the way a fingerprint should.On macOS the picture is split, and it’s worth being precise:
- For prompts that consult PAM —
sudoin a terminal, some system dialogs — immurok integrates through PAM the same way. - For the lock screen, macOS doesn’t let third-party PAM modules dismiss the login window or screensaver. So for that one flow, immurok falls back to keyboard simulation: it keeps your login password in the macOS Keychain and types it only after verifying a cryptographically signed match from your device. It’s the autotyper approach — but scoped to the single case the OS forces it into, gated behind a verified touch, and nowhere else.
That asymmetry isn’t a limitation we’re hiding; it’s a property of the platforms. Linux exposes a clean biometric path through PAM end to end. macOS exposes it for some flows and walls off the lock screen. immurok uses the real path wherever the OS offers one.
![immurok key with its leather case]()
Honest about the limits
immurok is local-first and deliberately narrow. It does not replace the Secure Enclave, it does not integrate with App Store purchases or
LAContextthe way Apple’s own biometrics do, and it does not pretend to be Touch ID. It is a wireless fingerprint key for the desktop setups Apple and the Linux ecosystem left without a good option — with the matching done on the device, the secrets kept off your disk, and the security model written up in full so you don’t have to take any of this on faith.If your desk is one of the awkward setups above, that’s exactly who I built it for. The macOS app, PAM module, and Linux app are on GitHub; if you want to try the device before launch, join the waitlist.
-
The immurok security model
06/30/2026 at 08:06 • 0 commentsA fingerprint key is only as good as the trust it can prove. Here's how immurok keeps your biometrics on the device, authenticates every touch, and refuses to phone home — ECDH pairing, HMAC-signed events, and signed firmware, end to end.
immurok replaces your password with a fingerprint. That only works if the fingerprint touch is something your Mac can trust — not just a button press that any nearby radio could fake. This post is the full picture of how that trust is built: what the device proves, what the host verifies, where keys live, and the things we deliberately refuse to do.
The threat model
We designed against a concrete set of attackers:
- A passive radio eavesdropper sniffing Bluetooth LE traffic in the room.
- An active attacker who can connect to the device, spoof advertisements, or replay captured packets.
- A malicious or compromised peripheral pretending to be your immurok key.
- A thief who walks off with the device itself.
- A supply-chain attacker trying to push tampered firmware onto the device over the air.
And a set of principles we hold regardless of attacker:
- Your biometric never leaves the sensor. Not over BLE, not to disk, not to a cloud.
- Every authentication event is cryptographically authenticated — a touch the host accepts must have come from your paired device.
- No cloud, no account, no telemetry. The entire system works offline. There is no server to breach because there is no server.
- The host is the policy engine; the device is the proof. macOS decides what a touch unlocks; the device only proves that a real, authenticated touch happened.
Biometrics stay on the device
The fingerprint sensor (an R559S capacitive module) does matching on-chip. Enrollment templates are stored in the sensor’s own flash and are never read out over the wire. The CH592F microcontroller that runs immurok’s firmware never sees your fingerprint image or template — it only ever receives a match result: page N matched, or no match.
That means the BLE link, the companion app, your Mac’s disk, and certainly our infrastructure (which doesn’t exist for this) never hold anything that could reconstruct a fingerprint. The worst case for a stolen device is a stolen device — not a stolen identity.
Matching strictness matters here too. The sensor exposes a configurable score threshold, and the firmware sets it explicitly rather than trusting a vendor default. We learned this the hard way: an early build left the score level unset, fell back to the module’s lax default, and a second validation step was missing in firmware — which let unenrolled fingers occasionally pass. The fix (firmware 1.3.14) pins the score level and re-validates the match index in firmware before signing anything. Biometric thresholds are now part of the build, not an accident of defaults.
Pairing: ECDH, once
When you pair a device, the app and the device run an ECDH key agreement over NIST P-256:
- The app generates an ephemeral P-256 key pair and sends its compressed public key (33 bytes) to the device.
- The device does the same and sends its public key back.
- Both sides compute the shared secret, then run it through HKDF-SHA256 (with a fixed salt and info string) to derive a 32-byte symmetric shared key.
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: devicePubKey) let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: "immurok-pairing-salt", sharedInfo: "immurok-shared-key", outputByteCount: 32 )The shared key never crosses the wire — each side derives it independently from the key agreement. The ephemeral private key is discarded the moment pairing completes. From then on, the app and the device share one secret that proves they paired with each other.
This is a single-device design on purpose: one key, one device, no roaming credentials to manage or leak.
Every touch is signed
A fingerprint match isn’t a bare “OK” byte that anyone could forge. When the device confirms a match, it emits a signed match notification over the custom GATT characteristic:
[0x21][page_id : 2 bytes][HMAC-SHA256(shared_key, 0x21‖page_id) : 8 bytes]
The app recomputes the HMAC with its copy of the shared key and rejects the notification unless the tag matches exactly. An attacker who can write arbitrary GATT values can’t produce a valid tag without the shared key — and the shared key was never transmitted. A forged or replayed-from-a-different-device “match” simply fails verification and is dropped.
On top of per-event signing, the app runs a challenge–response check when it (re)connects to a device, to make sure it’s still talking to the real key and not a clone or impostor advertising the same name:
- The app generates a fresh 8-byte random nonce.
- The device replies with
HMAC-SHA256(shared_key, nonce)[0:8]. - Only a device holding the shared key can answer correctly.
A device that passes is cached as verified (by its BLE identifier) so the check is cheap on subsequent connects, and the cache is cleared whenever you unpair.
Why a HID keyboard and a custom protocol
immurok presents itself to the system as a BLE HID keyboard. That’s not the security channel — it’s the connection anchor. Operating systems aggressively maintain connections to HID keyboards, which is what lets a tiny battery-powered key stay reliably reachable without a background daemon hammering the radio. This is the same on macOS and Linux — the host OS isn’t special-cased; both treat the device as a keyboard and keep it alive.
All the actual authentication traffic — pairing, signed match events, challenge–response — runs over a separate custom GATT service. We use purely random 128-bit UUIDs for that service, deliberately steering clear of the Bluetooth SIG’s reserved ranges and the Bluetooth Base UUID space, so our private protocol never collides with or impersonates a standardized service.
Splitting the roles this way means the convenient, always-connected HID surface carries no secrets, and the secret-bearing surface is a private protocol that a generic BLE client has no reason — and no schema — to poke at.
Where keys and secrets live
It’s worth being precise about which secrets live where, because it’s easy to assume “the app stores my secrets.” It doesn’t.
Your high-value secrets live on the device. SSH keys, OTP seeds, and API keys behind
imk://URIs are stored in the device’s own protected storage. They’re released only momentarily — into a child process’s environment atexec()time, under an active fingerprint cooldown — and are never written to the host’s disk, never held by the parent shell, and never land in an agent’s transcript. The device is the vault.The host only holds pairing material. On macOS, the shared key and the verified-device record live in the Keychain as generic password items, with
kSecAttrAccessibleAfterFirstUnlock— readable only after the user has unlocked the Mac once since boot, and never synced off the machine. That’s the pairing secret that proves this Mac paired with your device; it is not your SSH key or your API keys.The one host-stored password is the screen-unlock password — and only on macOS. This is where the two platforms diverge. On Linux, the lock screen goes through PAM like everything else, so screen unlock rides the same PAM path as
sudo— no stored password at all, just a verified touch satisfying the auth prompt. On macOS, the lock screen can’t be dismissed by PAM (the macOS login window and screensaver don’t consult third-party PAM modules), so for that one flow we fall back to keyboard simulation: the app keeps your login password in the Keychain and types it via simulated keystrokes after — and only after — it has verified a signed match from your device. Even in that fallback the password is at rest in the OS keystore, gated behind a cryptographically verified touch, and never crosses the wire. This applies to screen unlock alone — nothing else stashes a password on the host.The agent gate, briefly
The same trust primitives back immurok’s gate for AI coding agents: when an agent wraps a command with
imk run --agent --, a single verified fingerprint touch brokerssudo, SSH signing, and secret reads for the lifetime of that one subprocess, and a rejection cleanly kills it. The fingerprint is the human-in-the-loop signature. We wrote that flow up in detail in A fingerprint gate for your AI coding agent — the relevant point here is that it’s built on the very same signed-touch foundation, not a separate trust path.Firmware can’t be tampered with over the air
A wireless device that accepts wireless firmware updates is a juicy target. immurok’s OTA pipeline is built so that only firmware we built will ever boot:
- Each update image is encrypted with AES-128 and carries an HMAC-SHA256 signature plus a SHA-256 integrity hash.
- Those keys are generated per developer-machine (
generate_ota_keys.py) and never committed to git — they’re baked into the firmware build and the packaging tool. - Before committing an update, the device verifies the integrity hash and the signature. An image that fails either is rejected as unofficial firmware and discarded — it never gets promoted to the boot slot.
The update itself uses a dual-image (A/B) layout with a tiny separate bootloader (IAP): the new image is written to the inactive slot, verified, and only then swapped in. A failed or tampered update leaves the running firmware untouched.
When the attacker has the device in hand
Everything above assumes the attacker is on the radio. But a fingerprint key is a small object that lives on your desk, and the more interesting attacks happen once someone is holding it. We treat physical possession as its own threat, with two layers.
A stolen device can’t be re-paired. The moment a device has a saved shared key, the firmware flips its BLE bonding policy to refuse new bond requests. A thief can’t simply walk the device over to their own machine and pair it — the pairing slot is already claimed, and there’s no “forget and re-pair” path that doesn’t go through a wipe first.
Opening the case wipes the device. This is the one that matters if the attacker decides to get physical — desolder the flash, attach a debugger, probe the bus to extract keys. A spring contact inside the enclosure is wired to a tamper line on the microcontroller. Cracking the case open trips a hardware interrupt, and the firmware immediately erases everything that has value:
- the pairing shared key and the on-device keystore (your SSH keys, OTP seeds, and API secrets),
- all BLE bonds,
- every enrolled fingerprint template.
Then it lights a solid red LED, shuts off the radio, and halts until the device is power-cycled. By the time anyone has the case open far enough to touch the flash, there’s nothing left on it.
The part that took the most care is making the wipe un-interruptible. The obvious attack is to pop the case and immediately cut power, hoping to freeze the chip mid-erase with secrets still intact. So the wipe is a transaction: before erasing a single byte, the firmware writes a
case_openedmarker to a reserved flash page. If power is lost halfway through, the next boot sees that marker still set and resumes the wipe before doing anything else. The marker is cleared only after the erase fully completes. You cannot win the race by yanking the battery.Getting the detection itself reliable was its own small saga. The tamper line is read as a high-impedance signal (an open case pulls it to ~3 V through a high-value divider), so the pin has to be a true floating input — an early build that enabled the chip’s internal pull-down crushed the open-case signal down to 0.23 V and missed it entirely. Boot also deliberately checks only the stored marker, not the live line, so a case that’s open on the assembly bench during flashing still boots normally; it’s the act of opening a sealed unit that trips the wipe, not the static state.
This hardware tamper response ships on the latest revision (validated June 2026).
What we deliberately don’t do
Security is as much about absence as presence:
- No cloud and no account. There’s no backend that could be breached, no credential database, no password-reset flow to socially engineer.
- No telemetry. The device and app don’t report usage anywhere.
- We don’t touch
authorizationdb. macOS’s authorization database governs deeply sensitive system rights; immurok integrates through PAM and the screen-unlock path and leaves that subsystem alone entirely. - Open and auditable. The hardware design, the macOS app, the PAM module, and the Linux app are all open source. The firmware will be opened up before the end of the year — we’re holding it back only long enough to protect against cheap clones at launch, not to hide anything. The crypto described here isn’t a marketing claim you have to take on faith — it’s
CryptoKitcalls you can read inImmurokSecurity.swift.
The short version
Your fingerprint stays on the sensor. Pairing derives a shared key over ECDH that never crosses the wire. Every touch your Mac accepts carries an HMAC tag only your device could produce, and the device proves itself with a fresh challenge on every connect. Keys rest in the Keychain; firmware updates are encrypted and signed; nothing phones home. That’s the whole model — small enough to fit on a chip with 26 KB of RAM, and strong enough that the worst outcome of losing the device is buying another one.
We’ll keep publishing the details as the protocol evolves. If you want to read the code first, the hardware design, macOS app, PAM module, and Linux app are on GitHub— with the firmware to follow before year’s end.
-
Why we're building immurok
06/30/2026 at 07:58 • 0 commentsMillions of developers use desktop Macs and Linux machines without any biometric auth. We're building a tiny wireless fingerprint key to fix that.
If you use a Mac mini, Mac Studio, or a MacBook in clamshell mode with an external keyboard, you know the pain: no Touch ID. Apple’s only solution is a $199 Magic Keyboard — and if you prefer a mechanical keyboard, you’re completely out of luck.
Linux users have it even worse. fprintd exists, but good hardware options are scarce.
The problem
Every time you:
- Unlock your screen
- Run sudo / ssh
- Approve a system dialog
- Sign a Git commit
…you type a password. Dozens of times a day.
Our solution
immurok is a tiny wireless device with a capacitive fingerprint sensor. It connects via Bluetooth LE and replaces passwords with a single touch:
- Screen unlock — touch the sensor, screen unlocks
- sudo & PAM — custom PAM module intercepts auth prompts
- SSH agent — sign commits and SSH into servers with your fingerprint
- Open source — the macOS app and PAM module are fully auditable
Security first
Your fingerprint template never leaves the device. Authentication uses ECDH P-256 key exchange and HMAC-SHA256 signed messages. There’s no cloud, no account, no telemetry — everything works offline over Bluetooth.
We’ll be sharing more technical deep-dives on the BLE protocol, PAM integration, and firmware architecture in upcoming posts. Stay tuned.
superdog







