I Turned My ThinkPad's Fingerprint Reader into a FIDO2 Security Key

2026-02-24 0:41:10

[未分类]

Every time I log into GitHub or Google with 2FA, I reach for my YubiKey. On my MacBook at work, I just touch the Touch ID sensor — it's seamless, built right into the browser. But on my ThinkPad P14s running Linux? My laptop has a perfectly good fingerprint reader and a TPM 2.0 chip, yet Chrome treats them like they don't exist. So I built a virtual FIDO2 security key that uses both. No browser extension. No external hardware. Just touch your fingerprint sensor and you're authenticated. Here's how I did it — and how I used Claude to audit the security along the way. Please note that all of these are done via Claude Code, not a single line of code was written without AI assistance. This is a deep dive into the architecture, implementation, and security considerations of the project.  

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):
    1. A website (the "Relying Party") sends a challenge — a random byte string — along with its identity (like github.com)
    1. The authenticator generates a fresh P-256 ECDSA key pair, bound to that specific site
    1. The private key is stored securely (in a TPM, secure element, etc.) and never leaves the device
    1. The public key, a credential ID, and a signature over the challenge are returned to the website
    1. The website stores the public key — it can verify future signatures but can never impersonate the user
Authentication (GetAssertion):
    1. The website sends a new challenge plus the credential ID from registration
    1. The authenticator loads the private key, verifies user presence (fingerprint, PIN, button touch), and signs the challenge
    1. The website verifies the signature against the stored public key
The beauty of this model is that the server never sees the private key. Even if the server is breached, the attacker gets public keys — useless for impersonation. And because each key pair is bound to a specific site origin, phishing doesn't work either: a credential for 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
    • FIDO2/WebAuthn — the open standard that powers "Use your security key" prompts
macOS connects these pieces through the Secure Enclave and Touch ID. Windows does it through Windows Hello. On Linux? Nothing ties them together for the browser. Chrome on Linux supports USB security keys (YubiKeys, Titan keys) and it supports platform authenticators — but only if the OS provides one. Linux doesn't ship with a platform authenticator, and Chromium's snap sandbox makes it even harder: the browser can't access /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/tpmrm0 because snap strips the tss group from child processes
    • Fragile: Browser updates could break the extension, and users had to manually load it in developer mode
So I went back to the virtual HID device approach — but this time with full CTAP2 protocol support instead of just U2F.

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 called uhid (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:
    1. Browser opens /dev/hidrawN and sends CTAPHID packets
    1. Daemon reassembles the FIDO2 command (MakeCredential or GetAssertion)
    1. A desktop notification pops up: "Touch fingerprint sensor to confirm"
    1. After fingerprint verification, the TPM signs the challenge
    1. Response flows back through the virtual HID device to the browser
The browser has no idea it's talking to software. It thinks it found a USB security key.

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 from http:// 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
The main tradeoff: a YubiKey works on any computer, while TPM-bound keys only work on the machine that created them. But for a daily-driver laptop, that's exactly what you want — your credentials are physically bound to your hardware and can't be cloned.

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
If you're tired of reaching for a USB key on a laptop that already has biometrics built in, give it a try. Your ThinkPad has been capable of this all along — it just needed the glue.

The project is at github.com/mc256/tpm-fido2-thinkpad-linux. Issues and contributions welcome.

这篇博文发表在 未分类 目录下。
如需引用,请使用链接:https://note.mc256.dev/?p=2189

This article published in 未分类.
Cite this page using this link:https://note.mc256.dev/?p=2189

您的邮箱地址不会被公开,评论使用Gravatar头像。
Your email address will not be published. This blog is using Gravatar.

正在提交评论... Submitting ...
正在为您准备评论控件 Loading Comment Plugin
Copyright © 2013-2026 mc256. All Rights Reserved.
Designed By mc256.
Status Page by CloudFlare Worker.