Teaching Linux to speak passkey, in C#

Porting a Rust WebAuthn authenticator to .NET 10: a virtual USB security key, a TPM-backed signing key, and a PIN prompt that never trusts anyone.

3 min read

Painted illustration of a glowing circuit-board key hovering over a computer chip under a starry sky.

Passkeys are great on phones and Macs. On a Linux desktop the story is thinner: you can use a hardware security key, but the laptop's own TPM (the chip that exists precisely to guard keys) usually just sits there.

Then I found passkeyd, an opinionated Linux WebAuthn authenticator written in Rust. It pretends to be a USB security key, so every browser that supports hardware keys “just works,” and it keeps the credentials behind the TPM. It's a clever design, and I wanted to understand it all the way down. For me the best way to learn someone else's code has always been to port it, so cspasskeyd is a staged C#/.NET 10 port. The Rust original stays untouched.

Pretending to be a USB key

The trick at the center is /dev/uhid, the Linux interface that lets a user-space program create a virtual HID device. The daemon registers something that looks like a FIDO security key. The browser finds it, and from then on they talk CTAPHID over HID reports.

Stage one covers exactly what's needed for a real registration and sign-in:

  • CTAPHID INIT, PING, and CANCEL
  • CTAP2 GetInfo, MakeCredential, and GetAssertion
  • Keepalives while waiting on the user, and cancellation that actually cancels

CTAP2 requests run off the HID reader thread, one operation at a time per device. That sounds obvious, but getting it wrong is how you end up with a browser spinner that never stops.

Keys that never leave the chip

Every credential is an ES256 (P-256) key created inside the TPM, under a deterministic storage primary that's created for each operation and then flushed. There are no persistent TPM handles to leak. The C# side talks to the system TSS2 libraries through a small native C shim. It asks for a signature over a 32-byte SHA-256 digest and gets back the DER-encoded ECDSA signature WebAuthn expects.

The rule I care most about: if there's no TPM, it fails. There is no quiet fallback to software keys. A security tool that silently downgrades itself is worse than one that refuses to run.

Approval, not decoration

Every registration and every sign-in needs a fresh local approval. The root daemon doesn't draw UI. It launches a tiny, separate GTK4 prompt in the desktop user's session, and that prompt refuses to run as root. The two processes speak a deliberately boring, length-bounded binary protocol:

PIN prompt frame protocol
request:   "CPPR" | version 1 | u16 rp_len | u16 account_len | rp_id | account
response:  "CPRS" | version 1 | status (1 submitted, 2 cancelled) | u16 pin_len | pin

The prompt only collects the PIN. It doesn't decide anything. The daemon checks the PIN through PAM, using a dedicated cspasskeyd-pin service, so the machine's own authentication rules stay in charge. Relying-party names are validated and shown as plain lowercase labels. Account text is stripped of control and bidi characters so a hostile site can't dress up the dialog. And the prompt never fetches user.icon URLs: a site doesn't get to make my authenticator load a remote image.

Porting as a way of reading

The most valuable part so far hasn't been the code. It's the SECURITY.md that came out of reading the original line by line: findings I checked, mitigations the port must carry, and things that are explicitly out of scope, like the CTAP ClientPIN and extensions. Port, audit, and write it down turns out to be a very good way to learn a protocol.

Next up is the moment of truth: validating a live registration and sign-in in Edge against real TPM hardware and the real PAM stack, which the unit tests deliberately fake. After that comes stage two, with the enrollment UI, a manager, migration tooling, and packaging. Watching a browser say “use your security key” and knowing the key is my laptop's own TPM is the finish line I'm aiming for.