目录 | Table of Contents
- A Quick Primer on FIDO2 Cryptography
- The Problem: Linux Has the Hardware, but Not the Glue
- The Journey: From Extension to Virtual Device
- The Idea: Fake a YubiKey
- Architecture
- The Snap Sandbox Puzzle
- The 0x00 Byte That Took Hours
- Using Claude to Catch Security Vulnerabilities
- How It Compares
- Try It Yourself
- What's Next
A Quick Primer on FIDO2 Cryptography
Before diving into the implementation, it helps to understand what FIDO2 actually does under the hood. At its core, FIDO2/WebAuthn is an asymmetric cryptography protocol built on public-key pairs: Registration (MakeCredential):-
- A website (the "Relying Party") sends a challenge — a random byte string — along with its identity (like
github.com)
- A website (the "Relying Party") sends a challenge — a random byte string — along with its identity (like
-
- The authenticator generates a fresh P-256 ECDSA key pair, bound to that specific site
-
- The private key is stored securely (in a TPM, secure element, etc.) and never leaves the device
-
- The public key, a credential ID, and a signature over the challenge are returned to the website
-
- The website stores the public key — it can verify future signatures but can never impersonate the user
-
- The website sends a new challenge plus the credential ID from registration
-
- The authenticator loads the private key, verifies user presence (fingerprint, PIN, button touch), and signs the challenge
-
- The website verifies the signature against the stored public key
github.com simply won't activate on github-login.evil.com.
This project uses the TPM 2.0 chip as the key store. When you register on a site, the TPM generates an ECDSA key pair internally. The private key is wrapped in a TPM-specific blob that can only be unwrapped by the same physical chip — even cloning the disk to another machine won't help. The hmac-secret extension (exposed to web apps as the PRF extension) takes this further: it derives site-specific symmetric keys from the credential, enabling features like end-to-end encrypted storage backed by your hardware.
The Problem: Linux Has the Hardware, but Not the Glue
Modern ThinkPads ship with all the ingredients for platform authentication:-
- TPM 2.0 — a hardware security module soldered to the motherboard, capable of generating and storing cryptographic keys that can never be extracted
-
- Fingerprint reader — enrolled via
fprintd, used for sudo and login
- Fingerprint reader — enrolled via
-
- FIDO2/WebAuthn — the open standard that powers "Use your security key" prompts
/dev/tpmrm0 directly due to AppArmor confinement.
The Journey: From Extension to Virtual Device
This project started as a fork of psanford/tpm-fido, which implemented U2F (the predecessor to FIDO2) over a virtual USB device. I wanted full FIDO2/CTAP2 support with the PRF extension, so I started extending it. My first approach was a Chrome Native Messaging architecture: a Chrome extension intercepts WebAuthn API calls and forwards them to a native binary over stdin/stdout. This worked — I got CTAP2 with hmac-secret, resident key storage, and a distribution system with install scripts. But the extension approach had fundamental problems:-
- Only worked in Chrome — Firefox uses a different extension API for WebAuthn
-
- Snap sandbox: The native messaging host runs inside the Chromium snap, inheriting its AppArmor confinement. It couldn't access
/dev/tpmrm0because snap strips thetssgroup from child processes
- Snap sandbox: The native messaging host runs inside the Chromium snap, inheriting its AppArmor confinement. It couldn't access
-
- Fragile: Browser updates could break the extension, and users had to manually load it in developer mode
The Idea: Fake a YubiKey
Instead of fighting the browser's sandbox, I decided to work with it. The approach: Create a virtual USB FIDO2 device that the browser discovers exactly like a real YubiKey, but backed by the laptop's TPM and fingerprint reader. The Linux kernel has a facility calleduhid (User-space HID) that lets you create virtual HID devices from a regular process. You write a HID report descriptor with usage page 0xF1D0 (the FIDO Alliance's registered HID usage page), and the kernel creates a /dev/hidrawN node. Any program that enumerates HID devices — including Chrome's and Firefox's FIDO2 stacks — sees it as a real hardware security key.
Architecture
+--------------------------------------------------+
| Browser (any browser) |
| Chromium (snap) / Firefox (snap) / Chrome |
+-------------------------+------------------------+
|
/dev/hidrawN
(FIDO2 HID device,
usage page 0xF1D0)
|
+-----------+-----------+
| Linux HID Layer |
+-----------+-----------+
|
/dev/uhid
|
+-----------+-----------+
| tpm-fido daemon |
| (systemd user svc) |
+-----------+-----------+
| |
+----+---+ +----+----+
| TPM 2.0| | fprintd |
| keys | | verify |
+--------+ +---------+
The daemon runs outside the browser sandbox as a regular systemd user service. It has full access to the TPM and fingerprint reader. When a website requests WebAuthn authentication:
-
- Browser opens
/dev/hidrawNand sends CTAPHID packets
- Browser opens
-
- Daemon reassembles the FIDO2 command (MakeCredential or GetAssertion)
-
- A desktop notification pops up: "Touch fingerprint sensor to confirm"
-
- After fingerprint verification, the TPM signs the challenge
-
- Response flows back through the virtual HID device to the browser
The Snap Sandbox Puzzle
The trickiest part wasn't the FIDO2 protocol — it was getting Chromium's snap to see the device. Ubuntu ships Chromium and Firefox as snap packages with strict confinement. Even though AppArmor allows/dev/hidraw* access, the device cgroup restricts which specific hidraw devices a snap can open. Only devices "tagged" in udev rules are allowed.
The built-in snap rules tag known FIDO vendor/product IDs (YubiKey 1050:0402, Titan 18d1:5026, etc.) using ATTRS{idVendor}. But virtual uhid devices don't have USB-style idVendor/idProduct sysfs attributes — those only exist for real USB hardware.
After some digging through sysfs, I found that uhid devices encode their bus/vendor/product in the kernel device name:
/devices/virtual/misc/uhid/0003:1209:F1D0.0005
^ ^ ^
| | Product ID
| Vendor ID
Bus (USB)
So the udev rule matches on KERNELS instead:
SUBSYSTEM=="hidraw", KERNEL=="hidraw*", KERNELS=="0003:1209:F1D0.*",
TAG+="snap_chromium_chromium"
With the tag applied, Chromium's device cgroup allows access, and the virtual key appears in the browser's WebAuthn dialog. The same trick works for Firefox's snap by adding TAG+="snap_firefox_firefox".
The 0x00 Byte That Took Hours
One bug had me staring at logs for way too long. The daemon was receiving CTAPHID INIT packets, but parsing them as garbage:fidohid: init CID=00ffffff CMD=0x7f BCNT=34304
The CID should have been 0xFFFFFFFF (broadcast), but everything was shifted by one byte. The culprit: Linux's hidraw layer prepends a 0x00 report ID byte to output data for devices without explicit Report IDs. The uhid Output event includes this prefix, but I was parsing from byte 0 instead of byte 1.
A one-line fix:
if len(data) > 0 && data[0] == 0x00 {
data = data[1:]
}
After that, CTAPHID INIT parsed correctly, channels were allocated, CBOR commands flowed through, and the fingerprint prompt appeared. Importantly, this quirk is asymmetric: output events from uhid have the report ID prefix, but input events (device-to-host via InjectEvent) do not need one.
Using Claude to Catch Security Vulnerabilities
Building a security-critical system alone is risky. I used Claude to conduct a formal security audit of the codebase, and it caught several real issues that I patched in a single commit: H1 — Origin Validation: The WebAuthn handler wasn't validating that request origins were HTTPS URLs. An attacker could craft requests fromhttp:// or custom scheme origins, bypassing the transport security guarantees that WebAuthn relies on.
H2 — RP ID Cross-Validation: The handler wasn't verifying that the Relying Party ID was a registrable domain suffix of the request origin, per WebAuthn spec Section 7.1. A malicious site at evil.com could claim to be google.com and register credentials against it.
H4 — ECDH Curve Check: The hmac-secret extension performs an ECDH key exchange with the browser's ephemeral public key. But the code wasn't validating that the received point was actually on the P-256 curve. This is a classic invalid curve attack — by sending carefully chosen points not on the curve, an attacker can recover the shared secret through a series of queries.
Each of these was a real, exploitable vulnerability. Claude identified them with specific code locations and fix recommendations. Having an AI reviewer that understands both the FIDO2 spec and the Go implementation details was invaluable for a solo project like this.
How It Compares
| YubiKey | This Project | Chrome Extension | |
|---|---|---|---|
| Extra hardware | Yes (USB key) | No | No |
| Browser extension | No | No | Yes |
| Works with all browsers | Yes | Yes | Chrome only |
| Key storage | Secure element | TPM 2.0 | TPM 2.0 |
| User verification | Touch button | Fingerprint | Fingerprint |
| Snap-compatible | Yes (tagged VIDs) | Yes (custom udev) | Sandbox issues |
| Survives browser updates | Yes | Yes | May break |
| Portable | Yes (any machine) | No (bound to TPM) | No (bound to TPM) |
| Cost | $25-$55 | Free | Free |
Try It Yourself
The project is open source and works on any Linux laptop with a TPM 2.0 and an fprintd-compatible fingerprint reader. Tested on a ThinkPad P14s Gen 5, but other ThinkPads (and non-ThinkPad laptops) should work too.git clone https://github.com/mc256/tpm-fido2-thinkpad-linux.git
cd tpm-fido2-thinkpad-linux
./contrib/install-daemon.sh
The installer checks prerequisites, builds the binary, sets up systemd and udev, detects your browsers, and starts the service. After that, visit webauthn.io and register — you should see a fingerprint prompt instead of a "plug in your security key" message.
What's Next
-
- Passkey support: Full discoverable credential flow for passwordless login
-
- Multiple fingerprint prompts: Different fingers for different security levels
-
- GNOME integration: A proper polkit/GNOME Shell dialog instead of
notify-send
- GNOME integration: A proper polkit/GNOME Shell dialog instead of
The project is at github.com/mc256/tpm-fido2-thinkpad-linux. Issues and contributions welcome.
