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. |