pqkey builds with Rust stable, 1.89 or later; rust-toolchain.toml picks the
toolchain. The key runs on Linux. Portable code builds and tests on macOS;
operational commands there report that the key does not run on macOS yet.
| Script | What it does |
|---|---|
scripts/check.sh |
Runs every check CI runs: formatting, lints, tests, docs, the minimum Rust version, dependencies and coverage. --quick runs only formatting, lints and tests. |
scripts/e2e.sh |
Runs the end-to-end tests against four test keys, through libfido2 and python-fido2. |
scripts/fuzz.sh |
Runs each fuzz target for 30 seconds, or one target for as long as you ask. |
scripts/diagnose.sh |
Shows the state of the key on this computer: binary, service, devices and log. |
scripts/browser.sh |
Starts a local relying party for testing in real browsers (details). |
Each script prints one line per step and keeps the full output in
target/scripts/. The top of each script says what it needs.
The heap residue test runs credential generation, reconstruction, signing
and store roundtrips under the binary's wiping allocator. Its test allocator
retains freed blocks until they have been scanned, and live controls prove
that the scan finds each secret pattern. CI and scripts/check.sh run it in
debug and release, as they do the stack residue tests.
The final cleanup scan also checks blocks released by the last controls.
./install.sh rebuilds pqkey and restarts the key with your changes. To
watch the key's debug log, run it in the foreground. The log includes site and
user names.
pqkey stop
RUST_LOG=debug cargo run --release -p pqkey -- runCtrl-C stops it, and pqkey start brings the service back.
GitHub Actions runs these workflows:
- CI runs the checks of
scripts/check.shon x86_64 and arm64, and the lints and tests on macOS too. - E2E runs
scripts/e2e.sh's tests. - Security runs
cargo auditandcargo deny. - Fuzz runs each target for 30 seconds on changes and for 30 minutes every week.
Dependabot proposes updates every week. Cargo updates that are
semver-compatible and pass every workflow are merged automatically. Commit
changes to Cargo.lock and fuzz/Cargo.lock together.
The boundary is documented in the architecture notes. The current runner shares Unix facilities between Linux and macOS.
- Add a backend under
crates/pqkey/src/platform/and select it withcfg(target_os)in the facade. Keep native APIs, dependencies, messages and configuration there; do not add platform branches to command or protocol logic. - Implement the concrete virtual device and client link, using the shared report descriptor and identifiers. A callback device queues reports and signals a private pipe or socket, which it waits on with the worker's wake descriptor. Pending reports must not lose their wakeup. Drop stops callbacks and removes the device before the app worker is joined.
- Implement user-service inspection, installation, removal and actions, and system preparation and diagnostics. Preserve portable confirmation, readiness, restart and PIN logic. Return no privileged setup steps on a system that needs none; do not invent device groups or kernel modules.
- Implement the notification server's normalized capabilities, events, identity and optional refresh interval. Keep fail-closed decisions, prompt text, deadlines and cancellation in presence policy. Give a server identity only when stale prompts can be withdrawn safely.
- Implement state paths, executable identity, startup protections and the
suspend-aware clock. Keep the daemon information format and state lock
portable. Enable
ensure_supportedonly when the running key works; until then, operations return one clear error before side effects. - Run the portable tests with injected devices, clocks and notifications, and add native integration tests. Cover callback and worker wakeups, queued reports, timeouts, cancellation, shutdown, state overrides and command exit statuses. Keep every existing Linux assertion.
For macOS, obtaining the virtual-HID entitlement is part of implementing the
backend; the current stubs do not create a device or a LaunchAgent. Run all
project checks on both builds and the unchanged end-to-end suite on Linux
with /dev/uhid. Declare native-only dependencies for that target, and keep
both lockfiles synchronized after changing a manifest.
Each algorithm is described once, in the table in
crates/pqkey-ctap/src/crypto/alg.rs, and the compiler finds most of what
else has to change.
- Add a
CoseAlgvariant whose value is its COSE identifier, its row inCoseAlg::properties(its name and signature scheme), and its place inCoseAlg::ALL, the order getInfo lists the algorithms in. An algorithm that differs from another only in its identifier, as ESP256 does from ES256, shares its scheme and needs nothing more in Rust. - A new scheme needs a
Schemevariant and a module for its family, ascrypto/ecdsa.rs,crypto/eddsa.rs,crypto/mldsa.rsandcrypto/rsa.rsare. ECDSA or EdDSA on a new curve needs aCurveorEdwardsCurvevariant, a module for the curve next tocrypto/ecdsa_p384.rsorcrypto/eddsa_ed25519.rs, a case intests/residue.rsand, for keys derived from a seed, known answers in its family's module. A new type of key needs aCredentialSecretKeyvariant. Keys kept in a new form need aKeyKind, aPrivateKeyMaterialvariant and a key type instore/codec.rs;KeyKind::is_sealablesays whether they fit a sealed credential ID. A key that does not, as RSA's primes do not, is stored even for a non-discoverable credential. - Build and run clippy, and handle every match they point to, among them
the test verifier in
crypto/verify.rs, which pqkey's tests and the fuzz targets reach through pqkey-ctap'stest-supportfeature. A check at compile time says if an algorithm whose key can be sealed does not fit a sealed credential ID. - Run the tests. The table-driven ones cover the new algorithm, and the
known-answer tests of the table, of getInfo and of the CLI say what to
add to them. If
UNSUPPORTED_ALGORITHMSinfuzz/src/requests.rslists the algorithm, the fuzz crate's tests fail: replace it there with an identifier the key does not support. - Outside Rust, add it to
ALGORITHMSintests/e2e/ctap.pyand totest_get_info.py, toNAMESintests/browser/q.pyandtests/browser/index.html, to thefido2-token -Iline thattests/e2e/libfido2.shexpects (and its register-and-assert tests, if libfido2 implements the algorithm), and to the algorithm lists in the README and the architecture notes.
Then run scripts/check.sh, regenerate the fuzz seeds with cargo run --release --manifest-path fuzz/Cargo.toml --example seeds (the
credential_key seeds pick algorithms by their place in CoseAlg::ALL, so
give a new one layouts in fuzz/examples/seeds.rs first), run
scripts/fuzz.sh, and run scripts/e2e.sh on Linux.
These were tested on 2026-09-30 on Ubuntu 26.04.1 (aarch64, GNOME 50.1).
| Client | ES256 | ML-DSA | Notes |
|---|---|---|---|
| Chromium 153 (snap) | ✓ | ✓ | Passkeys and user verification need a PIN on the key. getPublicKey() returns null for ML-DSA and toJSON() leaves the key out, so relying parties read it from the attestation object. |
| Firefox 154 (snap) | ✓ | Drops algorithms it does not know: an ML-DSA-only request fails with NotAllowedError. Without a PIN, its account chooser shows "Unknown account". |
|
| python-fido2 2.2.1 | ✓ | ✓ | Used by the end-to-end tests. |
| libfido2 1.16 | ✓ | Has no ML-DSA type, so fido2-token -L -k cannot list ML-DSA passkeys; pqkey passkeys can. PIN, credential management and hmac-secret work. |
Start with pqkey status, which names what is missing and how to fix it, or
scripts/diagnose.sh for the whole picture.
The key stops answering and pqkey stop hangs. The Linux uhid queue has
an unlocked read race, seen especially on arm64. When it hits, dmesg shows
the following, with uhid_char_read in the call trace:
usercopy: Kernel memory exposure attempt detected from null address (offset 0, size 4376)
kernel BUG at mm/usercopy.c:102
pqkey waits for each event to settle before reading it to avoid the race, but cannot rule it out. Restart the key; if its process cannot be stopped, reboot.
The browser waits, but no prompt appears. On GNOME a prompt can queue behind another banner, so pqkey nudges the queue every 2 seconds. The prompt is also in the notification list (click the clock).
Every registration or sign-in fails at once. No notification could be
shown, so the key said no. journalctl --user -u pqkey says why. If your
desktop starts its own D-Bus session bus, see the comments in
contrib/systemd/user/pqkey.service.
A snap browser never asks for the key. Snaps open only devices tagged for
them, and the udev rules tag the key for the Firefox and Chromium snaps. If
pqkey status says the tag is missing, run ./install.sh.
pqkey passkeys lists nothing, although sites accepted the key. It lists
passkeys, the credentials the key can find by itself. A site that registers it
as a second factor (residentKey: "discouraged") keeps the credential's ID
and sends it back to sign in, and so does Chromium when the key has no PIN.
Those sign-ins work, but the key does not list them, and their prompt says
"Register a security key" instead of "Create a passkey".
Chromium says "Your device can't be used with this site". Chromium uses a
security key for passkeys only if it has a PIN. Set one with pqkey pin.
Firefox's account chooser shows "Unknown account". Without a PIN, the key leaves user names out of its answer (CTAP 2.3 §6.2.2). With a PIN set, Firefox asks for it and shows the names.
Chromium shows "Something went wrong" after Deny or after 30 seconds. The request was denied or timed out. Chromium keeps its sheet open until you click Cancel.
Another program fails while a browser waits for approval. The key serves one request at a time and answers the others "busy", as CTAP requires. Answer or cancel the waiting request first.
The service and the command use different passkeys. If you set
XDG_DATA_HOME in your shell, set it for the systemd user manager too (see
environment.d(5)).