Skip to content

Add optional face-verified WebAuthn/passkey platform authenticator (experimental)#1125

Open
qilsklo wants to merge 231 commits into
boltgolt:devfrom
qilsklo:webauthn
Open

Add optional face-verified WebAuthn/passkey platform authenticator (experimental)#1125
qilsklo wants to merge 231 commits into
boltgolt:devfrom
qilsklo:webauthn

Conversation

@qilsklo

@qilsklo qilsklo commented Jul 7, 2026

Copy link
Copy Markdown

Summary

This adds an optional, off-by-default software platform authenticator that
lets browsers create and use WebAuthn/passkeys unlocked by Howdy face
recognition. It presents a virtual FIDO2 HID device via the kernel's uhid
interface, so unmodified Firefox (≥114) and Chromium talk to it as if it
were a plugged-in security key — no browser extension, patch, or
native-messaging host required.

Flow: browser → CTAPHID over /dev/uhid → CTAP2 core → face verification (the same compare.py path PAM uses) → per-RP key signs the challenge.

None of this touches the existing PAM / login / sudo path. The feature is
not built or installed unless you configure Meson with -Dwith_webauthn=true,
and the daemon refuses to start unless explicitly enabled in config.ini.

What's included

  • Internal face-verification API (verification.py) wrapping compare.py
    with a clean verify_face() / FaceVerifier interface. PAM is unchanged.
  • CTAP2 authenticator core (webauthn/authenticator.py) — make_credential,
    get_assertion, get_info, etc., built on python-fido2 (no home-grown
    crypto or protocol). ES256, per-RP keys, persisted signing counter.
  • Credential store with atomic, flock-guarded writes.
  • Two keystores: a TPM 2.0 backend (keys generated and used inside the TPM
    via tpm2-pytss) and an AES-256-GCM software backend clearly labelled
    development-grade.
  • CTAPHID + uhid transport so stock browsers see a FIDO2 device.
  • Root daemon + hardened systemd unit + udev uaccess rule (all installed
    only behind -Dwith_webauthn).
  • CLI: howdy webauthn {init,status,list,remove,register,authenticate,run}.
  • Docs: docs/webauthn-design.md (architecture + threat model) and
    docs/webauthn.md (operator guide).
  • Drive-by: fixes howdy test crashing on NumPy 2.x (cv2.calcHist indexing).

Security posture (deliberately honest)

This is convenience-grade, not a certified FIDO authenticator, and not
equivalent to Windows Hello or a hardware security key:

  • Private keys are never stored in plaintext (TPM-sealed, or AES-256-GCM; the
    software keystore is filesystem-permission protected and marked as such).
  • Successful face verification is required immediately before every
    credential operation — no caching.
  • Never logs private keys, credential IDs, assertions, biometric embeddings,
    or secrets.
  • RGB-only webcams give weaker presentation-attack resistance than IR/depth
    systems; this is documented.

Limitations

  • Presents as a USB/roaming key, so browser prompts say "security key" and
    sites requiring authenticatorAttachment: "platform" may not offer it.
  • ES256 only; self-attestation only; single user per running daemon.
  • No autofill/platform-passkey D-Bus portal (credentialsd) integration yet —
    on the roadmap in the design doc.

Testing

83 unit + integration tests (python -m pytest tests/). The integration test
drives the full stack with python-fido2's own CTAP2 client over an in-memory
loopback (no root/uhid needed), verifying registration, self-attestation and
assertion signatures, and counter increments against an independent
implementation.

Try it

meson setup build -Dwith_webauthn=true && meson compile -C build && sudo meson install -C build
# config.ini: [webauthn] enabled = true
sudo howdy webauthn init
sudo systemctl enable --now howdy-webauthn
# then register a passkey at e.g. https://webauthn.io

New dependency: python-fido2 (required for the feature only). Optional:
tpm2-tss + python-tpm2-pytss for the TPM keystore.

saidsay-so and others added 30 commits December 26, 2020 09:33
Signed-off-by: MusiKid <musikid@outlook.com>
Signed-off-by: MusiKid <musikid@outlook.com>
Signed-off-by: MusiKid <musikid@outlook.com>
Signed-off-by: MusiKid <musikid@outlook.com>
Signed-off-by: MusiKid <musikid@outlook.com>
Add python-opencv as a dependency for ArchLinux
thecalamityjoe87 and others added 30 commits August 17, 2024 15:52
Update datetime since datetime.utcnow() was deprecated since Python 3.12.
…lation

Since pam_python depends on python2 and python2 cannot be installed through fedoras repositories the installation will fail. The beta of Howdy has fixed this issue. 
This problem is also explained in the copr repo. But it requires some searching.
Remove use of Environment Python
fix(pam): use environ variable when getenv doesn't work
Fix unwanted unlocks when auth client subprocesses exit with 0
Connect to signals of the shown window
Update README.md for Fedora 41 users
The current EDITOR handling is a bit lacking.

Improvements:

- check that the editor exists before trying to spawn a subprocess
- add vi as nano alternative
- if everything else fails, perhaps the user is using sudo. Suggest
  using 'sudo -E howdy [...]', as otherwise the command would run in
  a non-interactive shell (i.e. without sourcing the initialization
  files), and no environment variable is carried over (including
  EDITOR) unless explicitly preserved
