A FIDO2 security key in software for Linux, with post-quantum ML-DSA passkeys.
pqkey shows up in your browser as an ordinary USB security key. A user daemon
creates a virtual HID device through the kernel's uhid driver, so any FIDO2
client can use it, and every registration, reset and sign-in that asks for
your presence waits for you to approve it in a desktop notification.
- Post-quantum: ML-DSA-44, ML-DSA-65 and ML-DSA-87 credentials (FIPS 204), alongside ECDSA (ES256, ESP256, ES384, ESP384, ES512, ESP512, ES256K, ESB256 and ESB384), EdDSA (EdDSA, Ed25519 and Ed448), and RSA-2048 (RS256, RS384, RS512, PS256, PS384 and PS512).
- CTAP 2.3: PIN (protocols 1 and 2), passkeys with credential management,
always-UV, minimum PIN length and PIN complexity configuration,
minPinLength,pinComplexityPolicy,credBlob,hmac-secret,hmac-secret-mc,credProtect,largeBlobKey,thirdPartyPayment, fragmented large-blob storage, and packed self-attestation. - U2F: CTAP1 registration and authentication over USB HID, using ES256 credentials that also work over CTAP2. U2F uses approval rather than a PIN, and its prompt cannot identify the site. Always-UV disables it.
- Device identification: CTAPHID_WINK briefly names the software key in a desktop notification with no approval buttons. It expires after two seconds and does not authorize an operation. CTAPHID_LOCK supports channel leases of up to ten seconds, renewed or released by their owner.
- Rust throughout: pure-Rust cryptography, and
unsafecode only where pqkey calls into the operating system.
Warning
pqkey is a research project, not for production use. A software key is not a hardware key: its private keys are files in your home directory, which anything running as you, or as root, can use. See SECURITY.md for the threat model.
FIDO clients can identify a connection with WINK even when a notification server cannot show action buttons. If delivery fails or exceeds 500 ms, WINK returns a transport error; the key remains usable. Identification also works with PIN complexity and always-UV enabled. LOCK is volatile: expiry or daemon shutdown releases it, while channel resynchronization and authenticator reset preserve an unexpired lease. Clients should release leases in cleanup handlers.
You need Linux with systemd and the uhid module (Ubuntu, Debian and Fedora
have it), and a desktop whose notifications show buttons (GNOME, KDE Plasma or
dunst). Copy this one command:
git clone https://github.com/rainzhang05/SoftwareAuthenticator.git && SoftwareAuthenticator/install.shThe installer says how much disk space it will use (about 1.2 GB with the build tools and Rust), asks once to continue and once for your sudo password, and shows its progress as it:
- installs a C linker, Rust and ACL tools, if they are missing;
- builds pqkey and installs it as
/usr/local/bin/pqkey; - gives the key access to
/dev/uhidwith udev rules, and loads theuhidmodule at every boot; - starts the key as a systemd user service and asks you to choose its PIN.
A successful installation means the installed service is running, its HID
client can exchange messages, the configured presence provider is usable,
and its PIN requirements are complete. pqkey works in the same terminal,
with no reboot or new login. Missing requirements produce a nonzero exit
status and instructions; the installer log is ~/.cache/pqkey/install.log
(or $XDG_CACHE_HOME/pqkey/install.log).
./install.sh --yes approves installation; you still choose and confirm the
PIN. Without terminal input, a fresh key remains installed but setup fails
until you run pqkey pin, then pqkey setup, in a terminal. A configured key
can be updated without PIN input unless its policy requires a PIN change.
If your terminal's PATH excludes /usr/local/bin, run
export PATH="/usr/local/bin:$PATH" in that terminal and rerun the installer.
Before upgrading, stop the key and back up its complete state directory,
including the keys. Then run git pull and ./install.sh again. Reruns repair
partial setup and preserve credentials, valid PINs and authenticator policy.
Updates replace the executable atomically. If setup fails after replacement,
the previous executable remains at /usr/local/bin/pqkey.previous; the
installer prints retry and restore commands. The recovery copy is removed
after successful setup.
Registering or replacing a discoverable credential upgrades older stores to the authenticated membership format. Restore only the executable when the previous version understands that format. A version predating this format requires its matching complete state backup as well; credentials created after that backup will be absent.
Note
Anyone who can open /dev/uhid can create any HID device, keyboards
included. The rules give it to the plugdev group, and the installer adds
you to that group if you are not in it yet (on Ubuntu you already are).
Register pqkey on any site that supports passkeys or security keys, and approve the notification that appears.
| Command | What it does |
|---|---|
pqkey |
Shows the key's status, its PIN, its free passkey slots and anything to fix |
pqkey pin |
Sets or changes the PIN |
pqkey config |
Shows always-UV and PIN policy settings |
pqkey config always-uv on|off |
Requires or stops requiring the PIN |
pqkey config min-pin-length N [--rp RP_ID]... |
Raises the PIN minimum |
pqkey config pin-complexity [--yes] |
Enables the ASCII letter and digit rule |
pqkey config force-pin-change |
Requires a different PIN |
pqkey passkeys |
Lists the passkeys stored on the key |
pqkey passkeys delete QUERY |
Deletes the passkey whose site, user or ID contains QUERY |
pqkey reset |
Erases credentials, large blobs and PIN; resets settings |
pqkey stop, pqkey start |
Unplugs and plugs in the key |
Passkey listing uses CTAP 2.3 persistent read-only credential management when advertised, with ordinary credential-management authorization for older peers. The native client keeps its token in memory for that invocation; it adds no disk cache. Persistent grants survive daemon restarts and transient-token expiry. Changing the PIN, resetting the authenticator, or publishing a policy that requires a PIN change revokes them. Deletion obtains writable authorization.
Configuration changes ask for the current PIN when one is set. Always-UV
requires the PIN for registrations and sign-ins that ask for user presence.
Without a PIN, turn always-UV off or set one with pqkey pin before using
the key in a browser.
The minimum PIN length counts Unicode code points, from 4 to 63, with a
separate limit of 63 UTF-8 bytes. Raising it asks for confirmation, unless
--yes is given: only pqkey reset, which erases every passkey and large
blob, can lower it again. Repeated --rp options replace the RP IDs allowed
to receive the minPinLength extension; omitting them keeps the list. A key
can remember 8 RP IDs, each up to 253 bytes. The complete configuration request
must also fit the key's advertised 1,768-byte message limit, including CBOR
and PIN authentication. If it is too large, pqkey reports its encoded size
and limit before sending the command; use fewer or shorter RP IDs. The list
is applied in one operation. If a PIN change becomes required,
run pqkey pin and choose a different PIN meeting the current minimum.
Enable PIN complexity with
pqkey config pin-complexity. New PINs must then contain an ASCII letter and
digit in addition to meeting the length requirements. Only reset disables it;
enabling with an existing PIN requires one replacement.
pqkey passkeys lists only passkeys, which are discoverable credentials. A
site that registers the key as a second factor keeps the credential's ID and
sends it back to sign in, so the key does not list that credential. The
prompt makes the difference clear, saying "Create a passkey" or "Register a
security key".
Like a hardware key, pqkey accepts a reset only within 10 seconds of being
plugged in, so pqkey reset restarts it first. The key's state lives in
~/.local/share/pqkey.
FIDO clients can store a serialized large-blob array of up to 16,384 bytes,
transferred in fragments of at most 1,704 bytes. Writes require the PIN's
large-blob write permission when a PIN is set or always-UV is on. Reset
restores the empty array; deleting a credential leaves blob collection to
the client. Discoverable credentials registered with largeBlobKey keep a
secret 32-byte key for platforms to encrypt and decrypt their blobs.
Credentials explicitly registered with thirdPartyPayment: true retain that
metadata for both discoverable and non-discoverable credentials. Assertions
requesting it return the stored boolean inside signed authenticator data;
creation returns no payment extension output. pqkey passkeys marks enabled
passkeys in its PAYMENT column. Enabled non-discoverable credentials consume
storage but still require their RP's allow list and remain outside passkey
management. Ordinary eligible credentials continue to use sealed IDs.
The flag does not grant cross-RP access or implement a payment prompt. The client platform determines payment eligibility and UI, including Secure Payment Confirmation (SPC). Authenticator regressions and libfido2 signed-byte checks are separate from browser/SPC verification; see the verification notes.
Brainpool ESB256 (-265) uses brainpoolP256r1/SHA-256 and ESB384 (-267) uses brainpoolP384r1/SHA-384, as RFC 9864 §2.1 and the IANA COSE registries specify. Public keys carry both coordinates (crv 256/258; 32/48 bytes), and signatures are ASN.1 DER ECDSA. Both credential modalities use stored 33-byte IDs: even non-discoverable Brainpool credentials consume one storage slot and reduce remaining capacity. Both modalities keep their own counters. Non-discoverable records keep no user information; credential enumeration lists discoverable credentials only.
Independent Python cryptography/OpenSSL fixtures verify Brainpool derivation,
attestation and assertion signatures. Raw Python FIDO HID tests are provided;
actual HID execution requires Linux with /dev/uhid. Brainpool browser support
has not been verified. libfido2 1.14.0 retains their advertised algorithm IDs but
cannot create, verify or decode these credential keys for enumeration. These
limits do not change authenticator support or the client compatibility table.
| Client | ES256 | ML-DSA | Notes |
|---|---|---|---|
| Chromium 153 | ✓ | ✓ | Needs a PIN on the key for passkeys |
| Firefox 154 | ✓ | Ignores algorithms it does not know | |
| python-fido2 2.2.1 | ✓ | ✓ | Used by the end-to-end tests |
| libfido2 1.16 | ✓ | PIN, credential management and hmac-secret work |
These were tested on Ubuntu 26.04. Each client's limits are listed under Clients in the development notes.
Credential management also supports the backwards-compatible 0x41 command
used by libfido2 1.14.0, including its user-update operation. It accepts legacy
ClientPIN tokens and modern CM tokens with their RP restrictions. Older native
clients can decode only their supported classic algorithms; enumeration still
returns every supported credential. The native pqkey client continues to use
standard 0x0A. See the
preview verification notes
for regression coverage and execution limits.
pqkey status names anything that is missing and how to fix it, and
scripts/diagnose.sh shows the whole state of the key on your computer. For
browser behaviour, see
Troubleshooting.
pqkey setup --uninstallThis stops the key and removes its service. It then prints one sudo command
that removes pqkey, its udev rules and the uhid setting. Your passkeys stay
in ~/.local/share/pqkey until you delete that directory.
- SECURITY.md: the threat model, and how to report a vulnerability
- Architecture: how pqkey works, and what it supports of CTAP 2.3
- Development notes: the scripts that run every
check and test (
scripts/check.sh,scripts/e2e.shand more), and troubleshooting - Browser test kit: a local relying party that checks everything the key returns
pqkey is free software under the MIT License: you may use, change and share it, as long as the copyright notice comes with it. It comes with no warranty.