Skip to content

Windows non-admin: detect FIDO2 keys via access-denied signal and link to Windows security-key settings #58

Description

@token2

Windows non-admin: detect FIDO2 keys via access-denied signal and link to Windows security-key settings

Background

On Windows, raw FIDO HID access is reserved for elevated processes (since Win10
1903, the OS routes non-elevated apps through its own WebAuthn API). keyroost
already notes this — the Security Keys pane needs "Run as administrator." But
when running non-admin there are two usability problems:

  1. FIDO-only keys disappear entirely. The FIDO2 capability is detected from
    the HID usage page 0xF1D0, which a non-elevated process cannot read
    opening that HID collection returns ERROR_ACCESS_DENIED (5). So a FIDO-only
    key resolves to no device at all and vanishes from the sidebar, with no
    explanation. (Multiprotocol keys still appear via their PC/SC applets, just
    without the FIDO2 tab.)

  2. No guidance toward what does work non-admin. Windows' own Settings page
    (Sign-in options → Security Key → Manage) can change the PIN, manage
    biometrics, and reset the key without admin, because Settings is the
    privileged component. Users have no in-app pointer to it.

What I found (hardware-tested: Token2 PIN+ and FIDO-only keys, such as Yubico Security Key with no CCID interface)

Enumerating HID interfaces non-admin, the FIDO interface is the one — and
essentially the only one — that returns ERROR_ACCESS_DENIED. Its VID/PID are
still readable from the device path string (no device open needed). So "an
access-denied HID interface" is a reliable non-admin signal that a FIDO key is
present
, even though we can't read its 0xF1D0 usage page. This is exactly how
Windows itself gates the interface.

The webauthn.dll API was also investigated and is a dead end for external-key
management: WebAuthNGetPlatformCredentialList / WebAuthNDeletePlatformCredential
only return platform (Windows Hello / TPM) credentials, and there is no PIN or
reset function in the API at all (verified against the full header). So the
realistic non-admin scope is: detect + inform + link to Windows settings.

Sample non-admin HID enumeration (Token2 multiprotocol key)

dev 1: CreateFileW failed (err 5) path-VID:349E PID:0026  <-- protected (FIDO?)
dev 3: VID:349E PID:0026 usage_page:0x0001     # same key, non-FIDO HID interface
...
enumerated 25 HID interface(s), opened 24, found 1 FIDO

dev 1 is the gated FIDO collection (err 5); dev 3 is the same key's
keyboard/HID interface, openable but reporting generic usage page 0x0001.

Proposed workaround

A small Windows-only helper that, without elevation:

  • Detects FIDO keys by enumerating HID interfaces and flagging any that return
    ERROR_ACCESS_DENIED (5) (FIDO signature) or report usage page 0xF1D0
    (readable when not gated), pulling VID/PID from the device path.
  • Opens Windows' security-key page via
    ms-settings:signinoptions-launchsecuritykeyenrollment (with a
    ms-settings:signinoptions fallback).

In the GUI: on Windows, when a FIDO key is detected but no working FIDO access
exists, show the FIDO2 tab with an "Administrator rights needed" card explaining
the situation and offering an "Open Windows security-key settings" button.
For FIDO-only keys (which otherwise don't resolve into a device), synthesize a
minimal device entry from the detected VID/PID so the tab can appear.

Everything is #[cfg(windows)] and inert elsewhere; the helper contains the only
unsafe (FFI), confined to its own crate.

Implementation

Detection signal (the heart of the workaround)

For each enumerated HID interface, if CreateFileW fails with error 5, treat it
as a FIDO key:

if handle.is_null() || handle as isize == INVALID_HANDLE_VALUE as isize {
    let err = GetLastError();
    // ERROR_ACCESS_DENIED (5) on a HID interface is the signature of a
    // Windows-protected FIDO collection: we cannot open it non-elevated,
    // which is exactly the FIDO-HID gating. Treat it as a detected FIDO key,
    // using VID/PID parsed from the device-path string (no open needed).
    if err == 5 {
        push_unique(&mut out, FidoKeyInfo {
            product: None,
            vendor_id: path_vid,
            product_id: path_pid,
        });
    }
    continue;
}

VID/PID from the path string (no device open)

fn parse_vid_pid(path_lc: &str) -> (Option<u16>, Option<u16>) {
    fn grab(s: &str, key: &str) -> Option<u16> {
        let i = s.find(key)? + key.len();
        let hex: String = s[i..].chars().take(4).collect();
        u16::from_str_radix(&hex, 16).ok()
    }
    (grab(path_lc, "vid_"), grab(path_lc, "pid_"))
}

Open Windows' security-key settings

pub(crate) fn open_windows_security_key_settings() -> Result<()> {
    const PRIMARY: &str = "ms-settings:signinoptions-launchsecuritykeyenrollment";
    const FALLBACK: &str = "ms-settings:signinoptions";
    let open = to_wide("open");
    for uri in [PRIMARY, FALLBACK] {
        let uri_w = to_wide(uri);
        let h = unsafe {
            ShellExecuteW(std::ptr::null_mut(), open.as_ptr(), uri_w.as_ptr(),
                          std::ptr::null(), std::ptr::null(), SW_SHOWNORMAL)
        };
        if (h as isize) > 32 { return Ok(()); }  // >32 == success
    }
    Err(WinWebAuthnError::LaunchFailed)
}

GUI tab (non-admin Windows, FIDO key detected)

Administrator rights needed
A security key is connected, but managing its FIDO2 settings (PIN, passkeys,
reset, fingerprints) in this app requires administrator rights on Windows.
You can change the PIN, manage biometrics or reset the key without admin
rights using Windows' built-in security-key settings, or restart this app as
administrator for full management here.

with a single button: Open Windows security-key settings
open_windows_security_key_settings().

The code above is tested and confirmed. GUI produces a tab below:

Image

Caveats

  • Access-denied detection is a heuristic. FIDO is the HID class Windows
    protects this way, so on a normal machine it's reliable, but in principle
    another protected HID device could match. It detects "a protected HID interface
    is present, probably FIDO" rather than "definitely FIDO." For zero false
    positives, combine it with a VID/PID allowlist of known FIDO vendors.

  • Open regression in the GUI wiring. Integrating the detection call into the
    device-scan path emptied the sidebar entirely (all keys vanished, including
    PC/SC ones). It is currently gated behind KEYROOST_WIN_FIDO=1 to isolate it;
    the root cause is not yet found. The standalone helper and probe work
    (hardware-confirmed); the in-GUI device synthesis is not yet stable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions