Status: Accepted
Date: 2026-02-22
Step: 4 — PAM module (pam-visage) + system bus migration (visaged, visage-cli)
Steps 1–3 produced a working camera pipeline, ONNX inference engine, and daemon
(visaged) on the session bus. Step 4 makes sudo echo test authenticate via face
recognition. This requires two changes:
-
System bus migration. PAM modules execute in a context with no session bus. The daemon must register on the system bus, and the CLI must connect to it by default.
-
PAM module implementation. The stub (
pam-visage) had zero-argument C exports returningPAM_IGNORE. A correct PAM module requires the 4-argument C ABI, username extraction viapam_get_user, a synchronous D-Bus call, and safe fallback semantics.
Decision: Both visaged and visage-cli connect to the system bus by default.
Setting VISAGE_SESSION_BUS=1 falls back to the session bus.
Rationale: PAM modules are loaded by the PAM framework into the process invoking
sudo (e.g., sudo echo test). This process runs as root and has no user session
context. The D-Bus session bus is scoped to a user login session and is not available
in this context. The system bus is always reachable.
Trade-off: The system bus requires a D-Bus policy file to be installed at
/usr/share/dbus-1/system.d/org.freedesktop.Visage1.conf and the daemon must be
started with sudo. The session bus required neither. VISAGE_SESSION_BUS=1 restores
the Step 3 development workflow.
Alternative considered: Detect the context at runtime (check DBUS_SESSION_BUS_ADDRESS).
Rejected — explicit configuration is clearer, and the system bus is correct for all
production deployment paths.
Decision: pam-visage uses zbus::blocking::Connection::system() and
VisageProxyBlocking. There is no tokio runtime in the PAM module.
Rationale: PAM modules are synchronous by contract. Starting a tokio runtime inside
pam_sm_authenticate adds ~2–5 ms latency, requires runtime teardown on every PAM
call, and introduces thread-safety concerns with the PAM stack state. zbus::blocking
provides synchronous D-Bus access backed by an internal async executor that is
transparent to the caller.
Trade-off: The #[zbus::proxy] macro generates both VisageProxy (async) and
VisageProxyBlocking. The async variant is generated but unused in this crate. Dead
code elimination removes it from the .so at link time.
Decision: Every error path — daemon not running, D-Bus timeout, no face match,
panic recovery — returns PAM_IGNORE (25).
Rationale: PAM_IGNORE tells the PAM stack to skip this module and continue to the
next configured module (typically password). PAM_AUTH_ERR denies authentication
outright in some PAM stack configurations, which would lock the user out if the daemon
is unavailable. Since visaged can be absent (not yet installed, crashed, or
intentionally stopped), the PAM module must never be a single point of failure.
Implication: A user can always fall back to password authentication regardless of the daemon's state. This is the correct security posture for a supplementary biometric module.
Decision: The body of pam_sm_authenticate is wrapped in
std::panic::catch_unwind. Panics produce PAM_IGNORE rather than unwinding across
the extern "C" boundary.
Rationale: Unwinding a Rust panic across an extern "C" boundary is undefined
behavior per the Rust reference. The PAM stack does not have a Rust unwinding runtime.
A panic without catch_unwind would corrupt the calling process's stack.
Implementation: catch_unwind requires its closure to be UnwindSafe. Raw
pointers (*mut libc::c_void, *const libc::c_char) implement UnwindSafe because
they carry no ownership invariants that could be violated by an unwind.
Decision: pam_get_user and CStr::from_ptr are wrapped in explicit unsafe {}
blocks with SAFETY comments, even though the outer function is already unsafe.
Rationale: In Rust 2024 edition, unsafe_op_in_unsafe_fn becomes a hard error.
Unsafe calls inside an unsafe fn body without explicit unsafe {} blocks will not
compile. The crate enables #![warn(unsafe_op_in_unsafe_fn)] to catch this now.
Verified clean under RUSTFLAGS="-D unsafe_op_in_unsafe_fn" cargo check.
Note: The original implementation omitted these blocks. They were added as a fix after self-evaluation of the Step 4 implementation.
Decision: pam_visage writes diagnostic messages to stderr via eprintln!.
Rationale: Step 6 will add libc::syslog(LOG_AUTHPRIV, ...) for production
logging. eprintln! is visible in the sudo terminal during development and requires
no additional dependency. Adding syslog at Step 6 alongside packaging is lower-risk than
doing it in Step 4.
Known limitation: In production, stderr from a PAM module is discarded. Messages will not appear in any log until Step 6.
Decision: pam_sm_authenticate does not call the PAM conversation callback. Users
see either: (a) no password prompt (face matched), or (b) the normal password prompt
(face didn't match or daemon unavailable).
Rationale: The conversation API enables "Face recognized ✓" / "Face not recognized,
try password" feedback messages. It requires additional pam_get_item(PAM_CONV) +
struct pam_conv * FFI plumbing. Deferred to Step 6 alongside syslog and pam-auth-update.
Decision: crates/pam-visage/build.rs emits cargo:rustc-link-lib=pam.
Rationale: pam_get_user is declared as extern "C" in the Rust code but its
definition lives in libpam.so. As a cdylib, undefined symbols may resolve at dlopen
time via the process's already-loaded libpam. However, explicit linking catches missing
libpam0g-dev at build time rather than at first sudo attempt. It also documents the
dependency unambiguously.
| Limitation | Severity | Deferred to |
|---|---|---|
| D-Bus timeout: 10–25 s if daemon deadlocks | Medium | Step 6 |
No D-Bus caller authentication (user is caller-supplied) |
Medium | Step 6 |
eprintln! logging (not syslog) |
Low | Step 6 |
| No PAM conversation API messages | Low | Step 6 |
Manual /etc/pam.d/sudo edit; no pam-auth-update |
Low | Step 6 |
| IR emitter not active (dark frames without ambient light) | Low | Step 5 |
sudo echo testnow attempts face authentication viavisagedbefore prompting for a password. No password prompt appears if a face is matched.- The daemon and CLI now require
sudofor normal operation. Development without sudo is preserved viaVISAGE_SESSION_BUS=1. - The D-Bus policy file at
packaging/dbus/org.freedesktop.Visage1.confis now required for all deployments (previously only documented, not deployed). cargo test --workspacepasses (42 tests). Two new tests inpam-visagecover PAM constant correctness and the daemon-not-running error path.RUSTFLAGS="-D unsafe_op_in_unsafe_fn" cargo checkpasses clean — the crate is Rust 2024 edition-forward-compatible on the unsafe front.