facelock is a local biometric authentication system. The threat model assumes:
- Attacker has physical access to the machine (the entire point of face auth is physical-presence scenarios like unlocking a laptop)
- Attacker may have a photo or video of the enrolled user
- Attacker does not have root (if they do, game over regardless)
- Attacker cannot modify files in
/etc/facelock/,/var/lib/facelock/, or/lib/security/
Facelock is designed to keep biometric data under the user's exclusive control:
- Local-only inference: All face detection and recognition runs on-device via ONNX Runtime. No images, embeddings, or metadata are ever transmitted over the network.
- No telemetry: Facelock contains zero analytics, tracking, or phone-home code. After the one-time model download during
facelock setup, it never contacts any server. - No cloud dependencies: Authentication works fully offline. No account registration, no API keys, no external services.
- Data stays on disk: Face embeddings are stored in a local SQLite database (
/var/lib/facelock/facelock.db) with restrictive permissions (640, root:facelock). Optional AES-256-GCM encryption with TPM-sealed keys provides defense in depth. - Open source: All code is MIT/Apache-2.0 licensed. No proprietary blobs or obfuscated network calls. Privacy claims are verifiable by reading the source.
Attack: Hold a photo or video of the enrolled user in front of the camera.
Why this matters: This is the #1 attack against face authentication. Without mitigation, anyone with a Facebook photo can unlock the machine.
Mitigations (layered, implement all):
Add security.require_ir config flag, default true:
[security]
require_ir = true # Refuse to authenticate on RGB-only camerasImplementation:
// In camera capture, check if the negotiated format indicates IR
fn is_ir_camera(device: &DeviceInfo) -> bool {
// IR cameras typically support GREY (8-bit grayscale) or Y16 (16-bit)
// as their native format. RGB-only cameras are not IR.
device.formats.iter().any(|f| {
matches!(f.fourcc.as_str(), "GREY" | "Y16 " | "YUYV")
&& device.name.to_lowercase().contains("ir")
|| device.name.to_lowercase().contains("infrared")
})
}
// In daemon auth flow, before attempting recognition:
if config.security.require_ir && !is_ir_camera(&device_info) {
return DaemonResponse::Error {
message: "IR camera required for authentication. Set security.require_ir = false to override (NOT RECOMMENDED).".into()
};
}Rationale: Phone screens and printed photos do not emit infrared light correctly. An IR camera sees a flat, textureless surface where a real face would have depth and skin texture in IR. This single check eliminates the vast majority of spoofing attacks.
Limitation: IR camera detection by format/name is heuristic. Some cameras report YUYV but are actually IR. The facelock devices command should display whether each camera is detected as IR.
Require minimum variance across consecutive frames during authentication:
/// Check that frames have sufficient variance (not a static image)
fn check_frame_variance(embeddings: &[(Detection, FaceEmbedding)], min_frames: usize) -> bool {
if embeddings.len() < min_frames {
return false;
}
// Compute pairwise similarity between consecutive embeddings
// Real faces have micro-movements causing slight embedding variation
// A static photo produces near-identical embeddings (similarity > 0.99)
let mut max_similarity = 0.0f32;
for window in embeddings.windows(2) {
let sim = cosine_similarity(&window[0].1, &window[1].1);
max_similarity = max_similarity.max(sim);
}
// If ALL consecutive frames are too similar, likely a static image
// Real faces typically vary by 0.02-0.10 between frames
max_similarity < 0.998 // FRAME_VARIANCE_THRESHOLD in facelock-core/types.rs
}Config:
[security]
require_frame_variance = true # Reject static images (photo attack defense)
min_auth_frames = 3 # Minimum frames before accepting matchIn IR mode, verify that the face region has expected IR texture characteristics:
- Real skin has micro-texture visible in IR
- Photos/screens appear as flat, uniform surfaces in IR
- Compute standard deviation of pixel intensity within the face bounding box
- Reject faces with abnormally low texture variance
fn check_ir_texture(gray: &[u8], bbox: &BoundingBox, width: u32) -> bool {
// Extract face region pixels
let face_pixels = extract_region(gray, bbox, width);
// Compute standard deviation
let mean: f32 = face_pixels.iter().map(|&p| p as f32).sum::<f32>() / face_pixels.len() as f32;
let variance: f32 = face_pixels.iter().map(|&p| (p as f32 - mean).powi(2)).sum::<f32>() / face_pixels.len() as f32;
let std_dev = variance.sqrt();
// Real IR faces have std_dev > ~15; flat surfaces are < 5
std_dev > 10.0
}Attack: Replace ONNX model files with adversarial models that always match (or match specific attackers).
Mitigations:
Verify model integrity not just at download, but every time the daemon loads models:
impl FaceEngine {
pub fn load(config: &RecognitionConfig, model_dir: &Path) -> Result<Self> {
let manifest = load_manifest();
for model in &manifest.default_models() {
let path = model_dir.join(&model.filename);
if !verify_model(&path, &model.sha256)? {
return Err(FacelockError::Detection(format!(
"Model integrity check failed for {}. Expected SHA256: {}. \
Re-run `facelock setup` to re-download.",
model.filename, model.sha256
)));
}
}
// ... load models
}
}# Models owned by root, not writable by others
chown -R root:root /var/lib/facelock/models
chmod 755 /var/lib/facelock/models
chmod 644 /var/lib/facelock/models/*.onnxAttack: Read or modify the SQLite database to extract biometric data or inject fake embeddings.
Mitigations:
# Database owned by root, readable only by root and facelock group
chown root:facelock /var/lib/facelock/facelock.db
chmod 640 /var/lib/facelock/facelock.dbRuntime note:
- The daemon/setup paths must also secure SQLite
-waland-shmsidecar files to0640 - Audit logs and snapshots must be created with explicit restrictive modes instead of relying on ambient umask
- The systemd service should set
UMask=0027as a baseline defense-in-depth default
Face embeddings are biometric data. Unlike passwords, they cannot be changed. Document this:
- The database contains irreversible biometric templates
- If compromised, the user's face embeddings cannot be "rotated" like a password
- Embeddings should be treated as sensitive personal data
For high-security deployments, embeddings can be encrypted with AES-256-GCM using either a plaintext key file (encryption.method = "keyfile") or a TPM-sealed key (encryption.method = "tpm"). The TPM method seals the AES key at rest; it is unsealed at daemon startup and held in memory. See docs/configuration.md for the [encryption] and [tpm] sections.
Attack: Unauthorized user sends D-Bus messages to the daemon to trigger auth, enroll faces, or extract data.
Mitigations:
Access to the daemon is restricted by the D-Bus system bus policy defined in dbus/org.facelock.Daemon.conf. Only root and members of the facelock group are allowed to send messages to the daemon interface. The policy file is installed to /usr/share/dbus-1/system.d/ and enforced by the bus daemon itself. Setup and package install may also refresh a legacy /etc/dbus-1/system.d/ copy when present, but /usr/share/... is the canonical install path.
The daemon must also verify the caller UID via GetConnectionUnixUser on every method call and apply method-level authorization:
Authenticate,ListModels,PreviewDetectFrame: root or the matching Unix userEnroll,RemoveModel,ClearModels,PreviewFrame,Shutdown: root onlyReleaseCamera: root or the Unix user that owns the active preview camera sessionListDevices: root or a caller in thefacelockgroup
The D-Bus bus daemon enforces message size limits (typically 128MB by default, configurable in the bus configuration). This prevents oversized messages from consuming daemon memory without requiring application-level size checks.
Throttle authentication attempts to prevent brute-force:
let rate_limiter = RateLimiter::new(5, 60);
if !rate_limiter.check(&store, user)? {
return Err("rate limited");
}
// ... authentication attempt ...
if auth_failed {
rate_limiter.record_failure(&store, user)?;
}Implementation note:
- Failed attempts are stored in the shared SQLite
rate_limittable - Daemon mode and oneshot mode use the same window and thresholds
- Restarting the daemon must not reset a user's lockout state
Log all authentication attempts with outcomes:
fn identify(pamh: *mut libc::c_void) -> libc::c_int {
let user = pam_get_user(pamh);
let service = pam_get_service(pamh); // "sudo", "login", etc.
let result = do_auth(user, service);
// Log to syslog (PAM convention)
// Format: pam_facelock(service): auth result for user
syslog(LOG_AUTH | severity, "pam_facelock({}): {} for user {}",
service, result_str, user);
result
}This creates an audit trail in /var/log/auth.log or journald.
Allow different PAM services to have different security levels:
[security.pam_policy]
# Only allow face auth for these PAM services
allowed_services = ["sudo", "polkit-1"]
# Never allow face auth for these (always fall through to password)
denied_services = ["login", "sshd", "su"]After initialization, drop unnecessary capabilities:
// After opening camera, loading models, creating socket:
// Drop all capabilities except what's needed for ongoing operation
use caps::{CapSet, Capability};
caps::clear(None, CapSet::Effective)?;
caps::clear(None, CapSet::Permitted)?;
// Only keep what's needed: nothing (camera fd already open, socket already bound)The systemd unit (systemd/facelock-daemon.service) includes layered hardening:
Phase 1 (shipped): ProtectSystem=strict, InaccessiblePaths=/home /root, ReadWritePaths=/var/lib/facelock /var/log/facelock, PrivateTmp=yes, NoNewPrivileges=yes, UMask=0027
Phase 2 (shipped): ProtectKernelTunables/Modules/ControlGroups=yes, RestrictNamespaces=yes, LockPersonality=yes, RestrictRealtime=yes, RestrictSUIDSGID=yes
Deferred device/seccomp phase: DevicePolicy/DeviceAllow is intentionally omitted because cgroup device ACLs interfered with camera auto-detection, and seccomp filtering is deferred to future work. Standard Unix permissions still restrict /dev/video* and /dev/tpmrm0.
GPU compatibility note: MemoryDenyWriteExecute=yes is still intentionally omitted because it breaks ONNX Runtime JIT paths such as CUDA and TensorRT. Verify hardening score with:
systemd-analyze security facelock-daemon.service[security]
disabled = false
abort_if_ssh = true # Refuse face auth over SSH
abort_if_lid_closed = true # Refuse if laptop lid closed
require_ir = true # CRITICAL: refuse RGB-only cameras (anti-spoof)
require_frame_variance = true # Reject static images (photo defense)
require_landmark_liveness = false # Require landmark movement between frames (off by default)
min_auth_frames = 3 # Minimum frames before accepting (variance check)
[notification]
mode = "terminal" # Show "Identifying face..." on login screen
[security.pam_policy]
allowed_services = ["sudo", "polkit-1"]
denied_services = ["login", "sshd"]
[security.rate_limit]
max_attempts = 5 # Max auth attempts per user
window_secs = 60 # Rate limit window| Priority | Mitigation | Spec |
|---|---|---|
| P0 | IR camera enforcement (require_ir) |
02-camera, 05-daemon |
| P0 | Frame variance check (anti-photo) | 05-daemon |
| P0 | Model SHA256 at load time | 03-face-engine |
| P0 | D-Bus system bus policy | 05-daemon |
| P0 | D-Bus message size limits (bus-enforced) | 01-core-types |
| P0 | PAM audit logging | 06-pam-module |
| P0 | Database file permissions | 10-build-install |
| P1 | IR texture validation | 02-camera, 05-daemon |
| P1 | Rate limiting | 05-daemon |
| P1 | systemd hardening | 10-build-install |
| P1 | Capability dropping | 05-daemon |
| P1 | Service-specific PAM policy | 06-pam-module |
| P2 | Embedding encryption at rest | 04-face-store |
| P2 | Memory zeroing on drop | 01-core-types |
| P2 | Constant-time similarity comparison | 01-core-types |