…ndling

feat(config): better EDITOR handling
Research and architecture for an optional WebAuthn/FIDO2 platform-style
authenticator gated on Howdy facial verification. Chosen architecture:
transport-agnostic CTAP2 core (python-fido2) + virtual UHID CTAP2 HID
transport, keystore abstraction with TPM2 and development software
backends, credentialsd D-Bus portal as documented future path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
verification.py exposes verify_face(user) / FaceVerifier by wrapping the
compare.py subprocess exit-code contract already used by the PAM module,
leaving all existing authentication paths untouched. Includes pytest
scaffolding and unit tests driven by a stub compare script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Credential records (RP id, user handle, COSE public key, sign counter,
resident flag) persisted atomically as root-only JSON with flock guarded
read-modify-write. KeyStore abstraction with an AES-256-GCM software
development backend and an optional TPM 2.0 backend (tpm2_pytss): keys
created and used inside the TPM, only wrapped blobs on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transport-agnostic CTAP 2.1 command handling (MakeCredential,
GetAssertion, GetNextAssertion, GetInfo, Reset, Selection) built on
python-fido2 structures, with a mandatory fresh face verification in
front of every credential operation and packed self-attestation.
New 'howdy webauthn' CLI: init, status, list, remove, plus local
register/authenticate end-to-end tests against a fake RP. Existing
commands and PAM behaviour unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the CTAP2 authenticator core to a virtual FIDO2 HID device so
unmodified browsers can talk to it:

- webauthn/ctaphid.py: CTAPHID framing/reassembly, channel allocation,
  keepalives (UPNEEDED during face verification), CBOR dispatch, cancel
- webauthn/uhid.py: /dev/uhid virtual HID device with a FIDO usage-page
  report descriptor
- webauthn/service.py + webauthn_daemon.py: root daemon that resolves its
  target user from config and pumps host reports into the authenticator.
  Separate entry point so it never trips the CLI's "no root" guard
- howdy-webauthn.service / 70-howdy-webauthn.rules: systemd unit (hardened,
  refuses to start unless enabled in config) and udev uaccess rule
- meson: install the above behind -Dwith_webauthn (off by default), so the
  normal PAM/login/sudo path is completely unaffected

Tests: CTAPHID unit tests and an in-memory end-to-end test driving the
stack with python-fido2's independent CTAP2 client (registration,
self-attestation + assertion signature verification, counter increments).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-facing guide covering installation (-Dwith_webauthn), keystore setup,
browser usage (Firefox/Chromium), full config/CLI reference, the security
model and its convenience-grade classification, limitations,
troubleshooting, developer architecture and roadmap.

Installed to the datadir path the systemd unit's Documentation= references
when built with -Dwith_webauthn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Log each CTAP2 operation, its RP id/counts, the face-verification result
  and any CTAP error or unexpected exception via a "howdy.webauthn" logger,
  routed to the journal by the daemon. Never logs keys, credential IDs,
  assertions, client-data hashes or biometric data.
- Make UHidDevice.close() capture-and-null the fd and never raise, so the
  SIGTERM handler interrupting the read loop can't double-close (previously
  crashed with TypeError: 'NoneType' ... on shutdown).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browsers drive a built-in-UV, no-PIN authenticator with two messages our
handlers rejected outright, which aborted every registration and sign-in:

- Zero-length pinUvAuthParam (the CTAP2.1 PIN/UV state probe): we returned
  PIN_AUTH_INVALID. Now answer PIN_NOT_SET so the platform retries the
  ceremony using built-in user verification. A non-empty pinUvAuthParam (a
  token we never issue) is ignored; the face check remains the UV gate.
- getAssertion with up=false (silent credential-discovery pre-flight): we
  returned UNSUPPORTED_OPTION. Now return a no-UP/no-UV assertion without a
  face check and without advancing the counter. An RP rejects an up=0
  assertion as a login, so this is not a verification bypass.

Updates the unit tests to the corrected behavior and adds coverage for the
probe and the silent pre-flight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Overlapping ceremonies (e.g. clicking "sign in with a passkey" twice) could
crash the browser: keepalive, worker-response and feed-thread frames were
written to the HID device from several threads with no serialization, so a
foreign frame could be spliced into the middle of a fragmented CBOR response
and corrupt the host's reassembler.

- Guard all outbound framing with a send lock so each message reaches the
  host as one contiguous run of frames.
- On CTAPHID_INIT re-sync of a channel, cancel any in-flight operation (and
  its face verification / keepalives) instead of leaving it running.

Adds tests for non-interleaved concurrent sends and re-INIT cancellation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cancelled operation dropped its response frame entirely. Per the CTAP HID
spec, after CTAPHID_CANCEL the pending makeCredential/getAssertion must still
complete and return CTAP2_ERR_KEEPALIVE_CANCEL to the host; dropping it left
the browser waiting for a reply that never came and could crash it.

Now send the response on CTAPHID_CANCEL, and only suppress it for a
CTAPHID_INIT re-sync (where the channel was reset and the INIT frame is the
reply). Tests updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.