CLI Reference

All commands are subcommands of the facelock binary.

Global flags

The following flags are accepted by every subcommand (declared global = true):

FlagDescription
-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, --quietSuppress informational stdout and machine payloads where supported. Prompts, required notices, diagnostics and exit codes are unaffected; exceptions are listed below.
-v, --verboseRaise 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

FlagMeaning
(none)Full interactive wizard. Falls back to the non-interactive flow when stdin is not a terminal.
--non-interactiveNo 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.

FlagValuesWhat auto doesWizard step
--camera <PATH|auto>a /dev/video* path, or autoRe-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 presetsno auto: quality is a preference, not something the machine can report2
--execution-provider <cpu|cuda|rocm|openvino|auto>provider nameAsks 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>methodUses 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:

PresetDetectorEmbedder
standardscrfd_2.5g_bnkps.onnxw600k_r50.onnx
balancedscrfd_2.5g_bnkps.onnxglintr100.onnx
highdet_10g.onnxglintr100.onnx

Action flags

PairWithout either flag (wizard)Without either flag (--non-interactive)
--pam / --no-pamprompt (step 9)off
--systemd / --no-systemdprompt (step 6)off
--enroll / --no-enrollprompt (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

FlagRequiresMeaning
--service <NAME>--pamTarget PAM service. Default sudo.
--remove--pamRemove the facelock PAM line instead of adding it.
--if-present--pamTreat 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 addExplicitly 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--systemdDisable 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:

CodeMeaning
0the user has a usable enrollment
1not enrolled; an absent or unreadable marker reports this way
2error — 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
FlagMeaning
--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-sensitivealso permit the gated services common-auth, login, password-auth, password-auth-ac, sshd, system-auth, system-auth-ac, system-login
--if-presenttreat a missing service file as success instead of an error
--dry-runprint the resolved plan, write nothing, exit 0
--jsonemit 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:

CodeMeaning
0every requested service carries the line
1at least one exists without it
2at 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
FlagShortDefaultDescription
--follow-ffalseWatch for new entries (like tail -f)
--lines N-l20Number 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
FlagDefaultDescription
--allow-destructionfalseAuthorize irreversible destruction. Required except with --dry-run; nothing else implies it
--yesfalseSkip the confirmation prompt (also --no-confirm, -y)
--dry-runfalseClassify configured paths and remove nothing. Does not examine the contents of the roots
--leave-activation-barredfalseKeep the daemon stopped and D-Bus activation barred afterwards
--jsonfalseEmit 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:

  1. Explicit --user flag (highest priority)
  2. SUDO_USER environment variable
  3. DOAS_USER environment variable
  4. Current user ($USER or getpwuid)

Environment Variables

VariablePurpose
FACELOCK_CONFIGOverride 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_LOGControl log verbosity (e.g., facelock_daemon=debug). Outranks both the built-in default and -v. An unparseable value is reported at warn and ignored.