Introduction
Facelock is a modern face authentication system for Linux PAM. It provides Windows Hello-style facial authentication with IR-required capture and layered static-presentation checks, configurable as a persistent daemon or daemonless one-shot. Inference runs locally; model download occurs during setup, while the authentication path makes no network request and sends no telemetry.
Quick Start
The latest stable release is v0.2.0, which provides direct Debian 13, Ubuntu 26.04, and Fedora 44 packages and is served by the AUR entries, both APT suites, and the production COPR. See Quick Start for the exact package filenames, the APT source entry, and the current channel status.
just build
target/debug/facelock --help
Install the distro-specific native build dependencies first; Rust and the
dynamically loaded ONNX Runtime are separate prerequisites. just build does
not install the binary, and --help does not test inference or load ONNX
Runtime. Camera-facing development uses the
explicit built path and --config "$PWD/dev/config.toml"; management commands
remain root-gated and root ignores FACELOCK_CONFIG. See Quick Start
before enrolling or changing host authentication.
Operating Modes
| Mode | Config | How it works | Latency |
|---|---|---|---|
| Daemon | mode = "daemon" (default) | PAM connects via D-Bus, persistent daemon | fastest: no model load, no reopen when warm |
| D-Bus activation | systemd + D-Bus service | systemd starts daemon on demand | + daemon start on the first call |
| Oneshot | mode = "oneshot" | PAM spawns facelock auth subprocess | + model load on every call |
Daemon latency depends on camera state: a cold attempt pays a camera reopen, a retry within device.camera_release_secs of a failed attempt does not. That reopen cost is a property of your camera and driver, not a number to quote from someone else's laptop -- measure it with sudo facelock bench camera-reopen, which prints the open / STREAMON / warmup split.
The CLI works in all modes -- it connects to the daemon if available, otherwise operates directly.
Architecture
facelock (unified binary)
├── facelock setup Download models, validate systemd, configure PAM
├── facelock enroll Capture and store a face
├── facelock test Test recognition
├── facelock list List enrolled models
├── facelock preview Live camera preview
├── facelock daemon Run persistent daemon
├── facelock auth One-shot auth (PAM helper)
├── facelock devices List cameras
├── facelock tpm status TPM status
└── facelock bench Benchmarks
pam_facelock.so (PAM module)
├── daemon mode → D-Bus IPC to daemon
└── oneshot mode → fork/exec facelock auth
Crates
| Crate | Type | Purpose |
|---|---|---|
facelock-core | lib | Config, types, errors, D-Bus interface, traits |
facelock-camera | lib | V4L2 capture, auto-detection, preprocessing |
facelock-face | lib | ONNX inference (SCRFD detection + ArcFace embedding) |
facelock-store | lib | SQLite face embedding storage |
facelock-daemon | lib | Auth/enroll logic, liveness, audit, rate limiting, request handler |
facelock-cli | bin | All CLI commands, daemon runner, direct mode, benchmarks |
facelock-bench | bin | Standalone benchmark and calibration utility |
pam-facelock | cdylib | PAM module (libc + toml + serde + zbus only) |
facelock-tpm | lib | Optional TPM-bound encryption for embeddings at rest |
facelock-polkit | bin | Polkit authentication agent for face auth |
facelock-test-support | lib | Mock camera/engine for testing |
Face Recognition Pipeline
Camera Frame → SCRFD Detection → 5-point landmarks
→ Affine Alignment → 112x112 face crop
→ ArcFace Embedding → 512-dim L2-normalized vector
→ Cosine Similarity vs stored embeddings → MATCH / NO MATCH
Configuration
All keys are optional. Camera is auto-detected if device.path is omitted. See the Configuration chapter for full reference.
[device]
# path = "/dev/video2" # auto-detected if omitted (prefers IR)
[recognition]
# threshold = 0.80 # cosine similarity threshold
[daemon]
# mode = "daemon" # "daemon" or "oneshot"
[security]
# require_ir = true # refuse auth on RGB cameras
# require_frame_variance = true # reject photo attacks
Installation
See Quick Start for full instructions.
Privacy & Security
Privacy: Facelock is 100% local. Face detection and recognition run entirely on your hardware via ONNX Runtime. No images, embeddings, or metadata are ever sent to any external server. There is no telemetry, no analytics, no phone-home behavior. Models are downloaded once during setup -- after that, Facelock never touches the network.
Security:
- IR camera enforcement on by default (anti-spoofing)
- Frame variance checks reject static photo attacks
- Constant-time embedding comparison via
subtlecrate - AES-256-GCM encryption at rest with optional TPM-sealed keys
- Model SHA256 verification at every load
- D-Bus system bus policy
- PAM audit logging to syslog
- Rate limiting (5 face-detected authentication failures/user/60s by default)
- systemd service hardening
See Security for the full threat model.
License
Dual-licensed under MIT or Apache 2.0, at your option.
The ONNX face models used by Facelock are licensed separately under the InsightFace non-commercial research license.
Quickstart
Published packages
As checked on 2026-09-06, v0.2.0 is the latest stable release. Every stable channel serves it: the three AUR entries, both APT suites, and the production COPR.
The following SHA256 values are pinned from the release's MANIFEST.json. Each command chain verifies the downloaded bytes before invoking the package manager; stop if verification fails. These checks establish integrity against the reviewed release, not an independent signing or build attestation.
For Debian 13 amd64, install the published package directly:
curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock_0.2.0-1.deb13u1_amd64.deb &&
printf '%s %s\n' '2554a1dcd4eca7bb1e3c2f2fbafbffa31d2c6a84210d32e9e221ece9e6e8dace' 'facelock_0.2.0-1.deb13u1_amd64.deb' | sha256sum --check - &&
sudo apt install ./facelock_0.2.0-1.deb13u1_amd64.deb
For Ubuntu 26.04 amd64, use the corresponding suite build:
curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock_0.2.0-1.ubuntu26.04.1_amd64.deb &&
printf '%s %s\n' 'ee4bc06963752bf39e888c2d10ba5229f256978d0b14b134463e19525b323d79' 'facelock_0.2.0-1.ubuntu26.04.1_amd64.deb' | sha256sum --check - &&
sudo apt install ./facelock_0.2.0-1.ubuntu26.04.1_amd64.deb
The dots in those download filenames are GitHub-safe stored names.
MANIFEST.json records the native names facelock_0.2.0-1~deb13u1_amd64.deb
and facelock_0.2.0-1~ubuntu26.04.1_amd64.deb, and Debian reports the
installed versions 0.2.0-1~deb13u1 and 0.2.0-1~ubuntu26.04.1. The bytes and
the checksum are the same either way. Fedora 44 x86_64 users can install the
direct release RPM:
curl -fLO https://github.com/tyvsmith/facelock/releases/download/v0.2.0/facelock-0.2.0-1.fc44.x86_64.rpm &&
printf '%s %s\n' '65cf7d3167979daa8d5af6f0e5c96c25d21c6b27b07a47d8dd212007fab725c5' 'facelock-0.2.0-1.fc44.x86_64.rpm' | sha256sum --check - &&
sudo dnf install ./facelock-0.2.0-1.fc44.x86_64.rpm
The separately downloadable facelock, PAM module, and polkit-agent binaries
are release components, not a complete installation. They do not install the
configuration, models, service and D-Bus policy, PAM layout, or required shared
libraries; use a native package or the source installation path instead.
Arch users can install the stable source-build AUR package. facelock-bin is
the prebuilt alternative and facelock-git follows development; all three AUR
entries served version 0.2.0-1 on 2026-09-06, and each declares the
onnxruntime dependency the binary loads at runtime.
yay -S facelock
Debian 13 (trixie) and Ubuntu 26.04 LTS (resolute) on amd64 are the 0.2.0
stable APT targets. Both suites are published at
https://tysmith.me/facelock/apt and served 0.2.0 when checked on 2026-09-06:
| Supported target | Suite | Architecture | Required capability |
|---|---|---|---|
| Debian 13 | trixie | amd64 | TPM |
| Ubuntu 26.04 | resolute | amd64 | TPM |
Install the archive keyring, write a source entry naming your codename, then install the package:
sudo curl -fsSL https://tysmith.me/facelock/apt/tysmith-archive-keyring.gpg \
-o /usr/share/keyrings/tysmith-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/tysmith-archive-keyring.gpg] https://tysmith.me/facelock/apt trixie facelock" | sudo tee /etc/apt/sources.list.d/facelock.list
sudo apt update
sudo apt install facelock
On Ubuntu 26.04, use resolute instead of trixie in the source line. The
keyring holds one rsa4096 key, Ty Smith (Package Signing) <packages@m.tysmith.me>, fingerprint
E7F8A4C424C6D59BD38536B536A81FCD934C17CE, checked on 2026-09-06. Confirm it
with gpg --show-keys before trusting the source; signed-by scopes that key
to this repository alone, and apt update rejects the suite if the archive
signature does not match.
Existing v0.1.4 entries naming main or legacy keep working until 0.3.0:
main maps to the Trixie package set and legacy serves signed empty indexes.
At 0.3.0, apt update fails until the entry is removed. Rewrite those entries
to your operating system's codename now.
The production COPR targets Fedora 43, 44, and 45 only. It served 0.2.0-1 on
all three when checked on 2026-09-06. The older 0.1.3 build stays in the
repository; dnf resolves to the newest, so no pinning is needed.
tyvsmith/facelock-testing is a staging/candidate project, not a stable
channel. RHEL is not in the supported matrix. Fedora's dnf copr command comes
from dnf5-plugins; installing it is idempotent, and minimal installations may
not include it.
sudo dnf install dnf5-plugins
sudo dnf copr enable tyvsmith/facelock
sudo dnf install facelock
After any package install, keep a separate root shell open and run the wizard:
sudo facelock setup
sudo facelock test
The wizard offers camera selection, model download, encryption, daemon setup,
enrollment, and PAM configuration. If enrollment is selected there, do not run
a redundant initial facelock enroll. facelock test may exit zero when no
scan ran or when a completed scan did not match; verify the printed result,
then test sudo from a new terminal before closing the recovery shell.
Build from source
Source-build prerequisites
Facelock's minimum supported Rust version is 1.88+. rust-toolchain.toml selects
Rust 1.95 when Cargo is a rustup proxy; distro-provided Cargo does not interpret
that file and uses its installed compiler instead. The target distro versions
below all satisfy the 1.88 floor. ONNX Runtime is a separate runtime shared
library: compiling the Rust crate does not install it.
On Arch Linux, the rust package provides Cargo. Use the exact CPU runtime
package, onnxruntime-cpu; onnxruntime is a virtual provide shared by the
CPU, CUDA, and ROCm variants.
sudo pacman -Syu --needed base-devel git rust just clang gettext pkgconf pam v4l-utils wayland libxkbcommon tpm2-tss onnxruntime-cpu
On Debian 13, add deb http://deb.debian.org/debian trixie-backports main to a
root-owned file under /etc/apt/sources.list.d/. Trixie's native Rust 1.85 is
below the project floor; install both Rust packages explicitly from backports.
sudo apt update
sudo apt install build-essential git just clang gettext pkg-config libpam0g-dev libv4l-dev libwayland-dev libxkbcommon-dev libtss2-dev
sudo apt install -t trixie-backports rustc cargo
Ubuntu 26.04's native Rust satisfies the floor:
sudo apt update
sudo apt install build-essential git rustc cargo just clang gettext pkg-config libpam0g-dev libv4l-dev libwayland-dev libxkbcommon-dev libtss2-dev
Fedora 43/44/45 provides both the compiler dependencies and a system ONNX
Runtime. These are the names used by the RPM build, plus just for this
repository's commands:
sudo dnf install git rust cargo just gcc gcc-c++ clang-devel gettext pkgconf-pkg-config pam-devel libv4l-devel wayland-devel libxkbcommon-devel tpm2-tss-devel onnxruntime
Debian 13 publishes libonnxruntime1.21, and Ubuntu 26.04 publishes
libonnxruntime1.23. Their multiarch library paths and versioned SONAMEs
(libonnxruntime.so.1.21 and libonnxruntime.so.1.23) are incompatible with
Facelock's current trusted loader, which requires libonnxruntime.so.1 as the
SONAME and searches fixed runtime directories rather than multiarch paths.
A source build can compile and run non-inference commands such as --help,
but enrollment, authentication, preview, and inference benchmarks require a
separately installed compatible ONNX Runtime 1.20+ in a trusted runtime
location. Merely adding a symlink does not repair a mismatched SONAME.
The published .deb packages bundle the compatible CPU runtime 1.20.1;
this is not supplied by Cargo.
just build
target/debug/facelock --help
just build does not install anything or put facelock on PATH; use the
explicit target/debug/facelock path. target/debug/facelock --help does not
load ONNX Runtime and is not an inference check.
For camera development, first provide the checksum-verified model files and use the development configuration explicitly:
just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices
sudo target/debug/facelock --config "$PWD/dev/config.toml" enroll --skip-setup-check
sudo target/debug/facelock --config "$PWD/dev/config.toml" test
These commands are privileged because the management CLI enforces its normal
root gate even with dev/config.toml. FACELOCK_CONFIG cannot replace the
explicit flag under sudo: all effective-UID-0 processes ignore that variable.
The development configuration uses direct/oneshot operation and temporary
state; it is not an installed PAM setup.
On Arch, after installing onnxruntime-cpu, a source-based system install is:
just install
sudo facelock setup
sudo facelock test
just install builds as the invoking user and prompts for sudo only for the
file installation. It installs the binary, PAM module, service units, D-Bus
policy, configuration and supporting assets, but does not edit any PAM service.
Only the later wizard or facelock pam add does that. This source installer
uses the Arch/Debian /lib/security PAM layout. Use the distro package rather
than this installer on Fedora, and do not treat a Debian/Ubuntu source install
as inference-capable until a trusted ONNX Runtime has been installed separately.
NixOS source-tree module
Facelock is not in nixpkgs and Nix is not a published package channel or a row
in the supported release matrix. The repository does ship a flake and NixOS
module under dist/nix. Release CI gates flake evaluation, while the actual Nix
build remains advisory and the derivation disables its test phase. The flake
has no checked-in flake.lock, so evaluation resolves network inputs and is
not a locked, reproducible package publication.
The source-tree module exports nixosModules.default. Its public options are
services.facelock.enable, services.facelock.package, and
services.facelock.config; the last maps directly to Facelock's TOML
configuration. When enabled, it installs the selected package, writes the
configuration, enables the daemon, and adds Facelock to the sudo PAM service.
This interface is experimental and is not currently a usable authentication
installation. The derivation places ONNX Runtime under its Nix store output,
but privileged Facelock processes search only the trusted /usr/lib and
/usr/lib64 roots and intentionally ignore ORT_DYLIB_PATH. The module also
does not provision the model files or the encryption key needed for enrollment.
Do not enable its PAM rule on a system that depends on it until those gaps are
fixed and the complete NixOS path has been validated.
Explore safely
After system installation and setup:
sudo facelock devices
sudo facelock list
sudo facelock preview --json
sudo facelock status
sudo facelock bench camera-reopen
Camera commands touch real hardware. See Testing Safety before changing PAM, and Developer Commands for the full validation inventory.
Package lifecycle and retained data
For a source installation only, use the source checkout's removal recipe:
just uninstall
For a native package installation, remove Facelock through the same package manager that installed it; do not use the source uninstaller to delete package-owned files behind the package manager.
Ordinary package removal and just uninstall preserve the face database,
encryption keys, models, enrollment markers, logs, snapshots, and setup state.
Debian purge removes only provably safe entries under the compiled Facelock
roots; safety refusals are reported without stranding package-manager state.
Unsafe or externally configured remnants are retained for manual review.
To inspect the supported bounded erasure path while Facelock is still installed:
sudo facelock data purge --dry-run
sudo facelock data purge --allow-destruction
Purge admits only safe entries inside the three compiled Facelock roots. It does not follow links, cross mounts, remove unsafe/wrong-owner objects, or chase configured paths outside those roots. Its report may therefore list remnants that require manual review; it never promises whole-disk erasure. See Package Lifecycle Ownership.
Configuration highlights
The installed file is /etc/facelock/config.toml; source development uses
dev/config.toml. The default encryption method is keyfile, IR is required,
and plaintext enrollment is refused unless explicitly enabled. See the
Configuration Reference.
Configuration Reference
Facelock reads its configuration from /etc/facelock/config.toml.
FACELOCK_CONFIG overrides it only when the effective user is non-root. Every
effective-UID-0 process ignores the environment; use an explicit --config
where supported, or the default path.
All settings are optional. Facelock auto-detects the camera and uses sensible defaults. The annotated config file at config/facelock.toml in the repository serves as the canonical example.
[device]
Camera settings.
| Key | Type | Default | Description |
|---|---|---|---|
path | string (optional) | Auto-detect | Camera device path (e.g., /dev/video2). When omitted, Facelock auto-detects the best available camera, preferring IR over RGB. |
max_height | u32 | 480 | Maximum frame height in pixels. Frames taller than this are downscaled to improve processing speed. |
rotation | u16 | 0 | Rotate captured frames. Values: 0, 90, 180, 270. Useful for cameras mounted sideways. |
warmup_frames | u32 | 2 | Frames to discard immediately after opening the camera to let exposure and gain stabilize. Device quirks may override this. |
dark_threshold | f32 | 0.6 | Fraction of pixels that must be darker than dark_pixel_value before the frame is treated as unusably dark. |
dark_pixel_value | u8 | 10 | Pixel brightness cutoff used by the dark-frame check. |
ir_emitter | bool | false | Attempt to enable a controllable IR emitter when the camera opens. Only needed for hardware that does not auto-enable its IR LED. |
camera_release_secs | u32 | 3 | Daemon only. Seconds to keep the camera streaming after a failed authentication so an immediate retry skips the reopen cost. Cancellation and errors release the camera at once, and so does a success unless camera_release_after_success_secs is set. 0 disables the hold entirely (it used to be silently substituted with 5). |
camera_release_after_success_secs | u32 | 0 | Daemon only. Seconds to keep the camera streaming after a successful authentication too. 0 (the default) releases it immediately — the interaction is over, and on IR hardware the emitter LED goes out with it. Set it only where privileged actions repeat with no authentication caching in front of them (sudo with a zero timestamp_timeout, a polkit action without auth_admin_keep), so each one is a fresh authentication that would otherwise pay a camera reopen. Failures still use camera_release_secs; cancellations and errors always release at once. |
[recognition]
Face detection and embedding parameters.
| Key | Type | Default | Description |
|---|---|---|---|
threshold | f32 | 0.80 | Cosine similarity threshold for accepting a face match. Must be between 0.0 and 1.0. Higher values are stricter. See the range guide below. |
timeout_secs | u32 | 5 | Maximum seconds to attempt recognition before giving up. Must be > 0. |
no_face_timeout_secs | u32 | 2 | Seconds to keep scanning when no face at all has been detected. Once a face is seen, timeout_secs takes over — "seen, not matched yet" is the case worth waiting out. An empty-chair attempt ends early and charges no rate-limit budget. Clamped to timeout_secs (never an error); 0 disables the early exit. |
detection_confidence | f32 | 0.5 | Minimum confidence for the face detector to report a detection. Lower values detect more faces but increase false positives. |
nms_threshold | f32 | 0.4 | Non-maximum suppression threshold for overlapping detections. |
detector_model | string | "scrfd_2.5g_bnkps.onnx" | ONNX detector model filename. Must exist in daemon.model_dir. Bundled models are verified against the manifest; custom models require detector_sha256. |
detector_sha256 | string (optional) | unset | Required digest for a custom detector; bundled models use the manifest digest. |
embedder_model | string | "w600k_r50.onnx" | ONNX embedder model filename. Must exist in daemon.model_dir. Bundled models are verified against the manifest; custom models require embedder_sha256. |
embedder_sha256 | string (optional) | unset | Required digest for a custom embedder; bundled models use the manifest digest. |
execution_provider | string | "cpu" | ONNX Runtime execution provider. Values: "cpu", "cuda", "rocm", "openvino". GPU providers require a GPU-enabled ONNX Runtime package installed on the system. |
threads | u32 | 4 | Number of CPU threads for ONNX inference. |
Threshold range guide (ArcFace cosine similarity)
| Range | Description |
|---|---|
| 0.30 -- 0.50 | Very loose -- high false accept rate, not recommended |
| 0.50 -- 0.65 | Loose -- convenient but may accept similar-looking people |
| 0.65 -- 0.80 | Balanced -- good for most setups, low false accept rate |
| 0.80 -- 0.90 | Strict -- rarely accepts wrong person, may reject on bad angles |
| 0.90+ | Very strict -- may require near-ideal lighting and pose |
Run sudo facelock test to see your similarity scores, then set the threshold below your typical match score with some margin. Exit zero alone is not a match verdict; inspect the output.
Model tiers
| Tier | Detector | Embedder | Total size | Notes |
|---|---|---|---|---|
| Standard | scrfd_2.5g_bnkps.onnx (3MB) | w600k_r50.onnx (166MB) | ~170MB | Fast, good accuracy (default) |
| Balanced | scrfd_2.5g_bnkps.onnx (3MB) | glintr100.onnx (249MB) | ~252MB | ~15-30ms slower, better recognition |
| High accuracy | det_10g.onnx (17MB) | glintr100.onnx (249MB) | ~266MB | ~40-50ms slower, best accuracy |
Run sudo facelock setup to select a model tier interactively and download the required models.
If you point detector_model or embedder_model at a custom file, you must also set the matching SHA256 so the daemon can verify it at load time.
[daemon]
Controls how the PAM module reaches the face engine.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "daemon" | "daemon" connects to a persistent daemon via D-Bus system bus (models stay loaded; only a cold attempt pays a camera reopen -- measure it with sudo facelock bench camera-reopen). "oneshot" spawns facelock auth per PAM call (slower: model load on every call, no background process). |
model_dir | string | "/var/lib/facelock/models" | Directory containing ONNX model files. |
idle_timeout_secs | u64 | 0 | Shut down the daemon after this many idle seconds. 0 means never. Useful with D-Bus activation. |
[storage]
| Key | Type | Default | Description |
|---|---|---|---|
db_path | string | "/var/lib/facelock/facelock.db" | SQLite database for face embeddings. File permissions should be 600, owned by root:root. |
[security]
| Key | Type | Default | Description |
|---|---|---|---|
disabled | bool | false | Disable face authentication entirely. PAM returns IGNORE, falling through to the next auth method. |
abort_if_ssh | bool | true | Refuse face auth when connected via SSH (no camera available). |
abort_if_lid_closed | bool | true | Refuse face auth when the laptop lid is closed (camera blocked). |
require_ir | bool | true | Require an IR camera for authentication. RGB cameras are trivially spoofed with a printed photo. Only set to false for development/testing. |
require_frame_variance | bool | true | Require multiple frames with different embeddings before accepting. Defends against static photo attacks. |
frame_variance_max_similarity | f32 | 0.985 | Maximum similarity between consecutive matched frames in the variance window. Passive anti-photo check only; it does not stop video replay. |
ir_texture_min_stddev | f32 | 10.0 | Minimum raw-grayscale standard deviation for the IR texture check. |
require_landmark_liveness | bool | false | Require landmark movement between frames to pass liveness check. Detects static images by tracking facial landmark positions across frames. Experimental; off by default. |
landmark_displacement_px | f32 | 1.5 | Minimum pixel displacement for a landmark to count as "moving" between frames. Only used when require_landmark_liveness is true. |
landmark_min_moving | u32 | 3 | Number of facial landmarks (out of 5) that must show movement to pass the liveness check. Only used when require_landmark_liveness is true. |
suppress_unknown | bool | false | Suppress warnings for unknown users (users with no enrolled face). |
min_auth_frames | u32 | 3 | Minimum number of matching frames required before accepting. Only applies when require_frame_variance is true. |
bind_templates_to_device | bool | true | Skip templates enrolled on a camera that does not match the live camera at the configured granularity. Advisory, not device attestation. |
device_match_granularity | string | "model" | "model" compares VID:PID; "unit" also requires a stable serial. |
bind_legacy_templates | bool | true | Permit older templates without a device identity, with a re-enrollment warning. |
bind_device_aad | bool | false | Opt-in cryptographic camera binding for encrypted templates; requires re-enrollment and a usable device identity. |
allow_plaintext | bool | false | Permit encryption.method = "none"; without it, plaintext enrollment is refused. |
[security.rate_limit]
| Key | Type | Default | Description |
|---|---|---|---|
max_attempts | u32 | 5 | Maximum face-detected authentication failures per user per window; successful and no-face attempts do not consume this budget. |
window_secs | u64 | 60 | Rate limit window in seconds. |
[security.pam_policy]
| Key | Type | Default | Description |
|---|---|---|---|
allowed_services | list of strings | [] | If non-empty, only these PAM services may use facelock. |
denied_services | list of strings | [] | PAM services that must always skip facelock, even if otherwise allowed. |
[notification]
Controls how authentication feedback is delivered.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "terminal" | Notification mode. "off" -- no notifications. "terminal" -- PAM text prompts only. "desktop" -- desktop popups only (via D-Bus/notify-send). "both" -- terminal and desktop. |
notify_prompt | bool | true | Show prompt when scanning starts ("Identifying face..."). |
notify_on_success | bool | true | Notify on successful face match. |
notify_on_failure | bool | false | Notify on failed face match. |
[snapshots]
Save camera snapshots on auth attempts for debugging or auditing.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "off" | "off" -- never save. "all" -- every attempt. "failure" -- failed auth only. "success" -- successful auth only. |
dir | string | "/var/log/facelock/snapshots" | Directory for snapshot JPEG images. |
[encryption]
Controls how face embeddings are encrypted at rest.
| Key | Type | Default | Description |
|---|---|---|---|
method | string | "keyfile" | "keyfile" -- AES-256-GCM with a root-only key file. "tpm" -- AES-256-GCM with a TPM-sealed key. "none" requires security.allow_plaintext = true. |
key_path | string | "/etc/facelock/encryption.key" | Path to AES-256-GCM key file for keyfile method. |
sealed_key_path | string | "/etc/facelock/encryption.key.sealed" | Path to TPM-sealed AES key for tpm method. |
With method = "tpm", the 32-byte AES key is sealed by the TPM at rest. At daemon startup, the key is unsealed and held in memory. Embeddings use the same AES-256-GCM format as keyfile — no re-encryption needed when migrating between methods. The root-gated migration commands are sudo facelock tpm seal-key (keyfile → tpm) and sudo facelock tpm unseal-key (tpm → keyfile). seal-key requires the plaintext key to exist and refuses to overwrite a sealed key; unseal-key requires the sealed key and refuses to overwrite a plaintext key. Each command updates encryption.method only after writing the destination key.
[polkit]
| Key | Default | Description |
|---|---|---|
face_eligible_actions | ["org.freedesktop.login1.lock-sessions"] | Action IDs the optional agent may handle; it declines all others. |
[pam]
| Key | Default | Description |
|---|---|---|
config_dirs | ["/etc/pam.d", "/usr/lib/pam.d"] | Lookup order. Only the first directory is writable; later entries are vendor roots. |
[audit]
Structured audit logging of authentication events.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable structured audit logging to JSONL file. |
path | string | "/var/log/facelock/audit.jsonl" | Path to the audit log file. |
rotate_size_mb | u32 | 10 | Rotate the log file when it exceeds this size (in MB). |
[tpm]
TPM 2.0 settings for sealing the AES encryption key. These settings apply when encryption.method = "tpm".
| Key | Type | Default | Description |
|---|---|---|---|
seal_database | bool | false | Seal the SQLite database file with the TPM key in addition to the encryption key. |
pcr_binding | bool | false | Bind sealed key to boot state (PCR values). |
pcr_indices | list of u32 | [0, 1, 2, 3, 7] | PCR registers to verify on unseal. |
tcti | string | "device:/dev/tpmrm0" | TPM Communication Interface. |
GPU Acceleration
GPU support in Facelock is runtime-only -- Facelock itself needs no rebuild.
Install an ONNX Runtime built with the matching execution provider, satisfy the
vendor driver/runtime requirements, and set execution_provider in the
configuration. GPU paths are configuration-supported but are not part of the
release package validation matrix.
Setup
1. Install a GPU-enabled ONNX Runtime
| GPU Vendor | Arch Linux Package | Other Distros |
|---|---|---|
| NVIDIA | onnxruntime-opt-cuda | Install a compatible NVIDIA driver and ONNX Runtime with CUDA support |
| AMD | onnxruntime-opt-rocm | Install the ROCm stack and ONNX Runtime with ROCm support |
| Intel | none packaged | Build ONNX Runtime with the OpenVINO provider and install its required OpenVINO runtime |
On Arch Linux, these are official Extra repository packages as checked on
2026-09-05. Each provides and conflicts with the virtual onnxruntime
dependency, so the variants do not install side by side. If another variant is
installed, pacman prompts to remove it; review and accept that transaction to
switch providers:
sudo pacman -S onnxruntime-opt-cuda # NVIDIA
sudo pacman -S onnxruntime-opt-rocm # AMD
Arch packages no OpenVINO build of ONNX Runtime, in the repositories or the AUR
as checked on 2026-09-05. Build ONNX Runtime with the OpenVINO execution
provider yourself to use execution_provider = "openvino". Debian 13 publishes
libonnxruntime1.21, and Ubuntu 26.04 publishes libonnxruntime1.23 plus
libonnxruntime-providers, as checked on 2026-09-05. Their packaged provider
modules are CPU/oneDNN rather than CUDA, ROCm, or OpenVINO. They also install in
the multiarch library directory with versioned SONAMEs that Facelock's current
trusted runtime loader does not accept, so they cannot serve as its runtime.
Facelock's published .deb packages bundle a compatible CPU-only runtime and
therefore do not enable a GPU provider. Fedora's COPR package depends on
Fedora's CPU-only onnxruntime package.
2. Set the execution provider
Either let setup detect it:
sudo facelock setup --execution-provider=auto
auto asks the installed ONNX Runtime which providers it was built with and selects cuda > rocm > openvino > cpu, printing what it found either way. See --execution-provider=auto in the CLI reference.
Or set it yourself in /etc/facelock/config.toml:
[recognition]
execution_provider = "cuda" # or "rocm" or "openvino"
3. Restart the daemon
sudo facelock daemon restart
4. Verify
sudo facelock status
sudo facelock bench warm-auth
facelock status must report that the configured provider is built into the
installed ONNX Runtime. Then compare the benchmark with
execution_provider = "cpu"; timing alone is not proof that the GPU provider
loaded.
How it works
Facelock uses the ort crate with the load-dynamic feature. It accepts ONNX
Runtime 1.20+ from fixed, root-owned system and Facelock package directories;
privileged commands ignore ORT_DYLIB_PATH. For a configured GPU provider it
prefers the trusted system runtime over Facelock's bundled CPU runtime. The
execution_provider config selects which provider to register.
If the configured provider is not built into the installed runtime, status and daemon startup warn about the mismatch. ONNX Runtime may fall back to CPU, so do not infer GPU use merely from a successful authentication.
Supported providers
| Provider | Config value | Status |
|---|---|---|
| CPU | "cpu" | Default; covered by package validation |
| CUDA (NVIDIA) | "cuda" | Config supported, requires CUDA-enabled ORT; not release-matrix tested |
| ROCm (AMD) | "rocm" | Config supported, requires ROCm-enabled ORT; not release-matrix tested |
| OpenVINO (Intel) | "openvino" | Config supported, requires a custom OpenVINO-enabled ORT; not release-matrix tested |
systemd note
The systemd service has MemoryDenyWriteExecute=yes commented out because GPU inference runtimes (CUDA, TensorRT) use JIT compilation which requires writable+executable memory pages. If you are using CPU-only, you can re-enable this directive for additional hardening.
Troubleshooting
- "Failed to load execution provider": The GPU-enabled ONNX Runtime package is not installed or
libonnxruntime.sodoes not include the requested provider. - Slower than CPU: Ensure the GPU driver is loaded (
nvidia-smifor NVIDIA,rocm-smifor AMD). Small models like SCRFD 2.5G may not benefit from GPU due to transfer overhead. - Daemon exits during startup: Check
journalctl -u facelock-daemonand diagnose the reported error; runtime loading, provider initialization and device-memory failures require different remedies.
Architecture
Overview
Facelock is a face authentication system for Linux PAM. It detects faces via SCRFD, extracts embeddings via ArcFace, and matches against stored models using cosine similarity. Authentication and inference are local: they make no network calls and use no cloud service or telemetry. The separately invoked setup flow may download model files.
System Diagram
┌──────────────────┐ ┌──────────────────────────────────────┐
│ sudo / login │ │ facelock CLI │
│ (PAM stack) │ │ (enroll, test, list, preview, ...) │
└────────┬─────────┘ └───────────────┬──────────────────────┘
│ │
│ │ direct mode (fallback)
│ │ or IPC to daemon
┌────▼────────────┐ │
│ pam_facelock.so │──────────────────┤
│ (~2MB cdylib) │ │
│ │ │
│ daemon mode: │ │
│ → D-Bus IPC │ ┌───────▼──────────────┐
│ │ │ facelock daemon │
│ oneshot mode: │ │ (persistent process) │
│ → facelock auth │ │ │
└─────────────────┘ │ ┌─────────────────┐ │
│ │ V4L2 Camera │ │
│ │ (auto-detected) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ SCRFD Detection │ │
│ │ → Alignment │ │
│ │ → ArcFace Embed │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ SQLite Store │ │
│ │ (embeddings) │ │
│ └─────────────────┘ │
└───────────────────────┘
Crate Dependencies
facelock-core (config, types, IPC, traits)
├── facelock-camera (V4L2, auto-detect, preprocessing)
├── facelock-face (ONNX: SCRFD + ArcFace)
├── facelock-store (SQLite)
├── facelock-tpm (optional TPM encryption)
└── facelock-test-support (mocks, dev-only)
facelock-daemon (auth/enroll logic, liveness, audit, rate limiter, handler)
└── depends on: core, camera, face, store, tpm
facelock-cli (unified binary)
└── depends on: core, camera, face, store, daemon, tpm
facelock-polkit (polkit agent)
└── depends on: core
pam-facelock (PAM module)
└── depends on: libc, toml, serde, zbus ONLY (no facelock crates)
Mermaid Diagrams
The diagrams below render in GitHub, mdBook, and any Mermaid-capable viewer. They cover the same information as the ASCII diagrams above but add external integrations and the authentication data flow.
Crate Dependency Graph
graph TD
subgraph Workspace Crates
core[facelock-core<br/><i>config, types, errors,<br/>D-Bus interface, traits</i>]
camera[facelock-camera<br/><i>V4L2 capture, preprocessing</i>]
face[facelock-face<br/><i>ONNX: SCRFD + ArcFace</i>]
store[facelock-store<br/><i>SQLite embeddings</i>]
tpm[facelock-tpm<br/><i>TPM / AES-256-GCM</i>]
test[facelock-test-support<br/><i>mocks, fixtures</i>]
daemon[facelock-daemon<br/><i>auth, enroll, rate limit,<br/>liveness, audit</i>]
cli[facelock-cli<br/><i>unified binary</i>]
polkit[facelock-polkit<br/><i>Polkit auth agent</i>]
pam[pam-facelock<br/><i>PAM module cdylib</i>]
end
camera --> core
face --> core
store --> core
tpm --> core
test -.-> core
polkit --> core
daemon --> core
daemon --> camera
daemon --> face
daemon --> store
daemon --> tpm
cli --> core
cli --> camera
cli --> face
cli --> store
cli --> daemon
cli --> tpm
pam -. "no facelock crates<br/>(libc, toml, serde, zbus)" .-> pam
style pam fill:#f9f,stroke:#333
style core fill:#bbf,stroke:#333
style daemon fill:#bfb,stroke:#333
style cli fill:#bfb,stroke:#333
System Data Flow and IPC
flowchart LR
subgraph Clients
login[sudo / login<br/><i>PAM stack</i>]
cliclient[facelock CLI]
end
subgraph IPC
dbus[[D-Bus<br/>system bus]]
end
subgraph Daemon["facelock daemon"]
direction TB
cam[facelock-camera<br/><i>V4L2 capture +<br/>preprocessing</i>]
det[facelock-face<br/><i>SCRFD detection +<br/>alignment</i>]
emb[facelock-face<br/><i>ArcFace embedding</i>]
st[facelock-store<br/><i>load stored<br/>embeddings</i>]
match[facelock-core<br/><i>constant-time<br/>cosine match</i>]
cam --> det --> emb --> st --> match
end
subgraph External Systems
v4l2[(V4L2<br/>camera)]
onnx[(ONNX<br/>Runtime)]
sqlite[(SQLite)]
tpmd[(TPM)]
sysd[systemd]
syslog[syslog]
polkitd[polkit]
end
login -->|pam_facelock.so| dbus
cliclient -->|zbus client| dbus
dbus --> Daemon
cam ---|capture| v4l2
det ---|inference| onnx
emb ---|inference| onnx
st ---|query| sqlite
Daemon ---|optional key sealing| tpmd
Daemon ---|service activation| sysd
Daemon ---|audit logging| syslog
polkitagent[facelock-polkit] --> polkitd
polkitagent --> dbus
style dbus fill:#ff9,stroke:#333
style Daemon fill:#eef,stroke:#339
style match fill:#bfb,stroke:#333
Face Recognition Pipeline
Detection (SCRFD)
- Input: grayscale frame after CLAHE enhancement
- Output: bounding boxes + 5-point landmarks (eyes, nose, mouth corners)
- Confidence threshold:
recognition.detection_confidence(default 0.5) - NMS threshold:
recognition.nms_threshold(default 0.4)
Alignment
- Affine transform from 5 landmarks to canonical positions
- Output: 112x112 aligned face crop
- Uses Umeyama similarity transform
Embedding (ArcFace)
- Input: 112x112 RGB face crop
- Output: 512-dimensional L2-normalized float32 vector
- Cosine similarity = dot product (since L2-normalized)
Matching
- Compare live embedding against all stored embeddings for the user
- Accept if best similarity >=
recognition.threshold(default 0.80) - Frame variance check: multiple frames must show different embeddings (anti-photo)
Auth Flow
1. Pre-checks (disabled? SSH? lid closed? has models? rate limit? IR?)
2. Load user embeddings from store
3. Capture loop (until deadline):
a. Capture frame
b. Skip if dark
c. Detect faces
d. For each face: compute best_match against stored embeddings
e. Track matched frames for variance check
f. If variance passes (or disabled): return match
4. If timeout: return no_match
Operating Modes
Daemon Mode
The daemon (facelock daemon) runs persistently, holding ONNX models and camera resources in memory. The PAM module and CLI connect via D-Bus system bus. Benefits:
- No per-authentication model load, and no camera reopen when the stream is
already warm. The reopen a cold attempt pays is hardware-specific — measure
it with
sudo facelock bench camera-reopen, which splits it into open, STREAMON and warmup - Camera stays warm after a failed attempt for
device.camera_release_secs(default 3), so the retry a miss invites skips the reopen. Success, cancellation and errors release it at once — on IR hardware the emitter LED goes out with the interaction (ADR 008) - Single point of resource management
Oneshot Mode
The PAM module spawns facelock auth --user X for each auth attempt. The process loads models, opens camera, runs one auth cycle, and exits. Benefits:
- No background process
- Does not require systemd; it still requires the documented Linux-PAM, V4L2, model, and runtime prerequisites
Direct CLI Mode
The CLI detects whether the daemon is available over the system bus. If it is,
the CLI uses IPC; if daemon mode is configured but unavailable, it warns and
falls back to direct access (opening the camera and loading models inline).
Configured oneshot mode uses direct access without the degraded-fallback
warning. A non-default explicit --config also forces direct access, without
probing a daemon that may use different state or security settings. Commands
such as benchmarking, TPM maintenance and oneshot authentication are always
direct; unprivileged capability and enrollment-marker probes use no backend.
Security Layers
- IR enforcement: Only IR cameras allowed by default (prevents RGB photo attacks)
- Frame variance: Multiple frames must show micro-movement (prevents static photo)
- Rate limiting: 5 face-detected authentication failures per user per 60 seconds by default; successful and no-face attempts do not consume this budget
- Model integrity: SHA256 verification at every load
- D-Bus security: System bus policy restricts daemon access
- Audit trail: All auth events logged to syslog
- Process hardening: systemd service runs with ProtectSystem=strict, NoNewPrivileges, etc.
CLI Reference
All commands are subcommands of the facelock binary.
Global flags
The following flags are accepted by every subcommand (declared global = true):
| Flag | Description |
|---|---|
-c, --config <PATH> | Override the config file path. Takes precedence over FACELOCK_CONFIG. The packaged daemon reads only the default file, so under a non-default path enroll and test use direct camera access and setup --systemd refuses, except --disable, which stays allowed since stopping the packaged unit reads no config file; a symlink or .. spelling of the default counts as the default (see facelock setup). |
-q, --quiet | Suppress informational stdout and machine payloads where supported. Prompts, required notices, diagnostics and exit codes are unaffected; exceptions are listed below. |
-v, --verbose | Raise diagnostic verbosity on stderr, one level per repeat. The CLI starts at warn, daemon run at info. RUST_LOG overrides it. |
Diagnostics default to warn, so a command prints warnings and errors on
stderr and nothing quieter. The setup wizard's questions and the status
report are readable again, rather than interleaved with timestamped log lines.
-v raises the level one step per repeat; facelock daemon run keeps info,
because it writes to the journal, where nothing competes with it. RUST_LOG
outranks both, and the level changes output volume only: exit codes and stdout
payloads are identical at every level.
--quiet and -v are separate knobs on separate streams, so --quiet -v is a
real combination (silent report, loud diagnostics) rather than a contradiction.
--quiet suppresses ordinary informational output for commands using the message
seam: setup, enroll, test, remove, clear, is-enrolled,
capabilities, pam, and the --json payloads of list, devices and
status. Seven still write human text straight to stdout and stay noisy under
it until #140 is finished:
status, bench, tpm (every verb, encrypt/decrypt/reseal included),
config, daemon restart, hyprlock and audit, as do the human tables of
list and devices — so status --json --quiet is silent while a bare
status --quiet is not. preview --json is on neither list: its frame stream
is stdout by design and --quiet is documented not to reach it.
Required notices on the human setup/PAM paths also remain on stdout, including rollback guidance and the edit context shown before a confirmation.
Privilege model
Enrollment, model management, camera inspection/preview, system status, the
unified benchmarks, TPM operations, and audit access use protected system state
and require root. Many interactive management commands offer to re-execute via
sudo; examples without an explicit sudo rely on that terminal prompt.
Scripts and redirected/non-interactive calls must provide the required
privilege themselves. daemon run, PAM writes, data purge, and all audit
access refuse non-root callers without an elevation prompt.
is-enrolled, capabilities, config show, and pam status are deliberately
unprivileged reads. hyprlock is user-owned and refuses root. auth is the
direct, one-shot PAM helper: --user is required and it needs access to the
protected database and camera; it does not connect to the daemon or offer
elevation. The daemon separately restricts non-root D-Bus authentication
callers to their own accounts. Command-specific exceptions and hard-root
behavior are stated below.
Machine-readable output
Every command whose output a script would parse takes --json, and spells it
exactly that — one flag family, no short letter, no --output json. It is not
offered everywhere: a command gains it when it has a named consumer, which
today means facelock is-enrolled, facelock capabilities, facelock list,
facelock devices, facelock preview, facelock status, facelock pam add,
facelock pam remove, facelock pam status and facelock data purge. Each payload is described in
that command's section below; the rule behind the flag, and the promise each
payload carries, are in contracts.md under "CLI Machine
Output".
The payload goes to stdout and nothing else does — diagnostics are on stderr
whatever RUST_LOG says — so facelock devices --json is safe to pipe at any
log level. --quiet suppresses the payload on every one of these except
preview, whose frame stream runs until interrupted and would otherwise become
a command that prints nothing forever. What that leaves behind depends on the
command: where the exit code is the answer it is the whole answer, but status
exits 0 whenever it produced a report, so status --quiet --json leaves nothing
at all.
This changed: list --json --quiet and devices --json --quiet used to
print their payload and now print nothing; the exit code is unchanged.
facelock setup
Interactive setup wizard. Walks through camera selection, model quality, inference device, model downloads, encryption, the daemon, enrollment and PAM configuration. Every step can also be answered, or declined, from the command line.
The daemon is configured before enrollment on purpose. enroll and test
select their transport once, when they start, so on a first install a daemon
configured after them would never be the one they used: enrollment would fall
back to direct camera access and the recognition test would validate a
transport no later authentication takes. The step starts the daemon, or
restarts it if one is already running, because the daemon reads the encryption
method, the model preset and the inference device once at startup: on a re-run
of setup an untouched daemon would hold the answers from before the wizard.
A restart interrupts any authentication that daemon is mid-way through, so a
sudo prompt waiting on a face in another terminal falls back to a password
that once.
--systemd is not supported under a non-default --config. The unit runs
bare facelock daemon, which reads only /etc/facelock/config.toml, so the
daemon it enables would not use the file setup just configured. facelock --config /etc/facelock/scratch.toml setup --systemd --enroll exits non-zero
before it writes anything or calls systemctl, and says what to do instead:
copy the file to /etc/facelock/config.toml and re-run without --config, or
re-run without
--systemd to enroll with direct camera access under /etc/facelock/scratch.toml. The
wizard skips the daemon question under such a --config and says why.
--systemd --disable still runs, with a note that the unit it stops reads the
default file; a symlink or .. spelling of the default path counts as the
default.
facelock setup # interactive wizard
facelock setup --non-interactive # base setup, no prompts, no PAM/systemd/enroll
facelock setup --systemd # validate installed assets, reload and enable
facelock setup --systemd --disable # disable systemd units
facelock setup --pam # install to /etc/pam.d/sudo
facelock setup --pam --service polkit-1 # install to a specific service
facelock setup --pam --remove # remove the PAM line
facelock setup --pam --service hyprlock --if-present # a missing service file is success
facelock setup --pam --remove --if-present # ...on removal too
facelock setup --pam --service sshd -y --allow-sensitive # suppress the prompt and authorize the sensitive write
facelock setup --no-pam # wizard, but never touch /etc/pam.d
facelock setup --camera /dev/video2 # answer step 1 from the command line
Three rules generate the whole flag list. Supplying a value answers that
question and therefore replaces its prompt, which is why there is no
--skip-<x>-prompt family. A --no-<action> flag declines an action outright;
declining is not defaulting. And auto means re-derive from the hardware,
since omitting a flag already gives the default.
Modes
| Flag | Meaning |
|---|---|
| (none) | Full interactive wizard. Falls back to the non-interactive flow when stdin is not a terminal. |
--non-interactive | No prompts. Choices resolve to config-or-default. Runs the base setup only: directories, model download and verification, encryption, path permissions. No PAM, no systemd, no enrollment unless asked for explicitly. |
-y, --yes (alias --no-confirm) | Suppress ordinary confirmation prompts. Does not authorize a sensitive PAM edit. |
--yes and --non-interactive suppress the per-file "Proceed?" confirmation;
neither unlocks the sensitive-service gate. The shared auth stacks
common-auth, password-auth, password-auth-ac, system-auth,
system-auth-ac and system-login, plus login and sshd, require
--allow-sensitive. Thus even facelock setup --pam --service sshd --yes
refuses. Locking yourself out of a machine takes two independent decisions:
whether to skip the prompt, and whether to authorize the sensitive write.
Choice flags
Precedence for all four: CLI flag > config file > built-in default.
Supplying the flag suppresses the corresponding wizard step and writes the
value back to the selected config file (normally /etc/facelock/config.toml).
An unavailable explicit camera or TPM choice is fatal: --camera /dev/video9
on a machine without that node aborts, and --encryption tpm with no usable
TPM aborts. Provider selection has its own fallback behavior, described below.
| Flag | Values | What auto does | Wizard step |
|---|---|---|---|
--camera <PATH|auto> | a /dev/video* path, or auto | Re-classifies the attached devices and picks the single IR-capable node that advertises a format Facelock can decode. Zero usable IR devices and more than one are both errors that list what was found; an IR node excluded for formats such as Y8/Y10/Y12 is reported with its path and formats. | 1 |
--models <standard|balanced|high> | three presets | no auto: quality is a preference, not something the machine can report | 2 |
--execution-provider <cpu|cuda|rocm|openvino|auto> | provider name | Asks the installed ONNX Runtime which providers it was built with and takes the best, in the order cuda > rocm > openvino > cpu. Availability is a property of the runtime build, not of the hardware, and the choice is always printed. | 3 |
--encryption <tpm|keyfile|none|auto> | method | Uses the TPM when a working TPM 2.0 is present, otherwise a software keyfile. | 5 |
When security.require_ir = true and the wizard detects IR-classified nodes but
all of them advertise only unsupported formats, camera selection is a fatal
refusal. It lists every excluded IR path and format and does not present or
default to an attached RGB camera. With require_ir = false, decodable RGB
cameras remain available as explicit wizard choices.
Model presets:
| Preset | Detector | Embedder |
|---|---|---|
standard | scrfd_2.5g_bnkps.onnx | w600k_r50.onnx |
balanced | scrfd_2.5g_bnkps.onnx | glintr100.onnx |
high | det_10g.onnx | glintr100.onnx |
Action flags
| Pair | Without either flag (wizard) | Without either flag (--non-interactive) |
|---|---|---|
--pam / --no-pam | prompt (step 9) | off |
--systemd / --no-systemd | prompt (step 6) | off |
--enroll / --no-enroll | prompt (step 7) | off; enrollment needs a human in front of the camera |
Each pair is a clap override pair, so a later flag wins over an earlier
one: --pam --no-pam declines PAM, --no-pam --pam installs it. That
matters when a wrapper appends an override to a command line it did not
construct. --no-pam means nothing under /etc/pam.d is read, backed up or
written; it is not "use the PAM default".
--pam inside the wizard configures exactly one service, --service
defaulting to sudo, and does not apply the multi-select's pre-checked
candidates. --enroll answers the "enroll a face now?" confirmation as well as
forcing the step, so it runs unattended.
Action modifiers
| Flag | Requires | Meaning |
|---|---|---|
--service <NAME> | --pam | Target PAM service. Default sudo. |
--remove | --pam | Remove the facelock PAM line instead of adding it. |
--if-present | --pam | Treat an absent service file as success rather than an error, on the add side as well as --remove. Read, parse and write failures stay fatal. Without it, a service that is not there is a hard error. |
--allow-sensitive | --pam add | Explicitly authorize adding Facelock to common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac, or system-login. Does not suppress the confirmation prompt and conflicts with --remove. |
--disable | --systemd | Disable and stop the units without changing installed assets. |
The parser enforces these, so facelock setup --remove is an error naming the
missing --pam rather than a silently ignored flag.
How setup flags compose
--pam and/or --systemd on their own perform just that action and touch
nothing else. Any flag that only makes sense while the base setup runs —
--non-interactive, a choice flag, or any of --no-pam / --no-systemd /
--enroll / --no-enroll — forces the base setup, and the requested actions
run in addition. -y on its own does not force it, so facelock setup -y --pam is still PAM-only. When both run the order is base setup, then systemd,
then PAM.
--pam is an alias onto facelock pam add | remove, which is
the primary spelling and the one that takes several services in one process.
Existing setup --pam invocations keep parsing. Sensitive additions now use
the same explicit --allow-sensitive authorization as facelock pam add,
while -y only suppresses the prompt.
Eight services are gated: the shared auth stacks common-auth,
password-auth, password-auth-ac, system-auth, system-auth-ac and
system-login, plus login and sshd.
facelock setup --pam --service login refuses until --allow-sensitive is
added, even when -y is present.
--execution-provider=auto
auto inspects the execution providers compiled into the installed ONNX
Runtime and selects the first available provider in this order: CUDA, ROCm,
OpenVINO, CPU. It does not probe the GPU or install a provider. An ONNX Runtime
built with CPU support only therefore resolves auto to cpu, even on a
machine with a supported GPU. Setup prints the resolved provider before it
writes the configuration. If the runtime cannot be queried, auto warns and
selects cpu.
The interactive inference-device prompt runs this same probe and highlights the provider it finds — an explicit GPU provider already in the config wins over detection — annotating CUDA, ROCm and OpenVINO as available or not in the installed build. Nothing is written until a selection is confirmed.
An explicit provider name is written without proving it is usable. CUDA gets driver/runtime presence warnings, but setup does not install those dependencies; inference may still fail or fall back to CPU later.
Both base setup flows reconcile the per-user enrollment markers behind
facelock is-enrolled against the database, which is
what backfills users who enrolled before markers existed. Standalone PAM or
systemd actions do not perform this reconciliation.
facelock is-enrolled
Report whether a user has a usable face enrollment. Unprivileged and cheap
enough to call repeatedly from a lock screen: it reads the selected config to
derive the enrolled/ directory beside storage.db_path, then reads one marker.
An unreadable or invalid config falls back to /var/lib/facelock/enrolled/.
It never activates the daemon, opens a camera, or reads the face database.
No group is involved (ADR 010): the marker sits under two 0711 root:root
directories, so any local user can open its own marker by name. A missing or
unreadable marker (ENOENT or EACCES) is reported not-enrolled rather than
as an error.
facelock is-enrolled # prints enrolled / not-enrolled
facelock is-enrolled -u alice # specific user (-u is short for --user)
facelock is-enrolled --json # machine-readable
facelock is-enrolled --quiet # no stdout; the exit code is the answer
The exit code is the contract — branch on it rather than parsing stdout:
| Code | Meaning |
|---|---|
| 0 | the user has a usable enrollment |
| 1 | not enrolled; an absent or unreadable marker reports this way |
| 2 | error — an invalid --user, an unparseable marker, or an I/O failure other than absence or access denial |
--json emits one object and does not change the exit code:
{"enrolled":true,"models":2,"updated":"2026-08-12T00:00:00Z"}
models is 0 and updated is null when the user is not enrolled. The
error case prints its reason on stderr and no payload at all.
The marker is a hint for deciding whether to offer a face-auth affordance; PAM
at authentication time remains authoritative and nothing in the auth path
consults it. See contracts.md, "facelock is-enrolled Exit
Codes", for the stability promise and for how markers are reconciled with the
database.
facelock capabilities
Report what this build can do, as capability names. Unprivileged: it answers
from the binary's own clap tree and compiled-in constants, reading no config
file, activating no daemon and opening no camera. It is what replaces grepping
--help in a wrapper script.
facelock capabilities # one name per line
facelock capabilities --json # {"version", "capabilities"}
With the name array elided:
{"capabilities":["capabilities","devices-json","is-enrolled"],"version":"0.1.4"}
Both forms exit 0 — the command has no failure mode — and --quiet suppresses
stdout, leaving the exit code as the whole answer. A build that predates the
command answers by failing: clap's unrecognized-subcommand error on stderr,
exit 2, nothing on stdout. A caller reads any non-zero exit as "no capabilities
at all", which is the true answer for that build.
Probe by name, never by version. The names this build emits, what each one
promises, and the stability rules that govern them are in
contracts.md, "facelock capabilities".
facelock enroll
Capture and store a face model.
facelock enroll # current user, auto-label
facelock enroll -u alice # specific user (-u/--user)
facelock enroll -l "office" # specific label (-l/--label)
facelock enroll --skip-setup-check # enroll on a tree setup never marked complete
Accepts 3–10 quality-filtered captures with exactly one face per accepted frame
and checks angle diversity. The capture deadline is
3 × max(recognition.timeout_secs, 5) seconds (15 seconds by default); it can
finish sooner after ten accepted captures. Re-enrolling with the same label
replaces the previous model on success; a cancelled or failed re-enrollment
leaves the previous model in place.
Under a non-default --config, enrollment uses direct camera access under that
file and never the running daemon, which reads only /etc/facelock/config.toml;
when daemon.mode = "daemon" a note on stderr says so. The same holds for
test, list, remove, clear, devices and preview (which then has only
its text preview).
Without --skip-setup-check, an install whose setup-complete marker is missing
is offered facelock setup first. Accepting runs setup and then returns without
a separate enrollment; setup can itself enroll, but that step can be declined.
The original enroll --user and --label choices are not forwarded into the
wizard. Declining setup also returns successfully without enrolling.
--skip-setup-check bypasses that offer. It is for a
tree assembled by hand or by a configuration manager, where the marker was never
written but the models, database and encryption key are all in place; enrollment
still fails on its own terms if any of them is not.
facelock test
Test face recognition against enrolled models.
facelock test # current user
facelock test -u alice # specific user (-u/--user)
Reports match similarity and latency when a scan runs. A zero exit status means the command completed, not necessarily that a face matched or even that the camera was opened: no enrollment, no enrollment for the configured embedder, and a completed non-match all return zero with explanatory output. Inspect the human result; this command has no machine-readable success contract.
facelock list
List enrolled face models.
facelock list # current user
facelock list -u alice # specific user (-u/--user)
facelock list --json # JSON output
--json emits an array of objects:
[
{
"id": 1,
"label": "office",
"user": "alice",
"created_at": 1700000000,
"embedder_model": "w600k_r50.onnx",
"device_id": ""
}
]
facelock remove
Remove a specific face model by its decimal MODEL_ID, as shown by
facelock list. The argument is an unsigned 32-bit integer; hexadecimal
spellings are not accepted.
facelock remove 3 # remove model #3
facelock remove 3 -u alice # for specific user (-u/--user)
facelock remove 3 -y # skip confirmation (-y/--yes)
The selected user scopes the removal. Declining confirmation exits 0 without deleting. A nonexistent model also returns 0; the direct backend reports that it was not found, while the daemon's empty reply cannot distinguish that case.
facelock clear
Remove all face models for a user.
facelock clear # current user
facelock clear -u alice -y # -u/--user; -y/--yes skips confirmation
Only that user's models are removed. No models or a declined confirmation is
a successful no-op. This command does not remove keys, model files, audit logs,
or other users' enrollments; machine-wide retained state belongs to data purge.
facelock preview
Live camera preview with face detection overlay.
facelock preview # Wayland graphical window
facelock preview --json # one JSON object per frame on stdout
facelock preview -u alice # match against user (-u/--user)
The window opens on the invoking user's Wayland session even though the
command runs as root: the compositor socket is resolved from the invoking
uid's /run/user/<uid> directory (via SUDO_UID/DOAS_USER), never from
inherited XDG_RUNTIME_DIR, and the connected peer must be a process
running as that uid. A bare-name WAYLAND_DISPLAY picks among the sockets
in that directory; a value carrying a path is ignored. The self-re-exec
(facelock preview answering the sudo prompt) carries WAYLAND_DISPLAY
across, so a session running several compositors previews on the right one;
a direct sudo facelock preview has no display name and scans, so with
more than one live compositor run
sudo --preserve-env=WAYLAND_DISPLAY facelock preview to name it. Without
a reachable compositor the preview falls back to text-only mode.
--json shipped as --text-only, which stays a hidden alias and keeps
parsing; the payload is unchanged. One object per line, one per frame:
{"faces":[{"confidence":0.5,"height":180.0,"recognized":true,"similarity":0.75,"width":180.0,"x":112.0,"y":88.0}],"fps":15.0,"frame":1,"height":480,"jpeg_size":24576,"recognized":1,"unrecognized":0,"width":640}
Keys come out sorted, which is serde_json's doing and not a promise.
jpeg_size is present only when the daemon serves the frames; the direct
(oneshot) path has no JPEG and omits that key, and every other key is on both.
Numbers are f32 rounded then widened to f64, so a rounded 0.988 reaches
you as 0.9879999756813049: compare numerically, never as text.
facelock devices
List available V4L2 video capture devices.
facelock devices # human-readable listing
facelock devices --json # JSON output
Shows device path, name, driver, formats, resolutions, and IR status.
--json emits an array of device objects with path, name, driver,
is_ir, and formats; each format carries fourcc, description, and
sizes, a list of [width, height] pairs. It is a typed schema derived from
the device struct, so a script reads it rather than parsing the listing above,
whose columns, indentation and [IR] tag are free to change.
formats is empty whenever the daemon answers: the D-Bus device type does not
carry format detail, so only the direct (oneshot) backend fills it in. The
human listing omits the section for the same reason. Read formats for
capability detection only when you know you are on the direct path.
facelock status
Check system status — config, daemon, oneshot fallback, camera, models, encryption, enrollment, security posture, notifications, PAM wiring. Requires root. A check that cannot be performed (unreadable database, broken config) is reported as "cannot determine" — never as a guessed value.
facelock status
facelock status --json
facelock status --json | jq -e '.daemon.reachability == "responding"'
--json prints one object with a key per section of the report — config,
daemon, oneshot_fallback, camera, models, execution_provider,
encryption, enrollment, security, notifications, pam — each carrying
a state of ok, problem or unknown and, when it is not ok, a reason.
It is the same value the report is rendered from, and a test walks both outputs
of one fixture, so a section cannot answer differently in the two. This is the
form to branch on: the third line above is what replaces grepping the report
for [ok] responding. A fact nobody established is "state": "unknown" with a
reason and no value — never a null and never a false, so read a section's
state before any field beside it: on an unreadable database enrollment
carries no models key at all, and (.enrollment.models // []) would answer
"not enrolled" for a machine nobody could check.
Two sections keep a narrower question than their name suggests, and both
have the specific answer nested one level down. Under auto-detection
.camera.state reports only that detection is enabled, so it reads ok on a
machine with no camera at all — .camera.device.state is the hardware fact.
And .pam.state reports that pam_facelock.so is installed, not that anything
uses it — .pam.services is the scan. The full schema, the per-section table
of what each state answers, and the stability tier are in
contracts.md under "facelock status Semantics".
Exit codes do not change under --json: status exits 0 whenever it produced
a report, and the verdicts are in the document. --quiet therefore suppresses
the payload and leaves nothing behind, which makes --quiet --json a no-op
rather than a terser query.
The PAM services: line lists every service that carries the facelock line,
from the same scan facelock pam status --all
runs, and marks how many are a local override of a vendor file. It reads none configured only when every directory was read; when one could not be, it reads
not checked and names the place on a line of its own, because "nothing is
configured" and "I could not look" are different answers.
facelock config
Show or edit the configuration file. Bare facelock config is
facelock config show.
facelock config show
Print the config file path and its contents, then report whether it parses.
Unprivileged — it reads a 0644 file. A missing file or invalid configuration
is reported on stdout with exit 0; an actual read failure returns an error.
facelock config # show config path and contents
facelock config show # the same, spelled out
facelock config edit
Open the config file in $EDITOR (then $VISUAL, then nano/vi/vim),
validate it on save, and request a daemon restart when both the old and new
configurations are valid and a setting in the command's restart list changed.
Requires root. An invalid saved file is left in place with a warning and exit
0; it is not rolled back. $EDITOR and $VISUAL must name an executable, not
a shell command with arguments.
sudo facelock config edit
facelock daemon
Run or restart the persistent authentication daemon. Bare facelock daemon is
facelock daemon run, which is the form every shipped service unit invokes.
facelock daemon run
Run the daemon in the foreground. Requires root — it opens the camera and the face database. Normally managed by systemd, not run manually.
Run as non-root it hard-errors with the sudo hint and never offers to
re-exec, even from a terminal: the service unit that normally invokes it has
nobody to answer a prompt. daemon restart still prompts.
sudo facelock daemon # use default config
sudo facelock daemon run # the same, spelled out
sudo facelock daemon -c /path/to/config.toml # short alias for --config
sudo facelock daemon --config /path/to/config.toml
facelock daemon restart
Request a restart of the persistent daemon with systemctl restart facelock-daemon.service. If that command fails or cannot be launched, try a
D-Bus shutdown request so the service manager or later D-Bus activation can
start it again. The fallback result is not checked: exit 0 does not prove the
daemon restarted. Use facelock status to check reachability. --config does
not change which service is restarted or the configuration that service reads.
Requires root. If run interactively as a non-root user, the CLI prompts to
re-run via sudo.
sudo facelock daemon restart
facelock auth
One-shot authentication. Used by the PAM module in oneshot mode.
sudo facelock auth -u alice # authenticate (-u/--user)
sudo facelock auth --user alice --config /etc/facelock/config.toml
Exit codes: 0 = matched, 1 = scanned and not matched, 2 = error / no
opinion, 3 = rate limited, 4 = suppressed (no enrolled models with
security.suppress_unknown), 5 = all frames dark. The full table and its
compatibility invariants are frozen in docs/contracts.md ("facelock auth
Exit Codes").
facelock tpm
Everything that manages the embedding encryption key: the TPM device that can
seal it, and the key material itself. encrypt, decrypt and reseal live
here because the group owns the key's lifecycle — encrypt and decrypt run
software AES-256-GCM with no TPM involved.
facelock tpm status
Report the configured TPM path's presence, sealed-key presence, encryption method
and encrypted/plaintext row counts. Requires a readable database; it does not
test whether the TPM can unseal the key. Use unseal-check for that.
sudo facelock tpm status
facelock tpm seal-key
Seal the existing AES encryption key with the TPM and set encryption.method
to tpm. Requires a plaintext keyfile and refuses an existing sealed blob.
The plaintext keyfile is retained as a recovery backup; embeddings are not
re-encrypted.
sudo facelock tpm seal-key
facelock tpm unseal-key
Unseal the AES key from the TPM into a plaintext keyfile and set
encryption.method to keyfile. Requires a sealed blob and refuses an existing
plaintext keyfile, including the backup retained by seal-key. It leaves the
sealed blob in place and does not re-encrypt embeddings.
sudo facelock tpm unseal-key
facelock tpm unseal-check
Read-only check that the sealed AES key still unseals under the current PCR
values. Writes nothing and exits non-zero on failure. Diagnose the reported
cause first: an unavailable TPM, wrong encryption method, or missing/corrupt
blob is not evidence that resealing will help. A PCR-policy mismatch may call
for facelock tpm reseal when the key is recoverable
from the current TPM policy or a protected plaintext backup.
sudo facelock tpm unseal-check
facelock tpm pcr-baseline
Display the current PCR values for all configured PCR indices. Device operations
(seal-key, unseal-key, unseal-check, pcr-baseline, reseal) require a
build with the optional tpm feature and a usable configured TPM connection.
sudo facelock tpm pcr-baseline
facelock tpm encrypt
Encrypt all unencrypted embeddings in the database with AES-256-GCM. The cipher
is software either way; encryption.method decides only where the key lives.
sudo facelock tpm encrypt # encrypt using the configured key
sudo facelock tpm encrypt --generate-key # generate a new key file (or seal a new TPM key) WITHOUT re-encrypting embeddings
--generate-key creates or replaces key material, without changing the
configured method. It refuses when encrypted templates exist or the database
cannot be checked. With method none, it writes a keyfile; configure keyfile
before running facelock tpm encrypt without the flag. In-place encryption
refuses while security.bind_device_aad = true activates hard device binding;
use enrollment to create bound templates.
facelock tpm decrypt
Decrypt AES-256-GCM embeddings and legacy per-embedding TPM blobs into plaintext rows. Legacy TPM blobs require a build with TPM support. The command does not change the configured encryption method or remove key files; updates happen row by row, so a later failure can leave a partly converted database. It cannot decrypt templates sealed with device-bound additional authenticated data.
sudo facelock tpm decrypt
facelock tpm reseal
Re-seal the TPM AES key under the current PCR values. This is the recovery step
after a firmware or kernel change moves a measured PCR and the sealed key stops
unsealing. Requires root, and applies only when encryption.method = "tpm" —
under any other method it errors rather than quietly doing nothing.
sudo facelock tpm reseal
It requires an existing sealed blob and prefers unsealing it. Once the PCRs
have moved it falls back to the plaintext key backup. It seals against the
current PCRs, so running it before an update does not authorize the future PCR
values. Without a recoverable key it fails. facelock tpm unseal-check checks
the current TPM path; it does not validate the availability of a plaintext
recovery backup.
facelock bench
Benchmark and calibration tools.
Every bench subcommand requires root (DEC-6): direct-mode access needs the
0600 root:root database on the enrollment-dependent measurements, and the
auth benchmarks may need TPM access besides. cold-auth, warm-auth, and
calibrate require enrolled faces; report requires the database but can
report timing with no enrolled faces.
These are direct capture/inference measurements, not full PAM or daemon
authentication: the benchmarks do not run the authentication policy, rate
limiter or liveness checks. They do not change configuration or save captures.
The user is selected from SUDO_USER, then USER, then LOGNAME, then
unknown; the benchmark group has no --user option and does not use
DOAS_USER or a UID lookup. Missing a timing target or face match does not
itself make a benchmark exit non-zero.
camera-reopen needs no enrolled face and loads no models — but is root like
the rest: it closes and reopens the camera --iterations times (default 5) and
reports the per-phase median. That total is what device.camera_release_secs
trades LED-on time against — holding the stream warm after a failed attempt
buys a retry exactly this much (ADR 008).
facelock bench cold-auth
Measure cold-start authentication latency, including model load and the first
authentication attempt: sudo facelock bench cold-auth.
facelock bench warm-auth
Measure ten authentication attempts with models already loaded:
sudo facelock bench warm-auth.
facelock bench preview
Measure frame capture and face-processing latency (detection and embedding):
sudo facelock bench preview.
facelock bench enrollment
Measure snapshot capture and embedding without storing the result:
sudo facelock bench enrollment.
facelock bench model-load
Measure SCRFD and ArcFace model loading: sudo facelock bench model-load.
facelock bench calibrate
Compare faces from ten live captures with the selected user's enrolled
templates, sweeping thresholds from 0.20 through 0.80 in steps of 0.05. Recommend
the threshold whose pairwise match rate is closest to 90%, then sweep detector
confidence from 0.30 through 0.90 on another frame:
sudo facelock bench calibrate. This does not measure false-accept rates or
write the recommendation to configuration.
facelock bench camera-reopen
Measure open, STREAMON and warm-up phases. Use --iterations <N> to replace the
default five repetitions:
sudo facelock bench camera-reopen
sudo facelock bench camera-reopen --iterations 10
facelock bench report
Report environment details plus model-load, preview and enrollment-snapshot
timings: sudo facelock bench report. With enrolled faces it also measures
warm capture/match and an approximate cold-auth time (model reload plus one
capture on the already-open camera); otherwise those rows say N/A/SKIP.
It does not run the standalone cold-auth loop, calibrate or camera-reopen.
The printed model-pack and build labels are fixed text, not detected facts;
use the actual configuration and build metadata when interpreting them.
facelock pam
Manage the facelock line in /etc/pam.d service files. This command owns every
write to /etc/pam.d; setup --pam is an alias onto it, and the setup wizard
calls the same writer.
--service is repeatable on all three verbs and defaults to sudo, so several
services are configured in one process, under one root check. add and
remove require root and never offer to re-exec under sudo; status reads
only and needs no root.
A service name is looked up in /etc/pam.d first and /usr/lib/pam.d second —
Linux-PAM's own order, first hit wins — because packages ship their
configuration there: on current Arch polkit installs /usr/lib/pam.d/polkit-1
and there is no /etc/pam.d/polkit-1 at all. Only /etc/pam.d is ever written
to. A service that exists only in a vendor directory is copied there first, with
the facelock line already in it and a two-line header saying what it was forked
from; the package's own file is left byte for byte. That copy reports
overridden rather than installed, and pam status reports a service with no
local copy as vendor-only rather than as missing. Deleting the override
restores the vendor file. Named pam remove does that automatically only while
the two-line Facelock header, the bytes below it after removing the module rule,
and the file owner/mode still match the first existing vendor service in the
configured search order. If either copy
has drifted, it removes the module rule but keeps the local override and says
why. If no current vendor source exists, an exact header naming a normalized
configured candidate is reported as absent and the local override is retained;
an arbitrary header path is not trusted or opened. Set [pam] config_dirs if your distribution's vendor directory is
somewhere else for explicit add, named remove, and test resolution.
Machine-wide pam remove --all deliberately ignores that setting, scans the
compiled system roots /etc/pam.d and /usr/lib/pam.d, and separately scans
the fixed detection-only generated root /etc/authselect.
Fedora RPMs support the same service-scoped leaf-file setup. They do not ship
or select an authselect profile, and Facelock never writes the generated
system-auth or password-auth files. Choose an application-owned leaf such
as sudo, polkit-1, or another explicit service; generated authselect symlinks
are refused by the writer's no-follow checks.
facelock pam add
sudo facelock pam add # /etc/pam.d/sudo
sudo facelock pam add --service polkit-1 --service hyprlock # several at once
sudo facelock pam add --service sshd --allow-sensitive # unlock a gated service
sudo facelock pam add --service hyprlock --if-present # a missing file is success
sudo facelock pam add --service sudo --dry-run # print the plan, write nothing
sudo facelock pam add --service sudo --json # machine-readable result
| Flag | Meaning |
|---|---|
--service <NAME> | service to act on; repeat for several (default: sudo) |
-y, --yes (alias --no-confirm) | skip the per-file confirmation, and nothing else |
--allow-sensitive | also permit the gated services common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac, system-login |
--if-present | treat a missing service file as success instead of an error |
--dry-run | print the resolved plan, write nothing, exit 0 |
--json | emit one JSON document instead of human text (implies --no-confirm) |
--yes never implies --allow-sensitive: they are separate authorizations,
"do not ask me" and "yes, edit system-auth". Every service is validated before
any file is written, so a rejected service name leaves the rest untouched.
The confirmation is skipped as if --yes were given whenever it could not be
answered — no TTY on stdin, no TTY on stderr (where the prompt is drawn, so
2>install.log counts), or --json — and the gate is decided before any
prompt exists, so an unattended pam add --service system-auth still refuses.
On Debian and Ubuntu, a selected packaged pam-auth-update profile is already
one Facelock auth path. Before planning any direct add (including
setup --pam), Facelock verifies the exact fixed-root profile, saved selection
and live Primary block without following links. It refuses a duplicate with:
sudo pam-auth-update --disable facelock, verify a real correct password
succeeds and a wrong password fails, then retry the original Facelock command
with all of its services and flags.
Any disagreement between the saved selection and live graph, or untrusted
state, also refuses without writing PAM or backup state.
facelock pam shared-profile-status is an internal, read-only Debian package
maintainer probe. It exits 0 only for that exact active profile, 1 when the
profile is cleanly unselected, and 2 for untrusted or inconsistent state; it
never changes PAM or backup state.
Any symlinked service file is refused rather than written through: on an
authselect system system-auth and password-auth link into generated state,
and even an in-directory link would make a recorded service name resolve to a
different file. A file with more than one hard link is refused too: a link
count says another name exists and not where, so the edit cannot be shown to
stay in the directory.
Before an in-place edit, add writes a 0600 root:root backup under
/var/lib/facelock/pam-backups/<service>.<timestamp> and an adjacent versioned
JSON provenance record. The record stores a confined service name, backup
basename, positive monotonic sequence, hashes, and prepared/committed state;
it never stores a target path. Only the exact
<service>.<seconds>-<nine-digit-nanoseconds> basename grammar is recognized.
This also moves the human and JSON backup value from the former adjacent
/etc/pam.d/<service>.facelock-backup location to the dedicated state path.
Legacy adjacent files remain visible as rollback hints and are removed by a
default pam remove, but they are not rewritten into versioned provenance.
--dry-run is honoured after the root check, so it still needs root.
pam status is the unprivileged read to reach for instead.
facelock pam remove
sudo facelock pam remove # /etc/pam.d/sudo
sudo facelock pam remove --service login # removal is never gated
sudo facelock pam remove --all # every recognized owned edit
sudo facelock pam remove --service sudo -y # accepted compatibility flag
sudo facelock pam remove --service hyprlock --if-present # a missing file is success
sudo facelock pam remove --service sudo --keep-backup # retain rollback state
sudo facelock pam remove --service sudo --dry-run --json
Takes the same flags as add except --allow-sensitive, which it does not
offer: removal can only take away a way to authenticate, so there is nothing to
gate. It never prompts; -y/--yes (alias --no-confirm) is accepted for
symmetry and compatibility but does not change removal behavior. Named removal
uses the configured lookup path.
By default it removes committed Facelock-owned provenance and backups for the
requested service, including the legacy adjacent <service>.facelock-backup
name. Unresolved prepared state is preserved for recovery. --keep-backup
opts out of cleanup. A cleanup error remains non-zero, but the JSON action is
cleanup-failed and the human diagnostic says that the PAM state change
already completed.
For a Facelock-created local vendor copy, named removal first uses the normal
crash-safe complete-file replacement to remove the module rule, then deletes
the override only after moving its exact published inode to a no-replace
transaction quarantine and rechecking that inode, canonical-name absence, and
the current vendor bytes and metadata. The first existing later-root service
wins; Facelock does not accept a matching lower-priority copy. The pre-removal
document must contain exactly the
one Facelock-emitted rule; extra or customized rules are drift. A restart also
recognizes the exact header-bearing copy after the line is already absent.
Header, payload, owner/mode, or vendor drift keeps the override; Facelock never
deletes a merely similar local file. If the current vendor source is absent,
only a header path derived from a normalized configured later-root candidate is
recognized, solely to report why the local override is retained; header paths
are never opened.
remove --all is the package-safe, config-independent form. It opens the
compiled /etc/pam.d, /usr/lib/pam.d, and detection-only /etc/authselect
roots without following links, enumerates the opened directory descriptors,
and uses directory-relative regular/single-link reads. A symlink is skipped
only when its exact absolute target is the same service in a later compiled
root that is scanned independently; every other linked entry is an unmanaged
blocker. This lets Fedora's unrelated generated PAM links be checked at their
fixed root without traversing the links. Directory contents are detection
ground truth; provenance can authenticate an arbitrary service Facelock
previously changed, but never supplies a target path. An exact pre-0.2
auth sufficient pam_facelock.so edit is recognized only under a
conventional service basename. Dot-prefixed and package/administrator artifact
names such as .pacsave, .rpmsave, pam-auth-update .pam-old, and ~
require strict provenance for that exact name or an exact current Facelock
vendor-copy header; unowned artifacts are ignored and preserved. A customized control,
options or spacing, corrupt provenance for a candidate, any other linked entry,
or a reference in a read-only root is an unmanaged blocker. Nothing is changed
when preflight finds one. The same scan recognizes an exact unchanged
Facelock-created vendor override even if a previous run already removed its
module rule, so package cleanup can finish that bounded intermediate. A
drifted or source-absent override is not a blocker by itself: when its
Facelock rule is provenance-owned or the exact canonical line, --all
removes the rule and keeps the file, the in-place rewrite named removal
already performs. A file edited after add is judged the same way: the
recorded hash no longer matching only means the file changed, so a
conventional service with the exact canonical line is still Facelock's to
clean. A reference it cannot vouch for blocks the whole run, and that blocker
names facelock pam remove --service <name> as the way out.
With --dry-run, an existing PAM backup directory is inspected read-only and
must already have its trusted owner and mode. The preview does not repair or
sync that directory, acquire its write lock, or run recovery; it refuses the
preview if the directory is not already trusted.
Before the first PAM file changes, the command persists rollback state for the
complete target set and one bounded, root-owned whole-set journal. Each
replacement re-resolves and rechecks the planned identity. A later failure or
the final compiled-root rescan finding any active reference exchanges every
earlier original inode back in reverse order. Only after the rescan is clear is
a self-contained commit marker published and cleanup finalized. Version 2
journal and commit targets carry a required delete_override boolean; version
1 state remains recoverable and must omit it. Once committed, a flagged target
is deleted only while the exact installed inode still matches and the
journaled header payload, owner/mode, and first existing fixed-root vendor
service still agree. The journal backup's full prepared identity and the
line-removed installed hash are checked before parsing that shape. An already
absent flagged target is an idempotent completed
unlink. Recovery rolls back a prepared journal and completes a durable commit
marker. It recognizes
an exact intent-only, pre-publication service as unstarted only while the
canonical full identity still matches and both temp and binding are absent.
After a reverse exchange, rollback removes the identity-checked replacement
temp, then its publication binding, then delegates the remaining base intent
to that exact intent-only recovery. Each boundary is restartable; ordinary
forward publication keeps its existing cleanup order.
Cleanup recovery resumes exact pair quarantine/unlink state; a fully absent
pair is already clean, but partial or conflicting state blocks. --keep-backup
preserves versioned and legacy rollback state for every target; the default
cleans only validated Facelock-owned state.
The command reads no Facelock config, database, model, camera, daemon, or ONNX
Runtime state. Package uninstallers invoke it while the CLI and PAM module are
still installed. Debian and RPM removal abort if cleanup cannot prove a clear
final scan. Booted coverage runs direct dpkg/rpm and the apt-get, apt,
and dnf frontends through abort retention and blocker-free success. Arch
packages also ship a Remove-only libalpm PreTransaction hook with
AbortOnFail, so pacman stops before removing either file.
This all-or-nothing promise covers direct PAM edits owned by this command.
The packaged Debian pam-auth-update profile is opt-in (Default: no), so
fresh installation leaves common-auth unchanged. Package removal never
silently disables a selected profile: it probes first and aborts with the
package, module, PAM graph, direct edits and daemon state retained. Run
sudo pam-auth-update --disable facelock, prove a real correct password
succeeds and a wrong password fails, then retry removal. No older package
persisted evidence distinguishing auto-enable from a later administrator
choice, so every existing selection is preserved; automatic legacy migration
is intentionally deferred. With the profile unselected, removal performs a
read-only direct-cleanup preflight and the journaled cleanup before generated
service lifecycle handling. Ordinary removal stops the daemon but preserves
its enabled state for reinstall; only purge retires that state. The inert
profile metadata leaves with the package without a generated-graph transition.
Fedora #226 retired the packaged authselect profile and added a read-only
upgrade guard. This command only detects references in generated
/etc/authselect state and never changes that state.
facelock pam status
facelock pam status # /etc/pam.d/sudo
facelock pam status --service sudo --service polkit-1
facelock pam status --service sudo --json
facelock pam status --all # everything configured
facelock pam status --all --json
Unprivileged, and the probe to branch on instead of grepping /etc/pam.d
yourself: it answers from the same file, without root, and reports "absent" and
"unreadable" as themselves rather than as "not configured". It offers
--service, --all, --if-present and --json, and neither --dry-run nor
--allow-sensitive — there is no write to preview or gate. The exit code is
the answer, on the same 0/1/2 scale as is-enrolled and grep:
| Code | Meaning |
|---|---|
| 0 | every requested service carries the line |
| 1 | at least one exists without it |
| 2 | at least one is absent, unreadable, misnamed, symlinked out of the directory, or hard-linked |
Across several services the worst outcome wins. --if-present means here what
it means on add and remove: an absent service file is reported and no
longer forces exit 2, so exit 0 becomes "every requested service that
exists carries the line" and optional integrations can be installed and then
verified with the same flag on both commands. It forgives absence only — a
service whose file is a dangling or looping symlink is still exit 2, because an
unresolvable link is not an absent file.
sudo facelock pam add --service hyprlock --service swaylock --if-present
facelock pam status --service hyprlock --service swaylock --if-present
A service whose file is a local copy hiding a package's own of the same name reads
facelock PAM line present (local override of <vendor path>) rather than
facelock PAM line present, and its JSON row carries a shadows key naming
that file. It is configured either way; the note says the copy will not follow
the package's updates. This is a property of the row, so it appears with
--service as it does with --all, and on pam add and pam remove rows too.
--json emits one document:
{"command":"status","dry_run":false,"module_path":"/lib/security/pam_facelock.so","services":[{"action":"present","backup":null,"path":"/etc/pam.d/sudo","service":"sudo"}]}
module_path is where pam_facelock.so was found, or null when no candidate
hit — a property of the machine rather than of a service, and what tells an
integrator that a service carries the line while the module it names is at a
path nothing looks at. add refuses before writing when the module is missing;
remove can clean up a stale reference even then. Neither write verb includes
this key in its document.
The document's shape, the action vocabulary, and the rule that a consumer
must tolerate an action it does not recognize rather than treat it as an
error, are a stability contract — see contracts.md, "facelock
pam Semantics", along with the exit codes for add and remove and what
--json does on a validation failure.
facelock pam status --all
--all answers the other question: not "is this name configured?" but "what is
configured on this machine?". It replaces --service (the two conflict) with
every service in the resolved directories whose file names pam_facelock.so,
so a polkit-1 or an omarchy-lock-face nobody thought to ask about is
reported. It scans rather than reading a list of what facelock has edited,
because such a list drifts the moment /etc/pam.d is edited by hand.
facelock pam status --all
facelock pam status --all --json
Nothing configured exits 1: a machine with no facelock line anywhere is not
configured, and --if-present does not convert that — a name reaches the report
by having been found, so there is nothing to forgive. The one exception is a
file deleted between the listing and the read, which reports absent like any
other.
A directory that could not be listed exits 2 and is named, rather than being
reported as holding nothing. The "nothing is configured" sentence is scoped to
the directories that were read and names the rest as unread in the same
breath, so taking the human answer with 2>/dev/null cannot turn "I could not
look" into "nothing is there". A directory that does not exist is neither
case: it demonstrably holds no service files, and the default search path names
a vendor directory many machines do not have. --all --json adds a
directories key listing every directory searched with a status of
scanned, absent or unreadable.
Only regular files are read. A FIFO, socket, device node or symlink to a
directory in a pam.d directory is skipped rather than opened, since reading a
FIFO blocks until a writer appears and a diagnostic that hangs on a malformed
/etc/pam.d is worse than one that omits an entry no PAM stack could use. An
entry that merely could not be examined — a symlink into a directory you may
not traverse, a symlink loop, a dead mount — is not skipped: it is reported
unknown, exit 2, which is what --service says about it too. The exception
is a path that is simply not there, which is an absence rather than an
unanswerable question: a dangling symlink is skipped by --all and reported
unknown by --service. An entry whose name is not valid UTF-8 is skipped
and logged.
facelock hyprlock
Manage hyprlock lock-screen integration: the face glyph in placeholder_text,
and the ignore_empty_input = false setting that lets a bare Enter submit to
PAM. Runs as your normal user and refuses to run as root, since it edits
$XDG_CONFIG_HOME/hypr/hyprlock.conf (falling back to
~/.config/hypr/hyprlock.conf). The config must already exist. enable creates
an adjacent .facelock-backup if none exists; disable leaves it in place.
--no-icon is for a hyprlock font with no Nerd Font glyphs; it flips the
functional setting and leaves any existing icon alone. disable sets
ignore_empty_input = true only when neither pam_fprintd.so in
/etc/pam.d/hyprlock nor fingerprint:enabled = true in the config is detected.
The fingerprint glyph alone does not preserve the setting. It does not restore
the backup or remember the setting's original value.
Wiring /etc/pam.d/hyprlock itself is a separate, root step — see
facelock pam. disable and status read
/etc/pam.d/hyprlock for integration hints; writes are limited to the selected
hyprlock configuration and its backup.
facelock hyprlock enable
Enable empty-Enter submission and add the face glyph. --no-icon changes only
the functional setting and leaves the placeholder text alone.
facelock hyprlock enable
facelock hyprlock enable --no-icon
facelock hyprlock disable
Remove the face glyph and apply the fingerprint-aware setting change above:
facelock hyprlock disable.
facelock hyprlock status
Report the current integration state without changing it:
facelock hyprlock status.
facelock audit
View the structured audit log of authentication events.
sudo facelock audit # show last 20 entries (default)
sudo facelock audit -l 50 # show last 50 entries
sudo facelock audit --lines 50 # long form
sudo facelock audit -f # follow mode: stream appended entries
sudo facelock audit --follow # long form
| Flag | Short | Default | Description |
|---|---|---|---|
--follow | -f | false | Watch for new entries (like tail -f) |
--lines N | -l | 20 | Number of recent entries to display |
Requires root and never offers elevation. Disabled logging or a missing log prints an explanation and exits 0, including in follow mode. Follow mode polls file growth every 500 ms; it does not reset its offset after truncation or rotation, so restart it after the log is replaced.
facelock data
Manage retained Facelock state on this machine. One verb today, purge.
data is a noun group with a single subcommand on purpose. A top-level name was
rejected:
facelock purge
That spelling would sit beside facelock clear, which already means "remove all
face models for a user", and the two would differ only in blast radius — naming
the object first makes that difference the first word you read.
facelock data purge
Destroy retained Facelock state: enrolled embeddings, the database and its sidecars, encryption keys and sealed blobs, models, enrollment markers, audit logs, snapshots and upgrade backups.
This is the sanctioned way to destroy biometric data before uninstalling, and
the only one. Package removal deliberately leaves retained state behind, so
there is no removal path that also destroys data — see
contracts.md under "Fixed-root purge boundary".
sudo facelock data purge --dry-run # classify configured paths; removes nothing
sudo facelock data purge --allow-destruction # destroy, after a confirmation prompt
sudo facelock data purge --allow-destruction --yes # destroy without the prompt
sudo facelock data purge --allow-destruction --json # destroy and emit the machine document
sudo facelock data purge --allow-destruction --yes --leave-activation-barred
| Flag | Default | Description |
|---|---|---|
--allow-destruction | false | Authorize irreversible destruction. Required except with --dry-run; nothing else implies it |
--yes | false | Skip the confirmation prompt (also --no-confirm, -y) |
--dry-run | false | Classify configured paths and remove nothing. Does not examine the contents of the roots |
--leave-activation-barred | false | Keep the daemon stopped and D-Bus activation barred afterwards |
--json | false | Emit the machine document instead of the human report |
Two gates, and neither implies the other. --yes suppresses the
confirmation prompt. --allow-destruction authorizes the destruction itself.
A wrapper that passes --yes to every command so scripts run unattended has
not thereby authorized a purge, and --json — which suppresses the prompt for
the same reason it does on facelock pam add, because a question on stderr
while a parser waits on stdout is a hang — does not authorize one either. This
is the same split facelock pam add draws between --yes and
--allow-sensitive.
Root is required, and the check runs first: a non-root invocation refuses
before the prompt, before the daemon is touched, and before anything is read.
There is no sudo re-exec offer, because the command is typically invoked from
an uninstall script where a stray prompt is a hang.
What is traversed. Only the three compiled roots: /etc/facelock,
/var/lib/facelock, /var/log/facelock. The roots themselves are never
removed. Configuration cannot add a root. A configured path that points
outside them — daemon.model_dir, storage.db_path, encryption.key_path,
encryption.sealed_key_path, audit.path, snapshots.dir — is reported as an
external remnant and left untouched; deciding what to do with it is yours.
What is refused. Symbolic links, hard-linked files, non-regular objects,
wrong ownership, group- or world-writable modes, anything on another mount, and
anything below the 64-level depth or 10,000-entry caps. A refusal is reported,
not worked around. /var/lib/facelock/pam-backups stays opaque unless it is
already empty, because a remaining entry there is unresolved PAM cleanup
evidence. /etc/pam.d is never traversed — use
facelock pam remove for PAM lines.
The daemon during a purge. Stopping facelock-daemon.service is not
enough on its own: the D-Bus activation file lets any PAM Authenticate call
restart it mid-traversal. So purge takes an exclusive lifecycle lock, bars
activation, stops the daemon, and restores exactly the prior state when it
finishes — including on error, on panic, and on Ctrl-C. An interrupt stops
the traversal at a deletion boundary before the daemon is allowed back.
--leave-activation-barred keeps the daemon stopped and masked after the purge,
for a caller that uninstalls next. Face authentication stays off until the
named barrier file is removed and systemctl daemon-reload runs, or until the
machine reboots — the barrier lives on tmpfs.
Repeating a purge is safe. Every refusal leaves the object in place, so a second run after fixing an ownership or link problem picks up what the first one could not prove safe.
Removing a name is not erasure. Filesystem deletion does not securely erase SSDs, snapshots, or backups. The report says which names were removed and which remnants remain; it never describes purge as forensic destruction of biometric data.
Reading the report
The exit status is 0 whenever the purge ran and the daemon lifecycle was restored, whatever the purge could not remove — a safety refusal is a reported outcome, not a crash, and the same rule the Debian purge follows so a failed removal cannot strand a half-purged install. A nonzero exit after a rendered report means the purge ran and the lifecycle needs attention, not that nothing happened. The report, not the exit code, is the answer. A run that retained anything says so in the negative and never claims completeness:
Removed 14 name(s) from the compiled Facelock roots.
2 object(s) inside the roots were retained:
/var/lib/facelock/facelock.db — regular file has 2 links
/etc/facelock/config.toml — symbolic link
1 configured path(s) lie outside the compiled roots and were left untouched by design:
storage.db_path = /srv/faces/faces.db
Facelock data was NOT completely destroyed. The remnants above are still
present; re-run after resolving them, or remove the external paths yourself.
Removing a name is not erasure. Filesystem deletion does not securely erase
SSDs, snapshots, or backups.
With --json, branch on complete — it is the engine's own verdict, false
whenever a remnant, an external path, an unclassifiable configuration or an
interrupt occurred. A document is emitted only when a pass actually ran, so its
presence distinguishes "the purge ran" from "the purge never started", and
lifecycle_restored distinguishes a clean finish from one that needs
attention:
{
"mode": "purge",
"roots_examined": true,
"removed": [{"logical": "/var/lib/facelock/enrolled/alice", "kind": "File"}],
"remnants": [
{
"logical": "/var/lib/facelock/facelock.db",
"kind": "HardLink",
"detail": "regular file has 2 links"
}
],
"external": [{"field": "storage.db_path", "path": "/srv/faces/faces.db"}],
"config_note": null,
"complete": false,
"interrupted": false,
"secure_erasure": false,
"lifecycle_restored": true,
"lifecycle_error": null,
"activation_barred": false,
"activation_barrier_path": null
}
secure_erasure is a constant false, present so no consumer has to infer it.
What --dry-run does and does not tell you
A dry run classifies the configured paths and stops. It does not open the compiled roots, so it reports nothing about what is stored inside them:
Dry run: nothing will be removed. Reporting what purge would find.
Scope: configured paths only. The contents of /etc/facelock, /var/lib/facelock
and /var/log/facelock were NOT examined, so this reports nothing about what is
stored there. A real purge traverses them and reports what it removed and
retained.
Its document says the same in mode and roots_examined, and gives no
completeness verdict — complete is null, never true. An empty dry run
means "nothing was checked", not "nothing is there":
{"mode": "dry-run", "roots_examined": false, "complete": null, "removed": []}
Use it to see which configured paths fall outside the roots before authorizing a purge, not to confirm that data is gone.
If the purge is interrupted
Ctrl-C raises the lease's interrupt flag; the engine stops at a deletion
boundary, and the daemon is restored only after it has stopped, so an interrupt
never leaves the daemon racing a live traversal.
An interrupted run prints no report at all. The signal handler restores the daemon and then terminates the process, which happens while the command is still waiting on that restore — before it reaches the point where it would print anything. Whatever was deleted stays deleted.
Repeating a purge is always safe, so the way to see what remains after an interrupt is to run it again. A second run reports what the first one left.
User Resolution
For commands that accept --user:
- Explicit
--userflag (highest priority) SUDO_USERenvironment variableDOAS_USERenvironment variable- Current user (
$USERorgetpwuid)
Environment Variables
| Variable | Purpose |
|---|---|
FACELOCK_CONFIG | Override config file path only while the effective user is non-root. Every effective-UID-0 process ignores it, including ordinary root CLI commands; use the explicit global --config flag when supported. |
RUST_LOG | Control log verbosity (e.g., facelock_daemon=debug). Outranks both the built-in default and -v. An unparseable value is reported at warn and ignored. |
Auxiliary Commands
The workspace builds three executables outside the unified facelock command
tree. They do not inherit facelock's global flags, privilege dispatcher, or
output contracts.
facelock-bench
facelock-bench is a developer benchmark binary. Current Debian, RPM, Arch,
and release artifact paths do not install or publish it; the Nix derivation's
workspace-wide install result has not been established as a delivery contract.
Build it from a checkout and run the resulting path explicitly:
cargo build --release --bin facelock-bench
target/release/facelock-bench --help
After providing a configuration, models, camera access, and any per-command prerequisites in the table below, for example:
target/release/facelock-bench camera-reopen --iterations 10
It has exactly eight verbs:
| Command | Measurement | Additional prerequisite |
|---|---|---|
facelock-bench cold-auth | model load, camera/store open and captures until first detected face or timeout; config loading precedes the timer | plaintext enrolled templates |
facelock-bench warm-auth | ten captures and matches with models loaded | plaintext enrolled templates |
facelock-bench preview | camera capture and face processing (detection and embedding) | camera and models |
facelock-bench enrollment | five capture-and-embed snapshots, without storing them | camera and models |
facelock-bench model-load | detector and embedder load | models |
facelock-bench calibrate | ten live captures compared with the current user's enrolled templates, sweeping thresholds from 0.20 through 0.80 toward a 90% match rate | camera, models and current-user plaintext enrollment; it does not estimate false-accept rates |
facelock-bench camera-reopen | open, STREAMON, warm-up and total reopen latency | camera; optional --iterations <N>, default 5 |
facelock-bench report | environment plus model-load, preview and enrollment-snapshot timings; warm and approximate cold-auth timings when plaintext templates exist | camera, models and a readable database; templates are optional for timing |
There are no --config, --quiet, --verbose, --json, or --user options
and no automatic root gate. FACELOCK_CONFIG selects the configuration only
for a non-root process; effective-UID-0 processes ignore it and read the fixed
default. Access to the default root-owned database and many camera devices may
still require privileges.
This older standalone path reads only plaintext embedding rows. It cannot
benchmark the current default encrypted (keyfile) store. Do not turn off
encryption on a real enrollment merely to use it; prefer the supported
sudo facelock bench ... commands, which understand the configured store and
apply a consistent root gate.
Where a verb needs a user, facelock-bench reads USER, then LOGNAME, then
uses the literal name unknown. It does not use --user, SUDO_USER,
DOAS_USER, or a UID lookup. Running it through sudo therefore commonly
selects root, not the invoking desktop user. Measurement reports go to
stdout; diagnostics go to stderr. RUST_LOG controls diagnostic filtering.
These measurements do not run PAM/daemon policy, rate limiting or liveness
checks, and a missed timing target or non-match does not itself fail the
command. report approximates cold authentication by reloading models and
capturing once on its already-open camera; it does not run the standalone
cold-auth loop, calibrate or camera-reopen. Its model-pack and build labels
are fixed text rather than detected metadata.
The ordinary capture paths open with an empty quirks database; camera-reopen
loads the real quirks database for its reopen measurement.
facelock-polkit-agent
facelock-polkit-agent is an experimental session service, not a CLI. It has
no options or subcommands. In particular, do not run it with --help or
--version: those strings are not parsed and the process will instead connect
to D-Bus and start the agent.
The binary needs all of the following:
- a working system D-Bus and polkit authority
- the user's session D-Bus
- a usable Facelock daemon and enrollment
- a valid local session ID in
XDG_SESSION_IDfor registration - an action ID listed in
[polkit].face_eligible_actions
It reads the ordinary configuration as the session user. If that load fails,
it uses the restrictive default allowlist containing only
org.freedesktop.login1.lock-sessions. LANG supplies the registration
locale, with en_US.UTF-8 as the fallback. If XDG_SESSION_ID is missing, the
binary submits the literal string auto; it does not resolve a session ID
itself. Registration must succeed before the agent can handle requests.
Packages may install the executable, but they intentionally do not install an
autostart entry. A desktop-session integrator that has tested the agent can use
this shape in ~/.config/autostart/org.facelock.AuthAgent.desktop:
[Desktop Entry]
Type=Application
Name=Facelock polkit authentication agent
Exec=facelock-polkit-agent
OnlyShowIn=ExampleDesktop;
X-GNOME-Autostart-enabled=true
Replace ExampleDesktop with the desktop identifier or remove OnlyShowIn
only after testing the session's agent selection. Polkit permits one
authentication agent per session. Registering this experimental agent can
displace the desktop's password agent; when Facelock declines or fails, that
can produce a denial instead of a password dialog. Keep a recovery path and do
not deploy it as a universal replacement. The internal
FACELOCK_POLKIT_SKIP_REGISTER test hook is not a supported user setting.
For per-action policy and the fallback limitation, see
contracts.md. For build, test and maintenance
commands, see developer-commands.md.
facelock-synth-face
facelock-synth-face is a test fixture writer, built from
facelock-test-support. No package installs it. It takes one argument, an
output directory, and writes the loopback tier's synthetic face sequence as
raw video: ir.y8 (640x480 GREY, 24 frames back to back), rgb.yuyv (the
same frames as YUYV 4:2:2 with neutral chroma) and frame-00.pgm (the first
frame, for a look). The output is deterministic: the same bytes on every host.
cargo run -p facelock-test-support --bin facelock-synth-face -- /tmp/synth
The face is drawn procedurally and is nobody's (test/loopback/NOTICE.md).
test/loopback/run-loopback-tier.sh runs the binary itself; the only reason
to run it by hand is to inspect a frame. Exit 2 means the argument is
missing, exit 1 that the directory could not be written.
Developer Commands
This index is derived from Cargo targets and the public justfile metadata. Regenerate
it with python3 test/docs-inventory.py --write; just check-docs detects drift.
Run recipes from a repository checkout. Recipes can build, download, install, remove,
or publish state: inspect just --show RECIPE and read the linked guide before using
one. An entry here records an interface, not evidence that a release or hardware test ran.
Executables
| Executable | Crate | Reference |
|---|---|---|
facelock | facelock-cli | Reference |
facelock-bench | facelock-bench | Reference |
facelock-polkit-agent | facelock-polkit | Reference |
facelock-synth-face | facelock-test-support | Reference |
The PAM module is a shared library, not a command: see contracts.
Prerequisites and effects
- Build/test/lint recipes need the development dependencies
- Package/container recipes need Podman, their declared images and build tools; testing safety explains the tiers
- Camera/TPM/GPU recipes need the named devices/models; skipped hardware is not verification
- Install/uninstall recipes change system files through sudo; use a disposable guest for testing
- Release recipes may change versions or publish externally; follow releasing and inspect the recipe before invocation
- Documentation checks inspect examples; walkthroughs establish actual clean-system results
Public recipes
Arguments in square brackets are optional; defaults are shown. This is a syntax index, so substitute real values for metavariables before running a recipe.
| Invocation | Description |
|---|---|
just audit | Scan Cargo.lock for RustSec advisories; requires cargo-audit and applies .cargo/audit.toml. |
just build | Build in debug mode (development) |
just build-release | Build in release mode (for install) |
just build-smoke-binaries | Build only the release facelock (tpm) and PAM module the CI smoke tiers consume. |
just check | Run local tests, lint, format, audit, PAM isolation and documentation/install/release contracts; excludes full packaging and hardware lanes. |
just check-agent-docs [base=] | Check repository instructions and lifecycle contracts; optional base ref adds a coupling check. |
just check-docs | Verify instructional coverage, references and parser acceptance (no example execution). |
just check-package-names-live | Resolve documented dependency names against live upstream repositories (network required). |
just check-pam-standalone | Build PAM independently and reject forbidden async-io backend dependencies. |
just check-workflow-policy | Pin the trust boundary of the comment-triggered Claude workflow (docs/security.md, CI Trust Boundary). |
just clean | Clean build artifacts |
just docs-inventory | Report the tracked documentation, public recipes and Cargo executables as JSON. |
just docs-site-check | Build with mdBook 0.4.44 and check rendered links/assets; retain the temporary site for review. |
just fmt | Format code |
just fmt-check | Format check |
just install | Build release binaries as the invoking user, then elevate for system file installation. |
just install-files | Install pre-built binaries to system (requires root, no build) |
just link-models [src=] | Populate models/*.onnx from an existing checkout or install tree |
just lint | Lint every workspace target with Clippy, denying warnings (matches CI). |
just lint-tpm | Lint every workspace target with the tpm feature enabled (matches CI). |
just mo | Compile and validate available PO catalogs into target/locale (requires msgfmt). |
just pot | Regenerate both gettext POT templates from source messages. |
just release <version> | Validate and update release versions, then print the commit/tag/push steps; does not publish. |
just release-preflight [tag=] | Check release prerequisites and pinned evidence; infer tag from Cargo.toml unless supplied. |
just show-paths | Show installed file locations |
just test | Run all unit tests |
just test-all | Run all tests including hardware-dependent (ignored) tests |
just test-apt-repo [trixie_manifest=] [resolute_manifest=] | Test local signed APT publication/client resolution using both supplied manifests or stable stand-in packages. |
just test-arch-camera-free | Automated camera-free E2E tests (Arch container, no camera needed) |
just test-arch-camera-required | Both camera-required E2E tiers, recorded for release-preflight (requires camera) |
just test-arch-dev-shell | Dev shell — interactive Arch container with host models for fast iteration (requires camera) |
just test-arch-integration | Automated daemon integration tests (Arch, requires camera) |
just test-arch-layout | Check installed state-directory permissions and enrollment-marker visibility in Arch. |
just test-arch-loopback [ir=] [rgb=] | Both camera-required E2E tiers against a synthetic v4l2loopback camera, recorded for release-preflight |
just test-arch-oneshot | Automated oneshot (daemonless) integration tests (Arch, requires camera) |
just test-arch-package-select | Test selection of the main Arch package rather than its debug split. |
just test-arch-pam | Automated PAM smoke tests (Arch container) |
just test-arch-pkg | Package test — build the real dist/PKGBUILD with makepkg, install it with pacman, validate |
just test-arch-release-shell | Interactive Arch shell with locally staged binaries, no host model mounts (for camera testing). |
just test-cargo-vendor-contract | Prove the deterministic, exact Cargo source component used by Debian builds. |
just test-classify-changes | Test CI packaging path classification using temporary Git histories. |
just test-copr [release=44] | COPR-equivalent build — Packit SRPM + mock from-source rebuild on a Fedora chroot (slow, opt-in) |
just test-copr-lanes | Every Packit/COPR release target rebuilt from source at its declared depth |
just test-copr-pkg [release=44] | COPR lifecycle lane — mock source rebuild, then the booted package lifecycle |
just test-copr-smoke [release=45] | Branched-release COPR lane — mock source rebuild, then the runtime smoke |
just test-deb | Run both exact supported-suite Debian package gates. |
just test-deb-dev-shell | Dev shell — interactive .deb container with host models for fast iteration (requires camera) |
just test-deb-package-contract <manifest> | Validate every binary package named by one exact generated manifest. |
just test-deb-package-contract-test | Exercise exact Debian manifest identity, checksum, and atomic-staging mutations. |
just test-deb-release-shell | Interactive Ubuntu 26.04 shell with a locally built .deb and test config, no host model mounts. |
just test-deb-resolute-pkg | Ubuntu 26.04 Resolute package — exact source build, TPM/PCR, and booted lifecycle. |
just test-deb-source-contract | Static Debian source/metadata/release-consumer contract. |
just test-deb-trixie-pkg | Debian 13 Trixie package — exact source build, TPM/PCR, and booted lifecycle. |
just test-debian-postrm-purge | Exercise Debian remove/purge policy below disposable fixed roots only. |
just test-docs-walkthrough <scenario> <identity> <output> | Execute one explicit walkthrough scenario using a pinned identity inside a disposable guest. |
just test-legacy-system-assets | Validate immutable system assets and migrate only exact historical /etc copies. |
just test-locale-install-contract | Check locale installation across package paths; compile a fixture when gettext is available. |
just test-packaging-matrix | Every packaging lane the release gate requires, recorded for release-preflight |
just test-packit-config | Packit config schema gate — runs the real packit in a digest-pinned Fedora container |
just test-release-artifacts | Static contract: the release publishes exactly once, after validation |
just test-release-contract | Fast release contract tests that do not require distro package tools. |
just test-release-matrix | Complete Track V version/matrix gate. |
just test-release-native-ordering | Native version comparison tools run only inside disposable, digest-pinned containers. |
just test-rpm [release=44] | Test RPM packaging in Fedora container |
just test-rpm-authselect [release=44] | Static and booted, model-free Fedora authselect retirement lifecycle |
just test-rpm-dev-shell [release=44] | Dev shell — interactive .rpm container with host models for fast iteration (requires camera) |
just test-rpm-lanes | Every declared Fedora release target at its declared lifecycle depth |
just test-rpm-pkg [release=44] | Package test — build real .rpm, install via dnf, validate under booted systemd |
just test-rpm-release-shell [release=44] | Interactive Fedora shell with a locally built .rpm and test config, no host model mounts. |
just test-rpm-smoke [release=45] | Branched-release lane — build the package, then boot it for a runtime smoke |
just test-source-install-daemon-lifecycle | Preserve the daemon's pre-install runtime state across source file replacement. |
just test-source-install-daemon-lifecycle-systemd | Exercise the source-install barrier against a real systemd and system bus. |
just test-upgrade-v014 | Both released-predecessor upgrade lanes — the stable entrypoint for #231 |
just test-upgrade-v014-contract | Released-predecessor upgrade lanes (#231) — container-free half, runs anywhere |
just test-upgrade-v014-deb | Debian half: install the real v0.1.4 .deb, upgrade to the candidate, roll back |
just test-upgrade-v014-pins | Confirm the pinned v0.1.4 assets are still the assets GitHub serves (needs gh) |
just test-upgrade-v014-rpm | Fedora half: same proof against the released fc44 RPM |
just uninstall | Remove source-installed system assets through sudo; retain biometric state and models. |
just uninstall-files | Uninstall files from system (requires root, called by uninstall) |
just version | Show current version |
Security Model
Threat Model
Facelock is a local biometric authentication system. The threat model assumes:
- Attacker has physical access to the machine (the entire point of face auth is physical-presence scenarios like unlocking a laptop)
- Attacker may have a photo or video of the enrolled user
- Attacker does not have root (if they do, game over regardless)
- Attacker cannot modify files in
/etc/facelock/,/var/lib/facelock/, or the distribution's PAM module directories
Privacy Guarantees
Facelock is designed to keep biometric data under the user's exclusive control:
- Local-only inference: All face detection and recognition runs on-device via ONNX Runtime. No images, embeddings, or metadata are ever transmitted over the network.
- No telemetry: Facelock contains zero analytics, tracking, or phone-home
code. Authentication makes no network request. The separately invoked
sudo facelock setupflow downloads model files when they are missing. - No cloud dependencies: Authentication works fully offline. No account registration, no API keys, no external services.
- Data stays on disk: Face embeddings are stored in a local SQLite database (
/var/lib/facelock/facelock.db) with restrictive permissions (600, root:root). New enrollments use AES-256-GCM keyfile encryption by default; TPM sealing is optional. - Open source: Facelock's source is dual-licensed under MIT or Apache-2.0. Dependencies and model weights have separate licenses; see the model notice. Privacy claims can be checked against the source.
Attack Vectors & Mitigations
1. Photo/Video Spoofing (CRITICAL)
Attack: Hold a photo or video of the enrolled user in front of the camera.
Why this matters: This is the #1 attack against face authentication. Without mitigation, anyone with a Facebook photo can unlock the machine.
Mitigations (layered, implement all):
A. IR Camera Enforcement (Required)
security.require_ir config flag, default true:
[security]
require_ir = true # Refuse to authenticate on RGB-only cameras
Rationale: Requiring an IR-classified capture path and applying the IR texture threshold raises the bar against static RGB presentations. It is one layer, not proof of sensor authenticity or resistance to video replay.
Limitation: IR classification uses an exclusively mono advertised format
set or an exact hardware quirk; the device name is never evidence. A mixed
YUYV/mono node is not auto-classified without a matching quirk. Use
sudo facelock devices to inspect the result. Y16 authentication additionally
requires a hardware-verified y16_bit_depth; Y8, Y10, and Y12 are
classification evidence but are not decoded.
B. Frame Variance Check (Required)
Require minimum variance across consecutive frames during authentication. Real faces have micro-movements causing slight embedding variation. A static photo produces near-identical embeddings (similarity > 0.99).
Config:
[security]
require_frame_variance = true # Reject static images (photo attack defense)
min_auth_frames = 3 # Minimum frames before accepting match
C. Dark Frame / IR Texture Validation (Recommended)
In IR mode, verify that the face region has expected IR texture characteristics:
- Real skin has micro-texture visible in IR
- Photos/screens appear as flat, uniform surfaces in IR
- Compute standard deviation of pixel intensity within the face bounding box
- Reject faces with abnormally low texture variance
2. Model Tampering
Attack: Replace ONNX model files with adversarial models that always match (or match specific attackers).
Mitigations:
A. SHA256 Verification at Load Time (Required)
Verify model integrity not just at download, but every time the daemon loads models. Tampered files are rejected before any inference runs.
B. File Permissions on Model Directory (Required)
# Models owned by root, not writable by others
chown -R root:root /var/lib/facelock/models
chmod 755 /var/lib/facelock/models
chmod 644 /var/lib/facelock/models/*.onnx
3. Embedding / Database Security
Attack: Read or modify the SQLite database to extract biometric data or inject fake embeddings.
Mitigations:
A. Database File Permissions (Required)
# Database owned by root, readable by root only
chown root:root /var/lib/facelock/facelock.db
chmod 600 /var/lib/facelock/facelock.db
B. Embedding Sensitivity Warning
Face embeddings are biometric data. Unlike passwords, they cannot be changed. The database contains irreversible biometric templates -- if compromised, the user's face embeddings cannot be "rotated" like a password.
C. Encryption at Rest (Implemented)
Templates are encrypted at rest by default. The key lives in a plaintext key file (encryption.method = "keyfile", the default) or sealed to the TPM (encryption.method = "tpm"), and either way the embeddings themselves are AES-256-GCM. The keyfile is generated at mode 0600 on first use. A TPM-sealed key is unsealed once at daemon startup and held in memory.
Plaintext storage (encryption.method = "none") is an explicit opt-out: enrollment refuses to write unencrypted templates unless security.allow_plaintext = true. Auth is never affected by any of this. A decrypt failure falls back to the password, never to a lockout.
tpm.pcr_binding is off by default, and turning it on is a commitment rather than a hardening tweak. With it on, the sealed key is bound to a PCR selection recorded in the sealed blob, and unsealing replays a real PolicyPCR session against the machine's current PCRs. A firmware or kernel change to a bound PCR makes the key refuse to unseal. Face auth then falls through to the password, which is the safe failure, but the templates stay locked until you act.
Recovery is one command:
sudo facelock tpm reseal
It re-seals the key under the current PCR state, recovering the key from the existing blob if the PCRs still match and from the plaintext encryption.key backup if they do not.
Keep that encryption.key backup. It is the recommended setup and it is what makes a reseal painless: without it, a PCR change after a firmware update means re-enrolling every face. The honest cost is that while the backup exists, the tpm method's protection against anyone who can read the file is the backup's own 0600 root-only permissions, not the TPM. Deleting the backup buys stronger at-rest confidentiality and pays for it in re-enrollment.
See Configuration for the [encryption] and [tpm] sections, and docs/security.md for the full finding.
4. D-Bus IPC Security
Attack: Unauthorized user connects to the daemon via D-Bus to trigger auth, enroll faces, or extract data.
Mitigations:
A. D-Bus System Bus Policy (Required)
The D-Bus system bus policy (/usr/share/dbus-1/system.d/org.facelock.Daemon.conf) governs who may own the bus name and which methods each caller may send. Two grants (ADR 010): root may send anything on the interface and receive its signals; every local user may send exactly one method, org.facelock.Daemon.Authenticate, which is what lets screen lockers and the polkit agent unlock with no group and no re-login. There is no facelock group; signal receipt is root-only. (/etc/dbus-1/system.d/ is the admin-override location for local customization.)
Because the bus admits every local user's Authenticate, the in-daemon per-method UID check is the boundary for that method. Before executing any method, the daemon calls GetConnectionUnixUser to verify the caller's UID. Authenticate allows root, or a non-root caller acting on its own username — a user must be able to request authentication for themselves, since screen lockers run their PAM stack as that user. Every other method, including Enroll, Shutdown, and the preview methods, is restricted to root (UID 0), so no non-root caller can enroll faces, pull camera frames, or shut down the daemon.
B. D-Bus Message Size Limits (Required)
The D-Bus bus daemon enforces message size limits, preventing memory exhaustion attacks.
C. Rate Limiting (Recommended)
Throttle authentication attempts: 5 per user per 60 seconds by default. Prevents brute-force and rapid-retry attacks.
5. PAM Module Hardening
A. Audit Logging (Required)
All authentication attempts are logged to syslog with user, service, and outcome:
pam_facelock(sudo): match for user alice
pam_facelock(sudo): no_match for user bob
This creates an audit trail in /var/log/auth.log or journald.
B. Service-Specific Policy (Recommended)
Allow different PAM services to have different security levels:
[security.pam_policy]
allowed_services = ["sudo", "polkit-1"]
denied_services = ["login", "sshd", "su"]
6. Daemon Process Hardening
A. Capability Dropping (Recommended)
After initialization, the daemon drops all unnecessary capabilities.
B. systemd Hardening (Required)
The systemd unit includes: ProtectSystem=strict, ProtectHome=yes, NoNewPrivileges=yes, PrivateTmp=yes, and other sandboxing directives.
Security Configuration Reference
[security]
disabled = false
abort_if_ssh = true # Refuse face auth over SSH
abort_if_lid_closed = true # Refuse if laptop lid closed
require_ir = true # CRITICAL: refuse RGB-only cameras (anti-spoof)
require_frame_variance = true # Reject static images (photo defense)
require_landmark_liveness = false # Require landmark movement between frames (off by default)
min_auth_frames = 3 # Minimum frames before accepting (variance check)
[notification]
mode = "terminal" # Show "Identifying face..." on login screen
[security.pam_policy]
allowed_services = ["sudo", "polkit-1"]
denied_services = ["login", "sshd"]
[security.rate_limit]
max_attempts = 5 # Max face-detected auth failures per user per window
window_secs = 60 # Rate limit window
Summary: Security Implementation Priority
| Priority | Mitigation |
|---|---|
| P0 | IR camera enforcement (require_ir) |
| P0 | Frame variance check (anti-photo) |
| P0 | Model SHA256 at load time |
| P0 | D-Bus system bus policy |
| P0 | D-Bus message size limits |
| P0 | PAM audit logging |
| P0 | Database file permissions |
| P1 | IR texture validation |
| P1 | Rate limiting |
| P1 | systemd hardening |
| P1 | Capability dropping |
| P1 | Service-specific PAM policy |
| P2 | Embedding encryption at rest |
| P2 | Memory zeroing on drop |
| P2 | Constant-time similarity comparison |
Troubleshooting
Camera selection
Start with the root-gated device report:
sudo facelock devices
v4l2-ctl --list-devices
Facelock identifies IR from an exclusively mono advertised format set or an
exact hardware quirk, never from “IR” in the device name. GREY is supported;
Y16 additionally needs a hardware-verified y16_bit_depth. Y8, Y10,
Y12, raw Bayer, and unknown formats are not decode paths. See
Compatibility before forcing a device.
Use preview to distinguish selection from detection problems:
sudo facelock preview
sudo env RUST_LOG=facelock_camera=trace facelock preview
Recognition and performance
facelock test may return zero when no camera scan ran or after a completed
non-match. Read the printed result. A cold daemon attempt includes model load
and camera reopen; measure the hardware-specific reopen cost with:
sudo facelock bench camera-reopen
PAM lockout recovery
Keep a separate root shell open for every host PAM test. From that shell, prefer the validated removal path:
If you still have a root shell open
facelock pam remove --service sudo
Current managed backups live beneath /var/lib/facelock/pam-backups/ with
versioned names and adjacent JSON provenance. Do not restore the
newest-looking file without reviewing its provenance and the live target.
Current Facelock does not automatically create
/etc/pam.d/sudo.facelock-backup; that path exists only when an operator or an
older release made it.
If you are locked out
With no root shell, boot recovery media, remount the root filesystem
read-write, and remove the exact pam_facelock.so rule or restore a separately
recorded and reviewed operator copy. Test sudo before any shared stack,
display manager, login, or sshd integration.
Daemon and logs
systemctl status facelock-daemon.service
journalctl -u facelock-daemon.service -n 50 --no-pager
sudo facelock status --json
sudo facelock -vv daemon run
The package uses the D-Bus system bus and the package-owned policy under
/usr/share/dbus-1/system.d/. An administrator may also have local fragments;
D-Bus merges them. sudo facelock setup --systemd validates installed assets
and reports preserved local policy for review.
See the canonical Troubleshooting page on GitHub for model verification, IPU relay, permissions, and detailed recovery guidance.
System Contracts
Stable contracts. Do not change without updating this document.
The CLI surface is the first half; the daemon, storage and protocol contracts follow it.
- Binaries
- CLI Subcommands
- Operating Modes
- Release Channels and APT Paths
- ONNX Runtime Trust and Fedora RPM Modes
- Package Lifecycle Ownership
- Filesystem Paths
- Config Schema
- Database Schema
- IPC Protocol
- PAM Semantics
- Polkit Agent Semantics
- Anti-Spoofing
- Models
Binaries
| Binary | Crate | Purpose |
|---|---|---|
facelock | facelock-cli | Unified CLI (daemon, auth, enroll, test, setup, etc.) |
facelock-bench | facelock-bench | Developer benchmark utility; not shipped by current Arch, Debian, RPM, or release-artifact recipes |
pam_facelock.so | pam-facelock | PAM authentication module |
facelock-polkit-agent | facelock-polkit | Polkit face authentication agent |
CLI Subcommands
| Command | Purpose |
|---|---|
facelock setup | Interactive setup wizard (camera, models, inference device, encryption, daemon, enrollment, PAM — the daemon before enrollment, so enrollment and the recognition test run on the transport later authentications use); removes a leftover facelock group from an older install, best-effort (ADR 010) |
facelock setup --systemd | Validate installed systemd/D-Bus assets, retire exact known legacy copies, reload, verify resolution, and enable the daemon unit. Refused under a non-default --config, before anything is written: the unit runs bare facelock daemon and reads only /etc/facelock/config.toml (see "facelock setup Flag Composition") |
facelock setup --pam | Alias onto facelock pam add|remove (see "facelock pam" below). Kept, and kept parsing, for every wrapper written against it |
facelock setup --pam --allow-sensitive | Explicitly authorize an add to a sensitive PAM service. Does not suppress confirmation and conflicts with --remove |
facelock pam add | Add the facelock line to one or more /etc/pam.d/<service> files. Root |
facelock pam remove | Remove it. Root. Cleans validated Facelock-owned rollback state by default; --keep-backup preserves it |
facelock pam remove --all | Config-independent, whole-machine removal of recognized Facelock-owned direct PAM edits beneath compiled roots. Root. Conflicts with --service |
facelock pam status | Report whether services carry the line. Reads only, no root — the probe to branch on instead of grepping /etc/pam.d |
facelock setup choice flags | --camera <PATH|auto>, --models <standard|balanced|high>, --execution-provider <cpu|cuda|rocm|openvino|auto>, --encryption <tpm|keyfile|none|auto>. Precedence: CLI flag > config file > built-in default. Inside the interactive prompt, the wizard's highlighted default comes from ONNX Runtime detection unless the config already names a GPU provider |
facelock setup action opt-outs | --no-pam, --no-systemd, --no-enroll decline an action outright (and their --pam/--systemd/--enroll counterparts force it). Later flag wins |
facelock is-enrolled | Report the user's enrollment marker state, a hint rather than proof that face auth is operational. Exit code is the contract; no daemon activation, no camera, no group: it opens the user's 0600 marker under 0711 directories (ADR 010) |
facelock capabilities | Report what this build can do: one capability name per line, or --json for {"version", "capabilities"}. Unprivileged, reads no config, activates no daemon. The feature probe to branch on instead of grepping --help |
facelock enroll | Capture and store a face |
facelock test | Test face recognition |
facelock list | List enrolled face models |
facelock remove <id> | Remove a specific model by decimal unsigned 32-bit ID, as printed by list; hexadecimal spellings are rejected |
facelock clear | Remove all models for a user |
facelock preview | Live camera preview |
facelock devices | List V4L2 cameras |
facelock status | Check system status. Root. --json renders the same report as one object a script can parse (see "facelock status Semantics") |
facelock config show | Show configuration. Bare facelock config is config show |
facelock config edit | Open the config file in $EDITOR and report validation on save; request restart for listed settings only when old and new config both parse. Root; invalid saved config is retained with a warning and exit 0 |
facelock daemon run | Run the persistent daemon. Bare facelock daemon is daemon run — the form every shipped service unit invokes |
facelock daemon restart | Request restart with systemctl restart, falling back to D-Bus Shutdown on any failure. Root; fallback failure does not change exit 0, and --config does not change the service's configuration |
facelock auth --user X | One-shot auth (PAM helper). --user is required here and only here; --config is the global flag, not a per-command one |
facelock hyprlock enable|disable|status | Manage hyprlock lock-screen integration (user, no root); enable accepts --no-icon to skip the cosmetic face glyph |
facelock tpm status | TPM status, sealed-key presence and encrypted/plaintext embedding counts. Root, like every tpm verb |
facelock tpm encrypt | Encrypt plaintext embedding rows, not the whole database; refuses active hard device binding. --generate-key only creates/replaces key material and refuses encrypted templates |
facelock tpm decrypt | Decrypt AES embedding rows and legacy TPM blobs into plaintext; does not change configuration or keys and cannot decrypt AAD-bound templates. Conversion is per-row, so failure can leave partial progress |
facelock tpm reseal | Re-seal the TPM AES key under current PCRs (recovery after a firmware/kernel change) |
facelock tpm seal-key / unseal-key | Migrate keyfile↔tpm key protection and update the configured method. Require the source key artifact and refuse an existing destination; retain the source artifact and leave embeddings unchanged |
facelock tpm unseal-check | Read-only: verify the sealed key still unseals (PCR policy satisfied) |
facelock audit | View audit log |
facelock bench | Benchmarks |
facelock data purge | Remove retained Facelock state inside the compiled roots. Root, and additionally --allow-destruction. Holds the lifecycle exclusion lease for the duration; reports retained names and never claims secure erasure (see "Fixed-root purge boundary") |
facelock data purge --dry-run | Classify configured paths and remove nothing. Root, no authorization flag, no lease — a preview must not stop the daemon as a side effect. Does not traverse the roots, and therefore reports no completeness verdict |
facelock data purge --leave-activation-barred | Keep the daemon stopped and D-Bus activation barred after purging, for a caller that uninstalls next. Conflicts with --dry-run |
Where a command goes. A top-level command names a user task and keeps its
spelling for the life of the binary. A noun group exists when the noun names a
distinct operational domain and owns two or more subcommands. The domains:
pam (/etc/pam.d), tpm (the TPM device and the encryption key), hyprlock
(hyprlock.conf), daemon (the running service), config (the config file),
bench (measurement runs). Facelock's primary objects, meaning face models,
cameras, the audit log and the install itself, are reached by top-level
commands and never earn a group. Inside a group the second word is spelled the
way its domain spells it, verb or noun: tpm seal-key and tpm pcr-baseline
follow tpm2-tools, bench cold-auth names a measurement. A new command must
fit an existing domain before it may claim a top-level name. Commands named by
pam_facelock.so or the service units never move. See ADR 009.
data is the one group that owns a single subcommand, and it is a stated
exception rather than drift. The domain is retained state on this machine —
distinct from config (the config file) and from the per-user face models the
top-level verbs reach. It is spelled as a group because the alternative is
worse. The rejected spelling illustrates the ambiguity:
facelock purge
That spelling would sit immediately beside facelock clear, which already
means "remove all face models for a user", and the two would differ only in
blast radius. Naming the object first makes that difference the first word a
reader sees, which on an irreversible command is worth a group that ADR 009's
two-subcommand rule would otherwise refuse.
Bare facelock data is a usage error, not an implied purge: unlike daemon
and config, whose bare forms are load-bearing invocations, nothing invokes
this one and a destructive default would be a trap.
The top-level set is pinned by the TOP_LEVEL_COMMANDS registry in
crates/facelock-cli/src/conformance/flags.rs, checked in both directions against
Cli::command(): a name in the registry the binary does not offer fails, and a
top-level command with no row fails too. Nested verbs are deliberately absent
from it — where a verb sits inside its group is that group's business.
CLI Flag Spelling
Flag spelling is a compatibility surface, not a presentation detail: pam_facelock.so
spawns facelock auth --user <name> --config <path> byte for byte, and wrapper
scripts hard-code the rest. Two things hold it still.
Shared clap arg structs in crates/facelock-cli/src/args.rs (UserArg,
ConfirmArg, JsonArg, DryRunArg) are flattened at every site, so a command
either offers a flag with the one spelling or does not offer it.
cli_flag_conformance in crates/facelock-cli/src/conformance/flags.rs walks
the whole command tree, nested subcommands included, and fails on any drift;
spending a new short letter means editing its registry on purpose.
The invariants it pins:
--useris-uon every command that has it, includingauthauth --userstays required — PAM names the subject and it must never fall back to the process owner. Every other--userdefaults to the current user--yesis-yand accepts--no-confirmeverywhere (it wassetup-only)--jsonand--dry-runtake no short letter--config(-c),--quiet(-q) and--verbose(-v) are declared once,global = true, and are accepted on either side of the subcommand name. No command re-declares them.facelock daemon -c Xandfacelock -c X daemonare equivalent, as arefacelock is-enrolled --quietandfacelock --quiet is-enrolled. A non-default--configmakessetup --systemdrefuse — except--disable, which stays allowed since stopping the packaged unit reads no config file — and routes every backend-using command direct (see "facelock setup Flag Composition" and "Operating Modes")--verbosecounts its repeats, one level per-vfrom the program's own starting level (warnfor the CLI,infofordaemon run).RUST_LOGoutranks it- every subcommand has non-empty
abouttext
legacy_invocations_still_parse, alongside it, is a table of real argv — the PAM
spawn included — that must keep parsing.
CLI Output Streams
stdout is the answer; stderr is everything else. Every facelock
subcommand prints its result — the JSON payload of --json, the rendered
table, the state word — on stdout, and only that. Diagnostics (tracing
output, whatever RUST_LOG selects, warnings such as the D-Bus fallback
notice) go to stderr on every process this repository builds.
This is what makes facelock devices --json | jq . and
facelock is-enrolled --json safe to pipe: an integration reading stdout gets
the payload whatever the log level, and an operator raising RUST_LOG to debug
cannot break a script by doing so. Before this was contract, the subscriber
inherited tracing_subscriber's stdout default and a single WARN corrupted the
JSON (#149).
An unparseable RUST_LOG is reported at WARN (on stderr) and the built-in
filter is used, rather than the value being silently discarded.
Diagnostics default to warn in the CLI and info in the daemon. Someone
typing a command reads its prompts and its report on the same terminal these
events land on, so the CLI prints warnings and errors and nothing quieter.
facelock daemon run keeps info, because the journal is its reader and
nothing competes with it there. -v raises the level one step per repeat from
whichever of the two this process started at. RUST_LOG outranks both,
including when it is the quieter of them: an override a flag can shout over is
not an override.
The level governs output volume and nothing else. Exit codes and stdout
payloads are identical at every level, so a consumer needs no flag it did not
need before. Every degradation an operator has to act on is WARN or above and
so survives the default: the D-Bus fallback in backend::select, an ONNX
Runtime that would not load, a provider that could not be queried, an
unreadable quirks file, an ignored RUST_LOG.
--quiet suppresses informational chatter, and on commands whose stdout is
the payload, the payload too; errors, prompts and exit codes are unchanged. A
quiet run that fails still says why on stderr and still exits non-zero, and a
prompt still asks — a silenced question is a hang, not a quieter program. This
is is-enrolled --quiet's rule ("leave only the exit code") generalized to
every payload: facelock --quiet devices --json writes nothing on stdout, and
the exit code is the answer. list --json and devices --json printed their
payload under --quiet before this rule; they no longer do.
The flag is read once, by the two suppressible stdout sinks of the message seam
— Terminal::info for human text, message::payload for machine output — so
no command implements it and no command can forget it. There is a third stdout
sink, Terminal::notice, which --quiet deliberately does not reach: it is
for the human lines that must be seen and must stay on stdout: pam add's
rollback instructions, the plaintext-embeddings warning, and the context a
confirmation needs to be answerable (pam add's preview of the edit, and the
orphaned-models warning ahead of setup's delete confirmation). Everything else
informational stays on Terminal::info — a notice that did not have to be
seen is just an unquietable one.
#140 tracks the commands
still printing human text directly.
preview --json is the one payload outside this rule: it emits a document per
frame until interrupted, so silencing it would leave a command that produces
nothing forever.
CLI Machine Output
Every command whose output a script would parse takes --json, and spells it
--json. One flag family (the shared JsonArg in
crates/facelock-cli/src/args.rs), no short letter, no --output json, no
per-command invention. cli_flag_conformance pins both halves: an arg whose
help advertises JSON must carry the id json, so a second spelling fails the
build instead of shipping.
A command gains --json when it has a named consumer, not to complete a
matrix. The coverage list is the JSON_COMMANDS registry inside that test,
checked in both directions against the clap tree, so adding a row is the moment
someone states who parses the output.
| Command | Payload |
|---|---|
facelock is-enrolled --json | one object. See "facelock is-enrolled Exit Codes" |
facelock capabilities --json | one object. See "facelock capabilities" |
facelock list --json | array of enrolled models |
facelock devices --json | array of IpcDeviceInfo (facelock_core::ipc): path, name, driver, is_ir, formats (empty whenever the daemon answers, which carries no format detail). Serde-derived, so it is a typed schema rather than a scrape of the human renderer, whose columns and [IR] tag are free to change |
facelock preview --json | one object per line, one per frame |
facelock status --json | one object, one key per report section, each carrying an ok/problem/unknown verdict. See "facelock status Semantics" |
facelock pam add|remove|status --json | one object, whose shape is a stability contract. See "facelock pam Semantics" |
facelock data purge --json | one purge or dry-run report. See "Fixed-root purge boundary" |
preview is on the list because it always emitted JSON. It shipped calling the
flag --text-only, which survives as a hidden alias and keeps parsing; the
per-frame payload is byte for byte what it was.
Machine output does not pass through the translation seam: every --json
payload is built with serde_json and is C-locale by construction. It reaches
stdout through message::payload, which takes an already-rendered &str and
consults no catalog, so routing a payload through the seam to pick up --quiet
cannot translate it on the way. Two documented exceptions to the C-locale
rule, both diagnostics rather than things to branch on: pam's error field
can interpolate a strerror string (see "facelock pam Semantics"), and
status's reason and error fields can interpolate an OS, parser or runtime
message (see "facelock status Semantics"). Neither is a vocabulary — a consumer
prints them and branches on the typed words beside them. Neither exception
covers facelock's own catalog: a probe whose diagnostic is a translated string
does not get a field, which is why status's daemon section has no error.
facelock setup Flag Composition
Flags compose; they are not mutually exclusive. The rule:
--pamand/or--systemdon their own perform just that action and touch nothing else. This preserves the historical standalone meaning, including--pam --service <name>,--pam --remove, and--systemd --disable.- Any flag that only makes sense while the base setup runs —
--non-interactive, a choice flag, or any of--no-pam/--no-systemd/--enroll/--no-enroll— forces the base setup to run, and the requested actions run in addition.
Consequently setup --systemd --pam now runs both (it previously dropped
--pam), and setup --non-interactive --pam now runs the base setup plus PAM
(it previously dropped --non-interactive). Both were silent flag drops.
--remove and --service require --pam, and --disable requires --systemd,
so a dropped flag is now a parse error rather than silence.
--systemd is unsupported under a non-default --config, in a base flow
and on its own. The unit it enables runs bare facelock daemon (ADR 009 §4),
which reads only /etc/facelock/config.toml: FACELOCK_CONFIG is ignored by a
privileged process and the unit passes no --config. A daemon enabled from a
setup run under any other file would authenticate with a store, camera, model
set and encryption policy that setup never configured. So facelock --config <other> setup --systemd ... exits non-zero before the base flow creates a
directory, downloads a model or mints a key, and before any systemctl call;
the message names the two ways out, copying the file to the default path or
running setup without --systemd. The wizard, which would otherwise ask,
prints why it is skipping the daemon step instead. --systemd --disable
reads no config file and still runs, with a note that the unit it stops reads
the default file. "Non-default" is decided on the
filesystem: a symlink or .. spelling of the default file is the default.
Under such an override, enrollment and the recognition test use direct
camera access under that file and never the daemon on the bus, whatever
owns the name (see "Operating Modes").
--if-present requires --pam and applies to the add side as well as
--remove. "Configure hyprlock if this machine has hyprlock" is the same
question in either direction, and it is what a provisioning script over a set
of optional integrations is asking. The flag turns a missing target service
file from an error into a successful no-op and does nothing else; read, parse
and write failures remain fatal, and without it both directions keep their
historical missing-file error. The exit code is facelock pam's and identical
on both: an absent service is reported absent and the alias exits 0.
The flag is not a way around a service that should resolve. Since the search
path took in the vendor directories, --service polkit-1 finds
/usr/lib/pam.d/polkit-1 on a stock Arch box without it. Reserve
--if-present for services a machine may genuinely not have. The default stays
a hard error, which is what catches --service polkti-1.
facelock setup System Asset Ownership
facelock setup --systemd is not an installer for immutable service assets.
The Debian/RPM/Arch packages and just install-files own these canonical
files:
/usr/lib/systemd/system/facelock-daemon.service/usr/share/dbus-1/system.d/org.facelock.Daemon.conf/usr/share/dbus-1/system-services/org.facelock.Daemon.service
Setup requires each path to be a single-link 0644 root:root regular file
whose bytes match the running build. It never creates, overwrites, changes the
mode or changes the owner of one. After daemon-reload, setup requires
systemd's FragmentPath to name the canonical unit and refuses drop-ins. D-Bus
activation is also winner-selected: setup refuses exact-name definitions in
the higher-priority /etc, /run, and /usr/local service directories. D-Bus
policy is different: every policy fragment is merged. Setup proves the
canonical policy bytes are present and the exact historical /etc duplicate
is retired; it preserves and reports every other local policy fragment and
never claims those administrator rules are ineffective.
The only static-file mutation setup may perform is retiring these historical legacy copies:
/etc/systemd/system/facelock-daemon.service/etc/dbus-1/system.d/org.facelock.Daemon.conf/etc/dbus-1/system-services/org.facelock.Daemon.service
The complete canonical, legacy, and fixed quarantine set is preflighted before
the first mutation.
A legacy path is removable only when it is a single-link 0644 root:root
regular file whose SHA-256 is in dist/legacy-system-assets.sha256, the
reviewed inventory of exact historical Facelock bytes. Missing paths are
healthy and repeated migration is a no-op. Modified, unknown, symlinked,
hardlinked, non-regular, wrongly owned or wrongly moded paths are preserved and
make the action fail with the exact path and recovery direction; an ambiguous
peer or quarantine collision prevents removal of every otherwise exact peer in
that run. The sole recoverable pre-existing quarantine state is an absent
public legacy name paired with its exact known, trusted fixed quarantine.
Before restoring any such pair, setup preflights the complete canonical and
three-pair set. It restores every recoverable pair in reverse order with
no-replace semantics, revalidates the result, then restarts the complete
migration preflight. A dual-name pair, changed quarantine, ambiguous peer, or
recovery collision is preserved and reported without overwrite.
Migration first moves every candidate with no-replace semantics to its fixed,
same-parent .facelock-migrate-* quarantine. A later staging or revalidation
failure rolls earlier candidates back in reverse order before any quarantine
is deleted. Rollback never overwrites a replacement: a collision preserves
both fixed names and reports incomplete recovery. Only after every legacy name
is absent and the complete canonical and quarantine set has been revalidated
does deletion of the quarantines publish the migration. Cleanup failure after
that boundary can leave a reported fixed quarantine, but cannot leave a mix of
active historical names; the next run authenticates, restores, and remigrates
that exact pair through the same bounded protocol. Rust uses Linux
renameat2(RENAME_NOREPLACE) without a replacing-rename fallback; the source
helper uses same-parent GNU mv -Tn and verifies both names so a no-clobber
skip is an error.
Production pins the trusted identity to UID/GID 0:0 and requires the layout
root, every existing parent, and every asset to have the documented trusted
ownership and modes. The source helper's explicit alternate-layout test mode
treats that real, non-linked layout directory's owner as the root-equivalent
identity and also rejects a group/world-writable root. That identity can change
the fixture just as root can change the production filesystem; it is not an
isolation boundary.
setup --systemd --disable stops and disables the unit without writing any
service asset. A concurrent privileged writer can always replace root-owned
names between filesystem operations; setup detects stable-path changes by
revalidating immediately before removal, but does not claim isolation from a
second root process. Administrators must not edit these paths concurrently.
just uninstall removes only canonical /usr assets. It never inspects or
removes any of the three historical /etc names, even when one is an exact
known regular copy; linked and parent-linked forms are preserved as well. It
preflights the complete canonical set before deletion: every existing parent
component must be a non-linked directory owned by the trusted root-equivalent
identity and not writable by group/other, while each existing direct target
must be a single-link 0644 regular file with that identity. A linked,
multiply linked, wrongly owned, wrongly moded or non-regular canonical target,
or any untrusted canonical parent, preserves every canonical peer and fails the
uninstall-files action. This path-based shell preflight does not claim
isolation from another process with the same root-equivalent authority.
--pam is an alias onto facelock pam add / facelock pam remove. The
plan resolution above stays on setup — --pam, --no-pam, --service,
--remove, --if-present and their precedence rules are unchanged — and only
the execution moved. The alias is exact, including the two things that make it
not a plain forward:
setup --yesmaps onto--no-confirmonly. It suppresses the ordinary per-file question and does not authorize a sensitive PAM mutation.--non-interactivehas the same prompt-only effect, as it always has.setup --pam --allow-sensitivemaps onto the writer's separate authorization and does not suppress the question. The flag conflicts with--remove, whose safe direction is never sensitive-gated.- The root refusal is a hard error, not a
sudore-exec. Standalone--pamnever offered the interactive escalation (needs_root_precheck), andfacelock pam add|removedoes not either.
Supplying a choice flag suppresses the corresponding wizard step. auto means
"re-derive from hardware", not "use the default" — omitting the flag already
gives the default. Under --non-interactive, an unresolvable choice is an error,
never a prompt.
Setup's automatic camera choices apply the same post-classification decodability
predicate as device.path-unset auto-detection. The interactive wizard excludes
nodes that advertise none of GREY/Y16/YUYV/NV12/MJPG before it auto-selects or
presents candidates, and --camera auto considers only the remaining
IR-classified nodes. Exclusion never reclassifies a node: an IR node whose only
formats are Y8/Y10/Y12 remains IR, but setup reports its path and advertised
formats and does not select it. GREY and Y16 remain eligible. If
security.require_ir = true and IR-classified nodes were detected but none has
a decodable format, the wizard aborts setup before constructing its menu,
reports every excluded IR path and format, and never presents, recommends, or
persists an RGB fallback; unrelated camera-enumeration and prompt failures
retain the wizard's recoverable camera-step behavior. With require_ir = false,
decodable RGB nodes remain explicit wizard choices. --camera auto likewise
errors instead of falling back to RGB when no usable IR candidate remains. An
explicit --camera /dev/videoN remains an operator override and is still
subject to the auth/open fail-closed checks.
Encryption method transitions (--encryption). Switching to tpm with a
keyfile present seals that key (same bytes) rather than minting a new one.
Switching to keyfile with a sealed key present and a usable TPM unseals it into
the keyfile. A fresh key is minted only when no key artifact exists, behind the
orphaned-models guard. When both artifacts exist and hold different keys, setup
keeps the target's and prints a notice naming both files. A TPM device that
exists but fails to initialize is reported (warn + notice) rather than silently
downgrading to keyfile.
facelock pam Semantics
facelock pam add | remove | status and the machine-wide
facelock pam remove --all cleanup own every direct write to /etc/pam.d.
setup --pam is an alias onto it (above), and the wizard's step 9 calls the
same writer, so there is one implementation of the edit and one set of rules.
Resolution order: /etc/pam.d, then /usr/lib/pam.d. First hit wins. That
is Linux-PAM's own precedence, and it is not academic: on current Arch polkit
ships its configuration as /usr/lib/pam.d/polkit-1 and /etc/pam.d/polkit-1
does not exist, so a writer that looked only in /etc/pam.d could not
configure the service at all. The list is [pam] config_dirs (Config Schema
below) for a distribution whose vendor directory is somewhere else; there is no
way to ask Linux-PAM at run time which one it was compiled with, so the default
for named add, remove and status is the pair above and configuration is never
required. Machine-wide remove --all uses its own fixed roots described below.
A hit that is refused —
a hard link or any symlink — is still a hit: the search does
not fall through to the next directory, because that would let a vendor file
silently take over from an /etc entry facelock declined to follow.
Only the first directory is ever written to. The rest are package-owned: an
edit there is clobbered by the next upgrade and makes pacman -Qkk report a
modified file. A service that resolves only in a vendor directory is copied
into /etc/pam.d with the facelock line already in it — one atomic write of
the final content, not a copy followed by an edit — and the copy carries a
two-line provenance header naming the file it was forked from and saying that
it shadows it and will not track vendor updates. The copy reports overridden
rather than installed, and the operator is told at the time, on the
unsuppressible notice stream, because a new shadowing file in /etc is a
durable change with a maintenance consequence. Deleting the override restores
the vendor file. Named pam remove deletes an unchanged Facelock-created
override after taking out its line; header, payload, metadata, identity or
current-vendor drift keeps the no-rule local override and reports why. If no
current vendor source exists, an exact header path derived from a normalized
configured later-root candidate is recognition only: removal retains the
local override and reports that the source is absent. An arbitrary path in a
header is never authority. The
vendor file is never read-modified-written, never backed up, and never renamed
over.
The module is probed too, and that is a different list. The service-file
order above says where a service file is looked up; the module
pam_facelock.so is looked up in /lib/security, then /usr/lib/security,
then /usr/lib64/security, first hit wins. Two lists for two things: one is
configuration, the other is a shared object, and they are never merged.
/lib/security is first so the answer on usrmerged Arch is unchanged;
/usr/lib64/security is where dist/facelock.spec installs on x86-64 Fedora
and RHEL, which is why the single hardcoded path was a refusal-to-write on the
distribution this repository ships a spec file for. There is deliberately no
Debian multiarch triple: Debian's idiomatic path is pam-auth-update, which is
out of scope for this command. The probe is read only — it finds the
module, and never installs, copies or links it. When it finds nothing, add
refuses and the refusal names every candidate, so an operator on an
unlisted layout can see what to add. The list is not configurable.
A vendor file that already carries the line needs no override. add
reports unchanged and writes nothing, and status reports present: a
distribution that ships face auth in its own PAM stack is configured, and
saying otherwise would send an integrator off to create a copy that adds
nothing.
Managed shared stacks are not leaf services. Debian and Ubuntu compose
their shared stacks with pam-auth-update; Fedora and RHEL use authselect.
Facelock does not write through either manager's generated shared files.
Before any direct pam add or setup --pam plan, Debian's detector reads only
the compiled /usr/share/pam-configs/facelock, /var/lib/pam/auth, and
/etc/pam.d/common-auth locations. It requires root-owned, non-writable,
regular single-link files opened beneath no-follow directory descriptors, the
exact packaged profile bytes, an exact Module: facelock selection, and a live
Facelock rule inside pam-auth-update's Primary block. An active profile refuses
the direct edit before backup state is created and says exactly how to run
sudo pam-auth-update --disable facelock, verify password authentication, and
retry the original Facelock command with all services and flags intact.
Selected-but-inconsistent or untrusted state fails closed rather than being
treated as inactive. A live managed rule whose saved selection is absent fails
closed as well. The roots are not configurable and the environment cannot
redirect them. Explicit named leaf services remain supported on every package
family: the writer resolves and edits only that requested service under the PAM
roots, while the sensitive-service gate and no-follow checks refuse generated
system-auth and password-auth links.
Confinement. A service name is one path component: not empty, no /,
not . or .., no interior NUL. Rejected before any I/O, on add, remove
and status alike. base.join(service) is not a confinement primitive — an
absolute name replaces the base — so this is the check, not the join.
Anything else is accepted: PAM_CANDIDATES is the wizard's menu, not an
allowlist, and a service that is not on it must keep working.
Every symlinked service entry is refused by named add, remove and status.
The writer lstats the entry for diagnostics, but read, mutation and recovery
all reopen the confined service basename relative to an already-open PAM root
with O_NOFOLLOW; neither a resolved absolute target nor a target recorded in
provenance is ever opened.
This applies even when the link text appears to remain in the same directory.
It also prevents Facelock from editing generated authselect state through
/etc/pam.d/system-auth or /etc/pam.d/password-auth, and prevents a hand-made
/etc/pam.d/polkit-1 -> /usr/lib/pam.d/polkit-1 link from turning a vendor file
into a write target. A symlink is a validation failure for the whole write run,
and pam status reports it as unknown with the retained fixed reason
symlinked outside /etc/pam.d; that token names the compatibility class, not a
claim that an in-directory link would be followed. The human diagnostic names
the link text and the directory whose entry was refused.
A file with more than one hard link is refused. A symlink is a visible
indirection this can reject by name; a second hard link says another name for
the inode exists but not where, so the edit cannot be shown to stay inside the
directory. pam status reports it as
unknown with the fixed reason hard-linked service file. This is
conservative rather than adversarial: a /etc that has been through a
deduplicating backup or jdupes -L can trip it with nobody attacking anything,
so the message says how to break the link. The atomic replace does not
retire the rule: a rename writes a new inode, so it leaves the other name
holding the old content — one of a file's names carrying the line and the
rest not is a worse answer than a refusal, and still a change to a file
facelock cannot name.
--if-present does not forgive a link fault. A dangling or looping symlink
is not an absent service file: absence is a fact about the directory, and an
unresolvable link is the absence of an answer about where a write would land.
Both are phase-one failures on add and remove under --if-present, and
exit 2 on status --if-present.
The sensitive gate is applied before any write. It checks the typed service
and the confined basename returned by resolution. Symlinks cannot provide an
alternate ungated name because every symlink is refused; the system-auth-ac
and password-auth-ac spellings remain explicit members because older
authconfig installations can use those names as real service files.
Two-phase across services. Every requested service is validated — name,
existence (subject to --if-present), the sensitive gate, and what the edit
would be — before any file is written. A validation failure writes nothing at all,
which is what makes a caller's loop all-or-nothing for the failure that
actually happens: a typo'd or gated service name. It is not a transaction:
a write-phase I/O error on service N leaves 1..N-1 written. Those are reported
per service and the exit code is non-zero; the remaining services are still
attempted. Each individual local mutation has its own serialized,
crash-recoverable transaction and rollback pair, described below. Named
pam add and pam remove do not use a whole-set journal; the compiled-root
pam remove --all transaction is specified separately below.
--no-confirm never implies --allow-sensitive, including through
setup --pam. They are separate
authorizations: "do not ask me" and "yes, edit the shared auth stack". The
gated services are common-auth, login, password-auth,
password-auth-ac, sshd, system-auth, system-auth-ac and
system-login. Six of the eight are shared stacks — files that other
service files include, so one edit reaches su, passwd, chsh and the
display manager at once — and which name a distribution uses is the only
difference between them (system-auth/password-auth on Fedora, RHEL and
Arch, the -ac spellings where authconfig wrote the real file,
common-auth on Debian and Ubuntu, system-login on Arch). Gating one
spelling made the gate depend on the operator's distribution. login and
sshd are the two that are not: each locks one specific door — the TTY, the
network — rather than every one at once. --yes and
--no-confirm are the same flag (the shared ConfirmArg spelling, so "skip
prompts" reads the same on setup, pam add, remove and clear) and neither
unlocks the gate. Both pam add and its setup --pam alias expose the same
explicit --allow-sensitive authorization. remove is never gated on
sensitivity — removal can only take
away a way to authenticate — and never prompts, which is what
setup --pam --remove has always done; the confinement rules below apply to
every verb, remove and status included. --yes/--no-confirm is accepted there for symmetry and has
nothing to suppress today.
With no TTY on stdin, pam add proceeds as if --no-confirm were given.
A question nobody can answer is a hang, not a safeguard, and this is what has
always made setup --pam work from a provisioning script — so
sudo facelock pam add --service sudo < /dev/null writes without the flag.
The prompt this skips defaults to yes, so the flag changes nothing about the
outcome on a TTY either; what it changes is whether you are asked. This never
touches --allow-sensitive: the sensitive-service gate is decided in the
validation phase, before any prompt exists to skip, so an unattended
pam add --service system-auth still refuses.
Exit codes.
| Command | Code | Meaning |
|---|---|---|
pam status | 0 | every requested service carries the line |
pam status | 1 | at least one requested service exists without it, in /etc/pam.d (missing) or only in a vendor directory (vendor-only) |
pam status | 2 | at least one is absent, unreadable, misnamed, symlinked, or hard-linked |
pam status --if-present | 0 | every requested service that exists carries the line |
pam status --if-present | 1 | at least one existing service carries no line |
pam status --if-present | 2 | as above, minus the absent case, which no longer forces 2 |
pam status --all | 0 | at least one service carries the line, and every directory was read |
pam status --all | 1 | nothing on the machine carries it, or an enumerated service has no line in the file Linux-PAM reads |
pam status --all | 2 | a directory could not be listed, or an enumerated service could not be answered for |
pam status --all --if-present | 0/1/2 | unchanged from --all: an enumerated name was found, so there is no absent case to forgive |
pam add, pam remove | 0 | every service reached its requested state and required default rollback-state cleanup completed — including unchanged, overridden (add created the /etc/pam.d copy), vendor-only (remove had nothing of its own to take out of a package-owned file), absent under --if-present, and declined |
pam add, pam remove | non-zero | a validation failure (nothing written) or a write failure, including cleanup-failed after the requested PAM state was reached |
pam remove --all | 0 | every recognized writable direct reference was removed, the final compiled-root scan was clear, and the whole-set transaction committed and cleaned; an empty scan is an idempotent success |
pam remove --all | non-zero | preflight, journal, identity, write, final-rescan or recovery failure; direct PAM mutations are rolled back where their exact identities prove that safe, otherwise transaction evidence is retained |
pam status is on grep's scale and is-enrolled's: a boolean query whose
exit code is the answer. Across several services the worst outcome wins. A
declined confirmation is exit 0, since the command did what the operator
asked, and --json is how a script tells it from an install.
--dry-run validates and prints the resolved plan without applying the
requested edit; a validation failure still exits non-zero. It is
honoured after the root check (see DEC-6 above). On remove --all it performs
no transaction recovery and creates no state.
A service that exists in no directory names them all. The refusal add
and remove raise, and the line status prints, both list every path tried:
the same question must not be answered two ways by two verbs. The machine
path field on a status row stays a single path — the first directory's,
where an override would go — because the field is one string and always has
been.
--if-present means the same thing on all three verbs. A service file that
is not there is not an error: on add and remove the service is reported
absent and the exit code is unaffected, and on status the absent row no
longer forces exit 2, so the exit code is decided by the services that do
exist. That is what lets "install the optional integrations with
--if-present, then verify" be written as a pair. It converts absence and
nothing else — an unreadable file, a rejected name, or a link out of the
directory is still an error on every verb.
pam status --all reports every configured service; a bare pam status
still means sudo. Without --all the command answers only about names it is
given, so a configured polkit-1 or omarchy-lock-face is invisible to it.
--all replaces the service list with every service in the resolved
directories whose file names pam_facelock.so. It is a flag rather than a new
default because a bare pam status exits 0/1/2 about sudo today, and an
integrator branching on that would have got a different answer without changing
a command line. --all and --service are mutually exclusive: enumerating and
naming are two questions, and a request that asked both would have to drop one
silently.
The scan parses; it keeps no manifest. A state file listing what facelock
has edited drifts the moment anyone edits /etc/pam.d by hand, restores a
backup, or removes a package, and the report is then confidently wrong. Names
are collected across every directory and then resolved through the rules above,
so --all and --service X cannot answer differently about one service. Four
consequences:
- a vendor file carrying the line while an
/etcfile shadows it without one is reportedmissing. The file Linux-PAM reads has no line in it, and dropping the name would hide the one machine an operator cannot otherwise explain. - an entry the resolver refuses (symlinked or hard-linked)
is an
unknownrow with its usual reason: never followed, never dropped. - a service file that could not be read is an
unknownrow too. Omitting it would report "not configured" for a machine this could not check. .facelock-backup,.pacnew,.pacsave,.pacorig,.rpmnew,.rpmsave,.rpmorig,.dpkg-old,.dpkg-new,.dpkg-dist, pam-auth-update's.pam-old, names ending in~, and dotfiles are not services. Each can carry the line, and none is a name Linux-PAM is ever asked for.- only a regular file is read. A FIFO blocks the read until a writer
appears, and a diagnostic command that hangs on a malformed
/etc/pam.dis worse than one that omits an entry no PAM stack could use; device nodes, sockets and symlinks to directories go the same way. The check is on the followed metadata, since a symlink is how a non-regular file reaches the scan. "Not a regular file" and "could not be examined" are different answers, and only the first is a skip: an entry whosestatfails — a symlink into a directory the caller may not traverse, a symlink loop, a dead network mount — is carried into the report asunknown, the same answer--servicegives for it. The one exception isENOENT, which is an absence and is skipped like any other file that is not there; a dangling symlink is therefore absent from an--allreport while--serviceon the same name reportsunknown, because a link pointing at nothing carries no facelock line but is still an entry the writer refuses to follow. - an entry whose name is not valid UTF-8 is skipped and logged. Spelling it
lossily would hand the resolver a name no file has and report a configured
service as
absentat a path that does not exist.
A directory that could not be listed is reported, not treated as empty.
"Nothing is configured here" and "I could not look here" are different answers,
and rendering them identically is what made a broken lock stack and a healthy
one look the same. Every directory searched appears in the --all document
with a status of scanned, absent or unreadable, and an unreadable one
makes the exit code 2 whatever the services said. An absent directory is
not an error: one that does not exist demonstrably holds no service files, and
the default search path names a vendor directory many machines lack, so
treating that as unanswerable would make every one of them exit 2 forever.
Nothing configured is exit 1. A machine with no facelock line anywhere is
not configured, which is the answer pam status already gives for a service
file with no line in it. --if-present does not change it. A name reaches an
--all report by having been found, so there is nothing for the flag to
forgive, and pam status --all --if-present on an unconfigured machine exits 1
like the bare form. One state produces an absent row anyway: a file deleted
between the listing and the read. It is the only one, and --if-present scores
it 0 there as everywhere else.
The empty answer is scoped to what could be read. "No service file under
these directories carries the line" is a claim, and it may only name directories
that were listed or proven not to exist. When some directory could not be read,
the sentence names the ones that could and says in the same breath which could
not; when none could be read there is no set to make the claim about, so only
the per-directory lines are printed. Without that scoping the sentence read
under 2>/dev/null asserts exactly what --all exists to stop it asserting.
facelock status summarizes the same scan. Its report carries one
PAM services: line built by running the scan above, so the two commands
cannot disagree about whether a service is configured. The detailed listing
stays in pam status --all. The summary keeps the "not checked" distinction:
it reads none configured only when every directory was read, not checked
when nothing was found and something could not be, and it names each unread
place on a not checked: line of its own.
PAM publication is complete-file atomic and identity-checked. Planning
captures the regular, single-link service file's device, inode, link count,
SHA-256 hash, exact mode, UID and GID. Stable identity comparisons bind all
seven values; timestamps and other unstable metadata are deliberately
excluded. Immediately before publication the writer reopens the confined
basename beneath its configured PAM write root and checks that same identity.
An existing local file is replaced with RENAME_EXCHANGE, leaving the exact
displaced inode named and open for a final check. There is an unavoidable
bounded interval after the exchange in which the complete replacement is the
canonical service file before that displaced-original check completes. If an
administrator or package replaced the file at the boundary, a second exchange
restores that complete intervening file; only a verified displaced inode is
unlinked. Neither side is ever partially written, and PAM's password fallback
is unchanged.
The replacement file preserves the existing file's owner, exact mode and
SELinux context. Ownership is applied before the final mode because fchown
can clear setuid/setgid bits. POSIX ACLs and xattrs other than the SELinux label
are not carried across. A vendor-only service is rechecked by identity and hash
immediately before a complete local override is published with
RENAME_NOREPLACE; an override that appeared after planning is preserved. The
new override takes the vendor file's owner and exact mode, but deliberately
does not copy the vendor file's SELinux xattr: the destination directory's
type transition supplies the local label. Each new file and its parent
directory are fsynced at the committed boundary.
Rollback state has a fixed root. An in-place pam add writes the original
service bytes under /var/lib/facelock/pam-backups, a 0700 root:root
directory independent of both [pam].config_dirs and storage.db_path. The
production store opens that fixed directory without following the final path
component, applies root ownership before the final mode, and verifies exact
directory type, 0700 mode and root:root ownership before accepting it. It
reopens, locks and verifies the descriptor again before trusting state in a
transaction. State-entry authority is the fixed expected root owner, never the
owner observed on the directory. Only explicitly injected setup/test roots use
the process EUID and EGID as their expected state owner.
The published backup and its adjacent JSON record are regular, single-link
0600 root:root files. Their exact basenames are
<service>.<seconds>-<nine-digit-nanoseconds> and that basename plus .json;
seconds are decimal and in range for u64, and nanoseconds are decimal,
exactly nine digits and below one billion. Collision probing is bounded,
advances the nanoseconds and publishes with no-clobber semantics. A failed
publication cleans only an identity created and validated by that transaction,
never a pre-existing name.
Version 1 provenance JSON is strict and rejects unknown fields. It contains
exactly version, positive sequence, state (prepared or committed), a
confined service, the exact backup basename, original_sha256 and
installed_sha256; both hashes are 64 hexadecimal characters, and no target
path is stored. A record can participate in sequence allocation, duplicate
detection, newest-backup reporting or cleanup only as part of a complete
record/backup pair whose names and schema agree, whose backup hash validates,
whose entries have the fixed expected state owner, mode 0600 and one link,
and whose record and backup can be read within 16 KiB and 1 MiB respectively.
Duplicate sequences make every pair at that sequence ambiguous for service
selection and cleanup. Sequence allocation checks overflow, and the newest
committed backup is selected by validated sequence rather than wall-clock
filename order.
Every multi-name mutation has a strict durable intent. Version 1 intent
JSON rejects unknown fields and always carries version, role, positive
sequence, confined service, strict transaction basename in backup,
original_sha256, installed_sha256, nullable record_sha256 and
replacement_record_sha256, and nullable original_device, original_inode,
original_links, original_mode, original_uid and original_gid. Option
fields are serialized as JSON null. Each intent must be a regular,
single-link, state-owner 0600 file no larger than 16 KiB, every present hash
must be 64 hexadecimal characters, and every present mode must identify a
regular file. Fields that are irrelevant to the selected role must be null;
any other combination invalidates the intent. For mutation-only roles the
backup value is a collision-resistant operation identifier, not a claim that
a rollback pair exists.
JSON role | Required role-specific fields |
|---|---|
prepare, cleanup | record_sha256 present; replacement-record hash and all six original identity/metadata fields null |
commit | record and replacement-record hashes present; all six original identity/metadata fields null |
pam_replace | record hash present; replacement-record hash null; exact original device/inode, original_links = 1, mode, UID and GID |
pam_remove | record hashes null; exact original device/inode, original_links = 1, mode, UID and GID |
vendor_create | record hashes and original device/inode/links null; original mode/UID/GID present as the expected newly-created destination metadata |
A strict publication binding authenticates every created inode. Version 1
binding JSON rejects unknown fields and contains exactly version, role, a
positive u64 sequence, confined service, strict transaction basename in
backup, intent_sha256, device, inode, links, sha256, mode, uid
and gid. JSON role is one of commit, pam_replace, pam_remove or
vendor_create. Both hashes are 64 hexadecimal characters, links is exactly
one, and mode identifies a regular file. The binding itself must be a
regular, single-link, fixed-state-owner 0600 file no larger than 16 KiB.
While the base intent exists, its exact bytes must hash to intent_sha256, and
its role, sequence, service and operation basename must agree with the binding.
The bound replacement hash must also equal the commit intent's replacement
record hash or, for a PAM/vendor mutation, its installed hash. After the named
replacement temp exists, Facelock publishes the binding atomically with
no-clobber semantics before the RENAME_EXCHANGE or RENAME_NOREPLACE that
makes the replacement canonical. The full identity is captured from the
still-open created temp only after its bytes and requested metadata/context are
applied, the file is synced, and its desired hash, mode, UID, GID and
single-link count are validated. Facelock then reopens the exact reserved temp
basename and full-compares it before publishing any binding. A failure at this
boundary uses identity-checked cleanup; any reopen, comparison or cleanup
uncertainty is ambiguous and preserves the base intent and filesystem evidence.
A crash in the gap before the binding is published leaves the base intent and
unbound temp conservatively preserved.
A synchronous no-clobber binding-publication failure is distinct from that
crash: it preserves the colliding administrator state entry and reopens and
full-compares the unpublished replacement temp. Facelock may remove the base
intent only after the exact temp unlink and parent-directory fsync succeed.
Any reopen, full-identity, unlink or fsync ambiguity returns
AmbiguousPublication and retains the base intent and colliding administrator
evidence. The temp is also retained unless its exact identity-checked unlink
succeeded and only the subsequent parent-directory durability sync failed; in
that case the temp name may already be absent. Commit, PAM replacement/removal
and vendor creation all use this same checked cleanup primitive.
The same full-identity cleanup applies after the binding is durable if source
drift or an exchange/no-replace failure prevents PAM or vendor publication.
Any uncertainty returns AmbiguousPublication and preserves the base intent,
binding and all remaining evidence, subject to the same post-unlink fsync
boundary above.
The reserved name grammar is exact:
- intents:
.facelock-intent-{prepare|commit|cleanup|pam-replace|pam-remove|vendor-create}-<transaction>.json - publication bindings:
.facelock-publication-{commit|pam-replace|pam-remove|vendor-create}-<transaction>.json - state quarantine:
.facelock-quarantine-backup-<backup>,.facelock-quarantine-record-<backup>.json,.facelock-quarantine-commit-<backup>.json - PAM-directory temps:
.facelock-pam-replace-<transaction>,.facelock-pam-remove-<transaction>,.facelock-vendor-create-<transaction> - PAM-directory vendor-retirement quarantine:
.facelock-vendor-retire-<transaction> - atomic state temps:
.facelock-tmp-<destination>-<64hex-content-hash>-<pid-digits>-<nanos-digits>
The strict atomic-state-temp destination grammar includes publication-binding
destinations; a binding temp is not authenticated by its prefix alone.
Backup and record temp destinations additionally require a confined service
component, so empty, . and .. services are never owned.
A reserved-looking name alone never establishes ownership. The applicable
intent or pair schema and derived names, the owner/mode/link requirements for
that role, the bounded content hash and, where applicable, the captured PAM
identity and metadata must all agree before Facelock resumes or removes it.
Every state match, committed-record transition, quarantine move and unlink
rechecks that the entry is single-link, state-owner and mode 0600 in addition
to its content and identity. Same-inode, same-content mode or ownership drift
is ambiguous and is preserved. State publication, vendor publication, state
quarantine and vendor-retirement quarantine moves use RENAME_NOREPLACE;
committed-record and existing-PAM transitions use RENAME_EXCHANGE.
If an atomic state temp-to-final RENAME_NOREPLACE succeeds but syncing the
parent directory fails, the result is AmbiguousPublication, not an ordinary
create failure, and every caller propagates it before cleanup. Prepare retains
its durable intent plus the visible final backup or record. Commit-replacement
publication retains the commit intent plus its named replacement. If the
destination is a publication binding, each of commit, pam_replace,
pam_remove and vendor_create retains its base intent, exact replacement temp
and visible binding. Checked cleanup is limited to definite failures before the
rename; strict identity binding lets recovery classify and complete each
retained evidence set.
After exchange or publication, the canonical file is reopened and compared against the binding's full device, inode, link count, hash, mode, UID and GID. This happens immediately after publication, immediately before a displaced inode is unlinked where that boundary exists, and immediately before the base intent is cleaned; commit checks once more after unlinking its displaced prepared record. A canonical mismatch preserves the canonical name, any remaining temp or displaced name, the base intent and the binding for manual inspection. These checks do not weaken complete-file atomicity or PAM password fallback.
One state-directory flock spans a local mutation. For an in-place add it
covers recovery, bounded timestamp and sequence allocation, durable prepare
intent and rollback-pair publication, PAM intent/temp/exchange, and committed
record intent/exchange. Local remove and vendor-create planning, publication
and recovery run under the same guard; unchanged vendor-override quarantine,
validation, deletion or restoration completes before the local-remove guard is
released. A competing writer or recovery cannot
discard an add's prepared pair between persistence and commit. Backup cleanup
after a successful or no-op remove is a separate locked quarantine phase; this
does not make named mutations multi-service atomic. The pam remove --all
transaction below keeps its whole-set lock through batch cleanup.
Recovery treats state as an untrusted hint and re-resolves only the recorded confined service under the current PAM write root. It recovers publication bindings before base intents. A bound commit, PAM or vendor canonical candidate must match the exact full created identity in its binding; an original PAM candidate must match the exact original identity captured in its base intent. While both state files exist they must bind to one another as described above. Without a valid binding, a named or published replacement is ambiguous and is preserved; only a clearly pre-temp base-intent shape is cleaned.
The base intent is removed first and the self-contained binding last. Recovery considers a binding orphaned only when the exact derived base-intent name is definitely absent. An invalid-mode, invalid-owner, malformed, mismatching, symlinked, or hard-linked exact entry blocks destructive binding recovery. If a crash truly leaves an orphan after the base unlink, recovery removes it only when the canonical file still matches the full bound identity and the temp is absent. A mismatch preserves the binding and every ambiguous name. A crash after the binding unlink leaves no publication-state debris.
Prepare recovery handles an intent alone, a backup alone, a record alone or a
complete pair. Bound commit recovery distinguishes pre-exchange, exchanged and
displaced-record-unlinked boundaries. Cleanup resumes the no-replace
backup/record quarantines and identity-rechecked unlinks. Bound PAM
replacement/removal recovery distinguishes an intent alone, a ready temp and
the exchanged canonical/displaced pair. A remaining ambiguous pam_replace
intent also blocks generic prepared-pair recovery, preserving the rollback
pair. Bound vendor-create recovery handles absent/temp and published/absent
boundaries. A wrong owner, mode, link count, schema, hash or identity, an extra
conflicting entry, a symlink/hard link, or any other ambiguity is preserved for
manual inspection.
pam remove takes no new rollback copy. After every successful or no-op
removal it deletes validated committed Facelock-owned pairs for that service and the
exact legacy <service>.facelock-backup entry by default. Pair deletion first
moves the record and backup to no-replace quarantine names, then rechecks the
identities before unlinking. Legacy cleanup is confined to the override root
and rechecks the exact regular, single-link entry immediately before unlink.
Malformed provenance, lookalike names, symlinks, hard links, changed entries
and unrelated administrator files are retained. --keep-backup opts out of
both versioned and legacy cleanup.
For a local copy created from a vendor-only service, named pam remove first
publishes the complete document without the Facelock rule through the existing
pam_remove exchange protocol. It then deletes the local override only when
the exact two-line Facelock header names the current vendor service. Current
vendor resolution reopens the configured later roots in order and stops at the
first existing entry; a malformed, linked, unreadable or oversized first entry
is a blocker rather than permission to accept a matching lower-priority file.
The remaining payload and mode/UID/GID must equal that bounded, regular,
single-link vendor file, the vendor file must contain no active Facelock rule,
and the complete local bytes must be either the exact header plus the one
document emitted by Facelock's insertion or the exact header-plus-vendor
no-rule restart shape. The journal backup used by batch cleanup is likewise
reopened within its size bound and must retain its full prepared identity
before its header is parsed.
The canonical local inode must still have the full identity captured by the
removal publication. Facelock moves that exact basename to the derived
.facelock-vendor-retire-<transaction> quarantine with a no-replace rename,
syncs the directory, and rechecks the quarantined identity, canonical absence,
exact emitted shape and ordered current vendor before identity-checked unlink.
If the local or vendor check fails while the canonical name remains absent,
Facelock restores the exact quarantine with a no-replace rename. A concurrent
canonical entry, quarantine collision, reopen failure, identity mismatch or
durability uncertainty preserves all available names and durable publication
evidence as ambiguous. Every quarantine, restore and unlink boundary is
restartable. Both service names are re-resolved beneath already selected PAM
roots; the header is a recognition hint, never a path to open. Header, payload,
metadata, vendor-source or identity drift preserves the local override after
removing its Facelock rule and reports that decision. When no current vendor
entry resolves, only a header naming a normalized path derived from a
configured later root is recognized, and it causes explicit retention rather
than deletion; the header path is not opened or followed. A restart may finish the
same exact deletion when the rule-removal exchange completed but the
header-bearing local override is still present.
pam remove --all is the config-independent whole-machine cleanup. It
ignores [pam].config_dirs and opens the compiled roots /etc/pam.d
(writable override), /usr/lib/pam.d (detection-only vendor state), and
/etc/authselect (detection-only generated state). Missing or corrupt config,
database, model, camera, daemon and ONNX Runtime state cannot redirect or block
it. It enumerates already-open directory descriptors and re-resolves each
confined basename with directory-relative, no-follow operations. A candidate
must remain a regular, single-link file whose bounded bytes and complete
identity can be rechecked. Directory contents are detection ground truth;
provenance is untrusted ownership evidence, never a target path.
No symlink is followed. A symlink is skipped only when its text is the exact
absolute path of the same service basename beneath a later compiled root that
this run scans independently. This accounts for stock links such as
/etc/pam.d/system-auth -> /etc/authselect/system-auth without trusting the
link's contents. Every other symlink, hard-linked, nonregular or unreadable PAM
entry that contains or could hide a Facelock reference is an unmanaged blocker.
A reference found in the independently scanned /usr/lib/pam.d or
/etc/authselect root is likewise an unmanaged read-only or external-root
blocker. Structural directories, including authselect profile directories, are
not PAM service files and are skipped. Nothing outside the fixed roots is
followed or deleted.
A writable direct file with a conventional PAM service basename is recognized
as Facelock-owned when every Facelock logical rule uses the exact pre-versioned
physical bytes auth sufficient pam_facelock.so. Dot-prefixed names and
the administrator/package artifact suffixes .facelock-backup, .pacnew,
.pacsave, .pacorig, .rpmnew, .rpmsave, .rpmorig, .dpkg-old,
.dpkg-new, .dpkg-dist, .pam-old, and ~ are not conventional legacy
candidates.
They are considered only when a strict provenance basename exists for that
exact confined service and the current complete-file hash equals
installed_sha256 in its validated committed pair, or when the regular local
file carries the exact Facelock vendor-copy header and matches its current
fixed-root vendor source as specified above. This lets remove --all find
every arbitrary name accepted by either named writer path without treating an
unowned administrator artifact as an active PAM service. Customized controls,
options or spacing, corrupt or ambiguous provenance for a candidate, invalid
bytes, path escapes, link swaps, identity drift and concurrent edits block the
whole run during preflight. Nothing is changed when preflight finds a blocker.
A drifted or source-absent local override is not automatically one of those
blockers: when its Facelock rule is provenance-owned or the exact canonical
line, --all rewrites the file in place, taking the rule out and keeping the
file, never retiring it. This is the same drift rule named removal already
follows. A committed record whose installed_sha256 no longer matches the
file proves only that the file changed after add; a conventional service is
then held to the exact canonical rule above, while an artifact name, a
candidate only because of its record, stays blocked. An administrator-managed
or stale-record blocker names its remedy: facelock pam remove --service <name>, or a hand edit, then retry.
An empty scan is an idempotent success.
The initial cleanup scan also recognizes that exact unchanged header-bearing
vendor override after its Facelock rule is already absent. This is the bounded
restart shape of the named removal above; other no-rule files are ignored and
preserved. The final active-reference scan excludes this cleanup-only shape.
The whole set is journaled before the first PAM mutation. Version 2 state
is strict JSON with unknown fields rejected, regular single-link fixed-state
owner and mode 0600, no-clobber publication, a 4 MiB encoded limit and at
most 1,024 unique confined services. Its reserved names and exact fields are:
.facelock-remove-all-<operation>.jsoncontains exactlyversion,operation,keep_backup, andtargets. Each target contains exactlyservice, strictbackup,original,installed_sha256, and the required booleandelete_override;originalcontains exactlydevice,inode,links,sha256,mode,uid, andgidand must describe a regular single-link file..facelock-remove-all-commit-<operation>.jsoncontains exactlyversion,operation,journal_sha256,keep_backup, andtargets. Each target contains exactlyservice, strictbackup,installed, and the required booleandelete_override, whereinstalleduses the same complete-identity fields and validation.
Version 1 journal and commit files remain recoverable only when every target
omits delete_override; version 2 requires it on every target. JSON null is
not an absent field. The corresponding journal and commit flags must match.
operation is <seconds>-<exactly-nine-digit-nanoseconds>: seconds parse as
u64, nanoseconds are below one billion, and collision allocation is bounded.
Only a prefix plus that valid operation grammar is reserved batch state; a
prefix-shaped strict provenance basename with another suffix remains ordinary
per-service provenance. Both state files and every hash are bounded and
validated, and duplicate services invalidate either target list before
recovery. A commit pairs with a journal only when operation, keep flag, ordered
service/backup set, exact journal hash and planned/committed installed hashes
and delete_override flags agree. Multiple, malformed or conflicting reserved
entries require manual review.
pam remove --all --dry-run opens an existing backup directory for read-only
inspection and requires its owner and mode to be trusted already. It performs
no owner/mode repair, directory sync, recovery or write locking; an untrusted
directory makes the preview fail closed with its metadata and entries intact.
One fixed-state-directory flock spans recovery of any earlier journal, the
authoritative root scan and complete preflight, bounded operation and rollback
pair allocation, publication of every #171 backup/provenance pair, journal
publication, every PAM exchange, the final fixed-root active-reference scan,
commit publication and evidence cleanup. Every per-service rollback pair is
durable before the journal, and the complete journal is durable before the
first PAM mutation. Each service then uses the #171 intent, publication
binding, exact created-identity and RENAME_EXCHANGE protocol while retaining
the displaced original inode.
A later failure or a non-empty or unanswerable final scan reverse-exchanges every earlier file in reverse order after complete identity checks. Recovery does the same for a prepared journal without a valid commit. The strict, self-contained commit authenticates the journal bytes and every installed complete identity; once it is durable, recovery finishes per-file publication cleanup and validated backup cleanup instead of rolling the PAM files back. Any mismatch or ambiguity preserves the journal and per-file intent, binding, temp or displaced evidence, including administrator bytes, for review. Files are always published as complete byte sequences and PAM password fallback is unchanged.
Prepared-journal recovery also recognizes the one provably unstarted
per-service publication shape: the canonical service still has the journal's
full original identity, the exact valid pam_replace intent agrees with the
prepared pair's sequence and record hash, and the exact replacement temp and
publication binding are both absent. It identity-checks and removes only that
intent before continuing rollback. Any mismatch or extra name remains
ambiguous. After reverse exchange and identity-checked replacement-temp
cleanup, rollback removes the exact publication binding before delegating the
remaining base intent to that intent-only recovery. Every boundary is
restartable; normal forward publication retains its existing intent-first
cleanup order. Rollback-pair cleanup is restart-idempotent across cleanup intent,
both quarantine moves and both unlinks. An exact matching cleanup intent is
resumed; a target whose canonical pair, quarantine pair and exact cleanup
intent are all absent is already clean. Partial, substituted or conflicting
pair state is preserved and blocks journal cleanup.
After the batch commit marker is durable and per-file publication evidence is
finalized, each target with delete_override = true is unlinked through the
writable-root directory descriptor only if its full committed identity still
matches. The corresponding journaled original backup must retain the full
identity captured by the prepared pair and parse as an exact Facelock-emitted
one-rule copy or exact no-rule restart shape. Its line-removed SHA-256 must
equal the journal target's installed hash. The header must name the first
existing service in the ordered later roots, whose bounded, re-opened regular
single-link entry must still match the payload and mode/UID/GID and contain no
active Facelock rule. Facelock never opens an arbitrary path from the header.
The same no-replace vendor-retirement quarantine protocol above performs the
committed deletion. Any mismatch preserves the override and the batch
journal/commit evidence. A crash after a checked unlink is restartable: absence
is accepted only for a committed delete_override target, while any partial,
substituted or unflagged absence remains ambiguous.
On success, default cleanup removes only validated Facelock-owned versioned
pairs and exact validated legacy <service>.facelock-backup state for every
committed target. --keep-backup instead commits and preserves the new pairs
and opts out of legacy cleanup. --json uses the standard single remove
document with one committed removed row per target (and a backup path only
under --keep-backup); an idempotent no-op has an empty services array.
--quiet suppresses that document.
Every shipped uninstall surface delegates to this cleanup before deleting
the binary or PAM module. This includes the Arch source and binary packages,
Debian prerm, RPM %preun, the Omarchy remover and just uninstall. Booted
package coverage exercises direct dpkg/rpm and their apt-get, apt, and
dnf frontends for abort retention and blocker-free success. Arch
also ships /usr/share/libalpm/hooks/facelock-pam-remove.hook, a package
Remove-only PreTransaction hook for target facelock; AbortOnFail
runs /usr/bin/facelock pam remove --all before pacman changes the package,
and the package scriptlet retains an idempotent second call. Debian and RPM
propagate cleanup failure so the package, binary and module remain. Source and
Omarchy removal stop before their deletes. The module can be removed only
after the cleanup's final compiled-root scan succeeds.
The all-or-nothing guarantee covers direct PAM edits owned and scanned by this
transaction and retention of the package/module when that cleanup fails.
Debian removal adds a read-only boundary before it: the exact shared-profile
probe runs first, followed by pam remove --all --dry-run, then the journaled
real cleanup, and only then the generated ordinary-removal service stop.
Ordinary removal preserves the unit's enabled state for reinstall; the
generated purge path alone retires that state. A selected Facelock
pam-auth-update profile blocks removal without changing common-auth, its
selection state, direct edits, the service, the binary, or the PAM module. The
diagnostic tells the administrator to run
sudo pam-auth-update --disable facelock, verify that a real correct password
succeeds and a wrong password fails, and retry removal. Unsafe or inconsistent
shared-profile state also blocks.
This release deliberately supports no automatic legacy/shared-profile
migration or deselection. Older packages persisted no durable fact that can
distinguish a package-auto-enabled profile from a later administrator choice;
therefore every selected state is administrator-owned and preserved. This is
the intentionally deferred legacy ambiguity: a future automatic transition
requires exact package provenance recorded before the choice, a byte-and-
metadata snapshot of the managed PAM graph, mutation while the old profile and
binary still exist, pam-auth-update, reapplication of only provenance-owned
direct edits, and real correct/wrong-password validation, with provable restore
or retained evidence on failure. An unselected profile needs no graph
transition: its Default: no metadata leaves with the package payload after
the fixed-root direct cleanup succeeds. Fedora is separate:
#226 owns only RPM payload retirement and the read-only upgrade guard.
Shared-stack migration, regeneration, editing and rollback are explicitly rejected.
remove --all scans /etc/authselect as a detection-only root and never edits
generated state.
--json emits exactly one document on stdout and no human text; --quiet
suppresses even that, leaving the exit code as the whole answer, as it does for
is-enrolled. Diagnostics stay on stderr either way. --json implies
--no-confirm: the per-file question is drawn on stderr while a parser waits
on stdout, so asking it would block the pipeline. It does not imply
--allow-sensitive — that is an authorization, and a machine caller has not
given one — so pam add --service system-auth --json still refuses.
On add and remove, a validation failure produces no JSON document: it
is reported as text on stderr and the process exits non-zero, matching
is-enrolled, whose unanswerable case prints a reason and no payload. The
phase that rejects is the phase that would have decided every row, so there is
no partial document to emit.
pam status is the other way round and always emits a document: it has no
all-or-nothing phase to fail, so a rejected service name — or one whose entry
this refuses to follow — becomes an unknown row inside the document (with the
reason in error) alongside the rows for every other requested service, and
the refusal is also written to stderr for a human. Exit 2 either way.
{
"command": "add",
"dry_run": false,
"services": [
{
"service": "sudo",
"path": "/etc/pam.d/sudo",
"action": "installed",
"backup": "/var/lib/facelock/pam-backups/sudo.1770000000-123456789"
}
]
}
pam status --json carries one extra top-level key, module_path: the
candidate pam_facelock.so was found at, or null when none was. It is a
property of the machine rather than of a service — which is why it is top-level
and not repeated in every service object — and it is what tells an integrator
that the line is present but names a module at a path nothing looks at. add
refuses before writing when the module is missing; remove can clean up stale
references without the module installed. Neither write verb carries the key.
pam status --all --json carries a second additive top-level key,
directories: every directory searched, in search order, each an object with
path and a status of scanned, absent or unreadable, and error on an
unreadable one. Only --all carries it, because only --all claims to have
looked everywhere; a named request resolves through the search path without
enumerating it.
A service object carries shadows when the file it names is a local copy
hiding a package's own: the value is the vendor path it hides. The key is
absent rather than null when nothing is shadowed, which is every row on a
machine with no vendor directory. It is a maintenance fact rather than a state
one, since the service is present either way, and it says the copy will not
follow the package's updates.
shadows is a property of the row, not of a flag or a verb. It appears on
any row whose file hides a vendor one — pam status with --service as well as
with --all, and pam add and pam remove alike — because one resolver
answers for every verb and a row that knows the fact does not withhold it. On an
overridden row it is the vendor file the copy was made from, which is what
that row has just started shadowing. The human line gained the same fact at the
same time: a configured service whose file shadows a vendor one reads
facelock PAM line present (local override of <path>) instead of
facelock PAM line present, on every form of pam status. Exit codes are
unchanged by it.
This shape is a stability contract. An object rather than a bare array so a
new top-level field is additive. Field names do not change and are not removed;
service, path, action and backup are always present on every service
object; error is present when action is failed, cleanup-failed or
unknown, and shadows when the file the row names hides a vendor one.
error is a
diagnostic, not a contract — branch on action, never on error's text. A
rejected service name reports the fixed C-locale string invalid service name,
a symlinked service entry reports symlinked outside /etc/pam.d — the retained
fixed name for the class, whichever directory it was in — and a
hard-linked one hard-linked service file, but the OS-level failures
(failed on a write, cleanup-failed after the requested PAM state was
reached, or unknown on an unreadable file) interpolate a strerror string,
which follows the operator's
LC_MESSAGES like any other C library message. Nothing else in a --json
document is locale-dependent. backup is the newest committed backup path the
calling process can validate for the service, falling back only to the exact
legacy adjacent <service>.facelock-backup path; it is null otherwise. The
root write verbs can inspect the 0700 state directory; an unprivileged
pam status normally cannot and may therefore report null even while such a
versioned backup exists. This is the documented location change from the pre-0.2
adjacent path, so consumers treat the value as an opaque absolute rollback
path. It is always null under --dry-run, which writes none, and normally
null after a default remove, which cleans owned state; --keep-backup
retains it. It is always null for an overridden service: the copy preserved
nothing, so legacy state at the override path is not this run's rollback and
is not reported as one. Deleting the override is its undo, and the vendor
original is untouched.
path on an overridden row is the override that was created, not the
vendor file it was read from; on a vendor-only row it is the vendor file,
which is the one that exists. path
is itself null when action is unknown because the name was rejected: no
path was ever resolved, and reporting /etc/pam.d/../escape named a path
nothing went near, which reads as one that was acted on. A service rejected for
being a symlink does carry a path — the link, which is a real entry this did
lstat — and its backup field probes only the exact legacy adjacent name,
since a pre-0.2 version may have written through the link. It does not use an
untrusted link to select versioned state.
The action vocabulary — new words may be added, so a consumer must tolerate
one it does not know rather than treat it as an error:
action | Verb | Meaning |
|---|---|---|
installed | add | the line was written (under --dry-run, would be) |
overridden | add | the service resolved only in a vendor directory, so an /etc/pam.d copy carrying the line was created from it (under --dry-run, would be) |
vendor-only | remove, status | the service resolves only in a vendor directory: nothing was written, and there is no local override to carry a line |
removed | remove | the line was deleted (under --dry-run, would be) |
unchanged | add, remove | already in the requested state |
absent | all three | the service file does not exist |
declined | add | the operator answered no at the per-file confirmation |
failed | add, remove | the write failed; see error |
cleanup-failed | remove | the PAM change completed but required rollback-state cleanup failed; see error |
present | status | the file exists and carries a facelock line |
missing | status | the file exists and carries none |
unknown | status | the file could not be read, the name was rejected, or the entry is a symlink or a hard link; see error |
pam status --json is what replaces grep -q pam_facelock.so /etc/pam.d/<service> in an integration script: it answers from the same file,
without root, and reports "absent" and "unreadable" as themselves rather than
as "not configured".
Repeatable --service. --service a --service b acts on both in one
process, one root check and one closing hint. Duplicates collapse. No
--service means sudo, which is what bare setup --pam has always meant.
PAM line placement. The direct CLI writer emits exactly this 36-byte literal; the literal itself has no trailing newline:
auth sufficient pam_facelock.so
The control is frozen to sufficient: a successful face can satisfy the
stack, while a non-match or unavailable face path continues under the
service-owned rules that follow. Ordinary login and privilege stacks commonly
reach their password modules there. Omarchy's face-only context instead
continues to pam_deny.so; its password attempt uses a separate PAM context.
There is intentionally no --control option. A caller cannot silently
substitute required, an extended control, or another stack policy for the
line whose behavior downstream consumers and Facelock cleanup rely on.
The line is inserted immediately before the first logical rule whose first
ASCII-whitespace-delimited type token is auth, matched
ASCII-case-insensitively and with Linux-PAM's optional leading -;
authtok_type= is not an auth type. If no auth rule exists and the first
physical line is exactly #%PAM-1.0, the line follows that header. Without
that exact leading header it starts at byte 0. This header-aware fallback is
the post-#192 contract; it supersedes #166's original top-of-file wording.
Omarchy owns this exact backend-neutral omarchy-lock-face skeleton:
#%PAM-1.0
auth required pam_deny.so
account include system-local-login
The direct writer produces this exact stack, leaving Omarchy's face-only lane to reach its plain denial when Facelock does not succeed:
#%PAM-1.0
auth sufficient pam_facelock.so
auth required pam_deny.so
account include system-local-login
Add idempotency and named pam status recognition are deliberately broader
than the emitted bytes. Any uncommented logical rule whose semantic bytes
contain the exact, case-sensitive byte sequence pam_facelock.so is an active
reference: add does not emit a duplicate, and status reports it present.
This is substring recognition, not a PAM module-token-boundary promise.
pam remove --all is stricter: a broad active reference is not automatically
Facelock-owned. Without validated provenance or the exact vendor-copy shape,
only the exact canonical physical line in a conventional service is eligible
for cleanup; custom control, spacing, or options block the whole-machine run
for administrator review rather than being rewritten.
This emitted-byte contract applies only to direct CLI service-file writes.
The packaged Debian pam-auth-update profile is opt-in (Default: no) and
intentionally emits [success=end default=ignore] pam_facelock.so. Legacy or
administrator-managed Fedora authselect profiles use authselect's generated
layout; Facelock RPMs no longer ship or select one. Both shapes remain visible
to the same broad add/status active-reference recognition, but neither is
required to match the canonical direct-writer bytes.
Service-file edits are byte-preserving. A backslash followed only by spaces
or tabs before LF or CRLF continues the same logical PAM rule, so insertion
never splits that rule. A # ends the semantic rule even after a continuation;
comment and blank physical lines remain untouched.
Removal drops the whole genuine logical Facelock rule. For recovery from older facelock output that inserted the canonical physical line between an administrator's continuation backslash and its following physical line, removal deletes only that injected line. This reconnects the administrator's logical rule instead of deleting it.
The editor never decodes the service file as UTF-8 and never reconstructs
unmodified lines. Existing LF or CRLF endings, invalid bytes, and the presence
or absence of the final newline survive unchanged; the one inserted line uses
the target rule's line ending, falling back to the document's first ending when
that target is unterminated. It uses the PAM header's ending when it follows
that header, or the document's first ending when it goes at the top. A header
with no final newline gains the separator before the inserted rule while the
rule itself remains unterminated. A byte-identical no-op writes no file and
takes no backup; an in-place add backs up before its real edit. Removal takes
no new backup and cleans validated rollback state by default; --keep-backup
preserves it. Vendor-override creation has no original at the override path to
preserve, as documented above. Golden fixtures pin insertion, removal,
invalid-byte, CRLF and no-final-newline behavior.
facelock status Semantics
facelock status renders one Health value twice: as the report a
person reads, and — under --json — as one document a script parses. Both
renderers are pure functions of that value, and a unit test walks the two
outputs of one fixture and fails the build when they disagree about any
section's verdict or when a section appears in one and not the other.
It stays root-only (see "CLI Privilege Model" below): every fact in the report
comes from root's view — the 0600 database, other users' markers, the daemon's
root-only methods — so there is no unprivileged half to split out. The
consumers are root-run scripts: test/run-integration-tests.sh waits on the
daemon by parsing this document, and a setup script verifies the enumerated PAM
state without reading prose.
Exit codes are unchanged and carry no verdict. status exits 0 whenever it
produced a report, whatever the report says, exactly as it did before --json
existed; a failure to reach the report (not root) is the only non-zero exit.
The verdicts are in the document. So --quiet --json prints nothing and exits
0 — --quiet suppresses the payload as it does everywhere, but here the exit
code is not an answer, which makes the combination a no-op rather than a
terser query.
Every fact is a tri-state. Each section object carries a state of ok,
problem or unknown, and a reason string on problem and unknown
only. unknown is the report's whole reason for existing: "the database
could not be read" is a different answer from "this user has no models", and
JSON makes that distinction easier to lose than prose does, because null and
false read as answers. So a fact nobody established is never a null and
never a false — it is "state": "unknown" with a reason. The nested facts
that can be undetermined carry the same three words: camera.device,
encryption.embeddings, enrollment.marker and pam.services.
Read state before anything beside it. A section whose state is not ok
may omit any of its detail keys, because a probe that did not finish has nothing
to report there: on an unreadable database enrollment carries no models key
at all. That is deliberate — an empty array would be a known "this user has no
faces", which is the collapse this whole document is shaped to prevent — but it
means a defaulting read is a bug. jq '(.enrollment.models // []) | length > 0'
answers false for a machine whose database could not be opened. Branch on
.enrollment.state == "ok" first, then read models.
reason is never catalog output. The document is machine output and does not
enter the message seam (message/mod.rs, "What must NOT come through here"), so
no reason is ever a translated string: the ones facelock authors are C-locale
literals, and the one localized why the health probe produces — the reason
every config-dependent fact carries when the file did not parse — is replaced by
the literal config not available on the way out.
reason is still not a fixed vocabulary. Some of them embed the diagnostic
the probe captured, because that text is the part worth having:
enrollment.reason can read database not accessible: <store error>, and
enrollment.marker.reason carries the marker file's own read or parse failure.
The error fields are the same kind of thing — config.error is the toml
parser's message, execution_provider.error the ONNX Runtime's own load
failure, and each pam.services.not_checked[].error a listing or read failure.
An OS error rendered by the C library follows the operator's LC_MESSAGES like
any other strerror string, exactly as pam's error field does. So reason
and error are both diagnostics, not contracts: branch on state and on
the section's typed words, print reason and error, and match on neither.
daemon carries no diagnostic, on purpose. It is the one probe whose error
string is not the transport's own words: the client attaches a localized hint
to a D-Bus AccessDenied — advice for a human reading stderr — and rendering
that chain yields the hint alone. Forwarding it would put translated text in a
payload on exactly the machine this section exists to diagnose, so the field is
not emitted. reachability and reason carry the fact; the hint still reaches
the person, on the report and on stderr.
The typed words are what a consumer branches on. state says how bad it
is; the section's own word says what it is: config.outcome
(valid/not_found/invalid), daemon.reachability
(responding/not_responding/not_configured), camera.selection
(configured/auto_detect), execution_provider.availability
(available/not_built_in/unrecognized/unqueryable), encryption.key.method
(tpm/keyfile/none), notifications.mode
(off/terminal/desktop/both), config.device.selection
(configured/auto_detect) and models.files[].purpose
(detector/embedder).
Each section's state answers its own question, and some are narrower than
the section name suggests. A section that owns a nested fact keeps the broad
question for itself and puts the specific one underneath, so reading only the
outer state can pass a machine the nested fact condemns:
| Section | Its state answers | And beside it |
|---|---|---|
config | did the file parse | — |
daemon | did the bus round trip complete, or was the bus deliberately never asked (reachability: not_configured, which daemon.mode = "oneshot" produces) | — |
oneshot_fallback | are the three files daemon-less auth needs on disk | — |
camera | under configured, does the node exist; under auto_detect, only that auto-detection is enabled | camera.device — whether a device was actually found and interrogated |
models | is the model directory there, with both configured files | — |
execution_provider | CPU is assumed available without loading ORT; for a GPU provider, whether ORT reports it compiled in, not whether drivers or an inference session work | — |
encryption | is usable key material in place — method: none is a problem even though the config asked for nothing, because plaintext storage is a finding rather than a preference | encryption.embeddings — whether the stored embeddings could be counted |
enrollment | does this user have at least one model | enrollment.marker — whether the is-enrolled marker agrees with the database |
security | are the checks enabled at all | — |
notifications | never a finding: ok whenever the config was read | — |
pam | is pam_facelock.so installed | pam.services — what the /etc/pam.d scan found, and whether it could see everywhere |
Two of those are worth stating outright, because the obvious read is wrong.
Under auto-detection "camera": {"state": "ok"} says detection is on, not
that a camera exists — a machine with no camera at all renders exactly that,
with camera.device.state reporting problem. And "pam": {"state": "ok"}
says the module is installed, not that anything uses it — a machine with the
module in place and nothing wired up renders that, with
pam.services.configured empty. The human report is equally generous in both
cases ([ok] auto-detect enabled, [ok] installed); the nested fact is where
the answer is.
The PAM section speaks pam status --json's vocabulary. Each row of
pam.services.configured is a {"service", "path", "action"} object with the
same action words, plus shadows when the file it names hides a vendor one
— the same key, absent rather than null when nothing is shadowed. Only
present rows appear, because a service that does not carry the line is not a
configured service; backup is not on these rows, because status does not
probe for backup files. pam.services.state is ok only when every directory
and every service file was read; a single unread place makes it unknown and
names each one in not_checked, so an incomplete list is never mistaken for a
complete one. What was found is still listed and still true.
Stability tier. Field names do not change and are not removed; new fields
and new sections are additive; a removal is breaking. New words may be added to
any of the typed vocabularies above, including state, so a consumer tolerates
a word it does not know rather than treating it as an error. Key order is not
part of the contract — parse the document, do not string-match it.
A conditional field is absent, not null, when it does not apply. The whole
list: reason (present unless state is ok), error, shadows,
installed_at, device, files, checks, delivery,
daemon.reachability, camera.configured_path, camera.present,
enrollment.models and enrollment.marker. A consumer must tolerate a section
carrying nothing but state and reason, which is what every
config-dependent section renders when the config did not parse. There is no roll-up verdict for the machine as a whole: what
counts as healthy is the consumer's policy, and the human report does not make
that judgment either.
The document for a fully healthy machine, which is the fixture both renderers are tested against:
{
"config": {
"state": "ok",
"path": "/etc/facelock/config.toml",
"outcome": "valid",
"device": { "selection": "configured", "path": "/dev/video2" }
},
"daemon": {
"state": "ok",
"bus_name": "org.facelock.Daemon",
"reachability": "responding"
},
"oneshot_fallback": {
"state": "ok",
"auth_bin": "/usr/bin/facelock",
"binary_present": true,
"models_present": true,
"database_present": true
},
"camera": {
"state": "ok",
"selection": "configured",
"configured_path": "/dev/video2",
"present": true,
"device": {
"state": "ok",
"path": "/dev/video2",
"name": "Integrated IR Camera",
"ir": true,
"quirks": []
}
},
"models": {
"state": "ok",
"dir": "/usr/share/facelock/models",
"dir_present": true,
"files": [
{ "purpose": "detector", "path": "/usr/share/facelock/models/det.onnx", "present": true },
{ "purpose": "embedder", "path": "/usr/share/facelock/models/emb.onnx", "present": true }
]
},
"execution_provider": {
"state": "ok",
"configured": "cpu",
"availability": "available"
},
"encryption": {
"state": "ok",
"key": {
"method": "tpm",
"sealed_key_path": "/etc/facelock/sealed.key",
"sealed_key_present": true,
"tpm_device_path": "/dev/tpmrm0",
"tpm_device_present": true
},
"embeddings": { "state": "ok", "encrypted": 2, "plaintext": 0 }
},
"enrollment": {
"state": "ok",
"user": "alice",
"models": [
{ "id": 1, "label": "front" },
{ "id": 2, "label": "side" }
],
"marker": { "state": "ok" }
},
"security": {
"state": "ok",
"disabled": false,
"checks": {
"require_ir": true,
"require_frame_variance": true,
"require_landmark_liveness": false,
"min_auth_frames": 3
}
},
"notifications": {
"state": "ok",
"mode": "both",
"delivery": { "prompt": true, "on_success": true, "on_failure": false }
},
"pam": {
"state": "ok",
"module_path": "/lib/security/pam_facelock.so",
"installed_at": "/lib/security/pam_facelock.so",
"services": {
"state": "ok",
"configured": [
{ "service": "sudo", "path": "/etc/pam.d/sudo", "action": "present" },
{
"service": "polkit-1",
"path": "/etc/pam.d/polkit-1",
"action": "present",
"shadows": "/usr/lib/pam.d/polkit-1"
}
],
"not_checked": []
}
}
}
A machine whose config did not parse renders all nine config-dependent sections
as {"state": "unknown", "reason": "config not available"}, plus whatever
that section knows without the config: daemon keeps bus_name (a property
of the design, not of the machine) and enrollment keeps user (resolved from
argv and the environment, never from the file). The other seven carry state
and reason alone. Meanwhile config reports the parse failure itself, and
pam, which is config-independent, still answers in full.
facelock capabilities
facelock capabilities answers "what can this build do?" — from the binary's
own clap tree and compiled-in constants, without reading a config file,
activating the daemon, or opening a camera. It is what replaces
facelock setup --help 2>/dev/null | grep -q -- "--no-pam" in a wrapper
script: help text is not an API, and a grep against it breaks on a reworded
flag description, a line wrap, or a translated help template.
Bare capabilities prints one name per line on stdout. --json prints one
document on stdout. Both exit 0 — the command has no failure mode — and
--quiet suppresses stdout entirely, leaving the exit code as the whole
answer, as it does for is-enrolled. Neither form localizes: a capability name
is an identifier, not prose.
The --json document, with the array elided — the names this build emits are
the table at the end of this section:
{"version": "0.1.4", "capabilities": ["capabilities", "devices-json", "is-enrolled"]}
version is this binary's own version — byte for byte the one facelock --version prints. capabilities is a sorted, deduplicated array of
strings.
Probe by name, not by version. A version comparison is the wrong test
twice over: a git or distro build can carry a version that says nothing about
what is in it (facelock-git is exactly that case, and is why a downstream
package pin cannot express "needs the pam verb"), and a backport can add a
feature without moving the number. The name list cannot drift from the binary
it came out of: capability_names_are_all_implemented maps every name to the
clap argument, subcommand or constant that declares it, and what each surface
means is pinned by the section of this document that owns it. version is
for humans and bug reports.
A build that predates the command answers by failing: clap's "unrecognized subcommand" error, usage text on stderr, exit 2, nothing on stdout. A caller reads any non-zero exit as "no capabilities at all", which is the true answer for that build.
Stability. The names are a contract of the same kind as the pam --json
action vocabulary, one degree stronger:
- a name, once emitted, never changes meaning
- names are added; none is ever removed or repurposed
versionandcapabilitiesare always present, and a new top-level field is additive — a consumer ignores fields it does not know- a consumer tolerates a name it does not know rather than treating it as an error
- key order within the JSON document is not part of the contract — parse the document, do not string-match it
Naming. Lowercase, hyphenated. A bare name (quiet, is-enrolled) means
the command or global flag itself exists; <command>-<feature> names one
feature of one command, and where the command's own name is hyphenated the
suffix simply appends (is-enrolled-json). One name promises one thing: a flag
that is not on this list is not being denied, only not yet promised.
| Name | Meaning |
|---|---|
capabilities | this command exists, so a consumer's membership test is uniform across every name |
config-edit | config edit exists — the verb ADR 009 split out of the old --edit flag |
daemon-processfd-session-gate | daemon Authenticate supports the ProcessFD-backed logind remote-session gate documented under IPC Protocol |
daemon-restart | daemon restart exists — the verb ADR 009 moved under daemon from the top-level restart |
data-purge | data purge exists — the sanctioned path to destroy retained biometric state, which no removal path provides |
data-purge-allow-destruction | data purge --allow-destruction exists as its own argument beside --yes, so a caller can verify that prompt suppression is not the authorization |
data-purge-dry-run | data purge --dry-run |
data-purge-json | data purge --json |
devices-json | devices --json |
is-enrolled | is-enrolled exists — the unprivileged enrollment probe whose exit code is the contract |
is-enrolled-json | is-enrolled --json |
pam-allow-sensitive | pam add accepts --allow-sensitive, the gate on the sensitive services; pam remove does not offer it, because removal is never gated |
pam-dry-run | pam add/pam remove accept --dry-run |
pam-if-present | pam add/pam remove/pam status accept --if-present |
pam-json | pam add/pam remove/pam status accept --json |
pam-multi-service | pam add/pam remove/pam status take a repeatable --service — several services in one process, one root check |
pam-remove-all | pam remove --all exists, conflicts with --service, and uses compiled-root whole-set cleanup |
pam-status | pam status exists — the unprivileged /etc/pam.d read (DEC-6 below) |
pam-status-all | pam status --all exists, and conflicts with --service — the enumerating form, which answers "what is configured on this machine?" rather than "is this name configured?" |
quiet | the global --quiet |
setup-allow-sensitive | setup --pam accepts --allow-sensitive as the explicit sensitive-service authorization; --yes remains prompt suppression only |
setup-if-present | setup --pam --if-present, on add and on --remove alike |
setup-no-pam | setup --no-pam |
setup-systemd | setup --systemd |
status-json | status --json — the machine-readable system report, one key per section. See "facelock status Semantics" |
tpm-decrypt | tpm decrypt exists — the verb ADR 009 moved under tpm from the top-level decrypt |
tpm-encrypt | tpm encrypt exists — the verb ADR 009 moved under tpm from the top-level encrypt |
tpm-reseal | tpm reseal exists — the verb ADR 009 moved under tpm from the top-level reseal |
The five names ADR 009 added are the only way a wrapper can tell a build that
takes daemon restart from one that still wants restart: the old spellings
were deleted rather than aliased, so probing by invocation costs a failed
command. Each promises only that the subcommand at that path parses.
CLI Privilege Model (DEC-6)
The CLI is root by default: every subcommand requires root except the six listed below, which are unprivileged by design, not by omission.
| Command | Why unprivileged |
|---|---|
facelock is-enrolled | Answers from the caller's own 0600 marker file; the unprivileged integration point (see Exit Codes above). Never probes D-Bus |
facelock hyprlock … | Edits the user's own dotfile — root would write root-owned files into $HOME, which is wrong, not just unnecessary |
facelock pam status | Reads 0644 files under /etc/pam.d and writes nothing. Same role as is-enrolled: the probe an integration runs without sudo, replacing a hand-rolled grep -q pam_facelock.so /etc/pam.d/<service>. A file it cannot read reports unknown and exits 2 rather than reporting it as missing |
facelock config [show] | Reads a 0644 file. The rename split the flag into a verb (ADR 009) and the privilege split survives it exactly: config show, and the bare config that means it, stay unprivileged; config edit is root |
facelock capabilities | Reports what the binary can do, derived from its own clap tree and compiled-in constants — no file, no D-Bus, no camera, no per-user state, so there is nothing to protect. Unprivileged because the consumer is a user-level setup script deciding whether to invoke sudo facelock … at all: a probe that needed root to answer "do I need root?" would be useless |
--help, --version | — |
Every other command requires root. Two escalation behaviors apply, and each command uses exactly one:
- Interactive prompt.
setup,enroll,test,preview,bench,tpm(includingtpm encrypt,tpm decryptandtpm reseal),daemon restart,config edit,remove,clear,list,status,devices. Run as non-root with a TTY attached, these askRoot required. Re-run with sudo? [Y/n]and re-exec viasudoon yes. Run as non-root with no TTY (scripted, piped, or closed stdin), they hard-error instead —Root required.\n Run: sudo facelock <cmd>— rather than hang waiting for input that will never arrive (ipc_client::require_root). - Hard error only.
facelock daemon run,facelock pam add,facelock pam remove,facelock auditandfacelock data purgenever offer the interactive prompt at all, even with a TTY attached — each is typically invoked non-interactively or by a wrapper, where a stray confirmation prompt is a hang, not a convenience (ipc_client::require_root_scripted).
facelock daemon run is in the hard-error class because every shipped
service unit invokes it, and a service manager must never be what a
confirmation prompt is waiting on. daemon restart keeps the prompt: a human
types it. Run by hand as a non-root user, daemon run refuses with the same
Run: sudo facelock daemon run hint a unit would get, TTY or not.
facelock pam add|remove are in the hard-error class because the surface they
replace was: standalone setup --pam bailed from its own root check rather
than prompting. The check runs before --dry-run is honoured, so a dry run
still needs root, and pam status is the unprivileged read to reach for
instead.
facelock data purge is in the hard-error class for the same reason and one
more: it is destructive. Offering to re-exec a purge under sudo would ask a
user who has just been told the command needs root to re-authorize destroying
their enrollments through a [Y/n] that reads like a convenience. Its root
check also runs before --dry-run, before the destruction authorization, and
before the confirmation prompt, so a non-root invocation refuses without
having read a file, prompted, or touched the daemon.
Authorization is separate from prompt suppression. facelock data purge
requires --allow-destruction in addition to root. --yes/--no-confirm
suppresses the confirmation prompt and grants nothing; --json suppresses the
prompt for the pipeline reason recorded under "CLI Machine Output" and grants
nothing either. Neither implies the other, in either direction. This is the
same split facelock pam add draws between --yes and --allow-sensitive,
and it exists for the same reason: a wrapper that passes --yes to every
command so scripts run unattended must not thereby have authorized a
security-relevant or destructive action. --dry-run reports and deletes
nothing, so it requires root but not the authorization.
facelock auth is not user-facing — PAM spawns it directly, and it is not
part of this table.
Ordering guarantee (C6). Every command that prompts for confirmation or
runs an interactive question runs its root check first, before that
prompt or any other output or side effect. remove and clear both ask a
Y/N confirmation before deleting a face model; historically remove's root
check ran after that confirmation, so a non-root user would confirm a
destructive action and only then discover it was refused — this is fixed.
The check also precedes the config parse. For the commands dispatched
through the shared config load (enroll, remove, clear, list, test,
preview, devices, bench, tpm …, audit), main runs the root gate
(require_root_for) before ConfigLoad::read(), so a non-root caller is
refused before the config file is read at all — a missing or broken config
answers ("no config file at …") only to a caller that has already passed
the root check, never ahead of Root required (issue #191). Commands
dispatched ahead of that load (config edit, daemon run,
daemon restart, setup, pam add, pam remove) keep the root check as
the first statement in the command's entry point, before any println!.
status is the one exception to gate placement, not to ordering: it
holds the unresolved load so a broken config renders as a finding in its
report, and its own root check still runs before its first line of output —
the read alone has no user-visible effect ahead of the refusal.
enroll carries the check in both places. main's gate is what
enforces the ordering above — root decided before the config parse — and
enroll::run re-checks on entry, as a hard error, because setup calls it
directly and run_with_plan's precheck is conditional on a base flow
running. The second check is unreachable on every path that exists today; it
is there so a future setup path that enrolls cannot do so unprivileged by
omission (issue #288).
The rows in crates/facelock-cli/tests/cli_smoke.rs pin this ordering per
gated command. They run the binary unprivileged even under a root test
runner: both CI test jobs are root in a container, and a row that skipped
there reported as a pass while asserting nothing (issue #189). They close
stdin, so what they witness is that the refusal preceded the output, not
which of DEC-6's two escalation classes produced it; require_root and
require_root_scripted share one non-interactive branch and are
indistinguishable to them. Two commands carry a second row that allocates a
real pty and does pin the class: daemon run must never print the prompt
with a terminal attached (issue #188), and enroll must always print it
(issue #288). enroll's rows also pass --config at a path that does not
exist, so its own backstop cannot answer in the gate's place and pass a row
that the gate should have failed.
Dropping, never skipping. Every one of those rows drops the process it
spawns to uid 65534 under a root runner, the pty rows included: a pty is
opened by the parent before the fork, so the child inherits descriptors the
kernel has already granted and can still read its own queued input after the
drop. The backstop test in commands::enroll calls run in-process, where
there is no child to drop, so it re-executes the test binary at itself and
drops that. None of these return early under root. A test that skips on the
one configuration CI runs is a test that asserts nothing while reporting a
pass, which is how this contract lost its coverage twice (issues #189, #303).
AccessDenied hint. A D-Bus AccessDenied reply carries one actionable
hint (ipc_client::add_access_denied_hint): root is required. Since almost
every D-Bus method is root-only (see IPC Protocol below) and, under ADR 010,
the bus admits a non-root caller to Authenticate alone, a denial from the
daemon's require_root and a denial from the bus policy have the same fix.
There is no group to join (ADR 010).
facelock test Semantics (N11)
facelock test is root-only (issue #96) and, being root, keeps full detail
on both transports: on the daemon transport, AuthResult.similarity is
redacted to non-root D-Bus callers only (redact_similarity_unless_root) —
since test requires root, it always gets the real score. The direct
transport never redacts.
test is a separate D-Bus method, not a privileged flavor of
Authenticate. On the daemon transport facelock test calls the
root-only TestAuthenticate method; Authenticate is real
authentication only. The daemon does not infer which it is serving from the
caller's UID, and must not: pam_facelock runs inside the PAM stack of the
authenticating program, and sudo is setuid-root — as are login, su,
and root-run display-manager greeters — so a real failed face
authentication at a sudo prompt reaches the daemon as UID 0. A design that
exempted root callers from rate-limit consumption therefore left the limit
inert on the primary documented PAM target. Intent travels with the method
call instead (AuthIntent in facelock_daemon::handler).
Both entry points run the same pre-flight gates — security.disabled,
enrollment / suppress_unknown, the rate-limit check, and require_ir —
via facelock_daemon::auth::pre_check_audited*. TestAuthenticate differs
in exactly two documented ways:
- The
abort_if_ssh/abort_if_lid_closedgates are skipped (PreCheckContext::test()). Those two exist to stop an attacker's physical-access shortcuts, not to block an admin who is already root (by construction, sincetestrequires root) and is deliberately diagnosing recognition over SSH or with the lid closed on a docked laptop. This is a context flag threaded throughpre_check, not a parallel copy of the gate logic (issue #95 was exactly that kind of drift). It applies identically on the direct transport, which callspre_check_audited_with_contextdirectly — the two transports no longer diverge here, as they did whiletesthad no daemon-side method of its own to carry the context. - A failed attempt consumes no rate-limit budget. The direct transport
gets this structurally (
direct::authenticatenever callsRateLimiter::record_failure); the daemon transport gets it becauseTestAuthenticateis the entry point that does not charge. Root-only is what makes a budget-free authentication endpoint safe to offer at all — root already owns the database and can clear the limiter directly, so exempting consumption for it costs nothing.
Authenticate charges a failed attempt on every transport and for every
caller including root — with one exception, added by ADR 008 §4: an attempt
where the camera never saw a face charges nothing (face_detected == false,
the -1 wire sentinel). Nobody was there, so no guess was made; a screen
locker that starts face auth on every wake, or a laptop opened in front of an
empty desk, would otherwise spend the user's whole budget before they sit
down. A face that was seen and did not match (-4) still charges. The rule
is identical on the daemon and one-shot paths, which share the rate_limit
table.
Such an attempt also ends early, at recognition.no_face_timeout_secs
(default 2, clamped to timeout_secs, 0 disables) rather than at
timeout_secs; the outcome it reports is exactly the one the full timeout
reports, so no client gains a case.
The rate-limit check (whether user is already over budget) is unaffected
by any of the above and still runs on both methods and both transports: an
already-limited user's test run reports "rate limited", exactly like real
auth would — surfacing an existing lockout instead of masking it.
Operating Modes
| Mode | Config | PAM Behavior | CLI Behavior |
|---|---|---|---|
| Daemon | daemon.mode = "daemon" (default) | D-Bus IPC to daemon | Uses daemon if available, falls back to direct |
| Oneshot | daemon.mode = "oneshot" | Spawns facelock auth | Operates directly (no daemon) |
Backend-using CLI commands select their transport once. With the default configuration in daemon mode they check whether the bus name has an owner without activating it; when no owner is found they warn on stderr and use direct mode. A later daemon method error propagates instead of triggering a second direct attempt. Oneshot mode selects direct access without a bus probe or fallback warning.
Under a non-default --config in daemon mode, backend selection for
enroll, test and the other backend-using commands does not ask the bus and
selects direct access, saying so once on stderr (status's own daemon probe
still runs). The packaged daemon reads only
/etc/facelock/config.toml, so whatever owns the bus name is configured by a
file the command is not reading; its answers would be about another store,
camera, model set and security policy. This is a selection the operator made,
not a degraded state, so it is not the fallback warning. Oneshot mode is
unchanged: direct access is already its configuration.
facelock is-enrolled Exit Codes
The exit code is the contract — is-enrolled is designed to drop into a
shell one-liner, so integrations should branch on the status, not parse stdout.
The name follows systemd's is-* family (systemctl is-active, is-enabled),
which is the established idiom for a boolean query whose exit code is the answer;
the codes themselves match grep's 0 = match / 1 = no match / 2 = error.
| Code | Meaning |
|---|---|
| 0 | User has a usable enrollment |
| 1 | Not enrolled / not usable (includes an unreadable or absent marker) |
| 2 | Error — bad arguments, an unparseable marker, or an I/O failure other than absence or access denial |
facelock pam status uses the same 0/1/2 scale for the same reason; see
"facelock pam Semantics" above.
Default stdout is enrolled / not-enrolled — the state word, as systemctl is-active prints active. --quiet suppresses stdout and leaves only the exit
code; it is the global -q flag, so facelock --quiet is-enrolled is the same
invocation. --json emits {"enrolled": bool, "models": N, "updated": "<ISO8601>"};
when the user is not enrolled there is no marker to read a timestamp from, so
models is 0 and updated is null.
is-enrolled reads the selected config to derive the enrolled/ directory
beside storage.db_path, then answers from the user's marker. An unreadable or
invalid config falls back to /var/lib/facelock/enrolled/<user>. It never
activates the daemon over D-Bus, never opens a camera, and never reads the
database — so it is safe to call repeatedly from a lock screen as an
unprivileged user. The marker is a hint that can drift from the database; PAM
at auth time remains authoritative and nothing in the auth path consults it.
Markers are written by enroll, remove and clear, and converged from the
database by setup, by daemon startup, and by the one-shot facelock auth
path. Convergence re-derives markers from the database rather than replaying
recorded steps, so it is idempotent and there is no migration state to keep.
The scope differs by caller and that difference is contract:
| Caller | Scope |
|---|---|
base setup flows, daemon startup (reconcile_all) | Every marker: backfills each enrolled user and prunes every marker the database does not account for; standalone setup PAM/systemd actions do not reconcile |
one-shot facelock auth | One marker — the user being authenticated. It has no reason to read other users' rows and no privileged directory listing to prune with |
An install upgraded from a release that predates markers backfills itself on the
first daemon start or the first authentication; until one of those happens,
is-enrolled reports not-enrolled for a user who is in fact enrolled.
On the one-shot path the convergence point is bounded on both sides, and both bounds are contract rather than convenience. It runs after the pre-flight gates, so an attempt rejected as disabled / SSH / lid / rate-limited / non-IR performs no marker write at all — no attacker-drivable filesystem work from the wrong side of the rate limiter. It runs before the camera is opened, so every later way an attempt can end — a signal, a failed model load, a camera another process is holding, an undecryptable template, the no-face timeout, a plain non-match — leaves the marker already converged. In short: an attempt that reaches the camera has converged the marker, whatever it goes on to decide.
That placement means the one-shot's write only ever converges a marker
upward: reaching it requires the enrollment gate to have passed. The downward
direction — a marker whose database rows are gone, which a daemonless install
has no reconcile_all to prune — is handled at the gate that has the evidence:
when the database authoritatively reports zero models for the user, the
one-shot deletes any marker claiming otherwise before returning the rejection.
That is a removal and nothing else: one unlink(2) on a single validated path
component, no temp file, no chown, no rename, and no marker directory
created. It is idempotent (a repeat attempt finds nothing to unlink) and it is
reachable only when the marker is already false, so it can delete a stale marker
and never a correct one.
facelock auth Exit Codes
The exit code is the only thing the PAM module learns from the oneshot
fallback, and the two sides upgrade at different moments: a package update
replaces /usr/bin/facelock on disk while every long-lived PAM host (a screen
locker, sshd) keeps the pam_facelock.so it already loaded. There is no
version handshake — the module maps the number and nothing else — so the table
is governed by three frozen invariants:
- Exit 0, 1 and 2 keep their meanings permanently. 0 = matched, 1 = scanned and not matched, 2 = error / no opinion. They are never redefined or repurposed; a class that leaves one of them moves to a new code, never onto a changed meaning of an old one.
- New codes are allocated only from the space an older module already maps
to
PAM_IGNORE(any code ≥ 3 it does not know). Old module + new binary can therefore only move a class's consequence toPAM_IGNORE— never toPAM_SUCCESSorPAM_AUTH_ERR. For rate limited (3) and suppressed (4), which were exit 2 before the split, that is byte-for-byte the pre-split behavior. For all frames dark (5), moved off exit 1, it is a deliberate semantic change the moment the new binary ships: a dark scan stops failing the stack (PAM_AUTH_ERR) and abstains — the daemon transport's consequence — under every module generation. - The module's arm for unknown codes stays
PAM_IGNORE, so new module + old binary is unchanged too. The same arm absorbs a binary killed by a signal (no exit code; the module reads it as 2).
The "new module" column below is the module that ships alongside this table (the same release as the binary emitting codes 3–5); every earlier module maps those codes through its unknown-code arm.
| Code | Class | PAM code (new module) | PAM code (older module) |
|---|---|---|---|
| 0 | Face matched | PAM_SUCCESS | PAM_SUCCESS |
| 1 | Scanned, no match | PAM_AUTH_ERR | PAM_AUTH_ERR |
| 2 | Error / no opinion: disabled, SSH, lid closed, storage, rate-limit check failed, IR required, unverified Y16, internal, cancelled, not enrolled | PAM_IGNORE | PAM_IGNORE |
| 3 | Rate limited | PAM_AUTH_ERR | PAM_IGNORE |
| 4 | Suppressed (no enrolled models + suppress_unknown) | PAM_AUTHINFO_UNAVAIL | PAM_IGNORE |
| 5 | All frames dark | PAM_IGNORE | PAM_IGNORE |
Codes 3–5 exist so the oneshot fallback carries the same PAM consequence per
class as the daemon transport: rate limited → PAM_AUTH_ERR, suppressed
(-3) → PAM_AUTHINFO_UNAVAIL, dark scan → PAM_IGNORE. Before the split
every pre-flight rejection collapsed to exit 2, so daemon unavailability
silently softened a rate-limited rejection from PAM_AUTH_ERR to
PAM_IGNORE; a dark scan diverged the other way, exiting 1 (PAM_AUTH_ERR)
where the daemon abstains. Benign under the recommended sufficient
stacking; wrong under required/requisite or an [authinfo_unavail=...]
action.
Storage-shaped failures during the attempt — a model list that cannot be
read, an embedding set that cannot be loaded or decrypted (a TPM unseal
broken by rotated PCRs lands here on every attempt) — exit 2, the storage
class, exactly as the daemon's -2 storage reply does. Neither may fold
into exit 1: an empty compare set is a guaranteed "no match" that charges
the rate limit and, under a required stack, locks the user out with the
correct password in hand (the daemon handler refuses the same fold; C3,
issue #105).
Residual transport divergence. With the classes above aligned, one
divergence remains between a current module's two transports: a non-match
where no face was seen (empty chair, recognition.no_face_timeout_secs,
nothing above the detector threshold). The daemon reply carries that as
-1/no-face and PAM abstains (PAM_IGNORE); the oneshot exit code has no
face-seen channel, so the same attempt exits 1 (PAM_AUTH_ERR). Closing it
needs another additive code under the same three invariants. During the
upgrade window an older module additionally reads codes 3 and 4 as
PAM_IGNORE (invariant 2) — the daemon transport under that same older
module already carries both classes in-band, so the two transports keep
today's divergence until the module updates. Timeouts (PAM_AUTH_ERR),
cancellation (PAM_IGNORE), and camera/storage/engine failures
(PAM_IGNORE) agree across transports.
The binary's half of the table is pinned in
crates/facelock-cli/src/commands/auth.rs
(every_rejection_class_pins_its_message_audit_label_and_exit_code,
rejection_classes_never_claim_the_match_codes,
the_preflight_short_circuit_pins_its_non_error_codes). The module's half
lives in crates/pam-facelock/src/oneshot_exit.rs, a dependency-free file
that facelock-cli's test suite include!s to pin the two halves against
each other class for class
(oneshot_exit_codes_map_to_the_daemon_transports_pam_codes) — the module
cannot be linked there, so sharing the source is what couples them. The
live module is exercised per code by the facelock-map-* fixtures in
test/run-container-tests.sh.
Release Channels and APT Paths
dist/release-matrix.json is the checked-in release-target authority. A strict
prerelease tag has the form vX.Y.Z-{alpha,beta,rc}.N; it creates a GitHub
prerelease and direct artifacts, but it must not publish to stable APT, stable AUR, or production COPR. The staging COPR project exists; the remaining
staging infrastructure is owned by issue #236, and neither is modified by the
prerelease identity workflow.
A stable vX.Y.Z tag may publish to stable APT and AUR only after validated
release metadata classifies it as stable. Production COPR additionally
requires a deliberately restored trigger: release job in the stable-tagged
Packit configuration. Prerelease-capable configurations keep that job inert.
Preflight and CI compare the public tyvsmith/facelock COPR API read-only with
the production chroot authority; they never change the project. The required
supported production COPR chroots are exactly Fedora 43, Fedora 44, and Fedora
45. Rawhide is the only optional allowed experimental production chroot: its
presence or absence is accepted, while any missing supported chroot or any
other extra chroot fails closed.
Every Packit copr_build target must be an explicit member of the checked-in
allowlist: fedora-43-x86_64, fedora-44-x86_64, or
fedora-45-x86_64. Mutable aliases such as fedora-all,
fedora-development, and their architecture-suffixed forms are rejected, as
is any other undeclared target. Rawhide is not a Packit staging or production
release target; both fedora-rawhide and fedora-rawhide-x86_64 fail
validation. The prerelease rule is that no alpha may publish to Rawhide.
Fedora 43 and Fedora 44 are the required full-lifecycle targets; Fedora 45 is a
required build/runtime-smoke target. That evidence is the COPR lane per target
described under "Packaging matrix evidence"; a direct-RPM result cannot supply
it. Rawhide cannot supply lifecycle, artifact, upgrade, rollback,
served-version, or availability evidence; it is limited to best-effort pinned
Track D smoke only. It is non-release and non-gating: its absence or a
Rawhide-only failure is not alpha-blocking, and its smoke result is not alpha
acceptance or release evidence. Promotion requires a separately reviewed
amendment and full Fedora gates.
The staging channel is tyvsmith/facelock-testing. .packit.yaml declares
exactly one copr_build job for it; a release-triggered staging job is
rejected, as is a second one. Its chroots equal the supported set exactly:
staging declares no optional experimental chroot, so Rawhide there is drift
rather than a permitted experiment.
copr_channels.staging.provisioned and the staging job's trigger are one
contract. While provisioned is false the job must carry trigger: ignore and
is dispatched by hand; when it is true the job must carry
trigger: pull_request. Either value alone fails the release matrix contract,
so the project cannot be declared live without the job that builds into it, and
the job cannot chase a project that does not exist. The switch is true: the
project exists with exactly the declared chroots. Provisioning it took three
reviewed edits: the switch, the trigger, and retiring the assertion that held
provisioned false until issue #236 created the project. manual_trigger
stays true on either setting, so a pull request offers the staging build rather
than starting it. While the switch is false,
test/check-live-release-channels.py --channel staging reports
not provisioned and contacts nothing; while it is true that comparison
queries the live project on every pull request and in preflight.
Only an explicit provisioned: false skips a channel. copr_channels.production
declares no such switch and must never grow one, so the production comparison
always queries its authority.
A channel comparison covers three properties, not one. The live chroot set must
match the channel's declared chroots. enable_net must be true, because the RPM
builds from source and cargo fetches crates during %build. The project's
Packit forge allowlist must contain copr_channels.<channel>.required_forge_project,
which is github.com/tyvsmith/facelock for both channels. The packit user's
admin permission is outside this contract: COPR serves project permissions only
to an authenticated owner, so a public comparison cannot make that claim. It is
the grant that failed silently for v0.1.4, which is why the release guide keeps
it as a hand-confirmed setup step.
The project's enable_net is only the default for a build that carries no value
of its own, and a Packit-submitted build always carries one. Every copr_build
job in .packit.yaml must therefore declare enable_net: true as well; Packit
defaults it to false, and that default is what the chroot gets no matter what
the project says. test/check-release-matrix.py enforces the job half, because
it is checked-in state that no public COPR response exposes until a build has
already run and already failed (#347).
Those project properties are the shape of a channel, not its contents. A second comparison
asks what it serves:
test/check-live-release-channels.py --expect-evr <EVR> requires the project's
latest succeeded build to carry the expected EVR. The latest succeeded build
is what the channel serves; the latest build of any state says only whether the
awaited one is running or dead. How strictly the EVR is read is the channel's
served_evr_exact, and it tracks whether that channel's Packit job pins the
release. Production carries update_release: false and is compared exactly.
Staging keeps Packit's 1.{timestamp}.{ref} suffix, which its per-pull-request
NVRs need, so it is compared as a prefix ending at a dot: 0.2.0-1 matches
0.2.0-1.20260904220135.v0.2.0 and never 0.2.0-11. The Packit flag and the
comparison move together or the release matrix contract fails.
That build's chroot list is reported, never required. It is what the build covered, not what the repository serves, and a single-chroot rebuild becomes the latest succeeded build while an earlier complete one still serves the rest. Which chroots a channel must enable is the project comparison's contract.
Exit status separates a verdict from a wait — 1 for a build of the expected EVR
that failed, was canceled, or was skipped; 2 for a build still running, never
submitted, or a query that could not be made; only a poller
distinguishes them, and every other caller treats both as failure. The release
workflow's verify-copr job polls it after publication, because Packit submits
the COPR build off the published release event and no job in the release run can
observe that submission. just release-preflight runs --expect-predecessor,
which resolves the EVR from the pinned predecessor's rpm_evr.
A release that production COPR never received may be recorded as
copr_channels.production.served_evr_gap, naming the EVR owed, the EVR served,
and the issue that owns it. The record is pinned at both ends: it must excuse
exactly the pinned predecessor's EVR, and it stops matching the moment the
channel serves anything other than the EVR it names. Both ends are read under
the channel's own served_evr_exact, so on production a suffixed rebuild of the
EVR the record names no longer matches it.
Only --expect-predecessor consults the record. A gap describes a release that
already shipped without reaching COPR, which is preflight's question;
verify-copr asks --expect-evr about the release it is publishing, and a
record that could answer that would silence the job on the failure it exists to
report. The record excuses no other release, and the next predecessor pin fails
the matrix contract until it is updated or deleted.
A pre-tag attestation binds the candidate commit to the EVRs each channel
serves, the artifact and repository digests, the signing key fingerprints, and
how fresh each channel's repository metadata was.
scripts/release-attestation.py renders and validates that document. No channel
in it may carry the production COPR identity, and a channel carrying the staging
COPR identity must serve exactly the declared staging chroots. The expectation
file the validator compares against, metadata_max_age_seconds included, is
reviewed release input produced with the release, not a value the attested
channels supply; the validator checks that it is a positive integer and does not
bound how loose a freshness window a reviewer may approve.
Issue #236 owns pre-tag and post-publication proof that optional Rawhide serves no alpha or candidate build. This contract does not provision, publish to, or otherwise mutate COPR or Packit infrastructure.
The public APT base is https://tysmith.me/facelock/apt/. Its stable suite
paths and payload identities are:
| Suite | Public Release path | Architecture | Package |
|---|---|---|---|
trixie | https://tysmith.me/facelock/apt/dists/trixie/Release | amd64 | facelock, TPM enabled |
resolute | https://tysmith.me/facelock/apt/dists/resolute/Release | amd64 | facelock, TPM enabled |
main | https://tysmith.me/facelock/apt/dists/main/Release | amd64 | the trixie package, until 0.3.0 |
legacy | https://tysmith.me/facelock/apt/dists/legacy/Release | amd64 | none, until 0.3.0 |
The v0.1.4 suite names main and legacy are compatibility suites, not
aliases or redirects, and neither ships a package of its own. Only main
carries a package: the exact signed trixie package set. legacy, which
served the non-TPM build, carries signed empty indexes so that apt update
still succeeds. Both are published with every stable release through 0.2.x
and both are removed at 0.3.0. dist/release-matrix.json declares them under
apt_suites.compat with that retire_at. test/check-release-matrix.py
requires their stanzas, the publisher steps, and the migration note while the
window is open, and refuses the stanzas and the note from that version on,
prereleases included; the publisher runs each step only when its stanza is
declared, so retirement is the stanza deletion. Existing source entries must
replace that suite with the host operating-system codename before 0.3.0 while
keeping the facelock component.
Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04
LTS (Resolute). Bookworm and Noble artifacts may remain in historical releases,
but those suites are unsupported and receive no new packages.
Both codenamed suites ship one binary package named facelock with TPM
support enabled.
There are no legacy/TPM package-name alternatives and the package declares no
Provides, Conflicts, or Replaces transition identity. Stable publication
consumes exactly two suite manifests, one matching package per suite, and a
prerelease or cross-suite version is rejected before signing or repository
writes.
Packaging matrix evidence
just release-preflight accepts packaging evidence only in the form
test/packaging-evidence.py validates (#313). Each packaging lane writes one
record, .packaging-evidence/<lane>.json, from the RESULTS_JSON: line its
validator prints. just test-packaging-matrix deletes the previous marker and
every record before its first lane, then folds the records into
.packaging-matrix-verified:
{"schema": 1, "commit": "<40-hex sha>", "tree_clean": true,
"started_at": "<ISO 8601 UTC>", "finished_at": "<ISO 8601 UTC>",
"required_lanes": ["test-arch-pkg", "test-copr-pkg-43", "test-copr-pkg-44",
"test-copr-smoke-45", "test-deb-resolute-pkg", "test-deb-trixie-pkg",
"test-rpm-pkg-43", "test-rpm-pkg-44", "test-rpm-smoke-45"],
"lanes": [{"name": "test-deb-trixie-pkg", "target": "debian-trixie", "channel": "apt",
"build_origin": "container-source-build", "runtime_policy": "bundled-ort",
"depth": "full", "commit": "<sha>", "models_present": true,
"pass": 40, "fail": 0, "skip": 0, "allowed_skip": 0, "mandatory_skip": 0,
"status": "pass"}]}
Lane claims use a fixed vocabulary. channel: apt, direct-rpm, aur,
copr. build_origin: container-source-build (the Debian assembler and
.dsc rebuild), host-binaries (release binaries staged from
target/release), makepkg-source-build, mock-source-rebuild (Packit SRPM
rebuilt from source in a mock chroot). runtime_policy: bundled-ort,
system-ort. depth: full, smoke, partial.
status: pass; partial for an exit 0 with an allowed skip or without
models; fail for a non-zero exit, a failed assertion, or a mandatory skip.
The counters are assertion counts by class, and skip equals
allowed_skip (the FACELOCK_ALLOW_MISSING_MODELS=1 opt-out) plus
mandatory_skip. models_present says whether the ONNX models were on hand
for every assertion that needs them; a lane with no such assertion
(test-rpm-smoke-45, test-arch-pkg) reports true because nothing was
withheld.
required_lanes derives from dist/release-matrix.json: one Debian lane per
APT suite whose platform row is a non-optional release target, the Arch recipe
lane, and two Fedora lanes per fedora.packit_release_targets entry, both at
the depth its platform rows declare. A row whose
evidence_eligibility.lifecycle is false (Rawhide) contributes nothing.
The two Fedora lanes exist because the matrix declares two Fedora delivery
paths and neither proves the other (#230). The direct-RPM lane
(test-rpm-pkg-<release> / test-rpm-smoke-<release>) stages host-built
binaries and a bundled ONNX Runtime. The COPR lane
(test-copr-pkg-<release> / test-copr-smoke-<release>) is what COPR itself
would publish: a Packit SRPM rebuilt from source in a mock chroot, installed
with dnf so the package's own Requires: onnxruntime resolves against
Fedora's system runtime, then booted for the same validation. A COPR lane is
required for every Packit release target: test/packaging-evidence.py raises
EvidenceError and refuses the aggregate outright if no platform row for that
release declares system ORT, rather than treating the lane as optional. The
artifact is checked against the COPR channel rules (validate-rpm.sh <rpm> copr), which fail if a bundled runtime rode along.
Because the validator compares every lane attribute against what the matrix
requires, a direct-RPM record offered as a COPR target's evidence is refused on
channel, and a COPR record built from host binaries or run against a bundled
runtime is refused on build_origin or runtime_policy.
full depth for an RPM lane is three stages under booted systemd: the RPM
service/PAM lifecycle, test/pkg-validate.sh, and the %config(noreplace)
upgrade lifecycle. Each is optional by the presence of its script in the image,
so test/run-pkg-validate-systemd.sh records depth: partial when a stage is
missing. No lane in the release matrix requires partial, so the aggregate
refuses such a record instead of accepting a short lifecycle as a full one.
The third stage needs a second, higher-versioned package differing only in the
config file. The COPR lane builds that upgrade candidate by repacking the
payload mock just produced: binaries taken from the installed package,
re-versioned to <mock version>.1, rebuilt through the same
dist/facelock.spec with its cargo lines no-oped and bundled_ort still off,
and re-checked with validate-rpm.sh <rpm> copr. It is the same trick the
direct lane uses for its 0.0.0 to 0.0.1 pair. It is a fixture, not a
second COPR build, and it is not byte-identical to one: what it pins is
rpm's %config(noreplace) behaviour across an upgrade of a COPR-shaped
package, not the reproducibility of a mock rebuild.
The marker is refused, and the aggregate refuses to write it, unless all of
the following hold: schema is 1; commit equals HEAD; tree_clean is true;
started_at and finished_at are ISO 8601 timestamps with an offset, in
order; required_lanes equals the derived list exactly (sorted, no
duplicates); every required lane has exactly one record and no record names a
lane outside that set; and every record carries schema 1, names HEAD, has
models_present true, has fail, skip, allowed_skip and
mandatory_skip all 0, has pass of at least 1, has status pass, and
carries the target, channel, build origin, runtime policy and depth the matrix
requires of that lane. A partial run therefore never produces release
evidence, and a record from one channel cannot stand in for another's lane.
The legacy one-line commit marker is refused with a message naming this
format. Preflight reads evidence from two places, in order: the
packaging-evidence-* artifacts a successful packaging.yml run at HEAD
uploaded (packaging-evidence-deb-<suite>, packaging-evidence-rpm-<release>,
packaging-evidence-copr-<release> and packaging-evidence-arch, fetched with
gh run download and aggregated the same way), then the local marker.
.packaging-evidence/ is dot-prefixed, so every upload step sets
include-hidden-files: true; without it actions/upload-artifact finds no
files and if-no-files-found: error fails the job. A run without those
artifacts is not evidence, whatever its conclusion; neither is a pull-request
run, which builds the merge commit, nor a run of any other workflow. The copr
jobs do not run on pull requests at all, so only an unfiltered run -- the
nightly or a workflow_dispatch -- can carry the full lane set. The marker and
the artifacts are maintainer-trust records: preflight checks their shape and their
binding to HEAD and the matrix, not that a real lane produced them. A forged
record is a deliberate act, not the slip this gate exists to catch.
Direct release publication
Builders in .github/workflows/release.yml produce workflow artifacts and
never touch the release. They hold contents: read under a deny-all workflow
default, and none of them may reference the publication credential or a
release-writing step. publish is the only job that writes the release, the
only one holding contents: write and RELEASE_PAT, and the only one that
compiles nothing. It runs after every builder and validator; a tag has no
release until it does.
publish requires all of, in order:
- The tag exists, equals the tag the validated version derives, and points at the commit the workflow built. An annotated tag is peeled on both sides. A tag carrying a PGP or SSH signature must verify. Publication reads the tag; it never creates, moves, or replaces one, and it never sends a tag name or target commitish.
- Exactly the canonical assets can be staged out of the builders' artifacts, no more and no fewer:
| Asset | Produced by |
|---|---|
facelock-x86_64-linux-gnu | build |
pam_facelock.so | build |
facelock-polkit-agent-x86_64-linux-gnu | build |
facelock_<debian-version>_<architecture>.deb, one per published suite | build-deb |
facelock-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpm | build-rpm |
facelock-debuginfo-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpm | build-rpm |
facelock-debugsource-<rpm-version>-<rpm-release>.fc<N>.x86_64.rpm | build-rpm |
apt-repo.tar.gz, stable releases only | publish-apt |
MANIFEST.json | publish |
The Debian and RPM names come from the validated version, Debian revision,
and RPM counter, so an artifact built from any other identity has no
canonical name and is never staged. build-rpm selects each of its three
packages by that identity and validates the payload package. A duplicate
name, an unmatched asset, a canonical name two artifacts claim, or a canonical
name no artifact provides each fail closed. A canonically named file in an
artifact other than its producer's is a builder's extra output; the release
fails closed on it, and the failure names the remedy: fix the builder and
re-run all jobs, since re-running only the failed job keeps the artifact.
build and build-rpm compile through just build-release, the recipe
just install and the packaging lanes use, which carries the tpm
feature; build fails if the resulting binary does not link
libtss2-esys. build compiles in the pinned trixie image build-deb
uses, not on the hosted Ubuntu runner: noble's tpm2-tss 4.0.1 is below the
4.1.3 tss-esapi-sys needs and its just 1.21.0 does not parse the
justfile. The job attests that image, the attesting set pins it for the
build slot, and the release artifacts contract holds the container
image, BUILD_IMAGE, and the trixie matrix image equal.
- Every staged asset matches the SHA-256 its builder attested. Each builder
writes a
release-digests-<slot>artifact naming what it produced, the image it produced it in, and the components it consumed. An asset attested by no builder, or by two, fails closed, and so does a build image or component two attestations claim. - The attesting set is exactly the slots the workflow uploads:
build,onnxruntime,cargo-vendor, onedeb-<suite>per published suite,rpm, and, on a stable release,apt. Each artifact holds one document declaring the job that slot belongs to. Anything running in a builder can upload an artifact of its own, so an extra attestation, a missing one, or one claiming another job's identity stops the release rather than being merged into the manifest. - Each slot declares exactly the provenance the release expects of it. An
attestation is a self-report, so the suite a slot fills, the image
dist/release-matrix.jsonpins for it, and the component names it may carry are held bypublishand compared exactly: a swapped image, an invented suite, an added or missing component, or a field the release has no rule for is refused. The matrix is an input publication cannot do without; a matrix that cannot be read, or that names no Debian suite, stops the release rather than shortening the allowlist. - Every attestation hashes to the output its job recorded. Artifacts are
untrusted until bound to a job output: the artifact store is shared by every
job in the run and writable with any job's runtime token, so a builder that
runs later can replace an earlier builder's payload and attestation as a
matching pair. A job output is recorded by the Actions service under the job
that produced it and cannot be rewritten by another job. Each attesting job
records the SHA-256 of its
digests.jsonas itsattestationoutput (build-debrecordsattestation-<suite>, each matrix leg setting only its own);publishreads them throughtoJSON(needs), passed by environment and file rather than a shell line, and refuses by slot any attestation whose bytes differ from the recorded value or whose job recorded no output, before anything in the document is read.verify-digestsandmanifestshare that loader, so neither can skip it. - The tag has no published release. A release already published is refused
before anything is written; the draft an interrupted run left behind is
reused, so a failed run can be re-run. An asset on that draft whose canonical
name changed in between, after a Debian revision or RPM counter bump, is
refused as unexpected and must be deleted from the draft by hand. Two
releases for one tag are refused with the
gh api --method DELETEcommand that removes the extra one.
The workflow runs once at a time per tag: concurrency is keyed by the ref
and never cancels the run in progress, so a second run queues rather than
passing the checks above beside the first. build-nix gates publication
through its flake evaluation; its nix build step is advisory.
The draft is created with those assets and the validated prerelease flag, and
is flipped to published only after the draft's asset list is read back from the
API and held to the allowlist a second time, MANIFEST.json now included, and
each published asset's size, and digest where the API exposes one, is held to
MANIFEST.json, the uploaded manifest to the file the job wrote.
MANIFEST.json covers the release: the tag, version, commit, and prerelease
flag; the source tarball URL and digest; the pinned build-image digests; the
reviewed ONNX Runtime and Cargo-vendor component digests; and the name, size,
and SHA-256 of every other asset. It replaces the SHA256SUMS file that
covered three binaries and was written before the packages existed.
facelock-bin takes its per-binary checksums from it.
Debian source and binary package contract
Trixie package builds use the official Trixie Backports cargo and rustc;
Resolute uses its native distro packages. Both must satisfy the workspace and
debian/control minimum of Rust 1.88. No rustup toolchain participates in
Debian source builds.
The Debian source package contains the exact tagged main upstream tarball, the
reviewed ORT component, the deterministic Cargo-vendor component, and the
Debian quilt delta. For upstream U, Debian version V, and architecture A,
the release manifest lists exactly these eight files in canonical order:
facelock_U.orig.tar.gz
facelock_U.orig-onnxruntime.tar.gz
facelock_U.orig-cargo-vendor.tar.xz
facelock_V.debian.tar.xz
facelock_V.dsc
facelock_V_A.buildinfo
facelock_V_A.deb
facelock_V_A.changes
The Cargo component is bound to the exact Cargo.lock, contains only regular
normalized files plus its lock hash, bytewise manifest, and generated legal
inventory, and is used through the package-only Cargo source replacement. The
inventory covers every exact vendored crate and records its path, name, version,
declared license or license-file, available authors/upstream metadata, and every
referenced license material that exists in the component. The ORT component contains the
reviewed library, license, third-party notices, version, commit, provenance,
manifest, and checksums. Neither component is added to the tagged main archive.
Complete .dsc rebuilds run with network denied and empty Cargo/Rustup caches.
The build uses only the extracted source components and declared distro build
dependencies, with Cargo locked and offline. The clean rebuild must produce the
same package identity, resolved dependencies, installed path set, and installed
file hashes as the release build. Fresh installation leaves
facelock-daemon.service disabled and inactive; D-Bus activation remains
available after explicit setup. Reinstall and upgrade restart the daemon only
when it was already active, including an active D-Bus-activated instance; they
preserve both enabled and disabled state and leave every inactive instance
inactive. The post-install convergence still removes the retired facelock
group, fixes ADR 010 ownership, leaves every legacy /etc systemd/D-Bus copy
untouched, asks the bus to reload policy, and registers the opt-in PAM profile
without selecting it. Exact known legacy-copy migration belongs to
facelock setup --systemd; package configuration never overwrites an
administrator-owned shadow. Package validation requires the installed TPM
command surface and the suite-native libtss2 dependency closure.
Two lanes read the same declared Depends. One resolves the exact candidate on
a pristine suite base and requires every declared dependency to be installed
there. The other installs it into the booted harness image, which also carries
systemd, a C toolchain, and a software TPM, and requires every declared
dependency to be shipped by the suite base, named by the harness exemption list
with its reason, or resolved by the runtime transaction itself. Both directions
of that list are enforced: an unlisted dependency the harness satisfies fails
the gate, and so does a listed pattern that no longer matches one. The
suite-base record that verdict rests on is taken before its stage installs
anything, held there by a static read of the harness Containerfile, and
compared at run time against that stage's own package database. Supplying an
exact package to a harness image that carries no lifecycle script is a failure,
not a skipped lane.
Release validation runs lintian on every built binary package and fails on
error-severity tags. Deliberate deviations are suppressed in
.github/workflows/scripts/validate-deb.sh, each with a recorded reason;
warning and lower severities are reported without gating.
Compat 13's generated dh_installtmpfiles post-install snippet is the sole
install-time tmpfiles activation and invokes systemd-tmpfiles for
facelock.conf only. The source postinst never runs a global tmpfiles create,
so another package's configuration cannot be activated by a Facelock
transaction.
Trixie's debhelper 13.24 omits its remove-only service stop when
dh_installsystemd --no-start is used, while Resolute's debhelper 13.31 emits
it. The package build therefore appends debhelper's canonical
prerm-systemd-restart template for the exact Facelock unit only when that stop
is absent. This compatibility path is idempotent: both suites produce exactly
one stop after successful PAM cleanup, neither starts or enables the daemon on
installation, and only the generated purge path retires enabled state.
ONNX Runtime Trust and Fedora RPM Modes
ONNX Runtime (ORT) is executable code loaded into the daemon, the
PAM-spawned oneshot helper, and other privileged Facelock processes. A runtime
must therefore be selected deterministically and validated before it is
mapped. Loading a bare libonnxruntime.so.1 through the dynamic linker's
ambient search path and inspecting it afterward is forbidden: ELF constructors
may already have executed before any post-map rejection.
Deterministic candidate order
The resolver considers candidates in this order and stops at the first one that passes the applicable trust checks and initializes ORT:
- A non-empty
ORT_DYLIB_PATH, only in an unprivileged process. - Trusted system locations for the configured GPU provider, or for
auto, which searches every one of them since it does not yet know which provider it will pick. ROCm andautofirst checklibonnxruntime.so.1beneath/usr/lib64/rocm/lib, then/usr/lib/rocm/lib; any non-CPU provider,autoincluded, then checks the configured-GPU compatibility namelibonnxruntime.sobeneath/usr/lib64, then/usr/lib. - Package-manager stable-SONAME candidates
/usr/lib64/libonnxruntime.so.1, then/usr/lib/libonnxruntime.so.1. - Facelock package-owned stable-SONAME candidates beneath
/usr/lib64/facelock, then/usr/lib/facelock, followed by the existing package-owned unversioned Debian compatibility names in those same roots.
The CPU provider skips step 2. A system runtime therefore precedes a bundled CPU fallback even in a direct package. A missing or rejected candidate advances to the next fixed candidate; no other directory is searched.
A process is privileged when its real or effective UID or GID is 0, its
real/effective UID or GID differs, the kernel marks it AT_SECURE, or the
calling thread has any inheritable, permitted, effective, or ambient Linux
capability. Capability inspection reads /proc/thread-self/status, never the
thread-group leader's status; an unreadable file or a missing, duplicate,
empty, or malformed capability field fails closed as privileged. Every such
process ignores ORT_DYLIB_PATH entirely and has no /usr/local candidate.
The explicit override is an unprivileged caller choice: it is still opened and
checked as a bounded ELF with the required architecture and SONAME before
mapping, but it does not claim package-manager root ownership.
Privileged pre-map validation
Every privileged system or bundle candidate has a fixed approved trust root and a normal relative path beneath it. One descriptor-held component walker is used on every kernel, with no alternate or weaker kernel-version path. The loader:
- resolves a symlinked trust root by path before any open — merged-/usr
distributions ship
/usr/lib64as a link tolib— following only root-owned, single-link symlinks with confined relative targets; an absolute or escaping target is rejected even when it names an approved location - requires the resolved trust root, each ancestor, and every traversed
directory to be root-owned and not group- or world-writable; the resolved
root is opened and retained with
O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_NONBLOCK, still refusing a link at open time - inspects every relative component and link through a held parent descriptor
using
O_PATH|O_NOFOLLOW|O_NONBLOCK; directory links, absolute targets, non-normal targets such as.or.., and paths that escape and later return beneath the root are rejected - follows only a root-owned, single-link, relative package SONAME chain beneath the held root; every link target is decomposed and walked again from held descriptors rather than resolved by an ambient pathname lookup
- opens the final object through its held parent with
O_RDONLY|O_NOFOLLOW|O_NONBLOCK, then requires a bounded regular file with exactly one hard link, root ownership, no group/world write bits, no setuid/setgid bits, and nosecurity.capabilityxattr - requires device, inode, link count, size, UID/GID, mode, modification time, and change time to remain stable across component inspection, the final open, the bounded read, and the last pre-map check
- requires a 64-bit ELF for the running architecture, SONAME exactly
libonnxruntime.so.1, and no RPATH/RUNPATH entry except exactly$ORIGINor${ORIGIN}
Only after every check passes is the same held read descriptor mapped (for
example through /proc/self/fd/<fd>). No pathname is reopened after validation.
If every candidate is missing, rejected, or fails ORT initialization, model loading fails and authentication degrades through its existing password fallback. Authentication never downloads a runtime or model.
Fedora package modes
dist/facelock.spec has two mutually exclusive ORT modes:
| RPM channel | Spec mode | Runtime payload and dependency contract |
|---|---|---|
| GitHub direct RPM (Fedora 44) | --with bundled_ort | Installs the pinned CPU runtime as %{_libdir}/facelock/libonnxruntime.so.1.20.1 with a package-owned libonnxruntime.so.1 symlink; carries no BuildRequires or Requires on Fedora onnxruntime |
| Packit/COPR (Fedora 43/44/45) | default %bcond_with bundled_ort disabled | Contains no bundled ORT library or bundle metadata; BuildRequires and Requires Fedora's runtime-only onnxruntime package, with onnxruntime-devel absent |
The COPR %check constructs a real ORT session from the checksum-pinned
minimal model in test/fixtures/; finding a library or running
facelock --version is not a substitute. The two RPM validators independently
reject a direct RPM with a system-ORT dependency and a COPR RPM with bundled
payload, and reject the inverse missing dependency/payload.
Track D validates only direct/COPR build success, real ORT runtime initialization, and the intended payload and dependency policy. It supplies no clean-install, upgrade, erase, rollback, served-repository, availability, alpha-acceptance, or release evidence. The two-Fedora-lanes contract under "Packaging matrix evidence" above owns exact-artifact package lifecycle proof; issue #236 owns staging and production repository publication and served-version proof.
Optional experimental Rawhide may attempt only the separately digest-pinned, best-effort system-ORT build/session smoke. It is non-release and non-gating, must not publish or modify a COPR channel, and cannot substitute for any supported Fedora result or any lifecycle, artifact, served-version, availability, alpha-acceptance, or release evidence.
Reviewed direct-bundle identity
The direct RPM bundle is exactly:
| Field | Reviewed value |
|---|---|
| Version | 1.20.1 |
| Upstream archive | https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-linux-x64-1.20.1.tgz |
| Archive SHA-256 | 67db4dc1561f1e3fd42e619575c82c601ef89849afc7ea85a003abbac1a1a105 |
| Upstream commit | 5c1b7ccbff7e5141c1da7a9d963d660e5741c319 |
| Library SHA-256 | a5faaf78a37590d3fe640f887620e74f6022d34550172b91ad2131bf0ad77d64 |
| License identity | MIT |
The release network stage downloads the archive to a file and verifies the
archive digest before extraction. It then verifies VERSION_NUMBER,
GIT_COMMIT_ID, and the library digest against the reviewed values. Streaming
an unverified response into tar is forbidden.
The prepared bundle contains the exact library plus upstream LICENSE,
ThirdPartyNotices.txt, VERSION_NUMBER, and GIT_COMMIT_ID, and generated
PROVENANCE.md, manifest.json, and SHA256SUMS. The checksum file covers
the library and every listed metadata/provenance file except itself. Direct RPM
assembly requires and re-verifies the complete prepared bundle.
Before creating the source archive or any rpmbuild tree, the whole assembly
enters .github/workflows/scripts/run-networkless.sh. That wrapper uses
util-linux enosys as a fail-closed seccomp boundary: it denies socket
creation/connection and message syscalls plus io_uring_setup, closes every
inherited non-stdio file descriptor, and requires a socket probe to fail with
ENOSYS before it invokes the assembly command. Cargo offline mode remains
defense in depth; it is not the network-isolation boundary.
The installed libonnxruntime.so.1.20.1 bytes must retain the exact reviewed
library digest above. Fedora strip/debug/post-processing must not rewrite the
pinned runtime; bundled mode disables the modifying strip hook, and validation
extracts the final RPM member and checks its digest. The RPM also ships the
license, notices, version, commit, checksums, provenance, and component manifest
under its documentation/license directories.
Those metadata files are inputs for later SBOM, release-manifest, attestation, and signing work. Their presence does not claim that Track D generated or signed a final SBOM/manifest, signed the RPM, or published a release. Issue #235 owns native signing and final immutable direct-artifact publication.
RPM tmpfiles transaction
The RPM transaction creates Facelock's runtime directories through the
package-scoped %tmpfiles_create facelock.conf invocation. It must not run a
global systemd-tmpfiles --create or otherwise process unrelated packages'
tmpfiles configuration. Package validation observes the directories created by
the actual install transaction and does not manufacture them with a later
global tmpfiles command.
Package Lifecycle Ownership
This is the Wave 0 ownership freeze for issue #232. It defines what package
lifecycle work is allowed to remove, and the Debian post-removal script
implements the bounded purge described below. Ordinary removal is not data
deletion. Ordinary remove remains preservation-only: removing the package
must leave a machine reinstallable without losing its biometric or operational
state.
facelock data purge is the explicit authorized destruction path, and it is
not a removal path. It does not weaken the rule above; it is the reason the
rule can hold. Because ordinary removal preserves biometric state, a user who
genuinely wants that state gone needs somewhere to say so, and this is the only
place. The two are separated on every axis: removal is triggered by a package
transaction, purge by a person typing a verb; removal preserves, purge
destroys; removal needs no authorization beyond the package manager's, purge
needs root and --allow-destruction. No removal path, on any package family,
may invoke it, imply it, or acquire its authorization on the user's behalf, and
no packaging script may recommend recursive deletion of retained state in its
place — reject_state_purge_command in
test/lifecycle-ownership-contract.sh fails the build if one does. The
sanctioned answer to "how do I delete my face data" is this command, and
nothing else.
The ownership classes are deliberately separate:
| Class | Examples | Ordinary removal |
|---|---|---|
| Package-owned static integration | binaries and shared libraries, systemd/OpenRC/runit/s6 units, D-Bus policy and activation, tmpfiles configuration, shipped quirks, PAM/authselect profiles, translations, bundled runtime libraries | Remove through the package manager. These files can be recreated byte-for-byte by reinstalling the package |
| Administrator configuration | /etc/facelock/config.toml and the package manager's saved replacement for an administrator-modified copy | Apply the native package-family rules below. Do not treat administrator configuration as biometric state or as disposable static integration |
| Biometric and operational state | the database and its WAL/SHM sidecars, encryption keys and sealed keys, downloaded models, enrollment markers, setup state, audit logs, and snapshots under the compiled roots | Preserve all of it. A reinstall reuses it; ordinary removal never interprets absence of the package as consent to discard it |
| PAM integration and provenance | a pam_facelock.so rule, a Facelock-created local override and its provenance header, legacy <service>.facelock-backup files, and rollback state under /var/lib/facelock/pam-backups | Attempt safe cleanup while the binary is still present. Delete provenance only after the corresponding PAM cleanup is proven complete |
| Externally configured state | any database, model directory, key, sealed key, audit log, or snapshot path configured outside the compiled Facelock roots | Never package-owned. Leave it untouched and report it as an external remnant |
PAM provenance and rollback files are not biometric state. They exist to explain or reverse an authentication-stack edit, so retaining all of them forever makes an otherwise successful uninstall look incomplete. Conversely, deleting them before the PAM edit is known to be gone destroys the evidence and rollback path for a service that still references a removed module.
Preserve PAM provenance when cleanup is incomplete; remove it only after
successful cleanup. Successful cleanup means the service file was safely
resolved inside /etc/pam.d, its Facelock rule was removed (or was already
absent), and any candidate override or backup was proven to be Facelock-created
and no longer needed. A Facelock-created override may be deleted to reveal its
vendor file only when it has no administrator changes. Never restore a backup
over a newer service file merely because the backup exists. An unreadable,
unwritable, wrong-owner, non-regular, changed, linked, or mount-separated
service file makes cleanup incomplete: preserve its override, provenance
header, and .facelock-backup, and report the exact remnant. Cleanup of one
service does not authorize deleting provenance for a different service.
The final Debian postrm never interprets rollback schemas. If
/var/lib/facelock/pam-backups is nonempty after the earlier binary-backed
cleanup, it retains and reports the entire opaque subtree; only a trusted empty
directory is eligible for ordinary fixed-root removal.
Native configuration lifecycles
The package families reach the same ownership result through different native mechanisms:
| Family and operation | Administrator-configuration contract |
|---|---|
Debian remove | /etc/facelock/config.toml is a Debian conffile and remains at its installed path. Biometric and operational state also remains |
Debian purge | dpkg removes the conffile, and the post-removal purge may then remove only safe remnants inside the compiled roots. Unsafe and external remnants are retained and reported |
| RPM erase | /etc/facelock/config.toml is RPM %config(noreplace). An unmodified copy is removed outright and leaves nothing behind. An administrator-modified copy leaves config.toml and is retained, byte for byte, as config.toml.rpmsave. A .rpmsave is retained state, not evidence of a failed erase and not something a Facelock script deletes |
| RPM upgrade | An unmodified /etc/facelock/config.toml is replaced in place by the new packaged file, with no .rpmnew and no .rpmsave. An administrator-modified one is kept byte for byte at its own path and the new packaged file arrives as config.toml.rpmnew, which Facelock never activates or deletes. .rpmnew is written only when the packaged config actually changed between the two versions |
| Arch package removal | the backup entry follows pacman's native saved-configuration behavior (including .pacsave when applicable). Facelock lifecycle code does not bypass it |
just uninstall | no package manager owns the config, so the source-install uninstall preserves /etc/facelock with the biometric and operational state |
Source-install daemon lifecycle
The privileged install and uninstall entrypoints invoke absolute
/usr/bin/sudo, /usr/bin/env, and /usr/bin/just paths with
PATH=/usr/bin:/bin. The install-files and uninstall-files recipes use the
exact #!/usr/bin/bash -p interpreter, set that fixed path before their first
operation, and reject caller-supplied Bash startup hooks through the structural
gate. Directly executed production helpers also use /usr/bin/bash -p and pin
their production path. The root-owned checkout and another actor with root or
equivalent filesystem authority remain trusted inputs.
just install-files serializes its protected interval with
/run/facelock/lifecycle.lock. It safely creates or validates
/run/facelock as root:root mode 0755 (the fixture-root identity in
offline tests), then captures the never-unlinked lock as a root:root mode
0600, zero-byte, single-link regular file without following the public path.
The lock stays held through manager/D-Bus restoration, migration publication,
signal cleanup, and descriptor release. This is the canonical cross-entrypoint
lifecycle lock path; other lifecycle entrypoints join it in their owning work.
Before its first replacement write, the installer records exactly one service
load, active, unit-file, fragment, effective command, and drop-in state. It
admits only loaded active/inactive service state or a genuine first install.
Probe failures, transitional/failed states, inconsistent manager/disk state,
untrusted effective assets, a concurrent lifecycle holder, and malformed or
duplicate properties abort before mutation. Persistent and runtime ordinary
unit paths are snapshotted independently. An admitted path is absent, an exact
administrator /dev/null or empty-file mask, or a bounded trusted regular
override retained by descriptor, metadata, and digest. Systemd precedence is
preserved; linked, multiply linked, writable, or changing state fails closed.
Every admitted online state receives an owned activation barrier at
/run/systemd/system.control/facelock-daemon.service. The no-clobber-created,
descriptor-held root:root mode 0600 empty regular file is manager-proved as
the effective mask before the daemon is stopped and before any install write.
The helper also proves the active system bus, its standard service-directory
topology, selected Facelock activation definition, policy-only includes, bus
owner state, and systemd delegation. Custom or unreadable activation topology,
direct D-Bus execution, or a higher-priority control conflict aborts. The
source installer never changes enablement and restores only the active/inactive
state captured at entry.
The source recipe writes service assets only to the canonical /usr paths. It
plans all three historical /etc public/quarantine pairs before the protected
writes, including exact known copies, interrupted exact quarantines,
administrator files, and systemd masks. After the canonical writes, it moves
only reviewed exact historical copies to fixed same-parent quarantines with
no-replace semantics. Trusted modified administrator files and masks remain at
their exact recorded identities; a selected administrator D-Bus activation
definition must still delegate to systemd. Unknown, linked, wrongly owned,
wrongly moded, changing, or ambiguous state fails closed.
Staging is not publication. Quarantines remain rollback-capable while later
source writes and permission fixes run. A recipe failure or HUP, INT, or
TERM restores every newly staged public name in reverse order before manager
and D-Bus restoration; a pair that was already an interrupted quarantine is
restored to that same pre-run state. The lifecycle treats the staging child
and its exact parent-side identity record as one signal-critical operation.
The child also traps exit and caught signals to reverse its locally known
prefix; parent cleanup independently reconciles unchanged or staged names
against the complete preplan even when the record was interrupted. Any mixed
pair that cannot be restored without replacement remains preserved and keeps
activation barred. Normal completion reloads and proves the
canonical winners while the activation barrier is still effective, then
revalidates and deletes the exact quarantines. Only after that commit and
another topology proof may cleanup quarantine/remove the barrier and restart
an initially active daemon. A pre-publication commit failure rolls back,
reloads, and proves the original winners while still barred. A collision,
partial publication, incomplete rollback, or unprovable manager/D-Bus state
retains the safest provable barrier and suppresses restart.
Barrier cleanup itself uses held descriptors, fixed same-parent quarantine names, no-replace recovery, bounded retries, and repeated disk/manager/D-Bus proof. An inactive or first-install daemon remains inactive. The initiating failure or signal remains the process result, and signal cleanup is non-reentrant until restoration and lock/descriptor release finish.
Ordinary source install fails closed without systemd. The sole exception is
the checked-in test/Containerfile offline image step, selected by its exact
mode and marker. It authenticates the copied lifecycle/migration helpers and
digest manifest, takes the same canonical lock inside the image, and proves no
manager, bus, activation asset, installed Facelock indicator, or unreadable
process identity exists before allowing writes. Distro maintainer scripts own
their separate native lifecycle. The mocked/static gate is
just test-source-install-daemon-lifecycle; the mount-free PID-1 gate is
just test-source-install-daemon-lifecycle-systemd.
Debian postrm purge is self-contained. It never invokes the already-removed
facelock binary. By the time postrm runs, package payloads cannot be treated
as available cleanup tools. The bounded purge makes its decisions from fixed
constants and the remaining filesystem state and uses the Essential
perl-base interpreter available throughout the package lifecycle; it cannot
delegate safety checks or deletion to the CLI it is purging.
The supported Debian-family artifacts are explicitly Architecture: amd64.
For quarantine and recovery, the helper therefore invokes x86-64
renameat2(RENAME_NOREPLACE) directly through Perl's syscall; an unsupported
architecture, kernel, or filesystem fails closed and retains the entry. It
never falls back to an absence check followed by a replacing rename.
The post-removal script classifies the six supported configured path fields
while the conffile is still available during remove, and repeats that report
at purge when a configuration file remains. Its bounded TOML classifier
recognizes canonical section assignments plus dotted and quoted key or table
components with their TOML table scope intact. If a valid representation is
outside that bounded grammar, including a multiline string, the script emits a
controlled warning that the configuration could not be fully classified. An
external value is reported but never opened, removed, or used as a traversal
root. A configuration object that cannot be safely inspected and is reported
as retained is protected from the later generic walk. The report therefore
survives dpkg's normal ordering, in which it can remove the conffile before the
final postrm purge call, without turning configuration into deletion
authority.
RPM and Arch have no Debian-style second purge phase. Their ordinary erase
therefore removes static integration and safely cleaned PAM provenance, while
preserving biometric state and whatever administrator-configuration artifact
their package manager retained.
CLI lifecycle exclusion lease
Destructive CLI maintenance (the facelock data purge work, #233) has no
package transaction to own its exclusion interval, so the facelock binary
establishes one itself through an RAII lifecycle lease. Acquisition takes the
canonical /run/facelock/lifecycle.lock — the same never-unlinked lock the
source install holds — with a non-blocking exclusive flock on a validated
mode 0600 zero-byte single-link regular file opened without following the
public path; refuses to proceed without a booted systemd; proves every
existing org.facelock.Daemon.service activation definition delegates
through SystemdService=facelock-daemon.service, because a unit mask cannot
inhibit direct D-Bus execution; and records the unit's active and enabled
state, admitting only loaded or not-found, active or inactive state. It then
creates the owned activation barrier — an empty mode 0600 regular file at
/run/systemd/system.control/facelock-daemon.service, the same mechanism the
source install uses — reloads the manager and proves LoadState=masked
before stopping the daemon and proving both ActiveState=inactive and that
nothing owns org.facelock.Daemon on the system bus. A masked unit without a
stale barrier, transitional or failed state, a foreign object at the control
path, and a non-delegating activation definition all fail closed before any
mutation, and a rejected acquisition rolls back whatever it had staged.
Release restores exactly the recorded state in reverse order — barrier removal proven against the held descriptor, manager reload with an unmasking proof, restart only if the daemon was active, then lock release by closing the descriptor — on normal completion, error return, panic unwind, and HUP/INT/TERM, with non-reentrant signal cleanup. A signal-triggered restore raises the lease's interrupt flag and then waits, bounded, for the operation's acknowledgement that nothing is touching the filesystem any more; only then does it unmask and restart. If the acknowledgement never arrives, the wait expires and the process dies with activation still barred — the recoverable state below — rather than restarting the daemon under an operation that may still be mutating the purge roots. An explicit barred release instead keeps the barrier and the stopped daemon for a caller that uninstalls next. Enablement is never changed on any path.
SIGKILL is the bounded failure mode: the barrier file survives, so face
authentication stays masked (password PAM fallback is unaffected) while the
flock dies with the process. Both the lock and the barrier live on tmpfs,
so a reboot fully recovers on its own. Before reboot, the next lease
acquisition adopts a control-path file matching the exact barrier identity
(empty, mode 0600, single-link, expected owner) as a stale barrier and
removes it on restore; any other object at that path is preserved and
reported. Manual recovery is removing the barrier file and running
systemctl daemon-reload.
Fedora authselect retirement boundary
The RPM does not ship or select an authselect profile, does not edit
system-auth or password-auth, and has no runtime or scriptlet dependency on
authselect. Fresh installation is PAM-inert. The supported opt-in is an
explicit, named leaf service through facelock pam add --service <name> or its
setup --pam --service <name> alias. That operation edits only the resolved
leaf service plus Facelock's fixed backup state; the selected authselect profile
and shared generated files remain byte-for-byte unchanged.
An incoming RPM upgrade runs the source-controlled
facelock-authselect-retirement-guard from %pre, while the old payload is
still installed. A fresh transaction is an immediate no-op. An upgrade also
succeeds without authselect installed or when the fixed selection-state file
/etc/authselect/authselect.conf is absent.
An already-installed v0.1.4 RPM cannot be retroactively guarded: direct uninstall runs only that installed release's unguarded scriptlets. Administrators must install a guarded release before a later uninstall so the upgrade guard can first retire the old authselect payload safely.
When that file exists, the guard reads no other authselect path and invokes no
authselect command. It requires a root-owned, root-group, regular, single-link
0644 file of at most 16 KiB, compares the first line's original bytes with its
shell-decoded value so no NUL or other control byte can be discarded, and then
accepts only the profile grammar used by authselect: one confined profile
identifier, custom/<identifier>, or @system-default. A malformed, linked,
oversized, control-bearing, or wrong-metadata file is untrusted and blocks the
package transaction without changing it.
The exact retired profile identifier facelock also blocks upgrade. The
diagnostic requires the administrator to inspect the active identity provider
and features, select an appropriate supported profile while asking authselect
to create a backup, and retry the RPM transaction. Facelock does not guess a
replacement or migrate generated PAM state. A different valid identifier,
including the separately administrator-owned custom/facelock, is preserved
unchanged and does not block the upgrade.
The booted Fedora lifecycle test uses the released 0.1.4 RPM to prove fresh, unselected, selected-retired, custom-profile, malformed-state, and authselect-absent upgrade cases. It also proves correct and wrong password fallback through the real selected profile, and the real RPM package test proves that service-scoped setup and removal leave the selection and shared generated files unchanged. Neither test mutates the host PAM stack.
Fixed-root purge boundary
The only purge roots are the compiled Facelock roots:
/etc/facelock, /var/lib/facelock, and /var/log/facelock. /etc/pam.d is
a separate, fixed root for the narrow PAM cleanup above; it is never a recursive
purge root. The nonempty /var/lib/facelock/pam-backups subtree is an opaque
exception inside the state root because any remaining entry is unresolved PAM
cleanup evidence. A configured path that remains within a compiled Facelock
root is eligible for a later Debian purge only under the same safety checks as
every other descendant.
Configured paths outside those roots are external remnants. This includes
external values of daemon.model_dir, storage.db_path, encryption.key_path,
encryption.sealed_key_path, audit.path, and snapshots.dir. Removal and
purge must leave them untouched, report that they were refused as external, and
must not claim that all Facelock data is gone. A path becoming external through
configuration does not expand package ownership.
The purge operates from fixed path constants and examines each entry without
trusting path traversal. A root and every traversed directory must be a
root-owned, non-group/world-writable directory on the root's device. Deletable
leaves are single-link regular files with the same ownership and write-mode
constraint. The only ownership exception is an owner-only regular file that is
one of the direct children of /var/lib/facelock/enrolled; those enrollment
markers are deliberately owned by the enrolled user. Other wrong-owner,
non-regular, or multiply-linked objects remain in place.
The helper pins every component from the fixed prefix through each purge root
with O_DIRECTORY|O_NOFOLLOW descriptors, opens regular candidates with
O_NONBLOCK|O_NOFOLLOW, and operates through /proc/self/fd/<fd> rather than
reopening public pathnames. It revalidates the complete fixed chain and every
opened descendant immediately before and after a removal quarantine. Mount IDs
from /proc/self/fdinfo are combined with /proc/self/mountinfo to detect bind
mounts even when their device number is unchanged; every descendant's st_dev
must also equal the opened root's device.
A safe regular file or empty descendant directory is first moved within its
trusted, non-writable parent by a descriptor-anchored, atomic no-replace
quarantine operation. A colliding quarantine name is preserved and reported;
the helper may try a later bounded candidate but never replaces the collision.
The helper reopens the admitted quarantine name and proves the original device,
inode, type, link count, ownership, mode, mount, and fixed-chain identity before
unlink or rmdir. Recovery uses the same atomic no-replace operation, so a
replacement at the public name preserves both public and quarantine remnants.
A failed unlink or directory removal restores the proven quarantine identity
with no-replace recovery when possible instead of stranding it under a hidden
name. After regular-file unlink, the still-open inode must reach link count
zero; otherwise an external hard-link remnant is reported without claiming the
data was fully removed.
A directory with any refused child is never moved. The helper never removes
the three compiled root directories because their parents are outside the
recursive purge boundary; safely admitted contents and empty descendant
directories are removed, while a root may still contain reported refused or
opaque remnants. After the helper returns, dpkg may remove an empty
/etc/facelock directory as part of native conffile purge. The helper must
never cross a mount point.
It must never follow a symbolic link, act through a hard-linked object, or
recurse through an object whose ownership cannot be proven safe.
The trusted-parent mode is also the purge concurrency boundary. Linux has no
inode-conditional unlinkat or rmdirat: after the quarantine identity's final
proof, deletion assumes no concurrent process with root or equivalent mutation
authority is changing names in that root-owned, non-group/world-writable
parent. The package transaction stops the service and owns this interval;
ordinary unprivileged users cannot enter it. A user-invoked CLI purge has no
package transaction and establishes the same interval itself through the CLI
lifecycle exclusion lease above. A separate same-authority process
that deliberately mutates those parents is outside the package-purge contract,
because no maintainer script can both delete a pathname and preserve an object
that such a process substitutes at the deletion syscall itself.
Traversal is iterative and bounded independently for each compiled root: at
most 64 descendant-directory levels and 10,000 inspected entries. A directory
beyond the depth limit remains as a reported subtree while safe siblings may
still be cleaned. Reaching the node limit stops further cleanup of that root
and reports the whole root subtree as retained rather than implying that only
the first unseen entry is unsafe. A root or descendant that fails any other
check remains in place and is reported. A safe root may still be cleaned around
an unsafe child, but the final report must name every remnant rather than
describe the root as removed. /etc/pam.d is not among these recursive roots,
so purge cannot erase an incomplete PAM edit or its rollback provenance.
Safety refusals and external remnants must not strand package-manager state. In particular, Debian purge reports and preserves an unsafe object but returns success after reporting safety refusals, so a link, mount, wrong-owner object, or partial unlink failure cannot leave the package permanently half-purged. This is not a broad recursive-delete contract: the script enumerates the bounded roots and rejects anything it cannot prove is inside them.
Finally, filesystem removal does not promise secure erasure. Unlinking files does not guarantee that data is absent from SSD flash translation layers, snapshots, backups, journal history, or remapped blocks. Lifecycle messages may say which names were removed and which remnants remain; they must not describe purge as forensic destruction of biometric data.
The facelock data purge entrypoint
Two callers implement this envelope: the Debian postrm helper, and
facelock data purge. They share the envelope and differ only in who owns the
exclusion interval — the package transaction for the first, the CLI lifecycle
exclusion lease above for the second.
The CLI entrypoint composes the two in a fixed order, and the order is the
contract. The root check runs first, ahead of --dry-run, the authorization,
the confirmation prompt, and any output or read. A dry run then short-circuits
to the report-only pass: it deletes nothing, so it takes no lease and must not
stop the daemon, because a preview with side effects is not a preview. A real
purge acquires the lease, runs the traversal with the lease's interrupt flag,
and acknowledges completion the instant the engine returns, before rendering
anything — a signal-triggered restore blocks on that acknowledgement, so any
work inserted between the two is time a signal handler is held off and, if it
outlasts the bound, a process that dies with activation barred. Release then
either restores the recorded daemon state or, with
--leave-activation-barred, keeps the barrier for a caller that uninstalls
next.
The engine is infallible by construction — every refusal is a reported remnant
rather than an error — so no failure path exists between acquisition and
acknowledgement. A panic between them is covered by the lease's Drop, which
raises the acknowledgement itself before restoring.
What the report may not claim. The completeness verdict is the engine's
PurgeReport::is_complete() verbatim, never an inference from "deletion did
not error". A run that retained anything — a remnant inside the roots, a
configured path outside them, an unclassifiable configuration, or an interrupt
— states in the negative that Facelock data was not completely destroyed, and
names every remnant with its reason and every external path with its
configuration field. Every completed run, complete or not, also states that
removing a name is not erasure. The --json document carries complete for a
script to branch on, plus a constant secure_erasure: false so no consumer has
to infer it.
Exit status is not the answer. The command exits 0 whenever the purge ran
and the lifecycle was restored, whatever the purge could not remove, for the
same reason the Debian purge returns success after reporting safety refusals: a
safety refusal is a reported outcome, not a crash, and an install must not be
strandable in a half-purged state by a caller that treated a refusal as a
failure. Callers branch on the report, or on complete in the document.
A failed lifecycle restore is reported before it is returned. The
destruction is irreversible and the report is its only record, so a release
that cannot restore the recorded daemon state renders the full report first —
remnants, external paths, erasure caveat, or the complete --json document —
and only then returns the error. Discarding the report to propagate the restore
failure would leave a caller unable to distinguish "the purge never ran" from
"the purge ran and the daemon did not come back", after their biometric state
was already destroyed. The document therefore carries lifecycle_restored and
lifecycle_error beside the purge result, and its mere presence means a pass
ran. The nonzero exit that follows means the lifecycle needs attention, not
that nothing happened.
A dry run gives no completeness verdict. Report mode classifies the
configured paths and never opens the compiled roots, so it has examined nothing
that a completeness claim would be about. It states its scope instead, and its
document reports mode: "dry-run", roots_examined: false and complete: null — never true, which a reader on a machine full of enrollments could
take for "my biometric data is already gone". That inference from absence of
evidence is exactly what this section forbids, and it is forbidden of the
preview as much as of the purge.
An interrupted run reports nothing, and that is the contract rather than a defect to work around. The report is rendered after the lease is released, because the document must carry the lifecycle outcome; release joins the signal thread, and that thread terminates the process as soon as its restore completes, so a signalled purge exits before it renders. Deletions already made stand. Repetition is safe by construction, so a caller learns what remains by running the command again, and no partial-report guarantee is offered or implied.
Repeating a purge is safe by construction, and the report is what makes that useful: every refusal leaves the object in place with a stated reason, so a second run after resolving an ownership or link problem picks up exactly what the first could not prove safe.
Filesystem Paths
| Path | Owner | Mode | Purpose |
|---|---|---|---|
/etc/facelock/config.toml | root:root | 644 | Configuration |
/var/lib/facelock/ | root:root | 711 | State dir. Traversable by every local user, listable by root only: users can open known names subject to each file's permissions (models/ is itself 0755 and listable — public data) |
/var/lib/facelock/facelock.db | root:root | 600 | Face embeddings. Read by the daemon (root) only; user-run PAM stacks request authentication through the daemon, they never read templates |
/var/lib/facelock/models/ | root:root | 755 | ONNX models — public, SHA256-verified downloads |
/var/lib/facelock/enrolled/ | root:root | 711 | Enrollment markers; traversable by all, listable by root only |
/var/lib/facelock/enrolled/<user> | <user>:<user> | 600 | {"models": N, "updated": "<ISO8601>"} — a hint for is-enrolled, never authoritative |
/var/lib/facelock/pam-backups/ | root:root | 700 | Fixed-root PAM rollback state; not affected by [pam].config_dirs or storage.db_path |
/var/lib/facelock/pam-backups/<service>.<seconds>-<nanoseconds> | root:root | 600 | Original PAM service bytes; nanoseconds are exactly nine digits |
/var/lib/facelock/pam-backups/<service>.<seconds>-<nanoseconds>.json | root:root | 600 | Strict versioned provenance for the adjacent rollback bytes |
/var/lib/facelock/pam-backups/.facelock-remove-all-<operation>.json | root:root | 600 | Strict prepared whole-set PAM removal journal |
/var/lib/facelock/pam-backups/.facelock-remove-all-commit-<operation>.json | root:root | 600 | Strict self-contained whole-set commit and recovery marker |
/var/log/facelock/ | root:root | 700 | Log dir — per-user auth history and raw face snapshots are root-only |
/var/log/facelock/audit.jsonl | root:root | 600 | Structured audit log |
/var/log/facelock/snapshots/ | root:root | 700 | Auth snapshots (raw face images) |
/usr/bin/facelock | root:root | 755 | CLI binary |
/lib/security/pam_facelock.so | root:root | 755 | PAM module |
/usr/share/locale/<lang>/LC_MESSAGES/facelock.mo | root:root | 644 | Compiled CLI catalog. Present only for languages po/ carries, which today is none |
/usr/share/locale/<lang>/LC_MESSAGES/pam_facelock.mo | root:root | 644 | Compiled PAM module catalog. A separate gettext domain from the CLI's, never merged with it |
Config-described data and model paths are overridable as documented by their
schema fields. /var/lib/facelock/pam-backups is deliberately fixed for the
PAM writer and shared state layout. Neither [pam].config_dirs nor
storage.db_path redirects it: the former selects PAM service roots and the
latter relocates biometric database state only. FACELOCK_CONFIG is honored
for unprivileged processes, but every effective-UID-0 process ignores the
environment and uses either an explicit --config path or
/etc/facelock/config.toml.
Runtime-created DB sidecars (-wal, -shm), audit logs, and snapshots are created with explicit restrictive modes. The packaged systemd unit also sets UMask=0027.
Traversal for everyone, listing for nobody (ADR 010)
The state directory and enrolled/ are 0711 root:root: any local user may
enter them, nobody but root may list them. That is the whole grant. Every
entry below is locked down in its own right — 0600 root:root database and
sidecars, 0600 <user>:<user> markers, and a 0700 root:root PAM-backup
directory containing 0600 root:root state files — and models/ carries
group/other read bits because its contents are public, SHA256-verified
downloads. No path relies on membership in a dedicated facelock group (ADR 010).
D-Bus is required for user-run screen lockers (hyprlock/swaylock) and the
polkit agent — their PAM stack runs as the user, and nothing makes the 0600 root:root database or encryption key readable to them — and the bus admits
their Authenticate call without any group (see IPC Protocol). Root-invoked
PAM (sudo, login, sshd) can also use the oneshot fallback, which reads
the files directly as root.
Known residual: any local user can stat a path it can guess by name —
facelock.db (size, mtime) or enrolled/<user> (existence) — because
traversal permits exactly that. Closing it would mean denying the traversal
that is-enrolled and model loading depend on. Accepted; before ADR 010 the
same residual existed for facelock group members.
Historical pre-ADR-010 contract change: permissions tightened (no paths moved)
The default paths are unchanged — the database stays at
/var/lib/facelock/facelock.db and the models at /var/lib/facelock/models;
no data moves on upgrade. What changed are modes and ownership, recorded
here per the repo rule that path and permission contracts live in this file:
| Path | Was | Now |
|---|---|---|
/var/lib/facelock/ | 750 root:facelock | 710 root:facelock |
/var/lib/facelock/facelock.db (+-wal/-shm) | 640 root:facelock | 600 root:root |
/var/lib/facelock/models/ | 755 root:root | 755 root:root (unchanged) |
/var/lib/facelock/enrolled/ | — (new) | 710 root:facelock |
/var/log/facelock/ | 750 root:facelock | 700 root:root |
/var/log/facelock/audit.jsonl | 640 root:facelock | 600 root:root |
/var/log/facelock/snapshots/ | 750 root:facelock | 700 root:root |
The group loses direct reads of the database, the audit log (per-user auth
history) and the snapshots (raw face images) — all strictly more sensitive
than anything the group needs, since every group operation goes through the
daemon. For an existing install the entire on-disk change is a chmod/chown
of the paths above plus mkdir enrolled/ — idempotent, applied by packaging
(tmpfiles, install scriptlets) and by the setup, daemon-start and direct/auth
state-layout paths; none of it touches the data itself.
Contract change: traversal opened to every local user (ADR 010)
No paths move. The two directories that carried a group grant drop it:
| Path | Was | Now |
|---|---|---|
/var/lib/facelock/ | 710 root:facelock | 711 root:root |
/var/lib/facelock/enrolled/ | 710 root:facelock | 711 root:root |
Everything else in the table above is unchanged. For an existing install the
on-disk change is a chmod/chown of those two directories — idempotent,
applied by packaging (tmpfiles, install scriptlets), setup and the runtime
state-layout paths (ensure_state_layout on daemon start, best-effort on
the auth path). sudo facelock setup, just install-files and the package
scriptlets remove a leftover facelock group best-effort; sudo groupdel facelock if it lingers.
Audit Log Entries
audit.jsonl is JSONL; each line carries timestamp, user, result (success, failure, error, rate_limited, suppressed, cancelled) and, when known, source (daemon, oneshot, test), similarity, frame_count, duration_ms, device, model_label, error.
cancelled (ADR 008 §5) is an attempt that was abandoned, not answered: the caller's bus connection went away, the system suspended, ReleaseCamera arrived, or a one-shot process was signalled. It is deliberately not a failure — no comparison reached a verdict, so it charges no rate-limit budget. The entry carries frame_count and duration_ms (how far the attempt got) and no similarity.
source names the code path that produced the entry — daemon (the Authenticate D-Bus method), oneshot (the facelock auth helper PAM spawns), or test (facelock test, on either transport: the daemon's TestAuthenticate method or the in-process direct loop). It records the enforcement path, not the caller's identity: daemon and oneshot are fully-enforced authentications whose failures count against the rate limit, while test skips the SSH/lid physical-presence gates and charges nothing. So a success stamped test is a recognition result, not a policy-approved authentication — and a real authentication is never stamped test, whatever privilege its caller holds. The field is absent on entries written before it existed.
Config Schema
TOML format. All keys optional — camera auto-detected, sensible defaults for everything.
Sections
| Section | Key fields |
|---|---|
[device] | path (Option), max_height, rotation, warmup_frames, dark_threshold, dark_pixel_value, ir_emitter, camera_release_secs, camera_release_after_success_secs |
[recognition] | threshold, timeout_secs, no_face_timeout_secs, detector_model, detector_sha256, embedder_model, embedder_sha256, threads, execution_provider |
[daemon] | mode (DaemonMode enum), model_dir, idle_timeout_secs |
[storage] | db_path |
[security] | disabled, suppress_unknown, require_landmark_liveness, require_ir, require_frame_variance, frame_variance_max_similarity, ir_texture_min_stddev, min_auth_frames, bind_templates_to_device, device_match_granularity, bind_legacy_templates, bind_device_aad, allow_plaintext, abort_if_ssh, abort_if_lid_closed, pam_policy, rate_limit |
[notification] | mode (off/terminal/desktop/both), notify_prompt, notify_on_success, notify_on_failure |
[snapshots] | mode (off/all/failure/success), dir |
[encryption] | method (keyfile/tpm/none — default keyfile), key_path, sealed_key_path |
[audit] | enabled, path, rotate_size_mb |
[tpm] | seal_database, pcr_binding, pcr_indices, tcti |
[polkit] | face_eligible_actions |
[pam] | config_dirs |
[polkit].face_eligible_actions is the allowlist of polkit action_ids for which
the face authentication agent may offer face auth. Default:
["org.freedesktop.login1.lock-sessions"]. Any action not in the list is declined
by the agent. An empty list disables face for all actions. High-risk actions
(pkexec, PackageKit, udisks mount, accounts-service) are excluded by default.
Scope: this allowlist governs the agent model only. Under the PAM model
(pam_facelock.so as auth sufficient in /etc/pam.d/*, the common Howdy-style
deployment that also covers sudo), the list is ignored: face is attempted for
every action in that PAM stack, always with password fallback because the line is
sufficient, never required. See docs/security.md §7a/§7b for the two models.
NOTE (agent model only): polkit registers a single authentication agent per session and does not chain agents. When this agent declines a non-allowlisted action it returns an error, which — depending on the desktop's agent registration — may present as an authorization denial rather than a fallthrough to a password dialog. The intended UX (non-eligible actions handled by the desktop's normal password agent) is unverified pending live-desktop testing and may require a design change. Behavior here is fail-closed: a non-eligible action is never face-authorized.
[pam].config_dirs is where named facelock pam add | remove | status looks
for PAM service files, in search order — Linux-PAM's own precedence, earliest
wins. facelock pam remove --all always uses the compiled /etc/pam.d,
/usr/lib/pam.d, and detection-only /etc/authselect roots and cannot be
redirected by configuration.
Default: ["/etc/pam.d", "/usr/lib/pam.d"]. The first entry is the override
directory: every write lands there and every later entry is read-only, so a
service that resolves only in a later one is copied into the first before the
line is inserted. Setting a package-owned directory first would make facelock
edit package files. An empty list is treated as the default rather than as a
request to disable the writer, and so is any list with a non-absolute entry
— a relative first entry would resolve the write target against the invoking
shell's working directory — and any list whose first entry is also one of the
later ones, spelled twice or reached through a symlink, which would collapse
the override layer onto a read-only one. pam is dispatched before the process-wide config
parse, so a missing or broken config yields the default list rather than an
error — editing /etc/pam.d must not be blocked by an unrelated config
mistake. FACELOCK_CONFIG is ignored in a privileged process, so the
environment cannot redirect where a root pam add writes; the global
--config flag is a process override and is honoured under sudo, which is
root naming a different file on purpose rather than the environment doing it
behind root's back. The one process it cannot reach is the packaged daemon,
whose unit passes no --config; that is why setup --systemd refuses under a
non-default value and why enroll and test go direct there (see "facelock
setup Flag Composition"). See "facelock pam
Semantics" above for the resolution rules themselves.
Encryption defaults (Plan 04). encryption.method defaults to keyfile: face
templates are encrypted at rest by default. method = "none" (plaintext) is refused at
enrollment unless security.allow_plaintext = true. Auth always degrades to password on
a decrypt failure — never a lockout.
Encryption key creation (#231). The keyfile is created at mode 0600 when it is
absent and the database holds no encrypted template, and never otherwise. Encrypted is
decided by each blob's version byte, not the sealed column. A store that cannot be
queried counts as encrypted rows existing, and so does one that cannot be locked: the row
check and the key write run inside one exclusive store transaction, which serializes them
against the single transaction an enrollment commits its template in. That ordering is the
whole guarantee — it does not invalidate a key a running daemon has already cached in
memory, so --generate-key against a live daemon is a known limit, not closed by this gate
(follow-up). Automatic creation happens only for method = "keyfile"; --generate-key is
the explicit way to mint a key before switching the method to it. Creation is an O_EXCL | O_NOFOLLOW write to a temporary beside the key, flushed, then
renameat2(RENAME_NOREPLACE) onto the key path with the parent directory flushed:
concurrent creators resolve to exactly one key, an existing key is never truncated, and the
key path never holds a partial file — true of the create path
(create_key_file_exclusive). The replace path (generate_key_file, what
--generate-key calls) truncates the key file in place instead. A symlink at the key
path is refused by both the creating and the reading path; the reader opens O_NOFOLLOW.
The same gate governs every writer of that key — the daemon, the one-shot
commands, facelock setup's automatic encryption policy, and facelock tpm encrypt with and
without --generate-key; --generate-key replaces a live key and is refused over
encrypted rows, naming facelock clear as the deliberate destructive step.
A refusal fails enrollment closed and leaves authentication alone: the auth path reads raw rows, serves the templates it can decrypt, reports the missing key rather than store corruption for those it cannot, and still falls through to the password prompt. Restoring the key artifact lifts the refusal at the next authentication or enrollment attempt without restarting the daemon.
Sealing key identity and the keyring (#354). Every model row written from
this version records the key id of the key that sealed it. The daemon and the
one-shot path load the configured method's key as primary and, best-effort, the
other method's artifact as secondary (keyfile under tpm; sealed key under
keyfile when built with tpm). A secondary that fails to load is logged and
ignored, never fatal, and never mints a key. A row naming a key id decrypts only
under that key; if it is not loaded the load fails naming the other artifact. A
pre-V7 row (NULL key id) is tried under the primary then each secondary. New
enrollments always seal under the primary. facelock tpm encrypt sets key id;
facelock tpm decrypt clears it. tpm seal-key, unseal-key, and reseal
never touch rows.
Enrollment atomicity (#308). An enrollment writes to the store exactly once, at the
end: after every accepted embedding has passed the minimum-capture and angle-diversity
gates and, with encryption on, been sealed, one transaction removes the previous model
under the same user and label and inserts the new model with all of its embeddings. Until
that commit the accepted embeddings exist only in memory, inside the wiping guard, and
every exit observed before the store write (a cancellation, including one that lands on
the final frame; the deadline passing with too few frames; a rejected set; a sealing
error; a storage error; a daemon crash) leaves the store exactly as it was: no model, no
embeddings, and a previous same-label template still in place and still authenticating.
A model row written by this version or later is therefore a complete template, and
facelock list and Authenticate can never observe an attempt that did not finish; the
store itself refuses to commit a model with fewer than MIN_EMBEDDINGS_PER_MODEL (3)
embeddings, the same floor the capture loop enforces. Both are pre-commit guarantees.
The cancel token is checked once more immediately before the store write, after sealing,
so a caller that departs during finalization still gets Cancelled and an unchanged
store; the commit is the one residual window. A cancellation or crash observed during or
after the commit can leave the new model stored while the reply is lost (the daemon
killed, the bus connection dropped, a client timeout between commit and reply), so a
caller may find a valid template it reported as failed, whereas a caller that cancelled
before the write is never surprised by one. No migration inspects rows written by earlier
versions: a partial model the old flow left behind stays until facelock remove or
facelock clear deletes it.
Camera hold semantics (ADR 008). device.camera_release_secs (default 3) is the
number of seconds the daemon keeps the camera streaming after a failed
authentication — the one ending a retry plausibly follows — so that retry skips the
reopen cost. A success releases the camera immediately unless
device.camera_release_after_success_secs (default 0) is greater than zero, in which
case a success holds for that many seconds instead; it is an opt-in for repeated
privileged actions with no authentication caching in front of them, and at its default
nothing about a success changes. Cancellation and every error (including a capture failure
or an all-dark scan) always release immediately, whatever both keys say: the interaction is
over, and on IR hardware the emitter LED goes out with it. camera_release_secs = 0 means
never hold after a failure; it previously fell back to 5 seconds. Enrollment follows
the same rule as authentication, on both keys. Preview frames are exempt: each one extends
the hold to max(camera_release_secs, 2s) so a ~10 fps preview never reopens per frame,
and the CLI still calls ReleaseCamera on exit. The hold deadline is absolute and polled
every 250 ms. One-shot mode (facelock auth) never holds — process exit is the release —
and ignores both keys. Changing either value needs no daemon restart: they are read per
request.
Hard device binding (opt-in). security.bind_device_aad = true folds the enrolling
camera's device_id into the AES-GCM AAD, so a template cannot be decrypted under a
different camera. Default false (fails closed on unstable ids). Complements the advisory
device coupling of Plan 02. The contract (#312):
- Enrollment with the flag on and no non-empty canonical
device_idis refused before the first model write, in the daemonEnrollpath and the direct path (throughConfig::ensure_enrollment_binding_allowed), and again inside the enrollment loop (Config::require_device_aad). The error names the key and the remedy. The flag is inert underencryption.method = "none": no refusal, no classification. - Authentication derives the AAD from each row's own
device_id(SecurityConfig::device_aad). A row with a NULL or emptydevice_iddecrypts with no AAD and is classifiedLegacyUnbound; it authenticates as before, provided every other row in the user's store decrypts (the first failing row fails the whole load; the unbound diagnostic is logged before decryption). The daemon logs a warning naming such model ids at each compare-set load;facelock listrendersunbound (re-enroll to bind)in the Camera column;facelock statusrenders#N: label, unbound (re-enroll to bind). The--jsonpayloads are unchanged (device_idis""for such a row): a consumer cannot tell an unbound row from a pre-coupling one without consulting the flag itself. A dedicated field is a follow-up, not part of this contract. - With the flag off, templates are sealed with no AAD. An absent AAD and an empty AAD are the same to the cipher; that equivalence is the ordinary-encryption contract.
- Turning the flag off (or disabling encryption) over a store sealed under it makes every
hard-bound template fail to decrypt;
list/statusstill report them bound andfacelock tpm decryptfails on the first such row. The decrypt error names the way back (re-enable, or re-enroll).facelock tpm encryptrefuses to run while the flag is on, since it re-seals rows without their device ids.
TPM sealed-key format & unseal semantics (Plan 04). The sealed-key blob is versioned:
0x01 = no PCR policy; 0x03 = PCR-bound, and self-describes its PCR index list. A
PCR-bound object is created with userWithAuth = false, and unseal starts a real policy
session and replays PolicyPCR — so a changed bound PCR makes unseal fail (finding #5).
facelock tpm reseal re-seals the key under the current PCRs (recovery path).
Camera Auto-Detection
When device.path is omitted:
- Enumerate
/dev/video0through/dev/video63 - Filter to VIDEO_CAPTURE devices
- Classify every node's IR provenance from queried evidence: a quirks
force_irmatch (authoritative by USB vendor:product ID; a name-only match only when corroborated by a real USB identity or the node's own mono-format evidence), otherwise a node whose queried formats are mono-only/IR-typical (GREY/Y8/Y10/Y12/Y16, with no color format mixed in). The device name never classifies a node on its own. Node-level disambiguation for multi-node USB devices: when several nodes share one quirk-matched VID:PID and at least one has an IR-typical format (GREY/Y8/Y10/Y12/Y16), only the format-bearing node(s) are IR. A quirk'sformat_preferencecounts as node-level IR evidence only when it is itself IR-typical and the node actually advertises it - Exclude devices that advertise no decodable pixel format (GREY/Y16/YUYV/NV12/MJPG) — e.g. raw Bayer sensor nodes (Intel IPU6/IPU7). This filter runs after step 3 and never feeds back into it: it changes which node is selected, never whether a node counts as IR. The IR-typical list (step 3) and the decodable list are deliberately different sets — a node whose only IR evidence is Y8/Y10/Y12 is IR and undecodable, and is excluded here with a syslog warning naming its path and formats
- Among the remaining nodes, prefer a quirks-confirmed IR node with a native
IR format, then any quirks-confirmed IR node, then an evidence-classified IR
node (breaking ties toward one whose name also carries an
ir/infraredtoken — a hint only, never a promotion of a node that lacks format evidence) - Fall back to first decodable device; if none, error listing every detected device and its formats
Opening a device (auto-detected or explicit device.path) negotiates a format
in priority order quirk format_preference > GREY > Y16 > YUYV > NV12 > MJPG
and fails if the device advertises none of them (no silent fallback to an
undecodable format).
A quirk's format_preference is compared whitespace-trimmed and is dropped
with a warning if it names a format facelock cannot decode, rather than
winning negotiation and then failing every capture.
On a selected or negotiated Y16 stream, authentication requires verified scale
provenance. A valid matched quirk y16_bit_depth in 8..=16 produces
VerifiedY16 { bit_depth } and pins the session shift to bit_depth - 8; a
missing or invalid value produces UnverifiedY16. Scene-derived calibration may
pin conversion for non-auth preview, enrollment, or benchmarking, but it never
upgrades provenance and is never accepted by the absolute IR texture check.
Interrogation derives the expected state from the normalized FourCC selected by
the same quirk preference and negotiation priority used at open. Authentication
rejects known UnverifiedY16 before opening the camera. After VIDIOC_S_FMT, the
opened camera recomputes state from the actual normalized negotiated FourCC;
negotiation drift to unverified Y16 skips warmup/calibration capture and receives
the same rejection before comparison. An actual GREY stream is NotY16 and
preserves the existing 8-bit behavior, even if that device also advertised Y16.
The rejection class is ErrorKind::Y16BitDepthRequired with the stable rendered
message Y16 IR texture scale is unverified; authentication requires a verified y16_bit_depth (8..=16) quirk. It uses the daemon’s in-band -2 error sentinel
and oneshot exit 2, both mapping to PAM_IGNORE so password fallback remains
available without an auth success. This gate applies regardless of
security.require_ir; disabling IR enforcement cannot bypass unknown Y16 scale.
It never reclassifies or downgrades Y16 to RGB and never skips texture
enforcement.
Open also rejects a padded stride: for GREY/NV12 (bytesperline == width)
and Y16/YUYV (bytesperline == 2 * width), a device reporting anything else
errors at open instead of decoding sheared frames. Compressed formats (MJPG)
are exempt — their bytesperline is not a row size.
FourCC normalization. V4L2 pads FourCCs to four characters with trailing
spaces ("Y16 "). Facelock strips that padding at every ingest point — device
enumeration (query_device) and quirks-file parsing — so DeviceInfo.formats
carries the unpadded spelling ("Y16", not "Y16 ").
The only machine-readable surface that changes is facelock devices --json
on the direct backend, which is where format detail exists at all: the
D-Bus DeviceInfo does not carry formats, so under the daemon backend
--json reports "formats": [] and there is no spelling to change
(BackendCaps::device_formats, false for BackendKind::Daemon). The
human-readable facelock devices table already trimmed.
Database Schema
SQLite with WAL mode and foreign keys:
CREATE TABLE face_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
embedder_model TEXT NOT NULL DEFAULT '', -- V5: embedder that produced the embeddings
device_id TEXT, -- V6: enrolling camera fingerprint "vid:pid:serial" (NULL = legacy/uncoupled)
key_id TEXT, -- V7: truncated SHA-256 id of the sealing key (NULL = pre-V7 or plaintext)
UNIQUE(user, label)
);
CREATE TABLE face_embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL REFERENCES face_models(id) ON DELETE CASCADE,
embedding BLOB NOT NULL, -- 512 x f32 = 2048 bytes (or encrypted blob)
sealed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE rate_limit (
user TEXT NOT NULL,
attempt_time INTEGER NOT NULL
);
Only failed authentication attempts are recorded in rate_limit, and only those where a face was actually detected (ADR 008 §4 — see §facelock test Semantics for the full charging rule). Daemon mode and oneshot mode share the same SQLite-backed window, so daemon restarts do not clear lockout state.
Schema version is tracked in schema_version; migrations are additive and forward-only. Current version: 7. Migration V7 adds the nullable face_models.key_id column, recording the truncated SHA-256 fingerprint of each sealing key; NULL indicates a pre-V7 row or plaintext embedding. Migration V6 adds the nullable face_models.device_id column (Plan 02 device coupling); pre-V6 databases open cleanly, keep their rows, and leave device_id NULL. NULL rows are governed by security.bind_legacy_templates (default allow-with-warn), so upgrades never lock a user out.
device_id is the canonical fingerprint ("vid:pid:serial") of the camera that enrolled the template. It is model-granularity at best and forgeable by a programmable USB device — advisory defense-in-depth, NOT attestation. See docs/security.md §Device Coupling.
Enrollment precondition (#309). Every non-NULL device_id matches its own enrolling camera at the security.device_match_granularity in force when it was enrolled (a model row does not match after switching to unit; re-enroll). Under model, a camera missing either the vendor or the product id is stored NULL (legacy-governed). Under unit, a camera with no non-empty serial, or with no full vendor:product identity, is refused: a unit enrollment never stores a NULL row, which would bind to nothing. With bind_templates_to_device = true and bind_legacy_templates = false, a camera with no usable identity is refused at any granularity, since the NULL row it would store could never authenticate. The check (Config::ensure_enrollment_binding_allowed, which delegates the coupling half to SecurityConfig::ensure_enrollment_binding_allowed) runs once per enrollment, after the camera is open and before the first model write, in both the daemon Enroll path and the direct path; a refusal is an ordinary enroll error naming the key and the remedy, and leaves no row behind. It never re-judges an existing template, so it can refuse a new enrollment but never lock an authentication out.
Upgrade and downgrade contract
An upgrade preserves everything the previous release wrote and changes only the schema version and the columns a migration adds. Specifically, across a native package upgrade:
- Embeddings keep their bytes. A row encrypted by an earlier release decrypts to the same plaintext under the same key. The upgrade never re-encrypts, re-seals, or rewrites a row.
- Key artifacts are never replaced.
encryption.keyandencryption.key.sealedkeep their bytes, mode and owner. No maintainer script creates, reseals or reads a key, and none changes PCR selection. - A missing key over encrypted rows is fatal, never replaced. When the key artifact is gone and the database still holds encrypted rows, the daemon and the one-shot path refuse to write a replacement and say why. Enrollment is refused; authentication keeps falling through to password. Writing a fresh key there would make a later restore of the real key useless.
- The default key is created only for a plaintext-only legacy database with no
key artifact. Creation is
O_CREAT | O_EXCL | O_NOFOLLOWwith the file and its parent directory flushed, so concurrent starts resolve to exactly one key and a symlink at the key path is refused rather than followed. - Config, models and audit data are untouched. Administrator edits to
/etc/facelock/config.tomlsurvive. - Enrollment markers keep their owner and mode, and their content is
reconciled. The marker is the one piece of state an upgrade is supposed to
rewrite: a marker that disagrees with the database is rewritten at daemon
startup, so
facelock is-enrolledcannot answer "not enrolled" for a user whose templates are present (#137). Byte identity is deliberately not the contract here, because it would preserve a marker that lies. - Modes converge to ADR 010 without content changing, and the retired
facelockgroup is removed if an older install left one. - PAM is never activated by an upgrade. An existing service file keeps its bytes; the packaged Debian profile is registered but not selected, and no authselect profile is reinstated on Fedora.
Migration failure never resets the database. A migration that cannot finish (full filesystem, interrupted process, corrupt page) leaves the database exactly as it was and reports the failure. Recreating a database it could not migrate would destroy every enrollment on the machine and look like a clean first run.
Downgrade is supported back to v0.1.4, with one limit. Migrations are
additive and forward-only: there is no down-migration from V7, and a package
downgrade leaves schema_version at 7. What is guaranteed is that the older
release still opens that database, reads its rows, and decrypts what it
encrypted, because V7 only adds a nullable face_models.key_id column that
older queries never name, and V6 only adds a nullable face_models.device_id
column that older queries never name. What is not guaranteed is that rows written by the
newer release carry data the older one understands: a key_id recorded after
the upgrade is invisible to an older release, so a template enrolled on the newer version and then
rolled back decrypts under the single key that older release loads if that key is the one that sealed it.
just test-upgrade-v014 is the gate for all of the above. It runs both halves
against the real published v0.1.4 artifacts; see docs/releasing.md.
IPC Protocol
D-Bus system bus (org.facelock.Daemon). Only used in daemon mode.
The daemon registers on the system bus via D-Bus activation.
- Bus name:
org.facelock.Daemon - Object path:
/org/facelock/Daemon - Interface:
org.facelock.Daemon
Methods
Authenticate, TestAuthenticate, Enroll, ListModels, RemoveModel, ClearModels, PreviewFrame, PreviewDetectFrame, ListDevices, ReleaseCamera, Ping, Shutdown
Method authorization contract (updated under DEC-6/N13 — the CLI's root-by-default privilege map left no unprivileged consumer for most of these, so tightening them to root-only closes the per-frame similarity hill-climbing oracle by construction rather than by redacting fields):
Authenticate: root or the matching Unix user. The one user-scoped method — screen lockers run their PAM stack as the user, so this is architecture, not policy. It is real authentication: a failed attempt that saw a face consumes biometric-guess budget, whatever the caller's UID; no-face attempts do not (see "facelock test Semantics"). Whensecurity.abort_if_ssh = true, an authorized non-root caller must also prove a live local process/session identity as described below.TestAuthenticate: root only. Same arguments and sameAuthResultreply asAuthenticate, and the same gates except that it skips the SSH/lid physical-presence aborts and charges no rate-limit budget on failure (see "facelock test Semantics" above). It exists so the daemon never has to infer a caller's purpose from their privilege; root-only is what makes a budget-free endpoint safe to expose.- Every other method —
Enroll,ListModels,RemoveModel,ClearModels,PreviewFrame,PreviewDetectFrame,ListDevices,ReleaseCamera,Ping,Shutdown— is root only. The bus policy (dbus/org.facelock.Daemon.conf) grants root the whole interface and every local user exactlyAuthenticate(ADR 010) — so adding a root-only method needs no policy edit, and a future user-scoped method needs one deliberately; there is no group policy, and signals are root-only. The per-method root/user-scoped decision is the in-daemon check on the caller UID fromGetConnectionUnixUser, keyed by a table-driven scope (authorize_methodinfacelock_daemon::server) so a new method is root-only by default until deliberately opened up.
Daemon ingress ordering is part of the authorization contract. The server
resolves and authorizes the caller/target pair before refreshing daemon
activity or touching handler reload, SQLite, audit, or capture state. Every
authorized non-root Authenticate then spends one token from a daemon-local,
per-caller-UID availability bucket before any of that shared work: capacity
10, one token restored per monotonic second. Saturation is an in-band
recoverable AuthResult (model_id = -2, label = "rate limited"), so PAM
continues to the password. The charge applies even when the request later
finds no model, fails another preflight gate, or meets a busy capture. UID 0
is trusted and exempt from this ingress bucket; TestAuthenticate remains
root-only and ingress-budget-free. This availability budget is distinct from
security.rate_limit: the latter remains the persistent, per-target-user
biometric-guess limiter and still charges root Authenticate failures under
the rules in "facelock test Semantics".
The remote-session gate is bound to the D-Bus caller rather than the daemon's
environment. For an authorized non-root Authenticate with
security.abort_if_ssh = true, the daemon asks
org.freedesktop.DBus.GetConnectionCredentials for the message sender's
unique bus name and requires the returned ProcessFD. It derives the PID from
that pidfd's kernel metadata, checks that the pidfd is live both before and
after org.freedesktop.login1.Manager.GetSessionByPID, and reads the selected
session's Remote property. It never trusts the credentials' numeric
ProcessID, never classifies the caller from a security label, and rejects a
PID-reuse race because a dead original pidfd invalidates the intervening
logind answer.
A remote session, omitted or invalid ProcessFD, dead caller, unavailable or
inconsistent logind answer, cancellation, or expiry of the four-second
provenance deadline (covering credentials, ProcessFD validation, and logind)
all fail closed as the same out-of-band
org.freedesktop.DBus.Error.AccessDenied message: Authenticate requires a live local caller process. The privileged daemon journal keeps the detailed
cause; the unprivileged wire does not distinguish remote from unverifiable
provenance. Credentials and login1 are queried asynchronously without
retaining the handler mutex. Caller departure, ReleaseCamera, suspend, and
shutdown cancel a pending query, so a stalled system-bus reply cannot delay
later handler operations or daemon shutdown. This check runs after method
authorization, non-root ingress charging, the early busy check, and live
config reload, but before handler preflight or capture admission. UID 0
bypasses only this remote-session provenance check: ordinary authorization and
the persistent
biometric-guess limiter remain unchanged. TestAuthenticate remains its
separate root-only diagnostic method. When abort_if_ssh = false, the daemon
performs no ProcessFD, PID, logind, or session lookup at all. The one-shot
transport continues to enforce SSH provenance from the PAM-sanitized
SSH_CONNECTION / SSH_TTY environment instead of D-Bus.
Ingress buckets are process-local, are discarded after enough idle monotonic time to refill the full burst, and are capped at 1024 UID entries with least-recently-seen eviction. Daemon restart therefore resets this availability budget but not the SQLite-backed biometric-guess budget. The cap bounds memory, not aggregate admission across many distinct local UIDs. If the ingress-state mutex is poisoned, every later non-root admission fails closed as the same recoverable in-band rate-limit result until daemon restart. Poisoned state is neither read nor mutated.
For an admitted request, a changed config mtime is claimed before rebuilding
the handler. A failed rebuild keeps the existing handler and is not retried
for that same mtime; a later mtime permits one new attempt. A completed rebuild
is generation-checked before installation: if a newer mtime was claimed while
it was building, the stale handler is discarded rather than reactivating older
security configuration. The nested lock order is config_mtime then handler;
no path acquires them in the opposite order.
Raw camera frames require privilege. Both PreviewFrame and
PreviewDetectFrame are root-only, so a non-root caller is denied with
AccessDenied before either method touches the camera. On top of that
denial the daemon strips jpeg_data from any non-root reply, so raw
camera/IR imagery cannot reach an unprivileged caller even if the
authorization table were ever to regress.
Method timeouts: Enroll runs synchronously inside the method call for up to
Config::enroll_timeout_secs() seconds server-side (3 × max(recognition.timeout_secs, 5)
seconds — i.e. minimum 15s). Clients MUST use a method timeout greater
than this deadline plus startup/inference margin for Enroll (the CLI uses
deadline + 15s); the shared 15-second client timeout applies to every other
method. A client timeout at or below the server deadline aborts the call while
the daemon is still enrolling.
Enrollment behavior is mode-independent: oneshot (facelock enroll in direct
mode) and the daemon's Enroll method run the same capture loop, so the
quality gate and the angle-diversity check apply in both.
Capture concurrency: Authenticate, TestAuthenticate, Enroll,
PreviewFrame, and PreviewDetectFrame are serialized by an in-flight
capture guard. While one capture is in progress, a concurrent call to any of
these methods fails immediately with an
org.freedesktop.DBus.Error.Failed error whose message contains daemon busy (no queuing behind the internal handler lock). An authorized non-root
Authenticate spends its ingress token before this busy rejection.
Authenticate and TestAuthenticate run their camera-independent audited
preflight on one locked handler generation before claiming the capture guard.
A preflight rejection such as no enrolled models therefore never occupies the
global capture slot. The handler stays locked across a successful preflight
and capture admission so live reload cannot turn the split into a fail-open
configuration race. A suspend, ReleaseCamera, or shutdown cancellation that
lands during preflight is carried across admission and prevents the delayed
request from opening the camera.
Clients (PAM included) must treat a busy error like any other daemon error — degrade to the next auth mechanism (password), never a lockout.
Signals
AuthAttempted(user: s, matched: b)— emitted best-effort after each successful method return fromAuthenticateandTestAuthenticate, including in-band preflight rejections where no camera opened; out-of-band D-Bus errors emit none. The payload intentionally carries no similarity score (the raw biometric score is an information leak / spoof-tuning oracle). The system bus policy (dbus/org.facelock.Daemon.conf) denies signal reception from the daemon by default; only root may receive it (ADR 010: no group policy).
Response types
The D-Bus methods return these values directly; the daemon's internal response enum variants are not wire wrappers:
| Method | Return value |
|---|---|
Authenticate, TestAuthenticate | AuthResult { matched, model_id, label, similarity } |
Enroll | (model_id: u32, embedding_count: u32) |
ListModels | array of ModelInfo { id, user, label, created_at, embedder_model, device_id } |
PreviewFrame | JPEG byte array |
PreviewDetectFrame | (JPEG byte array, array of PreviewFaceInfo { x, y, width, height, confidence, similarity, recognized }) |
ListDevices | array of DeviceInfo { path, name, driver, is_ir } |
Ping | string |
RemoveModel, ClearModels, ReleaseCamera, Shutdown | no return fields |
ModelInfo.device_id is the enrolling camera's canonical fingerprint; D-Bus
has no Option type, so an empty string is the NULL sentinel for
legacy/uncoupled templates. created_at is a Unix timestamp in seconds.
Authenticate error encoding
Authenticate returns AuthResult (matched: b, model_id: i, label: s, similarity: d).
TestAuthenticate returns the same type with the same sentinels — one
encoding, so the two cannot drift.
Sentinel model_id values (only meaningful with matched == false):
| model_id | Meaning |
|---|---|
| >= 0 | Matched model id (with matched == true) |
| -1 | No match, and no face was detected (also: no enrolled faces, and the pre-camera gates) |
| -2 | Recoverable daemon error; label carries the error message (rate limited, IR required, camera/storage failure) |
| -3 | Suppressed: no enrolled models and security.suppress_unknown = true |
| -4 | No match, and the detector did see a face |
Recoverable errors travel in-band (model_id -2), not as D-Bus errors, so
clients can distinguish "the daemon decided auth cannot proceed" from "the
daemon is unavailable". D-Bus errors remain for authorization failures,
daemon-busy, transport problems, and the non-root ProcessFD/session gate above.
In particular, a rate-limited state is a daemon decision and must never make
the PAM client retry via a root oneshot.
-4 exists because similarity cannot carry "was a face seen?": the score is
redacted to 0.0 for every non-root caller, so a user-run locker (hyprlock)
could not tell a genuine face-seen non-match from an empty frame and abstained
(PAM_IGNORE) for both. It is a detector signal — a face was present, never
how close it came to an enrolled template — so unlike similarity it is not a
hill-climbing oracle and is not redacted.
A PAM module older than -4 decodes it as an ordinary non-match (its sentinel
match falls through to the same arm as -1), so a daemon newer than the
installed module degrades to the previous behavior rather than breaking. In the
other direction, a -1 reply carries no face-seen signal at all, and the module
falls back to the score test it used before.
Rejection classes (AuthOutcome::Error)
The class of a rejection is carried as a type
(facelock_daemon::auth::ErrorKind), not inferred from its message. The audit
result label, the oneshot exit code, and the message itself all derive from
it; ErrorKind::render is the only place any of these sentences is written.
The wire has no field for the class, so the CLI's D-Bus client reconstructs it
with ErrorKind::classify, the exact inverse of render.
Three rendered messages are frozen protocol because the PAM module matches them to choose its return code, and it cannot link the daemon crate to share the type (its dependency ceiling is libc/toml/serde/zbus):
| Substring PAM matches | Class | PAM code |
|---|---|---|
rate limited | RateLimited | PAM_AUTH_ERR |
IR camera required | IrRequired | PAM_IGNORE |
cancelled (matched exactly) | AuthOutcome::Cancelled | PAM_IGNORE |
Y16BitDepthRequired is a stable recoverable class even though PAM does not
substring-match it: the daemon’s -2 encoding maps it to PAM_IGNORE, and
oneshot exit 2 maps it to the same code. Its full rendered message is pinned by
ErrorKind::classify and tests.
Changing any of these strings is a protocol break.
cancelled is not an ErrorKind. A rejection class is a statement about this
user's face; a cancellation is the absence of one, so it is its own
AuthOutcome variant (facelock_daemon::auth::CANCELLED_MESSAGE) that reuses
the recoverable-error encoding to cross a wire with no field for it. PAM
abstains on it: the attempt was abandoned, so the daemon has no opinion and the
password modules run. It is matched exactly rather than as a substring, so an
arbitrary error message that happens to mention cancelling cannot claim the row.
auth_attempted and a cancelled attempt. The signal carries only user and
matched, and its signature is frozen; a cancelled attempt therefore emits
auth_attempted(user, false), indistinguishable on the signal from a non-match.
The audit log is where the two are told apart (cancelled vs failure).
They are pinned byte-exactly in
crates/facelock-daemon/src/auth.rs (renderer, including the frozen
cancellation string) and
crates/facelock-daemon/tests/server_authz.rs (wire), and every class's
message, audit label and exit code are pinned together in
crates/facelock-cli/src/commands/auth.rs.
Daemon peer verification (PAM client)
Before trusting an Authenticate reply, the PAM module resolves the owner of
org.facelock.Daemon (GetNameOwner, activating the service first if
needed), requires the owner UID to be 0 (GetConnectionUnixUser), and pins
the method call to the owner's unique bus name. A non-root owner is refused:
the module falls through (oneshot fallback / password), never PAM_SUCCESS.
PAM Semantics
| Outcome | PAM Code |
|---|---|
| Face matched | PAM_SUCCESS (0) |
| No match, face seen (model_id -4) | PAM_AUTH_ERR (7) |
| No match, no face seen (model_id -1) | PAM_IGNORE (25) |
| Rate limited (daemon, model_id -2) | PAM_AUTH_ERR (7) — no oneshot fallback |
| IR required / unverified Y16 texture scale / internal daemon error (model_id -2) | PAM_IGNORE (25) — no oneshot fallback |
| Suppressed (model_id -3) | PAM_AUTHINFO_UNAVAIL (9) |
| Daemon unavailable / untrusted (non-root) peer | oneshot fallback, else PAM_IGNORE (25) |
Oneshot fallback (and mode = "oneshot") | per the facelock auth exit-code table above: 0 → PAM_SUCCESS, 1/3 → PAM_AUTH_ERR, 4 → PAM_AUTHINFO_UNAVAIL, 2/5/unknown/signal death → PAM_IGNORE |
| Config missing, unparseable, or untrusted (not root-owned / group- or world-writable, incl. parents) | PAM_IGNORE (25) |
| Timeout (structured zbus timeout or overall deadline) | PAM_AUTH_ERR (7) |
PAM module never blocks indefinitely. All operations have timeouts, including D-Bus connection establishment (overall deadline on a worker thread).
The oneshot fallback spawns facelock auth with a sanitized environment:
env_clear() plus an allow-list of SSH_CONNECTION, SSH_TTY, and a pinned
PATH=/usr/bin:/bin. No other variables (LD_*, XDG_*, DBUS_*, ...) are
inherited. Stdin is /dev/null.
Syslog Format
pam_facelock(<service>): <result> for user <username>
Polkit Agent Semantics
The facelock-polkit-agent offers face authentication for polkit actions, but
scoped to an allowlist — face is not a universal key for every privileged action.
| Outcome | Agent behavior |
|---|---|
action_id not in polkit.face_eligible_actions | Declines (returns org.freedesktop.DBus.Error.Failed) — see fallthrough-vs-denial caveat below |
| Allowlisted action, face matches | Responds success to polkit authority |
| Allowlisted action, no match / daemon error | Declines (same caveat) |
| Username cannot be resolved to a uid | Refuses to respond; never sends UID 0 for an unresolved name |
NOTE (agent model only): polkit registers a single authentication agent per session and does not chain agents. When this agent declines, the decline returns an error, which — depending on the desktop's agent registration — may present as an authorization denial rather than a fallthrough to a password dialog. The intended UX (non-eligible actions handled by the desktop's normal password agent) is unverified pending live-desktop testing. Behavior here is fail-closed: a non-eligible action is never face-authorized. Does not apply to the PAM model, which always falls through to the password prompt.
A decline never fails open to root, and never causes this agent itself to grant authorization it should not — but see the caveat above on whether polkit treats a decline as a fall-through to another agent or as an outright denial.
Anti-Spoofing
| Defense | Config | Default |
|---|---|---|
| IR camera enforcement | security.require_ir | true |
| Frame variance check | security.require_frame_variance | true |
| Frame variance cutoff | security.frame_variance_max_similarity | 0.985 |
| IR texture cutoff (raw frame) | security.ir_texture_min_stddev | 10.0 |
| Landmark liveness | security.require_landmark_liveness | false |
| Minimum auth frames (= variance window size) | security.min_auth_frames | 3 |
| Frame variance default const | DEFAULT_FRAME_VARIANCE_MAX_SIMILARITY | 0.985 |
IR classification is derived from queried device evidence: a node is IR when its
enumerated pixel formats are mono-only/IR-typical (GREY/Y8/Y10/Y12/Y16, with no
color format mixed in), or when a quirks force_ir entry matches (authoritative
by USB vendor:product ID; a name-only match requires corroborating format
evidence or a real USB identity). The free-text device name never classifies a
device on its own, and a GREY/Y16 format offered alongside a color format is
not treated as IR. A force_ir quirk is device-level ("this USB device has an
IR sensor"): when the device exposes multiple capture nodes and at least one has
an IR-typical format, only the format-bearing node(s) classify IR. A quirk's
format_preference participates in that decision only when the preferred
format is itself IR-typical and actually advertised; an RGB preference such as
MJPG cannot exempt an RGB sibling from demotion (see
docs/security.md §A).
Y16 texture enforcement is separately fail-closed on scale provenance: only a
valid verified y16_bit_depth quirk permits authentication; absent/invalid
depth rejects recoverably before auth capture, including when
security.require_ir = false. GREY is already 8-bit and is unaffected. Facelock
does not introduce a scene-derived or scale-invariant authentication metric in
this alpha.
Frame variance is passive
anti-photo only (does not stop video replay); it is evaluated over a sliding window
of the most recent min_auth_frames matched frames (see docs/security.md §B), with
a 0.985 cutoff rejecting truly static input (≳0.999) with margin; the
field-measured frozen-human band is 0.98–0.995, and the default sits inside it —
a fully frozen user recovers via the sliding window as soon as they move
slightly. IR texture is measured on the raw frame, never CLAHE. These defaults
must not be weakened without security review.
Models
| Model | File | Size | Default |
|---|---|---|---|
| SCRFD 2.5G | scrfd_2.5g_bnkps.onnx | ~3MB | Yes |
| ArcFace R50 | w600k_r50.onnx | ~166MB | Yes |
| SCRFD 10G | det_10g.onnx | ~17MB | Optional |
| ArcFace R100 | glintr100.onnx | ~249MB | Optional |
Configurable via recognition.detector_model and recognition.embedder_model.
Bundled model filenames are verified against the manifest hash at load time. Custom model files require matching recognition.detector_sha256 or recognition.embedder_sha256.
Compatibility
The current delivery targets are x86_64 Linux systems with V4L2 and
Linux-PAM; source builds require Rust 1.88+. Package validation covers Arch,
Debian 13 (trixie), Ubuntu 26.04
LTS (resolute), and Fedora 43/44/45. RHEL is not in the supported matrix.
Source templates exist for OpenRC, runit, and s6, but that does not establish
package or hardware support for every distribution using those supervisors.
Tested Distributions
Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04 LTS (Resolute). Other Debian and Ubuntu releases are unsupported.
| Distribution | Init System | Mode | Status |
|---|---|---|---|
| Arch Linux | systemd | daemon + D-Bus activation | Primary target |
| Debian 13 (Trixie) | systemd | daemon + D-Bus activation | Booted package gate |
| Ubuntu 26.04 LTS (Resolute) | systemd | daemon + D-Bus activation | Booted package gate |
| Fedora 43/44/45 | systemd | daemon + D-Bus activation | Release matrix target |
Expected to Work (untested)
No additional distribution is claimed as expected to work. Source templates do not convert a distribution into an untested support claim.
Cameras and formats
Facelock classifies an IR node from its advertised format set or an exact hardware quirk. It does not use a device name containing “IR” as evidence. The current hardware validation record covers the Logitech BRIO 046d:085e IR node using native GREY. Do not infer support for Intel RealSense or “Windows Hello” cameras from their product category alone.
| Format | Authentication support |
|---|---|
GREY | supported 8-bit grayscale path |
Y16 | conditional on a hardware-verified y16_bit_depth quirk from 8 through 16 |
YUYV, NV12, MJPG | supported decode paths; normally RGB unless an exact quirk says otherwise |
Y8, Y10, Y12 | IR-classification evidence only; not decoded |
| raw Bayer and other formats | not supported |
The shipped RealSense Y16 quirks deliberately have no bit-depth evidence, so authentication rejects those Y16 paths. A 16-bit V4L2 container does not prove the sensor's meaningful bit depth. Auto-detection excludes devices with no decodable format and reports the advertised formats.
RGB operation requires security.require_ir = false and is for development.
Frame variance and any enabled landmark-liveness check still apply, but they
do not restore the default IR boundary or guarantee resistance to presentation
attacks. See the canonical
Compatibility page on GitHub
for the IPU6/IPU7 relay notes, exact negotiation order, and current validation
evidence.
Init and PAM
systemd with D-Bus activation is the packaged daemon path. On non-systemd
systems, use daemon.mode = "oneshot" or install one of the source-tree
OpenRC/runit/s6 templates after a source install. The daemon command must run
as root.
Test PAM on sudo first while retaining a root recovery shell. Shared stacks,
console login, and SSH are sensitive targets and require the CLI's explicit
--allow-sensitive gate. PAM service availability is distribution-specific;
use facelock pam status rather than assuming a path exists.
Inference providers
CPU is the default and tested provider. CUDA, ROCm, and OpenVINO require a
matching ONNX Runtime build; configuration support is not evidence that a GPU
or a particular runtime package has been validated. The setup auto choice
inspects providers compiled into ONNX Runtime, not the hardware.
Testing and Safety
Unit, lint, audit, documentation, and contract checks need no host authentication changes:
just check
Camera tests, package lifecycle tests, and live authentication have different risk. Use containers for isolated PAM smoke coverage and an explicitly marked disposable guest for booted package/login scenarios. The walkthrough runner does not provision the guest and refuses ordinary hosts. See Testing and Safety and the Testing Walkthrough.
The development configuration is not rootless. Management commands retain
their root gate, and effective-UID-0 processes ignore FACELOCK_CONFIG:
just build
just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices
Do not use development setup as a shortcut into host PAM. Before any host PAM
test, validate the isolated tiers, retain a separate root shell, start with
sudo, and test from a new terminal. Prefer validated removal:
facelock pam add --service sudo
facelock pam remove --service sudo
Run those two commands from the retained root shell. Current managed backups
are versioned beneath /var/lib/facelock/pam-backups/ and carry JSON
provenance. Facelock does not automatically create
/etc/pam.d/sudo.facelock-backup; that path exists only if an operator or an
older release made it. Review any copy before restoring it.
facelock test can return zero without a match or camera scan. Treat its human
output, not exit status alone, as the result.
Testing and Safety
PAM, package lifecycle, service activation, enrollment, and authentication can change the machine or require real hardware. Do not exercise those paths on a workstation merely to validate documentation or a patch.
Safe local checks
These checks do not need root, a camera, installed models, or host PAM edits:
cargo test --workspace
cargo clippy --workspace -- -D warnings
just check
just check is broader than the first two commands: it includes formatting,
the RustSec audit, documentation/contracts checks, source-install lifecycle
tests, and package/release contract checks. See
Developer Commands for the generated inventory.
Ignored hardware tests need models and a camera and are not part of that safe baseline:
just link-models
cargo test --workspace -- --ignored
Container and guest tiers
The Arch PAM smoke container tests module loading and failure behavior without editing host PAM:
just test-arch-pam
The camera container recipes pass real devices through and perform live enrollment/authentication. Run them only when that hardware interaction is intended:
just test-arch-integration
just test-arch-oneshot
They default to a 90-second live-step timeout. A longer timeout uses
timeout(1) syntax:
FACELOCK_LIVE_TIMEOUT=5m just test-arch-integration
Synthetic camera
The same two scripts run against a synthetic camera, with no real device passed through and nobody in frame:
just test-arch-loopback
The camera is a v4l2loopback node fed by ffmpeg with a procedurally
rendered face sequence (test/loopback/NOTICE.md: drawn from arithmetic,
nobody's face). While it is fed, the node enumerates GREY only, so
facelock classifies it as IR by format evidence — the residual
Security describes — and the run keeps require_ir and
require_frame_variance at their product defaults, because the sequence
drifts frame to frame the way a person does. A second node fed YUYV is the
non-IR camera the require_ir refusal assertions need; without it they
report SKIP.
The tier needs two idle loopback nodes the calling user can write. Loading the module needs root; the recipe does not do it and exits 2 with this when the nodes are missing:
sudo modprobe v4l2loopback devices=2 video_nr=20,21 \
card_label=facelock-synth-mono,facelock-synth-color exclusive_caps=1,1
sudo udevadm settle && sudo chmod a+rw /dev/video20 /dev/video21
The settle matters: udev applies its own 0660 root:video mode when it
processes the add event, which can land after a chmod issued right behind
the modprobe.
If v4l2loopback is already loaded for something else, add nodes without
unloading it (v4l2loopback-utils, module 0.13 or later):
sudo v4l2loopback-ctl add -x 1 -n facelock-synth-mono /dev/video20
sudo v4l2loopback-ctl add -x 1 -n facelock-synth-color /dev/video21
While the tier feeds them, the nodes are visible to the host too. An idle
exclusive_caps=1 node enumerates no formats and auto-detection ignores it,
but a fed facelock-synth-mono node enumerates GREY only and classifies
as IR by format evidence, exactly as a real IR camera without a quirk entry
does; whichever of the two enumerates first wins a host auto-detect, so a
host face-auth attempt during the run (a sudo prompt, a lock screen) can
be judged against the synthetic face and fall through to the password. A
camera the quirks database knows still ranks above it. The labels carry no
ir token on purpose: auto-detection prefers a format-classified node whose
name says ir, and a synthetic node should never beat a real sensor on its
name (has_ir_name_token in crates/facelock-camera/src/device.rs). The
tier itself never reads the label. Pin device.path on the host or wait out
the run, which takes about two minutes.
FACELOCK_LOOPBACK_IR and FACELOCK_LOOPBACK_RGB pick other nodes
(FACELOCK_LOOPBACK_RGB=none runs without the twin). The script refuses a
node that has a parent device in sysfs — a real camera — or that another
process is already feeding, so it cannot open the host's webcam by mistake.
Only the loopback nodes are passed into the container.
It records the commit it passed at to .loopback-tier-verified, which
satisfies just release-preflight on its own, as the real-camera record
does. It is cheaper evidence, not the same evidence: it proves capture, IR
classification, the liveness gates, enrollment, the daemon and one-shot
paths and PAM end to end on a device the product treats as an IR sensor, and
it cannot prove that a real sensor's frames match a real face.
Container coverage is not proof that a booted package, display manager, or real login stack is safe. Use the evidence walkthrough in an explicitly marked disposable guest for those cases; its runner refuses ordinary hosts and does not provision a VM for you. See Testing Walkthrough.
Development configuration
dev/config.toml uses checkout models, oneshot mode, and temporary database,
key, snapshot, and audit paths. It is not rootless: the management CLI keeps
its normal privilege gate. Root also ignores FACELOCK_CONFIG, so pass the
configuration explicitly:
just build
just link-models
sudo target/debug/facelock --config "$PWD/dev/config.toml" devices
sudo target/debug/facelock --config "$PWD/dev/config.toml" enroll --skip-setup-check
sudo target/debug/facelock --config "$PWD/dev/config.toml" test
Do not run setup for this flow. Setup owns installed-system state, including
the fixed /etc/facelock/.setup-complete marker, and may offer systemd and PAM
changes. The explicit non-default configuration routes supported management
commands through direct access; it does not make a manually started daemon use
that backend.
facelock test returning zero is not proof of a match or even a scan. It also
returns zero when no usable enrollment exists and after a completed non-match.
Read its output.
Host PAM testing
Only test host PAM after the container and disposable-guest tiers are satisfactory.
- Open a separate root shell and keep it open.
- Optionally create and label your own emergency copy before Facelock touches
the service:
cp /etc/pam.d/sudo /root/sudo.pam.before-facelockfrom that root shell. - Add only the
sudoservice withfacelock pam add --service sudofrom the root shell. - Test a correct password and a wrong password in a new terminal, then test face authentication.
- If anything is wrong, run
facelock pam remove --service sudofrom the retained root shell.
Facelock-managed rollback files are versioned under
/var/lib/facelock/pam-backups/ with adjacent JSON provenance. They are not
the old /etc/pam.d/sudo.facelock-backup path. Never select the newest-looking
backup and copy it blindly: review its provenance and target state, or let the
CLI perform the validated removal. An adjacent
/etc/pam.d/sudo.facelock-backup exists only if an operator or an older
release created it; current Facelock does not create that emergency copy.
Do not begin with login, sshd, a display manager, or shared stacks such as
system-auth and common-auth. The CLI requires --allow-sensitive for these
targets because one error can affect many authentication paths.
If the retained root shell is unavailable, boot a recovery environment,
remount the root filesystem read-write, and remove the exact
pam_facelock.so rule or restore a separately reviewed operator copy. See
Troubleshooting.
Logging
Use global -v flags for privileged commands because they survive sudo's
environment filtering:
sudo facelock -v test
sudo facelock -vv daemon run
For target-specific filters, pass the environment through a trusted env
invocation:
sudo env RUST_LOG=facelock_camera=trace facelock devices
Clean-System Testing Walkthrough
This workflow records reviewable evidence for documentation commands on a
clean system. It never turns the documentation inventory into a script to run
blindly. Only explicit cases in test/docs-walkthrough/cases.json may execute;
unmapped executable and manual-only inventory rows remain visible as pending.
Use this workflow when collecting new clean-system execution evidence. It is not the completion gate for a documentation-only audit: source review, parser conformance, existing behavior tests, and package metadata can establish many documentation facts without replaying the commands. A pending execution record does not by itself identify a documentation defect.
Safety boundary
Booted scenarios run only in a disposable guest you provision and snapshot.
The runner does not create a VM. Before it will mutate the guest, it requires
the regular root-owned mode-0644 marker
/etc/facelock-walkthrough-guest.json with this shape:
{
"disposable": true,
"guest_id": "unique-per-guest-id",
"os": "debian-13",
"image": "exact-matrix-image@sha256:full-image-digest",
"init": "systemd",
"snapshot": "pristine-snapshot-id",
"level": "booted-vm",
"hardware": []
}
Match os, image, and init to the selected scenario. The runner also
verifies virtualization and refuses shared 9p, virtiofs, NFS, or CIFS mounts;
protected PAM/bus mounts; and unexpected camera or TPM devices. Hardware
scenarios must declare intentionally attached devices instead of inheriting
them accidentally. A marker is an authorization for this disposable guest,
not something to place on a workstation.
Inspect and validate the catalog
These repository-local commands do not run documented install/auth commands:
python3 test/docs-walkthrough/run.py list
python3 test/docs-walkthrough/run.py check
python3 test/docs-walkthrough/run.py report
list shows scenario IDs, check validates definitions, source pins, and total
mapping, and report compares explicit case mappings with the complete
documentation inventory. The separate walkthrough unit tests exercise safety
guards. Use refresh only when intentionally refreshing the checked-in
inventory and generated manual sections derived from documentation:
python3 test/docs-walkthrough/run.py refresh
git diff -- test/docs-walkthrough/cases.json
git diff -- test/docs-walkthrough/manual-sections.json
Review changed expectations and manual gate classifications before committing either generated file. A refreshed mapping is not evidence that any command was executed or reviewed.
The catalog includes repository and direct-package cases for APT, RPM/COPR, AUR, source, NixOS, OpenRC, runit, and s6, plus first setup, daemon/oneshot auth, desktop lock, physical TPM, GPU, and Y16 cases. Fixed adapters are reviewed route probes with source-context references, not literal replay of the referenced line. Literal source rows become ordered manual-section candidates. Listing or generating a case is not a claim that its expectations were reviewed or that it passed.
Pin release identity
Every run consumes an identity JSON rather than inferring binary publication from a tag or local build. For a GitHub release-asset channel, generate the readiness report for the intended release and channel:
RELEASE_TAG=v0.2.0-alpha.4
CHANNEL=github-alpha
READINESS_FILE=/tmp/facelock-walkthrough-readiness.json
python3 test/docs-walkthrough/run.py readiness --release "$RELEASE_TAG" --channel "$CHANNEL" --output "$READINESS_FILE"
The identity must bind release, normalized version, exact native package
version, a 40-hex artifact_commit, channel, runtime policy, and immutable
artifact evidence: name, URL, positive size, and 64-hex SHA256. GitHub binary
release channels additionally require a positive release asset ID. Source/Nix
identities instead name the exact GitHub tag archive; a tag archive does not
require a GitHub Release. The current readiness helper nevertheless queries
the Release API for these channels too, so its missing-release result is not a
valid source-archive availability check. Check the tag resolution and archive
identity separately; the source adapter does not require Release metadata.
The evidence record separately binds harness_sha256 and
harness_tree_dirty. Public-repository identities additionally bind the
downloaded package digest and repository URL, plus APT suite/signing-key
digest, COPR chroot, or AUR commit as applicable. An AUR source-built package
may omit the expected package digest; its evidence instead records the built
payload digest and the verified recipe commit. Every other repository channel
requires the expected package digest. artifact_commit is an asserted input
unless the installed record's source_commit_verification proves tag/build
linkage. Do not substitute a source checkout, staged build, or successful
rebuild for published-asset identity.
Run one explicit case
Copy the repository into the disposable guest without a shared host mount, install the marker, and use a new evidence directory:
SCENARIO=apt-trixie
IDENTITY_FILE=/root/facelock-walkthrough-identity.json
EVIDENCE_DIR=/root/facelock-evidence/apt-trixie
python3 test/docs-walkthrough/run.py run --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --output "$EVIDENCE_DIR"
python3 test/docs-walkthrough/evidence.py validate "$EVIDENCE_DIR/evidence.json"
Use --require-pass only when a passing outcome is required. A real
publication absence is evidence, not a reason to rewrite the record as a pass.
A clean apt-trixie run against an unpublished suite records the 404 its
Release URL returned; that record is the outcome, not a failed run to retry.
When an environmental prerequisite is deliberately unavailable, record an explicit blocked result rather than skipping silently. The generic runner always rejects camera and TPM devices; intentional hardware work uses the separate manual protocol.
SCENARIO=physical-tpm
IDENTITY_FILE=/root/facelock-walkthrough-identity.json
EVIDENCE_DIR=/root/facelock-evidence/physical-tpm
python3 test/docs-walkthrough/run.py blocked --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --reason "no dedicated TPM passthrough guest available" --output "$EVIDENCE_DIR"
Rootless container launcher
Container-eligible cases can use the guarded launcher with the exact image from the release matrix. It creates a named, UUID-scoped, rootless container with no mounts; it is not a substitute for booted systemd/PAM evidence:
SCENARIO=deb-trixie-direct
IDENTITY_FILE=/tmp/facelock-walkthrough-identity.json
CONTAINER_IMAGE=debian:13@sha256:full-image-digest
EVIDENCE_DIR=/tmp/facelock-evidence/deb-trixie-direct
python3 test/docs-walkthrough/run.py launch-container --scenario "$SCENARIO" --identity "$IDENTITY_FILE" --image "$CONTAINER_IMAGE" --output "$EVIDENCE_DIR"
Aggregate evidence
Validate a collection and use the strict aggregate only when intentionally requiring complete execution coverage of this walkthrough catalog:
EVIDENCE_ROOT=/root/facelock-evidence
python3 test/docs-walkthrough/evidence.py aggregate "$EVIDENCE_ROOT"
python3 test/docs-walkthrough/evidence.py aggregate --require-pass "$EVIDENCE_ROOT"
Aggregation reports missing cases and unmapped documentation inventory rows.
It does not convert manual-only commands into executed coverage or let one
distribution/channel stand in for another. This optional aggregate is not wired
into just release-preflight and is not the documentation-accuracy gate.
Manual evidence
manual-sections.json presents remaining manual commands as ordered steps,
including the exact documentation text, source location, and source hash.
Manual review is not a shortcut around that binding. A passing manual record
can qualify for completion only after its checked-in case has
review_status: reviewed, concrete per-step invocation/exit/output/state
expectations, and explicit fixture bindings. Refreshing the generated catalog
resets these definitions to candidates requiring another review. The record
must include manual_review with the operator, notes,
expectations_reviewed: true, and the fixture bindings used. Each passing step
must preserve the exact documented_command, record the actual argv, expected
exit, actual output and observed state, and reference a sanitized, hashed log.
If a required expectation cannot be observed, record the case as blocked or
failed rather than marking the section complete.
Command Documentation Audit
This audit supports issue #211. Its deliverable is accurate, current documentation, not a rerun of the project's behavioral or release qualification suites. Review commands against their parsers and implementations, package instructions against manifests and the relevant distribution repositories, and descriptions against the existing contracts and behavioral tests. Use a disposable container to settle an installation-specific uncertainty; use a VM or dedicated hardware only when a particular documentation claim cannot otherwise be established.
Documentation conformance and clean-system execution are separate evidence. A passing parser or source review does not establish a new hardware result; conversely, an example need not be rerun on hardware to correct its syntax or explain already-tested behavior accurately.
Authoritative surfaces
Cargo metadata discovers executable targets; the recursive Clap tree supplies
the unified CLI's commands, aliases, scoped options, positionals, defaults,
required arguments, value choices and conflicts. Public Just metadata supplies
the developer recipe index. The exact file classification lives in
test/docs-corpus.json; new instructional files and stale classifications fail
the inventory check, including in distribution source archives without .git.
- CLI reference: every public command and its scoped flags; the book includes this source
- Auxiliary commands: standalone benchmark verbs and the non-CLI polkit session executable
- Developer commands: generated public recipe signatures and executable destinations
- Configuration: defaults compared with the serialized Rust configuration and PAM-only policy declarations
- Contracts: privilege, paths, state ownership, output and integration behavior
- Man pages: scoped options checked against the parser; roff syntax checked separately
- Website and book: command extraction plus link, fragment and asset checks against assembled HTML
Historical proposals, intentional invalid examples, syntax metavariables and unsupported shell expressions have explicit classifications. Parsing never dispatches a documented command. Unsupported expressions need source/context review; their optional walkthrough records remain pending instead of silently becoming successful runtime tests.
Reproduce the deterministic checks
Use the development prerequisites in Quick Start, including Python 3, Bash, Just and the Rust toolchain. The site additionally requires the same mdBook 0.4.44 used by Pages; CI also runs mandoc.
just check-docs
just docs-site-check
The CLI inventory can be exported without accessing cameras or the system bus:
FACELOCK_DOCS_SURFACE_OUTPUT=/tmp/facelock-cli-surface.json cargo test -p facelock-cli --bin facelock conformance::surface::export_cli_surface_if_requested
Mutation fixtures cover invented commands/options, missing required arguments,
stale security defaults, shell quoting/redirection, missing documentation,
broken site links, changed source hashes and invalid walkthrough evidence.
The source archive test path must remain usable by Arch's package check();
Python is a check dependency, not an added runtime dependency.
Existing behavioral evidence boundaries
The audit supplements rather than replaces the existing semantic suites:
cli_smokechecks early privilege refusals, including PAM changes, state removal, TPM commands, and daemon startup; it also checks help/version and config-independent capabilitiesjson_stream_splitchecks machine output and diagnostic stream separationfacelock-corepath tests check privileged configuration overrides and protected path identity- The camera-free container suite checks real D-Bus/PAM preflight, authorization, and output behavior
- Package lifecycle/source-install suites check owned paths, install/removal behavior and service handling
- Physical camera, desktop, TPM, GPU and alternative-init walkthroughs still require the corresponding disposable environment and observations
A suite's presence in this list does not claim it was executed for a particular release. Keep each run's revision, exact command, environment and outcome with its evidence.
Dated channel observations
Observed on 2026-09-05:
- The
v0.2.0-alpha.1tag exists, but its GitHub Release was unavailable; no alpha release asset installation was established - A fresh pinned Debian 13 container reached the public APT
trixiesuite and received HTTP 404/no Release file; the documentation now identifies this suite as planned, not currently installable - Fresh pinned Fedora 43, 44 and 45 containers installed production COPR packages and ran their CLI; the observed packages were
0.1.3-1.fc43,0.1.3-1.fc44and0.1.3-1.fc45, not the alpha or latest stable0.1.4 - Stable APT/AUR/production COPR channels cannot establish prerelease coverage; the release matrix separates these channels
Those earlier observations established publication limitations, not a prerequisite to
reviewing the alpha.1 source. Its tag resolved to
53385c53b9c4b2e0f83368797c14599dc2c61485, and GitHub serves the tagged source
archive without a GitHub Release at that checkpoint. Keep source-review results distinct from
claims about uploaded release binaries or public repository packages.
See the walkthrough protocol when new runtime evidence is needed. Its catalog records route tests separately from exact documentation occurrences. Mechanically generated cases are execution candidates, not a documentation-accuracy score or a requirement to repeat established tests.
Documentation-accuracy follow-up
The 2026-09-05 follow-up reviewed the CLI, auxiliary executables, public Just recipes, configuration, contracts, man pages, Markdown guides and rendered site against their source definitions. It corrected privilege requirements, exit-status caveats, TPM recovery instructions, JSON examples, setup behavior, rate-limit accounting and claims about hardware, performance and security. The book now includes the canonical Quick Start instead of maintaining a second installation guide.
Distribution metadata checks covered source prerequisites on Arch, Debian 13, Ubuntu 26.04 and Fedora 43. The instructions distinguish native Rust versions from the pinned rustup toolchain, build dependencies from ONNX Runtime's shared library, and official Arch CPU/GPU packages from virtual package names. Nix instructions explicitly describe the current privileged-loader and model/key provisioning limitations; they do not present the module interface as a validated working installation. Model licensing refers to upstream terms and does not infer permission for personal desktop authentication.
Verification for this follow-up is deliberately documentation-focused:
just check-docs, the assembled-site link/anchor/asset check, man-page lint,
release-matrix documentation checks and focused offline regressions for package
instruction extraction and lookup. It does not repeat the full behavioral
suite below, install host authentication configuration, or claim new camera,
VM, GPU or TPM results. Published alpha assets are not required for these
source and documentation checks.
Published alpha.4 follow-up
The follow-up rebased the documentation onto
8fa96c9179a7d14a7c0ca1b74b686672335a8841, the commit identified by the
v0.2.0-alpha.4 tag, its release manifest and the successful
release run.
The prerelease
was published at 2026-09-06 02:07:12 UTC (September 5 in Pacific time).
- Downloaded all nine published assets and matched their sizes and SHA256 digests with the release metadata; the eight payloads also matched
MANIFEST.json - Matched the separately downloaded tag archive to the manifest's source digest,
28d35c51b4c0f3702291c526174e1bbb4d249daf86b017c0d652a4b30cda7053 - Inspected both Debian control archives and file inventories: native versions use tildes, but GitHub download filenames use dots; the dotted URLs work and the tilde URLs return 404
- Inspected the direct Fedora 44 RPM's version, dependencies, file inventory and scriptlets; it bundles ONNX Runtime, unlike the system-runtime COPR build
- Confirmed both Debian packages contain the manifest-pinned CPU ONNX Runtime 1.20.1 under
/usr/lib/facelock; the direct RPM places it under/usr/lib64/facelock - Ran only the released main executable's help and version in a disposable Ubuntu 24.04 container with
libxkbcommon0; it reports version0.2.0-alpha.4
The manifest itself has SHA256
b7f9fbc5df710ea3facb894e5ed4d67d2a4aaa8808261ecc15aada4dcfa7eb5e.
Matching downloaded bytes to release metadata is an integrity check, not an
independent reproducible-build or signing attestation. The tag is unsigned,
the release is not immutable, and the direct RPM has no package signature.
The downloaded files are retained locally in
/tmp/facelock-alpha4-audit.6w9Zm1; that temporary directory is not shipped.
This check corrected the current release links and exact package commands in
Quick Start, the README, the book and the HTML website. It also found redundant
setup-then-enroll instructions in the released Debian/RPM descriptions and
post-install prompts. The source package text now directs users to the setup
wizard; Quick Start explains the redundant alpha.4 prompt. Published alpha.4
bytes were not replaced.
Package descriptions now use the same bounded security claims as the guides,
and Arch/RPM removal prompts no longer promise that an unmodified package-owned
configuration file survives removal. The lifecycle contract remains the
authority for .pacsave and .rpmsave handling.
The review follow-up added pinned checksum gates before direct package installation and mdBook extraction, clarified PAM timeout and historical permission wording, and made configuration-default extraction ignore fenced examples. Official Debian/Ubuntu binary metadata and ELF inspection corrected the earlier claim that those distributions lacked ONNX Runtime packages: their published native libraries exist, but their multiarch locations and versioned SONAMEs do not satisfy the current trusted loader. The install guides distinguish that limitation from the compatible runtime bundled in Facelock's published packages.
Two requested behavioral changes remain outside this documentation audit: bootstrap setup does not forward an enrollment command's original user/label, and daemon restart does not check the D-Bus fallback's exit status. The CLI reference describes both limitations; correcting their behavior would require a separate implementation and contract change, not a documentation claim that the published binary already behaves differently.
The documentation audit and published-alpha inspection do not require stable
0.2.0 publication. Stable AUR, codenamed APT and production COPR still need a
separate post-publication check of their actual packages and metadata. The
live facelock-git recipe also still lacks the runtime dependency already
declared in this source tree; the install guides explicitly warn about it.
No host installation or camera, GPU, physical TPM or authentication retest was
performed for this follow-up. Pending walkthrough execution records remain
pending; refreshing their source pins does not convert them to evidence.
Earlier checkpoint: evidence identity and scope
The earlier audit checkpoint inventoried 69 instructional files, three executable
targets and 77 public recipes. Its isolated workspace run passed 1,713 tests with 12 ignored
hardware tests; all-target Clippy, formatting, source-archive extraction, site
links, mandoc and package-lifecycle documentation checks were also exercised.
These are local verification results, not release or hardware attestations.
The full just check gate completed; the final documentation/safety subset was
rerun after the last isolation fixes. Native Debian/RPM version-ordering checks
were skipped because dpkg and rpmdev-vercmp were unavailable. Dependency
audit completed with three yanked-package warnings allowed by existing policy.
That checkpoint's extractor recorded 1,350 occurrences: 417 executable, 21 manual-only,
733 schematic references, 177 historical and two intentional negative examples.
The catalog has 28 route/hardware definitions and 134 manual section candidates;
all 438 executable/manual occurrences had pending definitions, with none
unmapped. These are the counts at the preceding audit checkpoint; edits change
them. Use the inventory and walkthrough report for current counts. Pending
execution does not mean that the documentation assertion is unreviewed or
incorrect. Definition coverage is not execution completion.
The production COPR observations used build 10489915 and these retained
package hashes:
| Chroot | Native package version | SHA256 |
|---|---|---|
| Fedora 43 x86_64 | 0.1.3-1.fc43 | 66061f0d239a4ac58cd37b8f49a1189977bd34aaed7fb9ed4e6a849bcf01f2a3 |
| Fedora 44 x86_64 | 0.1.3-1.fc44 | 469fabc1c8678bccb48342d36107a3dd67429b0a8ac1406e742ed0d8f5884697 |
| Fedora 45 x86_64 | 0.1.3-1.fc45 | 521a7abbdf266bea57f1c8031912734c4968b57251ba9f9b1cec4e44f97f58b0 |
The tag commit 24aed886a71a3ed904f3232a1776a460f81b7d85 is an identity
assertion, not independently verified binary build provenance. Matching a
retained same-version cache payload does not exclude same-version repository
replacement. Repository completion therefore additionally requires verified
transaction-bound bytes; these container observations do not qualify.
Local, uncommitted artifacts are retained under target/docs-audit/ in the
audit worktree. inventory.json, examples.json and coverage.json contain
the final machine-readable inventories. evidence/apt-trixie-final,
evidence/copr-43-final, evidence/copr-44-final, evidence/copr-45-final
and evidence/alpha-blocked-final contain sanitized logs and JSON records.
These paths are build artifacts, not files shipped in a source checkout.
The APT/Fedora 44/alpha records name harness revision
1f20684f294697584c8cea9b000edb82c7d6c201; Fedora 43/45 name
628e2b83ff303d0c1cd2b8e3a932d375d42f0139. All were clean committed harness
runs at their recorded revision. Later documentation and guard changes are
not retroactively credited to these records: old source pins must not be
rewritten to qualify against the final inventory.
A further Fedora 44 probe at clean harness revision
8f4253f4c892719b594995286ceec6920130812c exercised the final isolation guards.
Its record and logs are in evidence/copr-44-guarded. All six pristine-state
observations were checked, including PAM and service assets. Validation accepts
it as a container observation and correctly rejects it as walkthrough
completion; transaction-byte and source-build provenance remain unestablished.
Published alpha assets are needed only to check their actual download URLs, metadata, hashes and delivered contents. Public-package instructions depend on the respective repository, and stable channels do not carry prereleases. Neither publication nor full booted/hardware walkthrough coverage is a gate for documentation-only review. Any unresolved claim should identify its specific missing evidence instead of blocking unrelated documentation work. No host PAM, desktop or device configuration is changed by the static audit.
Releasing
Current channel observation
The following is a dated 2026-09-06 observation: v0.2.0 is the latest published
GitHub release. The trixie and resolute APT Release URLs return signed
metadata for 0.2.0-1, main serves the same Trixie package, and legacy
serves signed empty indexes. All three AUR entries serve 0.2.0-1. The
production COPR serves 0.2.0-1 on Fedora 43, 44, and 45, from a build submitted
by hand after the tagged release run failed to produce one. These are
availability observations, not changes to the release policy below.
Versioning
Facelock uses Semantic Versioning:
- MAJOR (
1.0.0): Breaking changes to config format, database schema, D-Bus interface, or CLI flags - MINOR (
0.2.0): New features, non-breaking config additions - PATCH (
0.1.1): Bug fixes, documentation, dependency updates
The project is pre-1.0. The public contract is:
| Surface | What constitutes "breaking" |
|---|---|
Config (/etc/facelock/config.toml) | Removing or renaming keys, changing defaults that affect security |
| Database schema | Incompatible schema changes without migration |
D-Bus interface (org.facelock.Daemon) | Removing methods, changing signatures |
| CLI flags | Removing subcommands or changing flag semantics |
| PAM behavior | Changing auth/ignore/deny semantics |
Rust crate APIs are internal and not part of the versioning contract.
Prerelease identity conversions
Release input is strict SemVer: X.Y.Z or X.Y.Z-{alpha,beta,rc}.N. The same
identity is converted explicitly for each package manager:
| Surface | Alpha 1 | Stable |
|---|---|---|
| Git tag | v0.2.0-alpha.1 | v0.2.0 |
| Cargo | 0.2.0-alpha.1 | 0.2.0 |
| Debian upstream | 0.2.0~alpha.1 | 0.2.0 |
| RPM Version-Release | 0.2.0-0.1.alpha.1 | 0.2.0-1 |
| Arch pkgver-pkgrel | 0.2.0alpha1-1 | 0.2.0-1 |
| GitHub Release | prerelease | release |
The first alpha Debian revisions are
0.2.0~alpha.1-1~deb13u1 (trixie) and
0.2.0~alpha.1-1~ubuntu26.04.1 (resolute).
Package rebuilds advance independently of the Cargo version. Debian and Arch increment their package revision for a rebuild of the same prerelease and reset it for the next semantic prerelease. RPM uses one monotonic prerelease counter across the whole series:
Debian: 0.1.4-1 < 0.2.0~alpha.1-1 < 0.2.0~alpha.1-2 < 0.2.0~alpha.2-1 < 0.2.0~beta.1-1 < 0.2.0~rc.1-1 < 0.2.0-1
RPM: 0.1.4-1 < 0.2.0-0.1.alpha.1 < 0.2.0-0.2.alpha.1 < 0.2.0-0.3.alpha.2 < 0.2.0-0.4.beta.1 < 0.2.0-0.5.rc.1 < 0.2.0-1
Arch: 0.1.4-1 < 0.2.0alpha1-1 < 0.2.0alpha1-2 < 0.2.0alpha2-1 < 0.2.0beta1-1 < 0.2.0rc1-1 < 0.2.0-1
scripts/release-versions.sh is the executable conversion contract.
Repeating the same prerelease is a package rebuild; repeating a stable version
is rejected because Debian/Arch would otherwise advance while RPM remains at
release 1. Semantic version regressions are rejected before any file is edited.
just test-release-matrix verifies the exact order with native
dpkg --compare-versions, rpmdev-vercmp, and vercmp in disposable,
digest-pinned containers.
How to Release
Automated (recommended)
just release 0.2.0
# or
just release 0.2.0-alpha.1
This will:
- Convert and bump Cargo, Arch tag/pkgver/pkgrel, RPM Version/Release, and Debian upstream/revision metadata
- Run
cargo check --workspaceto verify the version bump compiles - Preserve package rebuild ordering, including the monotonic RPM prerelease counter
- Prompt you to update
CHANGELOG.md(add entries under the new version heading) - Print the
git commit/git tag/git pushcommands for you to run
Then push the tag to trigger the release workflow:
git push origin main --tags
What happens on tag push
The .github/workflows/release.yml workflow:
- Validates the tag against the checked-in release identity and target matrix
- Builds release binaries and uploads them as workflow artifacts
- Prepares the pinned ONNX Runtime and lock-bound Cargo-vendor source components with their reviewed manifests and checksums
- Builds two suite-specific TPM-enabled
.debpackages for trixie and resolute - Builds the direct
.rpmpackage in the pinned Fedora 44 container and validates contents - Validates the unlocked Nix flake evaluation (network inputs are resolved
because
dist/nixhas no checked-inflake.lock) - For a stable tag, builds the signed codenamed APT repository plus the
mainandlegacycompatibility suites until 0.3.0; missing APT signing secrets fail here, before the GitHub Release is public - Verifies the tag, assembles and validates every asset, writes
MANIFEST.json, and publishes the release exactly once - After a stable GitHub Release is public, attempts to publish
facelock,facelock-bin, andfacelock-gitto AUR; a missingAUR_SSH_KEYprints a skip notice and exits successfully, while an invalid configured key fails after publication - For a stable tag, triggers the GitHub Pages rebuild that deploys the updated APT repo
Validated prerelease tags set the GitHub Release prerelease output and upload
direct artifacts, but skip stable APT and all AUR publication. The workflow
guards use the validated release identity rather than substring matching.
COPR (Fedora) is not built by release.yml. It is handled by Packit,
which reacts to the release the publish job makes public in step 8. A draft
raises no release event, so nothing downstream fires until validation passes.
The workflow does watch: a stable run's verify-copr job polls the public
COPR API for the released EVR and fails the run if it never appears. It builds
nothing and can undo nothing — the release is already public by then — it only
makes a failed Packit submission loud on the day it happens. See the COPR
section below.
Builders build, publish publishes
No builder writes to the release. Each one uploads a workflow artifact and a
digest attestation naming what it produced, the image it produced it in, and
the components it consumed. Until the publish job runs, the tag has no
release at all: nothing is public and no downstream automation has seen
anything.
That split is the point. Every builder compiles or packages code this project
does not own, from every dependency's build.rs to rpmbuild and
dpkg-buildpackage. A builder holding the publication credential is a builder
that can publish whatever it likes. publish compiles nothing, so it is the
only job that holds RELEASE_PAT and the only one with contents: write;
every other job holds contents: read, and the workflow's own default is
deny-all.
publish runs after every builder and validator, and it:
- verifies the tag exists, names the validated version, and points at the built commit; where the tag carries a signature it must verify. The job reads the tag and never creates, moves, or replaces one.
- stages exactly the canonical assets out of the builders' artifacts. The allowlist is derived from the validated version, Debian revision, and RPM counter, so an artifact built from another identity has no canonical name and a file a builder added beside the one it was asked to produce is never staged.
- holds every staged asset to the SHA-256 its builder attested. An asset that changed between its build and publication stops the release, as does one no builder attested or one two builders claim.
- holds each attestation to the provenance its slot may declare: the suite,
the image
dist/release-matrix.jsonpins, and the component names. A builder cannot report another image or an extra component intoMANIFEST.json; a matrix the job cannot read stops the release instead of shortening the allowlist. - trusts an attestation only once it hashes to the job output its builder recorded. Artifacts are shared, writable storage for every job in the run; a job output belongs to the job that wrote it. An attestation that was replaced after its job finished, or whose job recorded no output, is refused by name.
- creates the release as a draft carrying those assets, then writes
MANIFEST.jsonover them, plus the source tarball digest, the pinned build-image digests, and the reviewed ONNX Runtime and Cargo-vendor component digests. It replaces the three-binarySHA256SUMSfile, which covered a fraction of the release and was written before most of it existed. - reads the draft back from the API, holds it to the allowlist a last time,
holds each published asset's size, and digest where the API exposes one, to
MANIFEST.jsonand the uploaded manifest to the file it wrote, and flips the draft to published once. A tag whose release is already published is refused before anything is written, so re-running the workflow after a failure is safe; re-running it after success goes red by design, atverify-creatable, with nothing written. One case needs a hand: if the Debian revision or RPM counter changed between runs, the draft still carries the asset built under the old name, and the readback refuses it. The failure names the file and the command that removes it,gh release delete-asset.
The workflow runs once at a time per tag (concurrency keyed by the ref,
never cancelling the run in progress), so a re-run started while a run is
inside publish queues behind it instead of racing it. Two drafts for one
tag, which only a race could leave behind, are refused with the
gh api --method DELETE command that removes the extra one.
A builder's extra output fails the release closed: a canonically named file in
an artifact the allowlist does not expect it from is refused at staging, and
the failure says so. Re-running only the failed publish job keeps that
artifact, so the remedy is fixing the builder and re-running all jobs.
A partial re-run of a single build-deb leg can similarly leave the other
suite's attestation unbound (attestation deb-<suite> is not bound to a job output); the remedy is the same, re-run all jobs.
Two consequences for the maintainer:
RELEASE_PATis required. It is now the only credential that can write the release, so an unset secret fails thepublishjob.just release-preflightchecks for it, and fails when it cannot check: withoutgh, or unauthenticated, the check is reported as unchecked and preflight does not pass.- A signed tag must be verifiable on the runner. Importing the maintainer's public key is release infrastructure tracked by #235; until it lands, an unsigned tag is accepted and a signed one that the runner cannot verify stops the release.
The publication prerequisites include build-nix: its flake evaluation must
pass, while its nix build step is advisory. No Nix lockfile is checked in, so
evaluation and builds depend on the currently resolved flake inputs. An
evaluation failure blocks the publish job before it creates a draft in that
run; fix the flake and re-run the failed jobs, never tag again.
test/release-artifacts-contract.sh (just test-release-artifacts) proves this
shape by fixture and by mutation. The workflow itself runs only on a tag, so
the gate never tags anything to test it.
Debian package channels
| Channel | Build env | Rust toolchain | TPM | Version suffix |
|---|---|---|---|---|
trixie | Debian 13 | official Trixie Backports cargo and rustc | Yes | X.Y.Z-1~deb13u1 |
resolute | Ubuntu 26.04 | native distro cargo and rustc | Yes | X.Y.Z-1~ubuntu26.04.1 |
Debian-family release support is exactly Debian 13 (Trixie) and Ubuntu 26.04
LTS (Resolute). Both codenamed suites ship one binary package named facelock with TPM
support enabled. No rustup toolchain participates in Debian source builds.
Bookworm and Noble artifacts may remain in historical releases, but those
suites are unsupported and receive no new packages.
Trixie package builds use the official Trixie Backports cargo and rustc;
Resolute package builds use the native Ubuntu toolchain.
Both .deb packages are uploaded to the GitHub Release for direct download.
Stable packages are published under the matching codename at
https://tysmith.me/facelock/apt/.
Each Debian source package consists of the exact tagged main upstream tarball,
the reviewed ORT component, the deterministic Cargo-vendor component, and the
Debian quilt delta. Complete .dsc rebuilds run with network denied and empty
Cargo/Rustup caches. Stable APT publication consumes exactly two suite
manifests, one for Trixie and one for Resolute, before signing or writing the
repository.
Each suite manifest contains exactly eight artifacts in this order: the main
orig tarball, ORT orig component, Cargo-vendor orig component, Debian quilt
delta, .dsc, .buildinfo, .deb, and .changes. The Cargo component carries
a generated legal inventory covering every exact lock-bound crate; its specific
DEP-5 stanza precedes the Facelock source catch-all. CI prepares that component
with Rust 1.95.0 through the immutable dtolnay/rust-toolchain action commit
4360b52568e2003a75bf9bc1d59f33a8e3fc893c, matching the repository's pinned
1.95 toolchain channel.
Every built .deb passes .github/workflows/scripts/validate-deb.sh in the
suite container before staging: package identity, forbidden transition fields,
generated dependencies, the required file set, the hash-verified ORT bundle,
and a lintian run that fails on error-severity tags. Deliberate deviations are
suppressed in that script, each with a recorded reason; warnings are printed
for review but do not gate.
Supported release matrix
dist/release-matrix.json is the checked-in authority. The release workflow,
APT configuration, Packit targets, and this table are checked against it.
| Platform | Architecture | Packaging/channel | Runtime | Support tier | Release target | Lifecycle depth |
|---|---|---|---|---|---|---|
| Debian 13 trixie | amd64 | one facelock package; TPM required; staged APT/direct deb | bundled ORT 1.20.1 | supported | yes | full |
| Ubuntu 26.04 LTS | amd64 | one facelock package; TPM required; staged APT/direct deb | bundled ORT 1.20.1 | supported | yes | full |
| Fedora 43 | x86_64 | staging COPR | system ORT | supported | yes | full through the 2026-12-02 EOL gate |
| Fedora 44 | x86_64 | staging COPR | system ORT | supported | yes | full |
| Fedora 45 branched | x86_64 | staging COPR | system ORT | supported | yes | required build/runtime smoke |
| Fedora Rawhide (Fedora 46 development) | x86_64 | optional experimental production COPR chroot | system ORT | experimental | no | best-effort pinned Track D smoke only |
| Fedora 44 | x86_64 | direct RPM | bundled ORT 1.20.1 | supported | yes | full |
| Arch Linux Archive snapshot 2026-08-18 | x86_64 | PKGBUILD and binary recipe | system ORT | supported | yes | full |
Production COPR requires Fedora 43, Fedora 44, and Fedora 45. Rawhide is the
only optional allowed experimental production chroot, so it may be present or
absent; missing any required chroot or enabling any unknown extra fails closed.
Every Packit copr_build target must be an explicit member of the checked-in
allowlist: fedora-43-x86_64, fedora-44-x86_64, or
fedora-45-x86_64. Mutable aliases such as fedora-all,
fedora-development, and their architecture-suffixed forms are rejected, as
is any other undeclared target. Rawhide is not a release target and is not a
Packit staging or production release target. Both fedora-rawhide and
fedora-rawhide-x86_64 fail validation, and no alpha may publish to Rawhide.
Fedora 43 and Fedora 44 carry the full lifecycle. Fedora 45 carries required build/runtime smoke. Rawhide remains best-effort pinned Track D smoke only; a Rawhide-only failure is not alpha-blocking, and Rawhide cannot supply lifecycle, artifact, upgrade, rollback, served-version, or availability evidence. Promotion requires a separately reviewed amendment and full Fedora gates. Issue #236 owns the pre-tag and post-publication proof that optional Rawhide serves no alpha or candidate build.
Container identities are pinned by registry/index digest, with the linux/amd64
manifest digest retained where the registry exposes both. They were resolved
from Docker Hub registry metadata and Fedora registry Docker-Content-Digest
on 2026-08-18. The Arch repository identity is pinned separately to
https://archive.archlinux.org/repos/2026/08/18/; every matrix-associated CI
and AUR pacman invocation installs that exact mirror before refreshing package
metadata.
Local distro validation
Before releasing, validate packages build and install correctly on each target:
# Automated (no camera needed)
just test-arch-pam # Arch container PAM smoke tests
just test-rpm # Fedora — validate file layout from manual install
just test-deb # delegate to both exact supported-suite package gates
just test-deb-trixie-pkg # Debian 13 — offline source rebuild, install, TPM, lifecycle
just test-deb-resolute-pkg # Ubuntu 26.04 — the same complete package gate
just test-rpm-pkg # Fedora — build real .rpm, install via dnf, validate
just test-rpm-lanes # every declared Fedora target at its declared depth
just test-rpm-authselect # Fedora — retired-profile upgrade guard lifecycle
just test-packit-config # Packit config schema — real `packit` in a pinned Fedora container
just test-copr # COPR-equivalent build only — Packit SRPM + mock from-source rebuild (slow)
just test-copr-pkg 43 # the same rebuild, then install it and run the booted lifecycle
just test-copr-lanes # every Packit/COPR target rebuilt from source at its declared depth
# Interactive (requires camera)
just test-deb-dev-shell # Ubuntu .deb with host models — fast iteration
just test-rpm-dev-shell # Fedora .rpm with host models — fast iteration
just test-deb-release-shell # Ubuntu .deb clean room — real user experience
just test-rpm-release-shell # Fedora .rpm clean room — real user experience
The test-rpm recipe validates file layout from manually installed binaries.
test-deb delegates to both supported-suite *-pkg recipes. The *-pkg
recipes build real packages using the same scripts as CI, install them with
the actual package manager (dnf / dpkg), and validate the result — testing postinst
scripts, dependency resolution, ORT bundling, tmpfiles triggers, and the full
install path.
The *-dev-shell recipes mount host models for fast interactive camera testing.
The *-release-shell recipes start from a clean package install with nothing from the
host — run facelock setup to download models, then enroll and test.
Fedora lanes
Every Fedora recipe takes a release — just test-rpm-pkg 43, just test-copr 45
— and defaults to 44. just test-rpm-lanes runs each declared release target at
the lifecycle depth dist/release-matrix.json gives it: full lifecycle for
Fedora 43 and 44, build plus runtime smoke for branched Fedora 45. Rawhide is
optional and experimental, has no lane, and can never stand in for a Fedora 43,
44, or 45 result.
Each Fedora target needs two lanes, not one. test-rpm-lanes proves the direct
.rpm: host-built binaries, bundled ONNX Runtime. That is not the delivery
path the matrix declares for Fedora, which is Packit publishing to COPR against
Fedora's system ONNX Runtime. just test-copr-lanes proves that one at the
same declared depths — test-copr-pkg 43, test-copr-pkg 44,
test-copr-smoke 45. Each rebuilds the package from source in a mock chroot
(the test-copr half), exports the RPM it built, installs it with dnf so the
package's own Requires: onnxruntime resolves, and boots it for the same
validation the direct lane runs. just test-packaging-matrix requires both,
and test/packaging-evidence.py refuses a direct-RPM record offered as a COPR
target's evidence.
The COPR lanes are slow even by this file's standards: each one compiles the
whole workspace and runs the spec's %check inside the mock chroot, and mock
needs a privileged container, so they run serially through a single staging
path (target/copr-lane/facelock.rpm). Never run two at once.
test/fedora-lane-image.sh resolves each lane's digest-pinned base image from
the matrix, so no Containerfile carries its own Fedora digest. It refuses a
release the matrix does not declare and refuses one that has reached its EOL
gate: Fedora 43 goes EOL on 2026-12-02, and from that date the Fedora 43 lane
stops with a message instead of quietly testing an unmaintained release. Set
RELEASE_MATRIX_TODAY to rehearse that date, the same override
test/check-release-matrix.py reads. Retiring the lane means retiring its
matrix rows, and moving the date is a deliberate matrix edit.
Fedora 43 is the only release carrying a gate today. The lookup is generic on
fedora.<release>_eol_gate, so adding a 44_eol_gate or 45_eol_gate key
gates those lanes immediately; until one exists, 44 and 45 run past their own
end of life without complaint.
just test-rpm-lanes runs each release through the recipe its matrix
lifecycle_depth names, and test/check-release-matrix.py requires that exact
pairing, so a full lifecycle lane cannot be quietly downgraded to a smoke lane.
The full lifecycle lane also pins %config(noreplace): an unmodified
/etc/facelock/config.toml is replaced in place on upgrade, a modified one
survives byte for byte with the new file diverted to .rpmnew, and erase
removes an unmodified copy outright while retaining a modified one as
.rpmsave. docs/contracts.md carries the same contract.
The RPM embeds a read-only retired-profile upgrade guard in %pre. The
model-free test-rpm-authselect gate boots Fedora with systemd and exercises
real authselect and PAM password success/failure across fresh, unselected,
selected-retired, custom-profile, malformed-state, and authselect-absent
transactions. It never changes the host PAM stack. The exact retired
facelock selection blocks with manual backup-and-reselection guidance;
ordinary selections are preserved and the new RPM ships no authselect profile
or dependency.
An already-installed v0.1.4 RPM cannot be retroactively guarded: direct uninstall runs only the scriptlets already installed from v0.1.4. Users must install a guarded release before a later uninstall so the guarded upgrade can retire the old authselect payload first.
Packaging gates in CI
.github/workflows/packaging.yml runs the lanes above in CI: both Debian suite
gates, every declared Fedora lane, the Arch package built from the real
dist/PKGBUILD, and the native version-ordering matrix. It downloads and
checksum-verifies the ONNX models first, through .github/actions/fetch-models,
so the daemon-start assertions execute instead of being counted as skipped.
Three schedules, because the full matrix takes about 1 h 45 min (measured 2026-09-02) and most pull requests touch no packaging:
| When | Lanes | Filtered |
|---|---|---|
| Pull request | all but copr | yes, per lane, only the lanes the diff reaches |
| Nightly, 07:00 UTC | all | no |
just release-preflight | evidence of a green run at HEAD | no |
The copr job never runs on a pull request regardless of the filter above --
mock needs a privileged container.
The pull-request filter is a changes job running
.github/workflows/scripts/classify-changes.sh, which classifies the merge-base
diff in plain bash and emits one output per lane. Each job gates on its own
output -- if: needs.changes.outputs.deb == 'true' for the Debian suites,
rpm for Fedora, arch for the Arch package, release_binaries for the
Arch-container build the Fedora lanes stage from, release_matrix for the
version-ordering matrix -- which reports a real "skipped" conclusion; GitHub's
own paths: filter would leave a required check pending forever instead.
A path only one family's recipe or harness reads selects that family's lane. Everything else that reaches a package selects every lane; when in doubt, every lane.
| Changed path | Lanes |
|---|---|
debian/, dist/apt/, test/*deb*, test/*apt*, .github/workflows/scripts/*deb* | deb |
dist/facelock.spec, dist/rpm/, .packit.yaml, test/Containerfile.{fedora,copr*,rpm*,packit}, test/*rpm*, test/*copr*, test/fedora-lane-image.sh, .github/workflows/scripts/*rpm* | rpm, release_binaries |
dist/PKGBUILD*, dist/facelock.install, dist/facelock-pam-remove.hook, test/*arch*, .github/workflows/scripts/*aur* | arch |
test/release-* | release_matrix |
the rest of dist/, systemd/, dbus/, config/, scripts/, justfile, Cargo.toml, Cargo.lock, crates/*/Cargo.toml, test/Containerfile*, test/*pkg*, the shared PAM/polkit/TPM validators, .github/workflows/packaging.yml, .github/workflows/release.yml, the other workflow scripts, .github/actions/ | all |
crates/facelock-cli/src/commands/pam.rs, commands/daemon.rs, lifecycle.rs | all |
any other file under crates/ | release_binaries |
Any package lane also selects release_matrix, since the versions it orders
live in debian/changelog, the spec and the PKGBUILD; rpm also selects
release_binaries, which it stages from. The three Rust files are listed because facelock pam remove --all runs from %preun, from Arch's
pre_remove and from Debian's prerm, so a change to that command can abort a
package removal without touching a packaging file. ci.yml and the other
non-packaging workflows select nothing; a container digest bump inside
packaging.yml itself still selects every lane, because a path cannot say
which job's image moved. just test-classify-changes pins the table.
Residual risk. A Rust change outside those three files runs only the
release_binaries lane on its own pull request: just build-release in the
pinned Arch container, which proves the workspace still compiles the way the
packages consume it, in minutes. It does not build or boot a package. A Rust
change to daemon startup, a new runtime dependency, a file the spec does not
ship: each of those leaves its own pull request green with the deb, rpm and
Arch lifecycle jobs reported as skipped. Do not read that as
packaging-verified. The nightly matrix catches it within a day, and the
release gate below catches it before anything ships. When a change is
packaging-relevant in a way the filter cannot see, run the lane by hand or add
the path to classify-changes.sh.
The COPR jobs go further and skip pull requests entirely. Each one compiles
the workspace inside a mock chroot, which needs a privileged container, and no
pull request has been shown to get one on a rootless-podman runner; making that
a required check before it is proven would block every packaging merge on an
unproven capability. So a COPR-only break — something that shows up when the
package is rebuilt from source or run against Fedora's system ONNX Runtime, and
not otherwise — survives its own pull request even when the filter fires. The
nightly and the pre-release workflow_dispatch are unfiltered and do run them;
locally, just test-copr-lanes.
The Debian lanes skip the .dsc rebuild on pull requests. The full gate
compiles the workspace twice per suite: once to assemble the candidate .deb,
once more from the extracted .dsc in a clean image to prove the source
package rebuilds standalone. Both are lto = true release builds and no cache
applies, so the rebuild is about ten of the lane's twenty-six minutes on a
runner that has reached the 90-minute cap (#337). packaging.yml sets
FACELOCK_DEB_SKIP_DSC_REBUILD=1 on pull requests, which drops that second
compile and nothing else: the .deb is still built from source with the
network denied, its dependency closure still proved, the booted lifecycle still
run. A lane that skipped the rebuild records depth=partial, which the release
matrix requires of nothing, so just release-preflight refuses it -- it refuses
pull-request runs regardless. The nightly, the dispatch, and a local
just test-deb or just test-packaging-matrix keep the rebuild. What survives
a pull request, then, is an incomplete source package: a file the
.dsc does not carry, or a build that only works from the Git checkout. The
nightly catches it within a day, the release gate before anything ships.
Release preflight (recommended)
Run this before creating/pushing a release tag:
just test-arch-loopback # the same tiers on a synthetic camera; records the commit
just test-arch-camera-required # or: camera + a person in frame; records the commit
gh workflow run packaging.yml --ref main # the packaging matrix, at this commit
just release-preflight # stable release checks
just release-preflight v0.2.0-rc.1 # prerelease checks; no stable secret access
just check
just test-arch-pam
just test-arch-camera-free
The end-to-end tiers come first because preflight cannot run them. It refuses to pass until one of two records names HEAD, so run whichever you choose after the last commit that will ship, not before:
just test-arch-loopbackrunstest-arch-integrationandtest-arch-oneshotagainst a v4l2loopback node fed with a procedurally rendered face, withrequire_irandrequire_frame_varianceon, and writes the commit to.loopback-tier-verified. No camera, no person, a few minutes. It proves the capture, IR classification, liveness, enrollment, daemon, one-shot and PAM paths end to end; it cannot prove that a real sensor's frames match a real face. The loopback nodes it needs and themodprobeline are in Testing Safety.just test-arch-camera-requiredruns the same two tiers against/dev/video*with a person in frame and writes the commit to.hardware-tiers-verified. It is the only run that proves real-sensor recognition of a real face.
Either satisfies the gate (test/e2e-tier-evidence.sh). Those two tiers are
the only automated evidence that face authentication works end to end: real
D-Bus activation, the real PAM stack, real capture, and the one-shot path PAM
falls back to. Nothing else ran them, and three of their assertions rotted
undetected as a result (#139). A run done by hand at this exact commit is
acknowledged by naming it: FACELOCK_LOOPBACK_TIER_ACK=<sha> or
FACELOCK_HARDWARE_TIERS_ACK=<sha> on the just release-preflight command.
Preflight also refuses to pass without complete packaging evidence for HEAD.
Every packaging lane writes a record of what it claimed and what it counted,
and test/packaging-evidence.py accepts the set only when every lane the
release matrix requires is present at this commit with zero skips and the ONNX
models on hand (the contract is in docs/contracts.md, "Packaging matrix
evidence"). That set includes a COPR lane per Packit release target beside the
direct-RPM lane, so a green Fedora .rpm result alone leaves the evidence
incomplete. Preflight reads it from the packaging-evidence-* artifacts a
successful packaging.yml run at that exact commit uploaded, fetched with
gh run download, or from .packaging-matrix-verified, which
just test-packaging-matrix writes after running every lane locally. A run's
green conclusion alone is not evidence: a path-filtered pull-request run skips
every lane and still concludes "success". A pull-request run cannot satisfy it
either: it builds the merge commit, not the commit being released. A
FACELOCK_ALLOW_MISSING_MODELS=1 run is a diagnostic: it writes its partial
lane records, and the marker is withheld. The one-line commit marker from before
0.2.0 is refused with a message naming the new format. A nightly run does not
satisfy it either: nightly builds whatever main was at 07:00 UTC, and a
release commit is a version bump nobody has built a package from yet.
just release-preflight checks local tools, required packaging files (including
.packit.yaml), and whether AUR_SSH_KEY, APT_GPG_PRIVATE_KEY, and
APT_GPG_PASSPHRASE are configured in GitHub secrets (via gh). COPR needs no
secret — it is driven by Packit. Preflight and CI also read the public
production COPR API and require its enabled chroots to equal the checked-in
authority: Fedora 43/44/45 are required and Rawhide is the only optional
experimental chroot. Rawhide may be present or absent; a missing required
chroot or any unknown extra is release-blocking drift. Preflight goes one step
further than CI and asks what production COPR actually serves: the EVR of the
predecessor pinned in dist/release-matrix.json. The checker never modifies the
project. Preflight always runs
packit config validate --offline against .packit.yaml, in the digest-pinned
Fedora container built from
test/Containerfile.packit — the same real schema gate just test-copr runs,
reachable without a host packit install. It has no skip path: podman is a
preflight prerequisite, and without it the gate fails rather than passing
unrun. just test-packit-config runs the same gate on its own.
Preflight also holds the APT compatibility window: main and legacy
compatibility suites present until 0.3.0, as dist/release-matrix.json
declares, and absent from the first 0.3.0 tree on. just test-apt-repo proves
the published shape when run, as a clean APT client; no workflow runs it.
Package repository setup (one-time)
AUR (Arch Linux)
Automated after setup. Every stable release run attempts AUR publication after
the GitHub Release becomes public. If AUR_SSH_KEY is absent, the publisher
prints a skip notice and exits successfully; an invalid configured key fails
after publication and needs manual recovery. Release operators must therefore
verify the live AUR packages rather than infer publication from a green job.
One-time setup (~10 minutes):
-
Create an AUR account at https://aur.archlinux.org/register
-
Add your SSH public key to your AUR account at https://aur.archlinux.org/account
-
Register the package names. CI's
publish-aur.shwill create any of these on first push if they don't already exist, but you can also pre-register them manually:REPO_ROOT="$(pwd)" # facelock (source build — default for `yay -S facelock`) git clone ssh://aur@aur.archlinux.org/facelock.git aur-facelock cd aur-facelock cp "$REPO_ROOT/dist/PKGBUILD" . cp "$REPO_ROOT/dist/facelock.install" . # dist/PKGBUILD ships a __SRC_SHA256__ placeholder; substitute the real # tarball digest before pushing or the recipe refuses to build. Download # first, hash the file after: piping curl into sha256sum hashes empty # input when the download fails, and that digest must never be published. TAG="$(sed -n 's/^_tag=//p' PKGBUILD)" curl -fSsL -o "/tmp/facelock-v${TAG}.tar.gz" \ "https://github.com/tyvsmith/facelock/archive/v${TAG}.tar.gz" && SUM="$(sha256sum "/tmp/facelock-v${TAG}.tar.gz" | cut -d' ' -f1)" && sed -i "s/__SRC_SHA256__/${SUM}/" PKGBUILD makepkg --printsrcinfo > .SRCINFO git add PKGBUILD facelock.install .SRCINFO git commit -m "Initial commit" git push cd .. # facelock-bin (prebuilt binaries from the GitHub Release — no cargo build) git clone ssh://aur@aur.archlinux.org/facelock-bin.git aur-facelock-bin cd aur-facelock-bin cp "$REPO_ROOT/dist/PKGBUILD-bin" PKGBUILD cp "$REPO_ROOT/dist/facelock.install" . makepkg --printsrcinfo > .SRCINFO git add PKGBUILD facelock.install .SRCINFO git commit -m "Initial commit" git push cd .. # facelock-git (VCS package tracking main) git clone ssh://aur@aur.archlinux.org/facelock-git.git aur-facelock-git cd aur-facelock-git cp "$REPO_ROOT/dist/PKGBUILD-git" PKGBUILD cp "$REPO_ROOT/dist/facelock.install" . makepkg --printsrcinfo > .SRCINFO git add PKGBUILD facelock.install .SRCINFO git commit -m "Initial commit" git push -
Generate an SSH key for CI and add the public key to your AUR account:
ssh-keygen -t ed25519 -f aur-deploy-key -N "" -
Add the private key as a GitHub repository secret named
AUR_SSH_KEY:gh secret set AUR_SSH_KEY < aur-deploy-keyOr use the web UI: https://github.com/tyvsmith/facelock/settings/secrets/actions
After this, every non-prerelease tag push automatically updates the AUR package.
COPR (Fedora)
Packit reads .packit.yaml from the released tag. Packit's documented
upstream_tag_exclude filtering applies to downstream synchronization jobs,
not to copr_build, so it is not a prerelease safety boundary.
The production tyvsmith/facelock job sits at trigger: release, restored by
hand for the stable release. A prerelease-capable configuration parks it back
at trigger: ignore, which makes an alpha-tagged config structurally incapable
of selecting a release-triggered production project. just release-preflight
rejects a production release job for a prerelease and rejects its absence for a
stable, so the trigger moves with the version instead of drifting away from it.
The deliberate stable restoration targets fedora-43-x86_64,
fedora-44-x86_64, and the separate fedora-45-x86_64 branched target.
Rawhide is Fedora 46 development in this matrix, not an alias for Fedora 45,
and not a staging or production Packit target. A configuration that targets
Rawhide for release fails the matrix check.
.packit.yaml deliberately uses JSON syntax, which is a valid YAML subset.
Release guards therefore parse its jobs semantically with the Python standard
library instead of comparing YAML spelling; general YAML outside that subset
fails closed. The production project is tyvsmith/facelock; the prerelease
staging project is tyvsmith/facelock-testing, covered below. Issue #236 still
owns the remaining staging infrastructure, so changing the project's chroots or
permissions belongs there, not to a release-identity change.
The COPR RPM is built from source with the spec's default
%bcond_with bundled_ort mode and does not bundle ONNX Runtime. Its
BuildRequires/Requires: onnxruntime use Fedora's runtime-only package; the
package check asserts onnxruntime-devel is absent and creates a real ORT
session from the checksum-pinned minimal model in test/fixtures/. (The ort
crate feature api-20 keeps the binary compatible with Fedora's runtime.)
One-time setup (~10 minutes):
- Create a Fedora Account at https://accounts.fedoraproject.org
- Log in to COPR at https://copr.fedorainfracloud.org and ensure the
tyvsmith/facelockproject exists with thefedora-43-x86_64,fedora-44-x86_64, andfedora-45-x86_64chroots enabled. The optionalfedora-rawhide-x86_64experimental chroot may be enabled or absent; no other chroot is allowed (Settings → Chroots). - Install the Packit-as-a-Service GitHub App on the repository: https://github.com/marketplace/packit-as-a-service
- In the COPR project → Settings → Permissions, grant the
packituser admin permission, and in the "allowed forge projects" field addgithub.com/tyvsmith/facelock. Builder permission is enough to build and not enough to edit the project, which is a distinction Packit makes for you: it reconciles the project against.packit.yamlbefore submitting anything, and a reconciliation it is not allowed to perform aborts every target. See "Why v0.1.4 never reached COPR" below. - In the COPR project → Settings, enable "Enable internet access during
builds". The RPM is built from source and
cargofetches crates from crates.io during%build; COPR's build chroot is network-isolated by default, so this toggle is required or the build fails resolving crates. The toggle is half of it: COPR takes network access per build, and a Packit submission carries its own value..packit.yamlmust also declareenable_net: trueon everycopr_buildjob, because Packit's default is false and it wins over the project. See "Why v0.2.0 built nothing in COPR" below.
Step 4's allowlist and step 5's toggle apply to both channels and are checked on
every pull request by python3 test/check-live-release-channels.py, which reads
them off the public project response. Step 5's .packit.yaml half is not in that
response and is checked by test/check-release-matrix.py instead. The permission
grant is not public, so it stays a hand-confirmed step -- and it is the one
that failed silently for v0.1.4.
Verify the COPR build locally before relying on it with just test-copr, which
reproduces the Packit SRPM + mock from-source rebuild on a Fedora chroot and
checks that the payload has no bundle while its dependencies select Fedora ORT.
Only a stable-tagged config with the deliberately restored production release trigger can populate production COPR automatically. A prerelease tag never points at production; staging below is where a candidate gets built.
Why v0.1.4 never reached COPR
Packit was installed, the release trigger was correct, and the release event
reached it. Every target still failed at submission with Copr project update failed for 'tyvsmith/facelock' project., thirteen seconds after publication
(#333).
Packit reconciles the COPR project against .packit.yaml before it submits
anything, and it edits the project whenever the config's targets are not
already a subset of the project's enabled chroots. The v0.1.4 config listed
fedora-42-x86_64, which the project had never enabled and which COPR no
longer offers. Editing a COPR project requires admin, and until this
section was written the setup steps above asked for builder. One chroot the
project did not have cost all three builds. v0.1.3 failed identically and
reached COPR only because it was submitted by hand fourteen minutes later.
Two rules follow, and both are enforced:
.packit.yamltargets must be a subset of the project's enabled chroots. The release matrix bindsfedora.packit_release_targetstocopr_channels.production.required_supported_chroots, andtest/check-live-release-channels.pyrequires the live project to enable every one of them, so a target the project lacks fails before the tag.- A COPR build that never lands must fail something. Nothing in the release
run can observe Packit's submission — it happens outside the run, after
publication — so the release workflow's
verify-coprjob polls the public COPR API for the released EVR and fails the run when it never appears. Before this existed, the chroot comparison passed on every day of the three months COPR served 0.1.3.
Packit's default fix-spec-file action rewrites Release: 1%{?dist} to
1.{timestamp}.{ref}, which would publish a v0.2.0 release as
facelock-0.2.0-1.20260904220135575676.v0.2.0 rather than the
facelock-0.2.0-1 the conversion table promises. The production copr_build
job therefore carries update_release: false, and production's served
comparison is an equality.
The mismatch would not stay cosmetic. RPM ranks the suffixed build above the canonical one:
0.2.0-1.fc44 < 0.2.0-1.20260904220135575676.master.0.g7d9ffe7.fc44
So on a machine with both the COPR repo and a directly installed RPM of the same
release, COPR wins every dnf update and the version the other channels ship
never takes hold. Packit's own documentation warns that an inherited release
suffix breaks NVR ordering. Pinning the release is what keeps every channel's
0.2.0 the same 0.2.0.
Staging keeps the default. It builds every pull request into one project, so
its NVRs have to differ from each other, and the snapshot suffix is what makes
them; its comparison ends at the boundary dot instead, accepting 0.2.0-1 and
0.2.0-1.<anything> while still refusing 0.2.0-11. The flag and the
comparison are one contract in test/check-release-matrix.py: neither channel
can change one without the other, because either half alone reds every stable
release.
What is proven, and what is not. The schema accepts the per-job flag
(just test-packit-config), Packit's own config parser resolves it to False
for the production job while staging stays True, and packit srpm with the
flag set produces facelock-0.2.0-1.fc44.src.rpm against
facelock-0.2.0-1.20260904220135575676.master.0.g7d8eef8.fc44.src.rpm without
it. What no local check can reach is packit-service applying the job's flag on a
real release event: just test-copr builds its SRPM through the CLI, which
reads package-level config, so that lane still carries the snapshot suffix and
proves the package builds rather than the EVR it will be published under.
The first stable tag after this change is the proof. If that release
publishes a suffixed EVR anyway, the service ignored the job-level flag and
verify-copr fails on an otherwise healthy build. The response is to delete
update_release from the production job and set its served_evr_exact back to
false, taking the suffix on both channels again.
Do not hoist update_release to the top level to force it. Top level
reaches staging too, and staging builds every pull request into one project:
without the snapshot suffix two pull requests produce the same NVR. Scoping the
flag to the production job is the whole reason production and staging can differ
here, so the fallback for a flag the service ignores is to stop asking for the
canonical EVR, not to ask for it somewhere that breaks the other channel.
just release-preflight asks the same question about the previous release:
test/check-live-release-channels.py --expect-predecessor requires production
COPR to serve the EVR pinned in predecessors. The v0.1.4 build was never
backfilled — 0.2.0 supersedes it — so that gap is recorded in
copr_channels.production.served_evr_gap and reported rather than failed. The
record names both EVRs and issue #333, and it retires itself: once the
predecessor pin moves past v0.1.4 the release matrix contract fails until the
record is deleted.
A recovery after a failed submission is a hand-submitted build from a checkout of the tag. Packit reacts only to new release events, so re-publishing is not an option:
git checkout vX.Y.Z
packit srpm --no-update-release
copr-cli build tyvsmith/facelock facelock-X.Y.Z-1.*.src.rpm \
-r fedora-43-x86_64 -r fedora-44-x86_64 -r fedora-45-x86_64
--no-update-release is not optional. The Packit CLI reads package-level
config, not a job's, so it would otherwise apply the snapshot suffix the
production job pins off and hand production an EVR its own gate refuses.
packit build in-copr has no equivalent flag, which is why the recovery goes
through copr-cli with an SRPM built locally. Name the three supported chroots:
copr-cli build with no -r builds every chroot the project has enabled, which
picks up the optional Rawhide one. This path needs a COPR API token in
~/.config/copr, which the Packit CLI did not.
Why v0.2.0 built nothing in COPR
v0.2.0 fixed everything v0.1.4 got wrong. Packit triggered on the release event,
reconciled the project, and submitted all three chroots under the canonical
0.2.0-1. Every one of them then failed in %build, four minutes in, on
Could not resolve host: index.crates.io.
enable_net is a per-build value, not a project one. The project toggle is the
default COPR applies to a build that does not carry its own, and a Packit
submission always carries its own: Packit's enable_net defaults to false and
is sent on every build it creates. So the toggle was on, the build task read
'enable_net': False, and the chroot had no resolver while cargo needed
crates.io. v0.1.3 is the control -- same spec, same project, and it built,
because a human submitted it with copr-cli, which omits the field and lets the
project default stand.
Three things had to be true at once for this to reach production, and each is now closed:
- The gate read the wrong layer.
test/check-live-release-channels.pycompares the public project response, which is the only placeenable_netappears before a build exists, and the value there was correct.test/check-release-matrix.pynow requiresenable_net: trueon everycopr_buildjob in.packit.yaml, which is the layer that decides. - The local lane hardcoded the answer.
just test-coprbuilds the Packit SRPM and rebuilds it undermock, and it passed--enable-networkunconditionally, so it modelled a chroot COPR was never going to give it. The lane now reads the flag out of.packit.yamland gets the same network the real submission would. - The docs asked for the toggle and stopped. Setup step 5 now names both halves.
Recovery is the hand-submitted build above. copr-cli sends no enable_net
unless --enable-net is passed, so it inherits the project toggle and needs
nothing else.
Staging COPR (tyvsmith/facelock-testing)
The project exists, with exactly the fedora-43-x86_64, fedora-44-x86_64,
and fedora-45-x86_64 chroots, internet access during builds enabled, and
github.com/tyvsmith/facelock on its Packit forge allowlist. Those three
properties are contract-checked on every pull request, not just described here:
test/check-live-release-channels.py reads all of them off the project response
and fails on any of them. dist/release-matrix.json records the claim as
copr_channels.staging.provisioned: true and holds the expected forge project
in copr_channels.staging.required_forge_project.
Builder permission for the packit user is the one setup step no gate can see.
COPR serves project permissions only to an authenticated owner, so the checker,
which reads the public project API, cannot compare them. Confirm that one by
hand in the web UI.
.packit.yaml declares a second copr_build job for it, on
trigger: pull_request with manual_trigger: true. Packit therefore offers the
build on a pull request and a maintainer dispatches it by hand with a
/packit build comment; nothing builds into staging on its own, and a tag never
publishes into staging.
python3 test/check-live-release-channels.py --channel staging now queries the
real project and compares it with the checked-in authority, on every pull
request in CI and again in just release-preflight. A chroot that appears or
disappears in COPR fails that gate, as does internet access switched off or a
forge allowlist that stops naming this repository.
The trigger and the switch move together, and test/check-release-matrix.py
enforces the pairing in both directions: provisioned: true requires
trigger: pull_request, and provisioned: false requires trigger: ignore.
Setting the switch back without moving the trigger, or the reverse, fails the
release matrix contract. The pairing is what stops a pull-request trigger from
aiming every Packit run at a project that answers 404.
Provisioning was three edits, and the contract rejected any two of them without
the third: the switch in dist/release-matrix.json, the trigger in
.packit.yaml, and retiring the staging COPR provisioning must stay unclaimed until issue #236 creates the project assertion in
test/check-release-matrix.py. That third assertion existed so provisioning
could not be claimed by a config change alone; retiring it was the moment
someone confirmed the project really exists. The live comparison above is what
holds the claim honest from here.
Staging tolerates no optional experimental chroot. Production accepts Rawhide's presence or absence; in staging any chroot beyond the supported three is drift.
Pre-tag attestation
scripts/release-attestation.py renders and validates the document that binds a
candidate to what its channels serve: the candidate commit, the EVR each channel
serves per target, artifact and repository digests, signing key fingerprints, and
when each channel last refreshed its repository metadata.
python3 scripts/release-attestation.py render --input facts.json --output attestation.json
python3 scripts/release-attestation.py validate --attestation attestation.json --expect expect.json
validate fails closed on a drifted candidate commit, a served EVR or digest
that disagrees with the recorded expectations, a changed signing fingerprint,
metadata older than metadata_max_age_seconds or stamped in the future, and on
any channel carrying the production COPR identity. Gathering those facts from
live staging repositories is issue #236's remaining infrastructure work; the
script and its contract cases run against fixtures today.
Note: a previously published release will not retroactively build — Packit reacts only to new Release events.
The old COPR_WEBHOOK_URL GitHub secret is no longer used and can be deleted
(gh secret delete COPR_WEBHOOK_URL).
APT (Debian/Ubuntu)
Automated after setup. Every stable release run attempts to build a signed APT
repository before it publishes the GitHub Release. APT_GPG_PRIVATE_KEY and
APT_GPG_PASSPHRASE are required; if either is absent or invalid, the stable
release remains unpublished.
One-time setup (~15 minutes):
-
Generate a GPG signing key (if you don't have one):
gpg --full-generate-key # Select RSA 4096, expiry 3y # UID: Ty Smith (Package Signing) <packages@m.tysmith.me> -
Export and add the private key as a GitHub secret:
gpg --armor --export-secret-keys "packages@m.tysmith.me" | gh secret set APT_GPG_PRIVATE_KEY -
Add the passphrase as a GitHub secret:
gh secret set APT_GPG_PASSPHRASE --body "your-passphrase"Or use the web UI: https://github.com/tyvsmith/facelock/settings/secrets/actions
The repository configuration lives in dist/apt/conf/distributions. Two
codenamed suites are published:
trixie: Debian 13 TPM build using Trixie Backports Rust/Cargoresolute: Ubuntu 26.04 TPM build using native Rust/Cargo
Two compatibility suites are published alongside them until 0.3.0, for clients whose source entry was written for v0.1.4:
main: thetrixiepackage, included bypublish-apt.shfrom the same validated artifactlegacy: no package;reprepro exportwrites signed empty indexes soapt updatekeeps succeeding
Those clients must replace the suite in their Facelock source entry
with their operating-system codename before 0.3.0. dist/release-matrix.json
declares the window under apt_suites.compat, and check-release-matrix.py
fails the first tree at or past retire_at that still carries the stanzas.
Prerelease packages are never inserted into any of these stable suites.
Stable publication requires exactly one suite-matching package for both
codenames before signing or repository writes begin.
The APT repo is hosted at https://tysmith.me/facelock/apt/ alongside the docs site. The public keyring is at https://tysmith.me/facelock/apt/tysmith-archive-keyring.gpg. It carries one rsa4096 key: Ty Smith (Package Signing) <packages@m.tysmith.me>, fingerprint E7F8A4C424C6D59BD38536B536A81FCD934C17CE, expiring 2029-03-27. dist/release-matrix.json pins that fingerprint, uid, and expiry under apt_signing_key. test/check-release-matrix.py fails the tree if docs/quickstart.md or this file quotes a different fingerprint or drops the pinned one, so rotate the pin and both docs together.
GPG key rotation: The fetched keyring is a client's only trust root; nothing redelivers it on its own. A client that never re-fetches stops verifying the repository once the key expires (2029-03-27) or is rotated early, because apt refuses an expired or unrecognized key's signature. Renewing the expiry instead of rotating does not help: gpg --edit-key <key-id> expire writes a new self-signature onto the same exported key, and a client's already-fetched copy still carries the old one. Redistribution is required either way.
The publisher enforces the pin at sign time: it refuses to sign unless the imported key's fingerprint, uid, and expiry all match apt_signing_key, naming the mismatch. Rotating the APT_GPG_PRIVATE_KEY secret without updating the pin therefore fails the release rather than publishing a keyring the docs do not match. When the signing key changes, generate a new key, update APT_GPG_PRIVATE_KEY and APT_GPG_PASSPHRASE, update the apt_signing_key pin and both docs, and cut a new release; just test-release-matrix fails once today reaches the pinned expiry, and fails again if the pin and the docs disagree. A keyring package that lets an installed client pick up the new key through apt upgrade is tracked in #346.
Manual AUR update (fallback)
If CI is not configured or fails:
- Download the release tarball, then compute the checksum from the file.
Piping curl into sha256sum prints the digest of empty input when the
download fails; downloading first prints no digest at all:
curl -fSsL -o "facelock-v$VERSION.tar.gz" \ "https://github.com/tyvsmith/facelock/archive/v$VERSION.tar.gz" && sha256sum "facelock-v$VERSION.tar.gz" - Clone the AUR repo (first time only):
git clone ssh://aur@aur.archlinux.org/facelock.git aur-facelock - Copy
dist/PKGBUILDanddist/facelock.installinto the AUR repo - Replace the
__SRC_SHA256__placeholder in the PKGBUILD with the real checksum from step 1 - Generate
.SRCINFO:cd aur-facelock makepkg --printsrcinfo > .SRCINFO - Commit and push to AUR:
git add PKGBUILD facelock.install .SRCINFO git commit -m "Update to v$VERSION" git push
Version Sources
The canonical version is in the root Cargo.toml under [workspace.package].
The version fields synced by just release are:
| File | Field |
|---|---|
Cargo.toml | [workspace.package] version |
dist/PKGBUILD, dist/PKGBUILD-bin | upstream _tag, converted pkgver, package pkgrel |
dist/PKGBUILD-git | converted display pkgver |
dist/facelock.spec | converted Version and monotonic prerelease Release |
debian/changelog | converted upstream and package revision in first entry |
The independently maintained dist/release-matrix.json records supported
targets, lifecycle depth, and immutable environment identities. Release
preflight, CI, and release metadata checks validate that authority; just release does not rewrite it.
The version facelock-git actually installs
dist/PKGBUILD-git's pkgver field is display only. AUR's web page and
.SRCINFO show it because makepkg --printsrcinfo runs without a checkout to
describe, and just release keeps it level with the release so the page does
not drift. What a build installs is whatever pkgver() computes at build time:
<released pkgver>.r<commits since that tag>.g<abbreviated object name>
git describe --abbrev=7 sets a floor, not a width: the object name is seven
hex characters, or more where seven would be ambiguous. So a build off v0.1.4
reads like 0.1.4.r650.ga8c48b7, and one off v0.2.0-alpha.1 like
0.2.0alpha1.r7.gdeadbee. Two properties make that version usable, and both are
enforced rather than assumed:
- it must outrank the release it descends from, or pacman refuses the upgrade and every AUR helper reports the package as permanently out of date
- it must rank below the next release, or the git package blocks the real one
Four things earn that, and all four were live faults (#330):
--tags | Every release tag since v0.1.2 is lightweight. Without it, describe walks back to the last annotated tag, v0.1.0-rc4. |
--match 'v[0-9]*' | The repository carries a non-version tag (assets), and describe takes it whenever it sits nearer HEAD. |
stripped leading v | pacman ranks an alphabetic first segment below a numeric one, so a surviving v sorts the build under every release. |
| converted prerelease suffix | v0.2.0-alpha.1 becomes 0.2.0alpha1, the same conversion the released package gets. pacman compares separator runs before segments, so keeping the punctuation ranks the build above 0.2.0alpha2, 0.2.0beta1 and the stable 0.2.0 alike. |
test/release-version-contract.sh holds the recipe to that shape against a
synthetic tag graph, and test/release-native-ordering.sh hands the result to
vercmp inside the pinned Arch container. release_arch_git_pkgver in
scripts/release-versions.sh is the one definition both read.
ONNX Runtime Bundling
The ort crate is built with feature api-20, so facelock requires ONNX Runtime
1.20 or newer at runtime. ONNX Runtime is forward-compatible, so a single
build works against any runtime ≥ 1.20.
ONNX Runtime is sourced differently per channel:
- GitHub-Release
.deb: bundles CPU-only ORT 1.20.1 under/usr/lib/facelock/, because ONNX Runtime is not available in Ubuntu repositories. - GitHub-Release direct
.rpm: builds the spec with--with bundled_ort, installslibonnxruntime.so.1under%{_libdir}/facelock/, and has no systemonnxruntimedependency. - COPR RPM (built from source by Packit): leaves the spec's
%bcond_with bundled_ortdisabled, contains no bundled runtime, and requires Fedora's systemonnxruntimepackage. - Arch Linux (PKGBUILD): depends on the virtual
onnxruntimecapability. The exact official-repository providers areonnxruntime-cpu,onnxruntime-opt-cuda, andonnxruntime-opt-rocm; there is no package literally namedonnxruntime.
The bundled ORT is a CPU-only fallback — users who install a system-wide GPU-enabled ONNX Runtime (CUDA, ROCm, OpenVINO) will have it take precedence automatically (the search order prefers system paths over the bundled copy).
The reviewed pins in .github/workflows/release.yml include the version,
upstream URL, archive and library SHA-256 values, upstream commit, and MIT
license identity. The download job verifies the archive before extraction and
the library after extraction, then emits manifest.json, SHA256SUMS, and
PROVENANCE.md beside upstream LICENSE, ThirdPartyNotices.txt,
VERSION_NUMBER, and GIT_COMMIT_ID. Direct RPM assembly re-verifies those
inputs and enters .github/workflows/scripts/run-networkless.sh before creating
the source archive or rpmbuild tree. That wrapper uses util-linux enosys to
deny socket and io_uring network syscalls, closes inherited non-stdio file
descriptors, and requires its network probe to fail with ENOSYS before
invoking rpmbuild; CARGO_NET_OFFLINE=true remains defense in depth. The RPM
ships the reviewed inputs under its package documentation/license directories
for SBOM and provenance consumers.
When upgrading the ort crate dependency, update every reviewed ORT pin and
the RPM bundle filename together and, if the crate requires a higher floor,
the api-NN feature in crates/facelock-face/Cargo.toml.
Rawhide may be attempted only with the digest-pinned experimental environment
recorded in dist/release-matrix.json. A Rawhide system-ORT build/session smoke
is best effort: absence or failure is nonblocking and can never stand in for
lifecycle, upgrade, rollback, artifact, served-version, availability, or alpha
release evidence. It must not publish or modify a COPR channel.
Upgrade Safety
Since facelock is a PAM module, broken releases can lock users out. Every release must:
- Pass
just check(tests + clippy + fmt) - Pass
just test-arch-pam(Arch container PAM smoke tests) - Pass
just test-arch-camera-free(camera-free daemon and one-shot E2E) - Pass
just test-arch-loopback(synthetic camera, no person) orjust test-arch-camera-required(a camera and a person in frame, the only run that proves real-sensor recognition) against the final release commit;just release-preflightfails until one has - Pass
just test-rpmandjust test-deb(multi-distro package validation) - Not change PAM auth semantics without explicit changelog entry
- Preserve
/etc/pam.d/sudobackup on install (/var/lib/facelock/pam-backups/sudo.<timestamp>) - Default to
PAM_IGNOREon internal errors (fall through to password)
Upgrading from the last release
just test-upgrade-v014 proves that state written by v0.1.4 survives an upgrade
to the candidate and a rollback back to v0.1.4. Two lanes, Debian trixie and
Fedora 44, each install the real published artifact rather than a synthesized
older build of the candidate.
What the lanes pin. dist/release-matrix.json carries a predecessors block
holding the GitHub release id, the asset id, the SHA256 and the byte size of
each predecessor artifact. The lane Containerfiles take those as build args and
carry no digest of their own, so one review changes the pin everywhere.
just test-upgrade-v014-pins asks the release API whether those assets are still
the assets it serves, which is how a re-uploaded or substituted predecessor gets
caught before a lane silently proves something about a different file.
What the lane images carry. Each image installs the runtime libraries the
released binary needs before the predecessor goes on. v0.1.4 wrote its Debian
control file by hand and never declared libxkbcommon0, which its own binary
links, so that release cannot start on a minimal Debian 13 at all. The candidate
is built from debian/control and derives the list with ${shlibs:Depends}.
Nothing is masked by supplying it: candidate dependency resolution belongs to
test/deb-dependency-closure.sh on a pristine suite base.
What the lanes build. Predecessor state comes from the released v0.1.4
binary, never from the candidate: plaintext rows, keyfile-encrypted rows, mixed
rows, and two swtpm-sealed shapes, one PCR-bound and one not. Each shape also
carries a modified config, the reviewed models, an enrollment marker, an audit
log and a hand-wired PAM service, because v0.1.4 has no facelock pam
subcommand and that is the shape a real upgrade finds.
What each lane proves after the upgrade. The V5 database reaches V6 with
legacy rows at device_id = NULL. A known embedding still decrypts to the exact
plaintext it was enrolled as, which a file hash cannot show: a preserved key and
a preserved ciphertext nobody can open any more hash identically. No key
artifact is replaced and none appears that was not there before. Modes converge
to ADR 010 without content changing. The enrollment marker keeps its owner and
mode and its content is reconciled against the database rather than preserved
byte for byte — the one piece of state the upgrade is supposed to rewrite
(#137). The administrator's PAM service is byte-identical, a correct password
still authenticates, and a wrong one still fails.
Version ordering on a development tree. Until just release bumps the
workspace, the candidate .deb built from the tree is 0.1.4-1~deb13u1, which
sorts below the published 0.1.4-1. The lanes build the same payload as an
upgrade-test version instead, and every run prints the version it chose and why.
Once the workspace version sorts above 0.1.4 the re-versioning stops and
FACELOCK_UPGRADE_TEST_VERSION becomes a no-op: the lane installs the shipped
version exactly. The native comparator inside the container decides either way,
so a lane can never quietly become a downgrade test. Whatever version it lands
on is spelled by scripts/release-versions.sh, the same file the release
workflow uses, so a pre-release candidate reaches the lane as
0.2.0~alpha.3-1~deb13u1 rather than in a Cargo spelling neither packager
would ever ship. The RPM release counter it passes is local to the lane, not
the series counter from "Prerelease identity conversions" above: the lane's
only ordering requirement is against the published predecessor, never against
a previously published prerelease.
Upgraders from v0.1.4 already have face authentication enabled. That
release's pam-auth-update profile shipped Default: yes, so installing it
switched Facelock on in common-auth. The packaged profile is Default: no
now, which applies to fresh installs; an upgrade leaves the global stack exactly
as it found it, and the lane fails if it is edited in either direction. Removing
an enabled profile would take face authentication away from someone using it, so
the lane treats that as the more dangerous direction, not a clean result.
Where it runs. Locally, by design: a cached run is about twenty minutes and
a cold one considerably more, so packaging.yml does not carry it and a
nightly-only job is the follow-up. just check runs the container-free contract
(just test-upgrade-v014-contract), so a broken lane definition still fails
every pull request.
Rollback. The candidate daemon starts and migrates the database before the
downgrade, so the predecessor is handed the file production would hand it. V6
has no down-migration and the schema stays at 6 after the package rolls back.
See docs/contracts.md for what that does and does not guarantee.
Contributing
Prerequisites
- Rust 1.88+ (
rustup update) - Linux and the native build dependencies listed in Quickstart
- A camera only for live capture/authentication work; IR is required by the default configuration
- Podman (for container tests)
Building
cargo build --workspace
The unified binary is then target/debug/facelock; it is not installed on
PATH. See Developer Commands for the full inventory.
Workspace structure
Facelock is a Cargo workspace with 11 crates:
| Crate | Type | Purpose |
|---|---|---|
facelock-core | lib | Config, types, errors, D-Bus interface, traits |
facelock-camera | lib | V4L2 capture, auto-detection, preprocessing |
facelock-face | lib | ONNX inference (SCRFD + ArcFace) |
facelock-store | lib | SQLite face embedding storage |
facelock-daemon | lib | Auth/enroll logic, liveness, audit, rate limiting, handler |
facelock-cli | bin | Unified CLI (facelock binary, includes bench subcommand) |
facelock-bench | bin | Developer standalone benchmark utility; see Auxiliary Commands |
pam-facelock | cdylib | PAM module (libc + toml + serde + zbus only) |
facelock-tpm | lib | Optional TPM-bound encryption for embeddings at rest |
facelock-polkit | bin | Polkit authentication agent for face auth |
facelock-test-support | lib | Mocks and fixtures for testing, plus the facelock-synth-face fixture writer; see Auxiliary Commands |
Version is declared once in the root Cargo.toml and inherited via version.workspace = true. Inter-crate dependencies use relative paths.
Code style
- Error handling:
thiserrorfor library error types,anyhowin binaries. ReturnResult<T>over panicking. Neverunwrap()in library code. - Logging:
tracingfor structured logging. Control verbosity viaRUST_LOGenv filter. - Tests:
#[cfg(test)]modules in each source file. - Formatting:
cargo fmt(default rustfmt settings). - Linting:
cargo clippy --workspace -- -D warningsmust pass with zero warnings.
Dependency rules
The PAM module (pam-facelock) must stay lightweight: libc, toml, serde, zbus only. No ort, no v4l, no facelock-core. This keeps the shared library small and avoids dragging heavy dependencies into every PAM-using process.
Each crate has a defined dependency boundary. See the Contracts chapter for the full table.
Testing
Tier 1: Unit tests (no hardware)
cargo test --workspace
cargo clippy --workspace -- -D warnings
Run these before every commit. They require no camera or models.
Tier 2: Hardware tests (camera + models)
cargo test --workspace -- --ignored
Requires a connected camera and downloaded models. These tests are marked #[ignore] and skipped by default.
Tier 3: Container tests (requires podman)
just test-arch-pam # Arch PAM smoke tests (no camera)
just test-arch-integration # end-to-end with camera (daemon mode)
just test-arch-oneshot # end-to-end with camera (no daemon)
just test-arch-dev-shell # interactive container shell for debugging
Container tests validate PAM integration without risking host lockout.
Tier 4: VM testing
Use a disposable VM with snapshots for testing PAM changes against real login flows.
Tier 5: Host PAM testing
Only after tiers 3--4 pass. Always keep a root shell open. Start with sudo only -- never add Facelock to login or display manager PAM until sudo works reliably.
All checks at once
just check # full local validation aggregate, including audit and docs/contracts
just check does not run the full packaging matrix or camera-required lanes.
For documentation-only changes, start with just check-docs and
just docs-site-check; use source review and the established behavior tests
to check meaning, and a targeted container probe for uncertain distro commands.
Translations
Facelock is wired for gettext but not yet translated. po/ holds only the two
.pot templates, and that is the intended state -- there are no .po files to
review, and no language is shipped.
Two catalogs, deliberately separate and never merged: facelock for the CLI
(extracted from the message seam in crates/facelock-cli/src/message/) and
pam_facelock for the PAM module, which has its own hard dependency ceiling.
just pot # regenerate po/*.pot from source (translators and CI only)
just mo # compile po/<lang>/*.po into target/locale for local verification
gettext is a build dependency of every package but stays optional for a source
install: English is compiled in as the fallback, so just install-files on a
machine without msgfmt installs untranslated rather than failing.
Installing a catalog is scripts/install-locale-catalogs.sh, and every
install path calls it -- deb, rpm, the three PKGBUILDs, Nix, and the source
install that OpenRC, runit and s6 systems use. just test-locale-install-contract
is what holds that together; it builds a throwaway pseudo-locale, because with
no .po in the tree nothing else would notice a broken install path until the
first translation landed. Add a packaging path, wire it there too.
Two things are still missing, both tracked in #140:
- a long tail of CLI print sites (counted per domain in #140) still writes
English directly instead of going through the message seam, so a translation
would cover the converted subset only. The conversion pattern is documented
in
crates/facelock-cli/src/message/mod.rsand is best done one domain at a time. - no translation has been accepted yet. Start one with
mkdir -p po/de && msginit -i po/facelock.pot -o po/de/facelock.po -l de.
Security considerations
Read the Security chapter before implementing any auth-related code. Key rules:
security.require_irdefaults to true. Never weaken this default.- Frame variance checks must remain in the auth path.
- Model files are SHA256-verified at load time.
- D-Bus message size limits are enforced by the bus daemon. Never allocate unbounded buffers.
- D-Bus system bus policy restricts daemon access.
- The PAM module logs all auth attempts to syslog.
- Daemon and oneshot authentication limit face-detected failures (5/user/60s by default); successful and no-face attempts do not consume this budget.
Contracts
Do not change binary names, paths, config keys, database schema, or auth semantics without updating the Contracts chapter.
Submitting changes
- Run
just check(or at minimumcargo test --workspace && cargo clippy --workspace -- -D warnings). - Run container tests if your change touches PAM, daemon, or IPC code.
- Keep commits focused. Separate refactoring from behavioral changes.
- Write clear commit messages that explain why, not just what.