Windows Hello–style biometric login for Linux. IR camera facial authentication via PAM — works on immutable distros (Bazzite, Bluefin, Fedora Silverblue, Fedora Kinoite, etc.) with zero system packages, daemons, or layering.
Note
Includes improvements contributed via SamVivan1/authFace — robust IR camera detection, distro-aware PAM configuration, and the GNOME Shell lock-screen scan indicator. See Upstream Merges & Security Pass.
- Face unlock for sudo, lock screen (GNOME/Sway), and
gdm-password - ~2 seconds from camera poll to authenticated
- Static musl binary — no dependencies, no runtime
- No daemon, no systemd units, no D-Bus
- GUI settings panel (optional GTK4 app) for camera selection and enrollment
- Immutable-first — everything fits in
/usr/localand~/.local, no/usrmodifications needed
Improvements merged from SamVivan1/authFace:
| Change | Before | Now |
|---|---|---|
| Multi-IR-camera support | Returned the first IR-named device in /sys/class/video4linux |
Collects all IR candidates and uses the first that actually opens as a GREY capture device |
| Distro-aware PAM | Only Fedora (pam_selinux_permit.so insertion point) |
Also handles Ubuntu/Debian gdm-password (#%PAM-1.0) |
PAM quiet flag |
no quiet |
pam_exec.so quiet suppresses pam_exec chatter |
| Detector model download | Required models/version-slim-320.onnx to be present |
deploy.sh fetches it (now pinned to a commit and SHA-256 verified) |
| Lock screen scan indicator | None (silent scan) | face-auth writes a status file; a GNOME Shell extension renders scanning/ok/fail |
Followed by a security pass over the whole tree — see CHANGELOG.md for the full list. The changes that affect how you use it:
- Enrolment needs root (
sudo face-enroll, or one click in the GUI viapkexec). Face templates are authentication data; when the store was world-writable, any local user could enrol a face for an account that had not enrolled yet, then log in as it. - The login prompt trusts only
/etc/face-auth.toml. Your own config can make matching stricter, never looser. See Configuration. face-authrequiresPAM_USERrather than falling back toUSER,LOGNAMEorid -un, and refuses remote (PAM_RHOST) sessions.
Upgrading re-secures an existing template store in place, so no re-enrolment is needed.
- Windows Hello–compatible IR camera support — raw GREY format, no RGB camera needed
- Automatic password fallback — if face auth fails, times out, or no camera, PAM falls through to password
- Static musl binary (~20 MB, zero runtime dependencies) — copy to any Linux system
- No daemon, no systemd, no D-Bus — just
pam_exec.sotriggered by PAM - Configurable via
/etc/face-auth.toml,~/.config/face-auth.toml, or environment variables - Built-in capture timeout (5s default) — camera hang won't lock you out
- GTK4 settings GUI — select IR camera, adjust threshold, preview live feed, enroll, improve matching, and test face recognition
- Works on immutable distros — no
rpm-ostree layer, no package installs, no/usrmodification
# 1. Install core authentication (PAM, models, binaries)
sudo ./deploy.sh
# 2. Enroll your face (templates are root-owned, so this needs sudo)
sudo face-enroll --user $USER
# 3. Test sudo
sudo -k && sudo true # triggers IR camera → exit 0
# 4. (Optional) Install the settings GUI
sudo ./deploy-gui.sh
# 5. Launch the GUI from app menu: "Face Authentication Settings"
# or run: face-auth-gtkA native GTK4/libadwaita settings panel for configuring and testing face unlock:
| Feature | Description |
|---|---|
| Live IR preview | Real-time camera feed with face-detection overlay |
| Camera picker | Dropdown to select between IR cameras (pre-filtered to openable devices) |
| Threshold slider | Adjust similarity threshold (0.1–0.95) — higher = stricter match |
| Enroll | Captures 5 frames and stores face embeddings (replaces existing) |
| Improve Matching | Captures 5 more frames and appends to existing embeddings |
| Test | Captures a single frame and compares against enrolled embeddings |
| Automatic config save | Camera and threshold changes persist to ~/.config/face-auth.toml |
The GUI is optional and deployed separately (no GTK dependencies bundled with the core auth binary).
- IR camera exposing raw GREY format (Windows Hello compatible, e.g. Shinetech ASUS FHD webcam)
- Linux kernel with
uvcvideo(standard on all distros)
Multiple IR cameras? The fork's auto-detection finds all IR devices and picks the first one that opens. If you have more than one, set
deviceexplicitly in config to pin a specific one (see Configuration).
- PAM with
pam_exec.so(standard on all distros) - SELinux (Fedora/Bluefin/Silverblue) — deploy script installs policy automatically
policycoreutilsfor SELinux policy compilation (installed by default on Fedora)- For the GUI: GTK4 + libadwaita runtime libraries (system-installed, not bundled)
You need a Rust toolchain. For the core auth (musl), add x86_64-unknown-linux-musl target.
For the GUI (dynamic GTK), the host target is sufficient.
# Install Rust if needed
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add musl target
rustup target add x86_64-unknown-linux-musl
# Clone and build
git clone https://github.com/pfalkingham/authFace.git
cd authFace
cargo build --release --target x86_64-unknown-linux-musl -p face-auth -p face-enroll
# Deploy
sudo ./deploy.sh# Install GTK development libraries
sudo pacman -S --needed gtk4 libadwaita # Arch / CachyOS
sudo dnf install gtk4-devel libadwaita-devel # Fedora
sudo apt install libgtk-4-dev libadwaita-1-dev # Debian / Ubuntu
# Build
cargo build --release -p face-auth-gtk
# Deploy
sudo ./deploy-gui.shIf deploy.sh finds no toolchain it prints this command for you to run. It
does not run it for you: deploy.sh runs under sudo, and rootless podman
driven through sudo -u often fails on a missing XDG_RUNTIME_DIR.
podman run --rm -v "$PWD":/src:Z -w /src docker.io/library/rust:alpine \
sh -c 'apk add --no-cache musl-dev && \
cargo build --release --target x86_64-unknown-linux-musl \
-p face-auth -p face-enroll'
sudo ./deploy.shrust:alpine targets musl natively, so the result is the same static binary.
Run the container as your own user (not under sudo) so the files in target/
stay yours. This does not work for the GTK GUI, which links against the host's
GTK4 and must be built on the host.
# Create a Fedora development container
distrobox create --image registry.fedoraproject.org/fedora:latest --name authface-dev
distrobox enter authface-dev
# Inside the container, install build deps (once).
# Note: Fedora does not package a musl std for Rust, so the musl build needs
# rustup rather than the distro `rust` package.
sudo dnf install -y gcc musl-gcc gtk4-devel libadwaita-devel
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--target x86_64-unknown-linux-musl
source "$HOME/.cargo/env"
# Build
cd ~/Projects/authFace
cargo build --release --target x86_64-unknown-linux-musl -p face-auth -p face-enroll
cargo build --release -p face-auth-gtk
# Exit container, then deploy on host
exit
sudo ./deploy.sh
sudo ./deploy-gui.shThe GUI binary links against GTK4 dynamically, so build it in an environment whose GTK version matches the host's — a distrobox sharing the host is fine, an unrelated container image may not be.
sudo ./deploy.shandcargo: if you installed Rust with rustup,cargolives in~/.cargo/bin, which is not on root'sPATH. The script looks there for the invoking user and runs the build as that user rather than as root, sosudo ./deploy.shworks and does not leave root-owned files intarget/.
sudo ./deploy.sh| Step | What | Details |
|---|---|---|
| Build | Compiles if cargo is available |
Falls back to pre-built binaries in target/ |
| Binaries | Installs to /usr/local/bin |
face-auth + face-enroll |
| Detection model | Downloads version-slim-320.onnx if missing |
From Ultra-Light-Fast-Generic-Face-Detector-1MB upstream |
| Recog. model | Downloads from InsightFace | w600k_mbf.onnx (~13 MB) to /usr/local/share/face-auth/ |
| Config | Installs default config | /etc/face-auth.toml |
| PAM | Patches PAM service files | Adds sufficient pam_exec.so quiet to sudo, gdm-password, swaylock |
| SELinux | Compiles and loads policy | Allows xdm_t to mmap camera for lock-screen auth |
| Storage | Creates embeddings directory | /var/lib/face-auth/<user>/ with sticky bit |
GDM lock-screen patching is distro-aware:
- Fedora/Bluefin/Silverblue — inserts after
pam_selinux_permit.so - Ubuntu/Debian — inserts after
#%PAM-1.0
Each PAM file is backed up with a .face-auth.bak suffix.
sudo ./deploy-gui.shAutomatically detects whether /usr is writable:
- Mutable systems: installs to
/usr/local/bin,/usr/share/applications/,/usr/share/icons/ - Immutable systems: installs to
~/.local/bin,~/.local/share/applications/,~/.local/share/icons/
Launch from the application menu: Face Authentication Settings, or run face-auth-gtk.
# Remove everything (core + GUI + models + config)
sudo ./uninstall.sh
# Remove only the optional GUI
sudo ./uninstall.sh --gui
# Remove everything including face embeddings
sudo ./uninstall.sh --purgeRestores PAM backups, removes binaries, models, config, SELinux policy, desktop entries, and icons.
The authentication path and the unprivileged tools trust different things.
During PAM authentication (face-auth, i.e. sudo / lock screen / login):
| Source | Effect |
|---|---|
/etc/face-auth.toml (root-owned) |
Authoritative for everything |
~/.config/face-auth.toml |
May only make authentication stricter — see below |
FACE_AUTH_* environment |
Ignored entirely |
For face-enroll and the settings GUI, the usual layering applies:
environment variables, then ~/.config/face-auth.toml, then /etc/face-auth.toml.
A user's own config is read (resolved via getent passwd, so it is their home
and not whoever happened to invoke the PAM stack), but it is applied as a
narrowing overlay:
| Key | At the login prompt |
|---|---|
threshold, detector_threshold |
Honoured only if >= the system value. A lower number is ignored. |
device |
Honoured only if the path is a real IR capture device on this machine (IR-looking sysfs name, opens as GREY). |
scan_duration_ms, scan_interval_ms, capture_timeout_ms |
Honoured within built-in bounds. |
model_path, detector_model_path, embeddings_dir |
Ignored — system policy only. |
This is what stops code running as you — which does not know your password —
from writing a permissive ~/.config/face-auth.toml and turning your next
sudo into a root shell. To loosen matching, edit /etc/face-auth.toml as
root; the GUI's slider starts at the system value for the same reason.
Example /etc/face-auth.toml:
device = "/dev/video2" # usually best left unset; see below
threshold = 0.6
model_path = "/usr/local/share/face-auth/w600k_mbf.onnx"
embeddings_dir = "/var/lib/face-auth"
capture_timeout_ms = 5000Environment variable names follow the field names, so the capture timeout is
FACE_AUTH_CAPTURE_TIMEOUT_MS (not FACE_AUTH_CAPTURE_TIMEOUT).
Leave
deviceunset unless you must pin it. UVC cameras normally expose a metadata node right beside the capture node under the same name — on the reference ASUS FHD webcam,/dev/video2captures and/dev/video3does not. Auto-detection opens each IR-named candidate and takes the first that is really a GREY capture device, which gets this right; a hand-written path often does not.
The GUI writes camera and threshold changes to ~/.config/face-auth.toml.
Face templates live in a root-owned directory (/var/lib/face-auth, mode
0700), so enrolment is a privileged operation:
# Replace existing embeddings with a new capture
sudo face-enroll --user $USER
# Append new embeddings to improve recognition across lighting/angles
sudo face-enroll --improve --user $USERCLI options: --frames, --interval, --device, --threshold, --model,
--embeddings-dir, --improve, -v.
The GUI's Enroll Face, Improve Matching and Test Authentication
buttons run the same helpers through pkexec, so you get a graphical
authentication prompt instead of a terminal. polkit's default for
org.freedesktop.policykit.exec is auth_admin_keep, so consecutive actions
within a few minutes will not re-prompt.
Why this is not user-writable: whatever can write a face template decides whose
face unlocks that account. If your own login could rewrite it, then so could
anything running as you, and a stolen browser session would become a root
shell at the next sudo.
The deploy script adds a sufficient pam_exec.so quiet line to:
| Service | File | Insertion point |
|---|---|---|
sudo |
/etc/pam.d/sudo |
After #%PAM-1.0 |
gdm-password |
/etc/pam.d/gdm-password |
After pam_selinux_permit.so (Fedora) / after #%PAM-1.0 (Ubuntu/Debian) |
swaylock |
/etc/pam.d/swaylock |
After #%PAM-1.0 |
sufficient means: if face-auth exits 0, the user is authenticated immediately.
If it fails (no match, no camera, timeout), PAM falls through to password prompt.
quiet suppresses PAM chatter on the lock screen so the unlock UI stays clean.
No timeout, setenv, or env_pass flags are needed — face-auth reads the camera
(not stdin) and resolves PAM_USER via its own fallback chain.
By default the scan is silent: face-auth runs headless inside PAM, so the only
feedback is the camera LED. A companion GNOME Shell extension shows live status
on the lock screen while the face is being scanned:
| Status | Indicator |
|---|---|
| Scanning | Pulsing pill with camera icon + "Scanning face…" |
| Success | Green check — "Face recognised" (briefly) |
| Failure | Red error — "Face not recognised — use your password" |
-
face-auth(the PAM binary) writes a status file to the authenticated user's runtime directory while it runs:/run/user/<uid>/face-auth-statuscontainingscanning, thenokorfail. -
The extension watches that file with a
Gio.FileMonitorwhile the unlock UI is on screen, and renders the indicator above it.The file is written with
O_NOFOLLOWbecauseface-authruns as root and the runtime directory belongs to the user — otherwise a symlink there would aim a root write at any file on the system.
No daemon, no D-Bus server — just a small status file, keeping the zero-footprint design of the core.
# Requires GNOME Shell 45+ (Fedora 39+, Bazzite, Bluefin, Silverblue, Kinoite)
extensions/authface-scan-indicator/install-extension.sh
# Then: Alt+F2 → r (X11) or log out/in (Wayland)Everything lives in ~/.local/share/gnome-shell/extensions/ — immutable-friendly.
Note: this shows on the session lock screen (Super+L / auto-lock), not on the GDM login/greeter screen. The greeter runs in a separate locked-down shell as the
gdmuser and does not expose hooks for third-party indicators.
PAM (sudo / gdm-password / swaylock)
│
▼
face-auth (static binary)
├─ Resolve PAM_USER via getent (refuses to guess from USER/LOGNAME)
├─ Refuse if PAM_RHOST names a remote host
├─ Load /etc/face-auth.toml + strictly-narrowing user overlay
├─ V4L2 capture from IR camera (640×400 GREY, auto-detected /dev/videoN)
│ └─ poll() with 5s timeout — exits cleanly if camera hangs
├─ Histogram equalization
├─ Face detection (RetinaFace-derived ONNX model)
├─ Resize to 112×112, normalize to [-1, 1]
├─ tract-onnx inference (MobileFaceNet, 512-d embedding)
├─ Cosine similarity vs stored embeddings (default threshold 0.6)
└─ Exit 0 (match) or exit 1 (no match → password prompt)
Uses InsightFace w600k_mbf.onnx (MobileFaceNet @ WebFace600K, ~13 MB, 512-d output)
from the buffalo_sc model pack, plus version-slim-320.onnx for face detection.
Licensed under MIT (InsightFace is MIT-licensed).
The recognition model is not bundled in this repository. deploy.sh downloads it from
InsightFace's official GitHub releases and verifies the SHA-256 checksum. The detection
model is auto-downloaded from the Ultra-Light-Fast-Generic-Face-Detector-1MB repository.
On Fedora/Bluefin/Silverblue with SELinux enforcing, the GNOME lock screen runs in the
xdm_t domain. This domain cannot mmap video devices by default. The deploy script
installs a minimal policy module:
allow xdm_t v4l_device_t:chr_file map;
To remove: sudo semodule -r face_auth
If the deploy script reported missing SELinux tools:
sudo dnf install -y policycoreutils
sudo checkmodule -M -m -o face_auth.mod selinux/face-auth.te
sudo semodule_package -o face_auth.pp -m face_auth.mod
sudo semodule -i face_auth.pp# List available IR cameras
ls /sys/class/video4linux/*/name
# Grant video group access (log out/in after)
sudo usermod -aG video $USER
# Debug output from a live sudo attempt
sudo -k; RUST_LOG=face_auth_core=debug,face_auth=debug sudo true
# Check PAM logs
journalctl | grep -i "pam_exec\|face-auth"
# SELinux denials
journalctl -k | grep face-auth | grep denied
# Test a stored face directly (skips PAM; needs root to read templates)
sudo face-auth --verify $USER
echo $? # 0 = match, 1 = no match, 2 = error
# Raise the capture timeout (note the _MS suffix)
FACE_AUTH_CAPTURE_TIMEOUT_MS=10000 sudo face-auth --verify $USER
# GUI not launching from app menu?
face-auth-gtk # run from terminal to see errorsface-auth no longer guesses the account from USER/LOGNAME. If you are
invoking it by hand, use --verify rather than setting PAM_USER yourself.
The selected device is not an IR sensor — it is an ordinary RGB webcam, or the
metadata node that sits next to the real capture node. Let auto-detection pick
one, or check v4l2-ctl --device /dev/videoN --list-formats.
Most Windows Hello IR modules strobe their illuminator, emitting a lit frame and a near-black one alternately. Check what yours does:
cargo run --example frame-statsOn the reference ASUS sensor the lit frames mean 71–237 (of 255) and the unlit
ones 2–5, strictly alternating at 15 fps. authFace captures frames in pairs and
keeps the brighter, so this is handled — but if frame-stats shows every
frame dark, the illuminator is not firing and no amount of software will help.
A dark frame is worse than useless: histogram equalisation stretches its narrow range across the full scale and turns sensor noise into a high-contrast grey field, which is what a flickering preview is showing you.
cargo run --example detect-cameraLists every V4L2 node, whether its name looks like an IR sensor, whether it really opens as a GREY capture device, and which one authentication would pick.
# See which IR device is detected
sudo face-auth --verify $USER # with RUST_LOG=face_auth_core=debug
# Pin a specific camera in your own config (must be a real IR device)
echo 'device = "/dev/video2"' >> ~/.config/face-auth.tomlA user config may only make matching stricter. To loosen it, lower
threshold in /etc/face-auth.toml as root — see Configuration.
- Face templates are root-owned.
/var/lib/face-authis mode0700, root:root, with templates at0600. Whatever can write a template decides whose face unlocks that account, so enrolment goes throughsudo/pkexec. - The PAM path trusts only
/etc/face-auth.toml. A user's own config may make matching stricter, never looser, and may not redirect the model or template paths.FACE_AUTH_*environment variables are ignored during authentication. See Configuration. - Identity comes from
PAM_USERonly.face-authrefuses to run if PAM did not set it, rather than falling back toUSER,LOGNAMEorid -un. - Remote sessions are refused. If
PAM_RHOSTnames a non-local host, face authentication is declined — the camera is at the console, so otherwise whoever is sitting at the desk would authenticate an SSH session.
- IR-only, no liveness detection: an IR camera resists casual photo spoofing, but there is no structured-light or dot-projection depth check. A high-quality IR-visible print or a 3D mask may bypass verification. This is the main residual risk and it is inherent to the approach — treat face unlock as a convenience over a password you still have, not as a stronger factor.
- No rate limiting or lockout. Every prompt allows a fresh scan window.
PAM's own
pam_faildelay/pam_tally2are not wired up. sufficientbypasses the rest of the auth stack. A successful match satisfies authentication outright; any otherauthmodule below the face-auth line is skipped. That is the point, but it means the strength of the whole stack becomes the strength of the face match.- SELinux policy scope: the lock-screen policy grants
xdm_tmmap access to all V4L2 devices. A trade-off for drop-in compatibility; narrowing it requires custom udev device types. - x86_64 only: V4L2 ioctl numbers and struct layouts are hardcoded.
ARM/aarch64 requires switching to the
v4lcrate. - Model integrity: both ONNX models are pinned by SHA-256 and the detector
URL is pinned to a commit, not a branch.
deploy.shaborts on mismatch.
authFace/
crates/
face-auth-core/ # Core library
src/
capture.rs # V4L2 capture + poll() timeout + IR camera auto-detect
config.rs # Layered config + narrowing overlay for PAM + per-user load
detector.rs # Face detection (RetinaFace-based ONNX model)
error.rs # Error types
inference.rs # tract-onnx model loading + encoding
lib.rs # FaceAuth struct, auth + enroll + scan
preprocess.rs # Histogram equalize, resize, normalize
storage.rs # Binary embedding I/O (versioned, atomic, 0600)
user.rs # NSS lookup + username validation
verify.rs # Cosine similarity
face-auth/ # PAM binary (stdin-less, PAM_USER fallback)
face-enroll/ # Enrollment CLI
face-auth-gtk/ # GTK4 settings GUI
config/
face-auth.toml.example # Documented config template
data/
desktop file + icon # App launcher assets
selinux/
face-auth.te # SELinux policy source
extensions/
authface-scan-indicator/ # GNOME Shell lock-screen scan indicator
extension.js
metadata.json
install-extension.sh
deploy.sh # Core auth installer
deploy-gui.sh # Optional GUI installer
uninstall.sh # Removal script (--gui, --purge flags)
MIT
This is a fork of pfalkingham/authFace (MIT). The
facial recognition model is InsightFace's w600k_mbf.onnx (MIT) and the face detector is
version-slim-320.onnx (MIT).