Repository navigation
Visage v0.4.0-rc.1
Pre-releaseAdded
-
visage onboard— one command from nothing to working face auth. Downloads
the ONNX models, enrolls several labelled angles with a prompt between each, and
verifies against the daemon before claiming success. Replaces thesetup→
enroll→list→ test sequence, which had two traps:enrolldefaulted to the
wrong user under sudo (below), and a single capture is fragile on hardware where
the IR emitter strobes or has no quirk entry.It refuses to onboard
rootunless--user rootis given explicitly, keeps
whichever captures succeeded if one fails, and exits non-zero if verification does
not recognise the enrolled face — so a failed onboarding cannot look like a
successful one. -
First hardware validation of passive liveness — and it did not discriminate.
New hardware report for the ASUS Zenbook 14 UM3406HA / Shinetech3277:0055IR
module, executing the blocking §2 checklist indocs/liveness-remaining-work.md
that had been open since 2026-02-25.A hand-held phone-screen spoof produced landmark displacement of 0.681 px —
higher than two genuine live attempts (0.263, 0.670) — so no threshold on that
metric admits the live minimum while rejecting the spoof. The identity stage
matched the same photo at 0.9013. Live users were falsely rejected 13–17% of
the time at the default 0.8 px floor — 20/23–20/24 live pass as of the journal at
2026-08-17 13:43 local, so the ≥9-in-10 reliability criterion is not met.DEFAULT_MIN_EYE_DISPLACEMENTis documented
against 640×480@30fps; this sensor is 640×360, and its "printed photo <0.3 px"
figure assumes a rigidly mounted photo rather than one held in a hand.Sample is n=1 on the spoof side, so
threat-model.md's "static photo" claim is
deliberately left unrevised. Do not lowerliveness.minDisplacementon this
evidence — it would widen the hole rather than close it.Also documented: this model ships more than one camera module, so the
04f2:b6d9quirk does not apply to3277:0055units, and a missing quirk does
not imply missing illumination — the emitter strobes by firmware default here. -
pam_visage.sonow accepts atimeout=Nmodule argument, exposed as
services.visage.pam.timeoutSeconds. The D-Bus method timeout was hard-coded
at 3 seconds.That is not only a hang guard — it is an upper bound on how long
authentication may take. A verify that exceeds it makes PAM fall through to
the password prompt, and the outcome is indistinguishable from a failed
match: both returnPAM_IGNORE, and nothing in the PAM log says "timed
out". On CPU-only hardware a verify can legitimately take seconds; measured
median on an ASUS Zenbook 14 was 2273 ms, leaving under 700 ms of
headroom against a 3-second ceiling. The symptom of getting that wrong is
intermittent password prompts that look exactly like recognition failures.A malformed, zero, or unparseable value logs a warning and falls back to the
3-second default rather than failing — a typo in a PAM line must never be
able to lock a user out. -
Five daemon settings are now NixOS options —
framesPerVerify,
framesPerEnroll,warmupFrames,verifyTimeoutSeconds, and
emitter.enable. The daemon has always read the corresponding
VISAGE_*environment variables, but the module exposed none of them, so the
only way to tune a deployment was a hand-written systemd drop-in.framesPerVerifyis the main latency knob, and its documentation now records
both interactions that make it non-obvious: the PAM timeout above, and the
fact that passive liveness fails closed below two frames with a detected
face — so lowering it to 2 can reintroduce false rejects. -
The hardware quirks database is now tested.
crates/visage-hw/src/quirks.rshad no
tests, andquirk_db()skips a malformed TOML rather than panicking — correct for a daemon
that authenticates logins, but it means an embedded-but-broken quirk compiles, ships, and
silently never fires. At runtime that is indistinguishable from a camera with no quirk.Four assertions now run in CI: every embedded source parses (count compared against
QUIRK_SOURCES.len(), so it cannot drift as cameras are added), every parsed quirk is
reachable vialookup_quirk, no two entries claim the same VID:PID, and emitter payloads are
coherent (non-emptycontrol_bytes;off_bytesmatching its length). Each was verified to
fail on a deliberately broken input, not merely to pass.contrib/hw/README.mdalso gained the registration step it was missing. It previously went
from "create a TOML file" straight to "submit a PR", never mentioning that a file must be
added toQUIRK_SOURCESto be read at all — so a contributor following it literally would
ship a quirk that does nothing.
Changed
-
services.visage.pam.enable's description no longer overstates what it
wires. It claimed face auth for "sudo, login, and screen lock"; it wires
sudoandloginonly. Screen lock is covered indirectly and only for
lockers that derive their PAM stack fromlogin— DMS/quickshell generates a
config with an identical module set, and so inherits face auth for free.
Lockers declaring their own PAM service get nothing and must be wired
explicitly; the description now shows how, and warns that grepping
/etc/pam.d/alone will report the lock screen as unwired when it is not,
because a locker's config may live in the user's state directory. -
visage onboardno longer verifies the instant the last capture returns.
It paused for zero milliseconds, so[3/3]fired while the user was still
holding the pose they struck to press Enter — the posture passive liveness is
most likely to reject, since it looks for landmark movement. Onboarding could
enroll perfectly and then fail its own verification for a reason unrelated to
enrollment quality. Now waits two seconds and says what to do with them. -
visage onboard's failure message no longer asserts a cause it cannot
know. It told users to check lighting and the IR emitter quirk. The daemon
deliberately collapses every failure into a plain non-match — distinguishing
"wrong face" from "identity matched but liveness rejected" would tell an
attacker holding a photograph that the photograph was recognised — so the CLI
genuinely does not know why a verify failed. It now says so, points at
journalctl -u visaged, and lists the causes in rough order of likelihood
with the liveness one first.
Fixed
-
Nix package:
bindgencould not find libclang.v4l2-sys-mit(pulled in by
visage-hwfor camera capture) runsbindgenin its build script, which dlopens
libclang.packaging/nix/default.nixdeclared onlypkg-configin
nativeBuildInputs, sonix build .#visagefailed with "Unable to find libclang
… set theLIBCLANG_PATHenvironment variable". AddedrustPlatform.bindgenHook.flake.nixhas carriedllvmPackages.libclang+LIBCLANG_PATHfor the devShell
since it was written, socargo buildin a dev shell always worked — the package
never did. Nothing caught the difference because no consumer referenced
pkgs.visage:services.visage.enableis set on no host and the package is in no
systemPackages, so the derivation was never realised and every build stayed green
for reasons unrelated to it. -
Nix package: version label was three patch releases stale. The derivation
hardcodedversion = "0.3.3"while[workspace.package]was at0.3.6, so any
built artifact carried a wrong externally-visible version. Now read from
Cargo.tomlso the two cannot drift. -
Nix package: ONNX Runtime is now fetched hermetically.
ort-sysdownloads a
prebuilt runtime fromcdn.pyke.ioin its build script, which a sandboxed build
correctly blocks — sonix build .#visagecould never work offline. Pointing
ORT_LIB_LOCATIONat nixpkgs'onnxruntimedoes not help either:ortlinks
statically by default and nixpkgs ships only.so, giving "could not link to
the ONNX Runtime build in …".Now the same archive
ort-syswants is fetched viafetchurlwith a pinned hash
and unpacked into a small derivation thatORT_LIB_LOCATIONpoints at.
Version-matched by construction (1.23.2, whatort 2.0.0-rc.11expects), so no
version skew, and reproducible.The archive is raw LZMA2, not an
.xzcontainer, and needs
xz --format=raw --lzma2=dict=64MiB. The 64 MiB dictionary is required and is not
the default: plain--lzma2decodes only ~8.9 MB of the ~93 MB payload and exits
non-zero, which — piped intotar— looks like a clean extraction, because a
truncatedararchive still yields a plausiblelibonnxruntime.a. A size floor
now refuses any decode under 80 MB. -
Nix package: PAM module path no longer hardcoded.
postInstalllooked for
target/release/libpam_visage.so, but currentrustPlatform.buildRustPackage
passes--target, so cargo emits totarget/<triple>/release/. It is now located
at install time and the build fails loudly if it is absent or ambiguous — which it
was, three times over:release/deps/,release-tmp/, and the real one. -
clippy::excessive_precisionon two pre-existing test literals, newly flagged
by a more recent clippy and failingcargo clippy -- -D warnings. In
alignment.rsthe value carried a trailing zero and was trimmed. Instore.rsthe
excess precision is deliberate — that test asserts a bit-exact f32 round-trip — so
it is annotated with a justified#[allow]rather than truncated, which would have
quietly weakened the case it exists to test. -
--userdefaulted torootundersudo, silently enrolling the wrong account.
Every privileged subcommand is root-only by the D-Bus policy, so they are always
run assudo visage …— where$USERisroot.sudo visage enroll --label x
therefore registered the face against root while PAM went on looking up the
real user, found nothing, and fell through to the password prompt.The failure was silent in the worst way: enrollment printed "Enrolled
successfully",sudokept asking for a password, and nothing indicated the two
were about different users. It also left a face credential attached to the most
privileged account on the machine, created by accident.current_user()now prefersSUDO_USER, falling back to$USERwhen not under
sudo. The help text onenroll,verify,listandremovesaid "defaults to
$USER" and has been corrected too.
Developer experience
-
The first integration tests.
crates/visaged/tests/— 12 hermetic tests, plus 3
hardware tests that CI skips. Added because both of the worst bugs in this project's
history were structurally invisible to unit tests:success=end, an invalid PAM control
keyword that libpam silently treats asignore, which made face auth a no-op on the
documented setup path from v0.1.0 to v0.3.2; and the V4L2 format cache (#48). Neither was a
logic error in a function — both were contracts between artifacts, which is the seam a
unit test cannot reach.pam_control_contractasserts the control string is a valid libpam control and identical
across the Debian, Arch and NixOS packaging — it rejectssuccess=endby name.
systemd_hardening_contractasserts every directivethreat-model.mdclaims exists in
both systemd definitions, which is issue #78's class generalised.
packaging_paths_contractasserts the deb ships artifacts the workspace actually builds and
thatExecStartmatches the install path.dbus_contractasserts the interface the daemon
serves is the one its three independently-compiled clients call — a rename there currently
compiles clean and breaks only at an authentication prompt.Hermetic: no hardware, no root, no D-Bus, no models, no new dependencies, and no CI
changes —cargo test --workspacealready picks them up. Each guard was proven to fail on
the defect it exists for before being committed. -
Fixed:
TimeoutStopSecwas missing from the NixOS module. Found by the new
systemd_hardening_contract, which failed on the tree as it stood. The directive was added
for #26 to bound a stuck v4l2 capture onsystemctl stop|restart, cutting a ~90s
post-hibernate hang to ~10s. It was present in the Debian unit only, so NixOS hosts never
got the fix — a running host reported systemd's 90s default, exactly the hang it was meant
to remove. -
Releases are cut from tags again, and cannot publish empty. v0.3.4, v0.3.5
and v0.3.6 shipped with no.debattached (issue #75). The release job gated
on the head commit message starting withrelease:, and adopting Conventional
Commits (chore(release): 0.3.4) silently severed it — nothing failed,
because each release was then created by hand and looked correct. The job is
now triggered by av*tag, asserts the tag matchesCargo.toml, attaches
only that version's changelog section rather than the whole file, derives
pre-release status from the version string, and refuses to publish at all if
no.debis present. -
The toolchain is pinned, so CI cannot change behaviour without a commit.
The identical commit passed CI on 2026-08-18 and failed on 2026-08-24, purely
becausedtolnay/rust-toolchain@stablefloated onto a compiler whose clippy
added a new lint; with-D warningsthat halted every merge in the repository
for six days.rust-toolchain.tomlnow pins the version — tracking what
nix developprovides, so a localcargo clippyreproduces CI exactly — and
the workflow reads it as the single source of truth. A new non-blocking
clippy-latestjob still runs against current stable, so new lints surface
early without being able to block a merge.
Security
-
The daemon's network isolation is now enforced, not merely asserted.
threat-model.mdjustifies its largest documented hardening gap —
MemoryDenyWriteExecute=false, required by ONNX Runtime's JIT — by stating
that the daemon "has no network access, no inbound connections". No unit
directive enforced that, so the compensating control for that exception did
not exist. Reported as issue #78.visaged.servicenow setsPrivateNetwork=truein both the packaged unit
and the NixOS module, placing the daemon in its own network namespace with
loopback only.visagedmakes no network calls of its own; the only HTTP
client in the workspace isureq, used solely byvisage-cliforvisage setupmodel downloads, which runs as a separate process and is unaffected.
D-Bus is an AF_UNIX filesystem socket and is not namespaced by
PrivateNetwork.
Notes
nix build .#visagenow succeeds end-to-end: compile, unit tests, and install of
the daemon, CLI, PAM module, D-Bus policy and both systemd units.