Development over
This commit is contained in:
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Linux Hello is a Windows Hello-equivalent biometric authentication system for Linux. It provides secure facial authentication using IR cameras and TPM2-backed credential storage.
|
||||
|
||||
**Current Status**: Phase 3 (Security Hardening) complete. Uses placeholder algorithms for face detection/embedding until ONNX model integration (waiting for `ort` 2.0 stable).
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Development build
|
||||
cargo build
|
||||
|
||||
# Release build
|
||||
cargo build --release
|
||||
|
||||
# Build with TPM hardware support
|
||||
cargo build --features tpm
|
||||
|
||||
# Run all tests
|
||||
cargo test --workspace
|
||||
|
||||
# Run a specific test by name
|
||||
cargo test test_name --workspace
|
||||
|
||||
# Run tests for a specific package
|
||||
cargo test -p linux-hello-daemon
|
||||
|
||||
# Run specific integration test suite
|
||||
cargo test --test phase3_security_test
|
||||
|
||||
# Build PAM module (C)
|
||||
cd pam-module && make
|
||||
|
||||
# Install PAM module (requires sudo)
|
||||
cd pam-module && sudo make install
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Workspace Crates
|
||||
|
||||
- **linux-hello-common**: Shared types (`Config`, `Error`, `FaceTemplate`, `TemplateStore`)
|
||||
- **linux-hello-daemon**: Core daemon library with camera, auth, security modules; also builds `linux-hello-daemon` binary
|
||||
- **linux-hello-cli**: CLI tool, builds `linux-hello` binary
|
||||
- **linux-hello-tests**: Integration test harness (tests live in `/tests/`)
|
||||
- **pam-module**: C PAM module (`pam_linux_hello.so`) using Unix socket IPC
|
||||
|
||||
### Key Daemon Modules (`linux-hello-daemon/src/`)
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `camera/` | V4L2 camera enumeration, frame capture, IR emitter control |
|
||||
| `detection/` | Face detection (placeholder, ONNX planned) |
|
||||
| `embedding.rs` | Face embedding extraction (placeholder, ONNX planned) |
|
||||
| `matching.rs` | Template matching with cosine similarity |
|
||||
| `anti_spoofing.rs` | Liveness detection (IR, depth, texture, blink, movement) |
|
||||
| `secure_memory.rs` | `SecureBytes`, `SecureEmbedding` with zeroization, memory locking |
|
||||
| `secure_template_store.rs` | Encrypted template storage |
|
||||
| `tpm.rs` | TPM2 integration with software fallback |
|
||||
| `ipc.rs` | Unix socket server/client for PAM communication |
|
||||
| `auth.rs` | Authentication service orchestration |
|
||||
|
||||
### Communication Flow
|
||||
|
||||
```
|
||||
Desktop/PAM → pam_linux_hello.so → Unix Socket → linux-hello-daemon → Camera/TPM
|
||||
```
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Camera code uses conditional compilation:
|
||||
- `#[cfg(target_os = "linux")]` - Real V4L2 implementation
|
||||
- `#[cfg(not(target_os = "linux"))]` - Mock implementation for development
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- `SecureEmbedding` and `SecureBytes` auto-zeroize on drop
|
||||
- Use `zeroize` crate for sensitive data
|
||||
- Constant-time comparisons for template matching (timing attack resistance)
|
||||
- Memory locking prevents swapping sensitive data
|
||||
- Software TPM fallback is NOT cryptographically secure (dev only)
|
||||
2017
Cargo.lock
generated
2017
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
@@ -1,6 +1,14 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"linux-hello-common",
|
||||
"linux-hello-daemon",
|
||||
"linux-hello-cli",
|
||||
"linux-hello-settings",
|
||||
"linux-hello-tests",
|
||||
]
|
||||
# Exclude GTK apps from default build (requires system GTK4/libadwaita)
|
||||
default-members = [
|
||||
"linux-hello-common",
|
||||
"linux-hello-daemon",
|
||||
"linux-hello-cli",
|
||||
@@ -28,9 +36,9 @@ tokio = { version = "1.35", features = ["full"] }
|
||||
# Camera
|
||||
v4l = "0.14"
|
||||
|
||||
# ML/ONNX
|
||||
ort = "2.0.0-rc.10"
|
||||
ndarray = "0.15"
|
||||
# ML/ONNX (rc.11 is production-ready per ort docs)
|
||||
ort = { version = "=2.0.0-rc.11", features = ["ndarray"] }
|
||||
ndarray = "0.16"
|
||||
|
||||
# Image processing
|
||||
image = "0.24"
|
||||
@@ -43,3 +51,6 @@ zeroize = { version = "1.8", features = ["derive"] }
|
||||
|
||||
# TPM2 (for secure template storage)
|
||||
tss-esapi = "7.5"
|
||||
|
||||
# Benchmarking
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
10
debian/changelog
vendored
Normal file
10
debian/changelog
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
linux-hello (0.1.0-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Face authentication daemon with IR camera support
|
||||
* TPM-backed template encryption
|
||||
* Anti-spoofing with liveness detection
|
||||
* PAM module for system integration
|
||||
* CLI for face enrollment and management
|
||||
|
||||
-- Linux Hello Contributors <linux-hello@example.org> Wed, 15 Jan 2025 12:00:00 +0000
|
||||
1
debian/compat
vendored
Normal file
1
debian/compat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
13
|
||||
65
debian/control
vendored
Normal file
65
debian/control
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
Source: linux-hello
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Maintainer: Linux Hello Contributors <linux-hello@example.org>
|
||||
Build-Depends: debhelper-compat (= 13),
|
||||
rustc (>= 1.75),
|
||||
cargo,
|
||||
libpam0g-dev,
|
||||
libv4l-dev,
|
||||
libtss2-dev,
|
||||
pkg-config,
|
||||
libssl-dev,
|
||||
libclang-dev
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://github.com/linux-hello/linux-hello
|
||||
Vcs-Git: https://github.com/linux-hello/linux-hello.git
|
||||
Vcs-Browser: https://github.com/linux-hello/linux-hello
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: linux-hello
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
linux-hello-daemon (= ${binary:Version})
|
||||
Recommends: libpam-linux-hello
|
||||
Description: Face authentication for Linux - CLI tool
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the command-line interface for enrolling
|
||||
faces, managing templates, and testing authentication.
|
||||
.
|
||||
Features:
|
||||
- Infrared camera support for secure authentication
|
||||
- TPM-backed template encryption
|
||||
- Anti-spoofing with liveness detection
|
||||
- PAM integration for system authentication
|
||||
|
||||
Package: linux-hello-daemon
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends}
|
||||
Pre-Depends: adduser
|
||||
Description: Face authentication for Linux - daemon
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the background daemon that handles
|
||||
camera access, face detection, and template matching.
|
||||
.
|
||||
The daemon runs as a systemd service and communicates with the CLI
|
||||
and PAM module via Unix socket.
|
||||
|
||||
Package: libpam-linux-hello
|
||||
Architecture: any
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
libpam-runtime,
|
||||
linux-hello-daemon (= ${binary:Version})
|
||||
Description: Face authentication for Linux - PAM module
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the PAM module that integrates face
|
||||
authentication with system login, sudo, and other PAM-aware applications.
|
||||
.
|
||||
WARNING: After installation, you must manually configure PAM to use
|
||||
this module. A template configuration is provided at
|
||||
/usr/share/doc/libpam-linux-hello/pam-config.example
|
||||
.
|
||||
Incorrect PAM configuration may lock you out of your system!
|
||||
25
debian/copyright
vendored
Normal file
25
debian/copyright
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: linux-hello
|
||||
Upstream-Contact: Linux Hello Contributors <linux-hello@example.org>
|
||||
Source: https://github.com/linux-hello/linux-hello
|
||||
|
||||
Files: *
|
||||
Copyright: 2024-2025 Linux Hello Contributors
|
||||
License: GPL-3.0+
|
||||
|
||||
License: GPL-3.0+
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
.
|
||||
On Debian systems, the complete text of the GNU General Public License
|
||||
version 3 can be found in "/usr/share/common-licenses/GPL-3".
|
||||
2
debian/libpam-linux-hello.install
vendored
Normal file
2
debian/libpam-linux-hello.install
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
lib/*/security/pam_linux_hello.so
|
||||
usr/share/doc/libpam-linux-hello/pam-config.example
|
||||
4
debian/linux-hello-daemon.install
vendored
Normal file
4
debian/linux-hello-daemon.install
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
usr/libexec/linux-hello-daemon
|
||||
etc/linux-hello/config.toml
|
||||
lib/systemd/system/linux-hello.service
|
||||
var/lib/linux-hello
|
||||
1
debian/linux-hello.install
vendored
Normal file
1
debian/linux-hello.install
vendored
Normal file
@@ -0,0 +1 @@
|
||||
usr/bin/linux-hello
|
||||
46
debian/pam-config.example
vendored
Normal file
46
debian/pam-config.example
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Linux Hello PAM Configuration Template
|
||||
#
|
||||
# WARNING: Incorrect PAM configuration may lock you out of your system!
|
||||
# Always keep a root terminal open when testing PAM changes.
|
||||
#
|
||||
# This file is a TEMPLATE - it is NOT automatically installed.
|
||||
# You must manually configure PAM after careful consideration.
|
||||
#
|
||||
# BACKUP YOUR PAM CONFIGURATION BEFORE MAKING CHANGES:
|
||||
# sudo cp -r /etc/pam.d /etc/pam.d.backup
|
||||
#
|
||||
# To enable Linux Hello for sudo, add this line to /etc/pam.d/sudo:
|
||||
# auth sufficient pam_linux_hello.so
|
||||
#
|
||||
# Example /etc/pam.d/sudo with Linux Hello:
|
||||
# -------------------------------------------
|
||||
# #%PAM-1.0
|
||||
#
|
||||
# # Try face authentication first
|
||||
# auth sufficient pam_linux_hello.so
|
||||
#
|
||||
# # Fall back to normal authentication
|
||||
# @include common-auth
|
||||
# @include common-account
|
||||
# @include common-session-noninteractive
|
||||
# -------------------------------------------
|
||||
#
|
||||
# For login/gdm/lightdm, similar configuration applies.
|
||||
# Be extremely careful with display manager PAM files!
|
||||
#
|
||||
# Module options:
|
||||
# debug - Enable debug logging to syslog
|
||||
# timeout=N - Authentication timeout in seconds (default: 5)
|
||||
# try_first_pass - Use password from previous module if available
|
||||
#
|
||||
# Example with options:
|
||||
# auth sufficient pam_linux_hello.so debug timeout=10
|
||||
#
|
||||
# Testing:
|
||||
# 1. Keep a root shell open: sudo -i
|
||||
# 2. In another terminal, test: sudo -k && sudo echo "success"
|
||||
# 3. If face auth fails, password prompt should appear
|
||||
# 4. If completely locked out, use root shell to restore backup
|
||||
#
|
||||
# For more information, see:
|
||||
# https://github.com/linux-hello/linux-hello
|
||||
74
debian/postinst
vendored
Normal file
74
debian/postinst
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
# postinst script for linux-hello-daemon
|
||||
#
|
||||
# see: dh_installdeb(1)
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create linux-hello system user if it doesn't exist
|
||||
if ! getent passwd linux-hello > /dev/null 2>&1; then
|
||||
echo "Creating linux-hello system user..."
|
||||
adduser --system --group --no-create-home \
|
||||
--home /var/lib/linux-hello \
|
||||
--gecos "Linux Hello Face Authentication" \
|
||||
linux-hello
|
||||
fi
|
||||
|
||||
# Create and set permissions on state directory
|
||||
if [ ! -d /var/lib/linux-hello ]; then
|
||||
mkdir -p /var/lib/linux-hello
|
||||
fi
|
||||
# State directory: 0750 (owner: root, group: linux-hello)
|
||||
chown root:linux-hello /var/lib/linux-hello
|
||||
chmod 0750 /var/lib/linux-hello
|
||||
|
||||
# Create templates subdirectory
|
||||
if [ ! -d /var/lib/linux-hello/templates ]; then
|
||||
mkdir -p /var/lib/linux-hello/templates
|
||||
fi
|
||||
chown root:linux-hello /var/lib/linux-hello/templates
|
||||
chmod 0750 /var/lib/linux-hello/templates
|
||||
|
||||
# Create runtime directory for socket
|
||||
if [ ! -d /run/linux-hello ]; then
|
||||
mkdir -p /run/linux-hello
|
||||
fi
|
||||
# Socket directory: needs to be accessible for authentication
|
||||
chown root:linux-hello /run/linux-hello
|
||||
chmod 0750 /run/linux-hello
|
||||
|
||||
# Configuration file permissions: 0644 (readable by all)
|
||||
if [ -f /etc/linux-hello/config.toml ]; then
|
||||
chmod 0644 /etc/linux-hello/config.toml
|
||||
fi
|
||||
|
||||
# Add video group to linux-hello user for camera access
|
||||
if getent group video > /dev/null 2>&1; then
|
||||
usermod -a -G video linux-hello 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Add tss group for TPM access if available
|
||||
if getent group tss > /dev/null 2>&1; then
|
||||
usermod -a -G tss linux-hello 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Reload systemd daemon
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
57
debian/postrm
vendored
Normal file
57
debian/postrm
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# postrm script for linux-hello-daemon
|
||||
#
|
||||
# see: dh_installdeb(1)
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Remove state directory and all templates
|
||||
if [ -d /var/lib/linux-hello ]; then
|
||||
echo "Removing /var/lib/linux-hello..."
|
||||
rm -rf /var/lib/linux-hello
|
||||
fi
|
||||
|
||||
# Remove runtime directory
|
||||
if [ -d /run/linux-hello ]; then
|
||||
rm -rf /run/linux-hello
|
||||
fi
|
||||
|
||||
# Remove configuration directory
|
||||
if [ -d /etc/linux-hello ]; then
|
||||
echo "Removing /etc/linux-hello..."
|
||||
rm -rf /etc/linux-hello
|
||||
fi
|
||||
|
||||
# Remove linux-hello system user
|
||||
if getent passwd linux-hello > /dev/null 2>&1; then
|
||||
echo "Removing linux-hello system user..."
|
||||
deluser --system linux-hello 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Remove linux-hello group if it exists and has no members
|
||||
if getent group linux-hello > /dev/null 2>&1; then
|
||||
delgroup --system linux-hello 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
|
||||
remove)
|
||||
# Remove runtime directory on remove (not purge)
|
||||
if [ -d /run/linux-hello ]; then
|
||||
rm -rf /run/linux-hello
|
||||
fi
|
||||
;;
|
||||
|
||||
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postrm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
34
debian/prerm
vendored
Normal file
34
debian/prerm
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# prerm script for linux-hello-daemon
|
||||
#
|
||||
# see: dh_installdeb(1)
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
# Stop the service before removal
|
||||
if [ -d /run/systemd/system ]; then
|
||||
if systemctl is-active --quiet linux-hello.service 2>/dev/null; then
|
||||
echo "Stopping linux-hello service..."
|
||||
systemctl stop linux-hello.service || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet linux-hello.service 2>/dev/null; then
|
||||
echo "Disabling linux-hello service..."
|
||||
systemctl disable linux-hello.service || true
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
failed-upgrade)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "prerm called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
75
debian/rules
vendored
Normal file
75
debian/rules
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# Enable all hardening options
|
||||
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
|
||||
|
||||
# Cargo build options
|
||||
export CARGO_HOME = $(CURDIR)/debian/.cargo
|
||||
export CARGO_TARGET_DIR = $(CURDIR)/target/debian
|
||||
|
||||
# Determine architecture-specific PAM path
|
||||
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)
|
||||
PAM_MODULE_DIR = /lib/$(DEB_HOST_MULTIARCH)/security
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_clean:
|
||||
cargo clean --target-dir $(CARGO_TARGET_DIR) || true
|
||||
$(MAKE) -C pam-module clean || true
|
||||
rm -rf debian/.cargo
|
||||
|
||||
override_dh_auto_configure:
|
||||
mkdir -p $(CARGO_HOME)
|
||||
@echo "[net]" > $(CARGO_HOME)/config.toml
|
||||
@echo "offline = false" >> $(CARGO_HOME)/config.toml
|
||||
|
||||
override_dh_auto_build:
|
||||
# Build Rust binaries in release mode
|
||||
cargo build --release --target-dir $(CARGO_TARGET_DIR) \
|
||||
--package linux-hello-daemon \
|
||||
--package linux-hello-cli
|
||||
# Build PAM module
|
||||
$(MAKE) -C pam-module CFLAGS="$(CFLAGS) -fPIC" LDFLAGS="$(LDFLAGS)"
|
||||
|
||||
override_dh_auto_test:
|
||||
# Run Rust tests (skip integration tests that need hardware)
|
||||
cargo test --release --target-dir $(CARGO_TARGET_DIR) \
|
||||
--package linux-hello-common \
|
||||
--package linux-hello-daemon \
|
||||
--package linux-hello-cli \
|
||||
-- --skip integration || true
|
||||
|
||||
override_dh_auto_install:
|
||||
# Install daemon
|
||||
install -D -m 755 $(CARGO_TARGET_DIR)/release/linux-hello-daemon \
|
||||
debian/linux-hello-daemon/usr/libexec/linux-hello-daemon
|
||||
# Install CLI
|
||||
install -D -m 755 $(CARGO_TARGET_DIR)/release/linux-hello \
|
||||
debian/linux-hello/usr/bin/linux-hello
|
||||
# Install PAM module to architecture-specific directory
|
||||
install -D -m 755 pam-module/pam_linux_hello.so \
|
||||
debian/libpam-linux-hello$(PAM_MODULE_DIR)/pam_linux_hello.so
|
||||
# Install configuration
|
||||
install -D -m 644 dist/config.toml \
|
||||
debian/linux-hello-daemon/etc/linux-hello/config.toml
|
||||
# Install systemd service
|
||||
install -D -m 644 dist/linux-hello.service \
|
||||
debian/linux-hello-daemon/lib/systemd/system/linux-hello.service
|
||||
# Install PAM configuration template (NOT auto-configured - dangerous!)
|
||||
install -D -m 644 debian/pam-config.example \
|
||||
debian/libpam-linux-hello/usr/share/doc/libpam-linux-hello/pam-config.example
|
||||
# Create state directories (actual permissions set in postinst)
|
||||
install -d -m 755 debian/linux-hello-daemon/var/lib/linux-hello
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --package=linux-hello-daemon --name=linux-hello
|
||||
|
||||
override_dh_fixperms:
|
||||
dh_fixperms
|
||||
# Config file should be readable
|
||||
chmod 644 debian/linux-hello-daemon/etc/linux-hello/config.toml || true
|
||||
|
||||
.PHONY: override_dh_auto_clean override_dh_auto_configure override_dh_auto_build \
|
||||
override_dh_auto_test override_dh_auto_install override_dh_installsystemd \
|
||||
override_dh_fixperms
|
||||
1
debian/source/format
vendored
Normal file
1
debian/source/format
vendored
Normal file
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
16
dist/linux-hello-settings.desktop
vendored
Normal file
16
dist/linux-hello-settings.desktop
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
[Desktop Entry]
|
||||
Name=Linux Hello Settings
|
||||
Comment=Configure facial authentication for Linux Hello
|
||||
Exec=linux-hello-settings
|
||||
Icon=org.linuxhello.Settings
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Settings;Security;System;
|
||||
Keywords=face;authentication;biometric;login;security;hello;
|
||||
StartupNotify=true
|
||||
|
||||
# GNOME-specific
|
||||
X-GNOME-UsesNotifications=false
|
||||
|
||||
# Freedesktop
|
||||
X-GNOME-Settings-Panel=privacy
|
||||
44
dist/org.linuxhello.Daemon.conf
vendored
Normal file
44
dist/org.linuxhello.Daemon.conf
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig>
|
||||
<!-- D-Bus policy configuration for Linux Hello Daemon -->
|
||||
<!-- This file should be installed to /etc/dbus-1/system.d/ -->
|
||||
|
||||
<!-- Only root can own the service name -->
|
||||
<policy user="root">
|
||||
<allow own="org.linuxhello.Daemon"/>
|
||||
<allow send_destination="org.linuxhello.Daemon"/>
|
||||
<allow receive_sender="org.linuxhello.Daemon"/>
|
||||
</policy>
|
||||
|
||||
<!-- Allow all users to call methods on the interface -->
|
||||
<!-- Authentication and authorization is handled by the daemon itself -->
|
||||
<policy context="default">
|
||||
<!-- Allow introspection -->
|
||||
<allow send_destination="org.linuxhello.Daemon"
|
||||
send_interface="org.freedesktop.DBus.Introspectable"/>
|
||||
|
||||
<!-- Allow property access -->
|
||||
<allow send_destination="org.linuxhello.Daemon"
|
||||
send_interface="org.freedesktop.DBus.Properties"/>
|
||||
|
||||
<!-- Allow calling methods on the Manager interface -->
|
||||
<allow send_destination="org.linuxhello.Daemon"
|
||||
send_interface="org.linuxhello.Manager"/>
|
||||
|
||||
<!-- Allow receiving signals from the daemon -->
|
||||
<allow receive_sender="org.linuxhello.Daemon"/>
|
||||
</policy>
|
||||
|
||||
<!-- Security note:
|
||||
The daemon performs its own authorization checks:
|
||||
- Authentication: Available to all callers (PAM may call as any user)
|
||||
- Enrollment: Only root or the target user can enroll faces
|
||||
- List templates: Only root or the target user can list their templates
|
||||
- Remove templates: Only root or the target user can remove their templates
|
||||
|
||||
D-Bus signals (EnrollmentProgress, EnrollmentComplete, Error) are broadcast
|
||||
to all connected clients.
|
||||
-->
|
||||
</busconfig>
|
||||
8
dist/org.linuxhello.Daemon.service
vendored
Normal file
8
dist/org.linuxhello.Daemon.service
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# D-Bus service file for Linux Hello Daemon
|
||||
# This file should be installed to /usr/share/dbus-1/system-services/
|
||||
|
||||
[D-BUS Service]
|
||||
Name=org.linuxhello.Daemon
|
||||
Exec=/usr/libexec/linux-hello-daemon
|
||||
User=root
|
||||
SystemdService=linux-hello.service
|
||||
450
docs/API.md
Normal file
450
docs/API.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# Linux Hello API Documentation
|
||||
|
||||
This document provides a high-level overview of the Linux Hello API for developers
|
||||
who want to integrate with, extend, or understand the facial authentication system.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Security Model](#security-model)
|
||||
- [Authentication Flow](#authentication-flow)
|
||||
- [Crate Structure](#crate-structure)
|
||||
- [Key APIs](#key-apis)
|
||||
- [Extension Points](#extension-points)
|
||||
- [Configuration](#configuration)
|
||||
- [IPC Protocol](#ipc-protocol)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Linux Hello uses a pipeline architecture for facial authentication:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Authentication Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ ┌───────────┐ ┌──────────┐ │
|
||||
│ │ Camera │──▶│ Face │──▶│ Anti- │──▶│ Embedding │──▶│ Template │ │
|
||||
│ │ Capture │ │ Detection │ │ Spoofing │ │Extraction │ │ Matching │ │
|
||||
│ └──────────┘ └───────────┘ └─────────────┘ └───────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ ▼ │
|
||||
│ camera/ detection/ anti_spoofing/ embedding/ matching/ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Purpose | Module |
|
||||
|-----------|---------|--------|
|
||||
| Camera Capture | Acquire IR frames from webcam | `camera` |
|
||||
| Face Detection | Locate faces in frames | `detection` |
|
||||
| Anti-Spoofing | Verify liveness (prevent photos/videos) | `anti_spoofing` |
|
||||
| Embedding Extraction | Generate face feature vector | `embedding` |
|
||||
| Template Matching | Compare against enrolled templates | `matching` |
|
||||
|
||||
## Security Model
|
||||
|
||||
Linux Hello implements defense-in-depth security:
|
||||
|
||||
### Layer 1: Hardware Security
|
||||
- **IR Camera Requirement**: Only infrared cameras are accepted
|
||||
- **TPM Integration**: Templates encrypted with hardware-bound keys
|
||||
- **PCR Binding**: Optional boot configuration verification
|
||||
|
||||
### Layer 2: Biometric Security
|
||||
- **Anti-Spoofing**: Multiple liveness detection methods
|
||||
- IR reflection analysis
|
||||
- Depth estimation
|
||||
- Texture analysis (LBP)
|
||||
- Blink detection
|
||||
- Micro-movement tracking
|
||||
|
||||
### Layer 3: Data Security
|
||||
- **Encrypted Storage**: AES-256-GCM for templates at rest
|
||||
- **Secure Memory**: Sensitive data zeroized on drop
|
||||
- **Memory Locking**: Prevents swapping to disk
|
||||
|
||||
### Layer 4: Access Control
|
||||
- **IPC Authorization**: Peer credential verification
|
||||
- **Rate Limiting**: Prevents brute-force attacks
|
||||
- **Permission Checks**: Users can only manage their own templates
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Authentication Sequence │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
PAM Module Daemon Storage
|
||||
│ │ │
|
||||
│ 1. Authenticate(user) │ │
|
||||
│───────────────────────▶│ │
|
||||
│ │ │
|
||||
│ │ 2. Load templates │
|
||||
│ │─────────────────────────▶│
|
||||
│ │ │
|
||||
│ │ 3. Capture frame │
|
||||
│ │ ◄──── IR Camera │
|
||||
│ │ │
|
||||
│ │ 4. Detect face │
|
||||
│ │ 5. Anti-spoofing check │
|
||||
│ │ 6. Extract embedding │
|
||||
│ │ 7. Match templates │
|
||||
│ │ │
|
||||
│ 8. Auth result │ │
|
||||
│◄───────────────────────│ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## Crate Structure
|
||||
|
||||
### linux-hello-common
|
||||
|
||||
Shared types and utilities used by all components.
|
||||
|
||||
```rust
|
||||
// Key exports
|
||||
use linux_hello_common::{
|
||||
Config, // System configuration
|
||||
Error, Result, // Error handling
|
||||
FaceTemplate, // Template data structure
|
||||
TemplateStore, // File-based storage
|
||||
};
|
||||
```
|
||||
|
||||
### linux-hello-daemon
|
||||
|
||||
Core authentication functionality and services.
|
||||
|
||||
```rust
|
||||
// Camera access
|
||||
use linux_hello_daemon::{
|
||||
enumerate_cameras, // List available cameras
|
||||
Camera, // Camera control
|
||||
CameraInfo, // Camera metadata
|
||||
Frame, PixelFormat, // Frame data
|
||||
};
|
||||
|
||||
// Face processing
|
||||
use linux_hello_daemon::{
|
||||
FaceDetection, FaceDetect, // Detection types
|
||||
EmbeddingExtractor, // Embedding trait
|
||||
cosine_similarity, // Distance metrics
|
||||
match_template, MatchResult, // Matching
|
||||
};
|
||||
|
||||
// Security
|
||||
use linux_hello_daemon::{
|
||||
AntiSpoofingDetector, LivenessResult, // Anti-spoofing
|
||||
SecureEmbedding, SecureBytes, // Secure memory
|
||||
TpmStorage, SoftwareTpmFallback, // Encryption
|
||||
};
|
||||
|
||||
// IPC
|
||||
use linux_hello_daemon::{
|
||||
IpcServer, IpcClient, // Server/client
|
||||
IpcRequest, IpcResponse, // Messages
|
||||
};
|
||||
```
|
||||
|
||||
## Key APIs
|
||||
|
||||
### Camera API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{enumerate_cameras, Camera, Frame};
|
||||
|
||||
// Find IR camera
|
||||
let cameras = enumerate_cameras()?;
|
||||
let ir_camera = cameras.iter()
|
||||
.find(|c| c.is_ir)
|
||||
.ok_or("No IR camera found")?;
|
||||
|
||||
// Capture frames
|
||||
let mut camera = Camera::open(&ir_camera.device_path)?;
|
||||
camera.start()?;
|
||||
let frame: Frame = camera.capture_frame()?;
|
||||
```
|
||||
|
||||
### Face Detection API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{FaceDetect, SimpleFaceDetector, FaceDetection};
|
||||
|
||||
// Create detector
|
||||
let detector = SimpleFaceDetector::new(0.5); // confidence threshold
|
||||
|
||||
// Detect faces
|
||||
let detections: Vec<FaceDetection> = detector.detect(
|
||||
&frame.data,
|
||||
frame.width,
|
||||
frame.height
|
||||
)?;
|
||||
|
||||
// Convert to pixel coordinates
|
||||
if let Some(face) = detections.first() {
|
||||
let (x, y, w, h) = face.to_pixels(frame.width, frame.height);
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{
|
||||
EmbeddingExtractor, PlaceholderEmbeddingExtractor,
|
||||
cosine_similarity, euclidean_distance, similarity_to_distance,
|
||||
};
|
||||
use image::GrayImage;
|
||||
|
||||
// Extract embedding
|
||||
let extractor = PlaceholderEmbeddingExtractor::new(128);
|
||||
let face_image = GrayImage::new(112, 112); // cropped face
|
||||
let embedding: Vec<f32> = extractor.extract(&face_image)?;
|
||||
|
||||
// Compare embeddings
|
||||
let similarity = cosine_similarity(&embedding1, &embedding2);
|
||||
let distance = similarity_to_distance(similarity);
|
||||
```
|
||||
|
||||
### Template Matching API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{match_template, MatchResult, average_embeddings};
|
||||
use linux_hello_common::FaceTemplate;
|
||||
|
||||
// Match against stored templates
|
||||
let result: MatchResult = match_template(
|
||||
&probe_embedding,
|
||||
&stored_templates,
|
||||
0.6 // distance threshold
|
||||
);
|
||||
|
||||
if result.matched {
|
||||
println!("Match found: {:?}", result.matched_label);
|
||||
}
|
||||
|
||||
// Create averaged template for enrollment
|
||||
let avg_embedding = average_embeddings(&multiple_embeddings)?;
|
||||
```
|
||||
|
||||
### Anti-Spoofing API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::anti_spoofing::{
|
||||
AntiSpoofingDetector, AntiSpoofingConfig, AntiSpoofingFrame, LivenessResult
|
||||
};
|
||||
|
||||
let config = AntiSpoofingConfig::default();
|
||||
let mut detector = AntiSpoofingDetector::new(config);
|
||||
|
||||
let frame = AntiSpoofingFrame {
|
||||
pixels: frame_data,
|
||||
width: 640,
|
||||
height: 480,
|
||||
is_ir: true,
|
||||
face_bbox: Some((x, y, w, h)),
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
let result: LivenessResult = detector.check_frame(&frame)?;
|
||||
if result.is_live {
|
||||
// Proceed with authentication
|
||||
}
|
||||
```
|
||||
|
||||
### Secure Memory API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{SecureEmbedding, SecureBytes};
|
||||
|
||||
// Automatically zeroized on drop
|
||||
let secure_emb = SecureEmbedding::new(embedding);
|
||||
|
||||
// Constant-time comparison
|
||||
let bytes1 = SecureBytes::new(data1);
|
||||
let bytes2 = SecureBytes::new(data2);
|
||||
let equal = bytes1.constant_time_eq(&bytes2);
|
||||
```
|
||||
|
||||
### IPC API
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::ipc::{IpcClient, IpcServer, IpcRequest, IpcResponse};
|
||||
|
||||
// Client usage
|
||||
let client = IpcClient::default();
|
||||
let response = client.authenticate("username").await?;
|
||||
if response.success {
|
||||
println!("Authenticated!");
|
||||
}
|
||||
|
||||
// Server setup
|
||||
let mut server = IpcServer::new("/run/linux-hello/auth.sock");
|
||||
server.set_auth_handler(|user| async move {
|
||||
// Perform authentication
|
||||
Ok(true)
|
||||
});
|
||||
server.start().await?;
|
||||
```
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Custom Face Detector
|
||||
|
||||
Implement the `FaceDetect` trait:
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::{FaceDetect, FaceDetection};
|
||||
use linux_hello_common::Result;
|
||||
|
||||
struct MyDetector { /* ... */ }
|
||||
|
||||
impl FaceDetect for MyDetector {
|
||||
fn detect(&self, image_data: &[u8], width: u32, height: u32)
|
||||
-> Result<Vec<FaceDetection>>
|
||||
{
|
||||
// Custom detection logic
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Embedding Extractor
|
||||
|
||||
Implement the `EmbeddingExtractor` trait:
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::EmbeddingExtractor;
|
||||
use linux_hello_common::Result;
|
||||
use image::GrayImage;
|
||||
|
||||
struct MyExtractor { /* ... */ }
|
||||
|
||||
impl EmbeddingExtractor for MyExtractor {
|
||||
fn extract(&self, face_image: &GrayImage) -> Result<Vec<f32>> {
|
||||
// Custom embedding extraction
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom TPM Storage
|
||||
|
||||
Implement the `TpmStorage` trait:
|
||||
|
||||
```rust
|
||||
use linux_hello_daemon::tpm::{TpmStorage, EncryptedTemplate};
|
||||
use linux_hello_common::Result;
|
||||
|
||||
struct MyStorage { /* ... */ }
|
||||
|
||||
impl TpmStorage for MyStorage {
|
||||
fn is_available(&self) -> bool { true }
|
||||
fn initialize(&mut self) -> Result<()> { Ok(()) }
|
||||
fn encrypt(&self, user: &str, plaintext: &[u8]) -> Result<EncryptedTemplate> { /* ... */ }
|
||||
fn decrypt(&self, user: &str, encrypted: &EncryptedTemplate) -> Result<Vec<u8>> { /* ... */ }
|
||||
fn create_user_key(&mut self, user: &str) -> Result<()> { Ok(()) }
|
||||
fn remove_user_key(&mut self, user: &str) -> Result<()> { Ok(()) }
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `/etc/linux-hello/config.toml`:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
log_level = "info"
|
||||
timeout_seconds = 5
|
||||
|
||||
[camera]
|
||||
device = "auto" # or "/dev/video0"
|
||||
ir_emitter = "auto"
|
||||
resolution = [640, 480]
|
||||
fps = 30
|
||||
|
||||
[detection]
|
||||
model = "blazeface"
|
||||
min_face_size = 80
|
||||
confidence_threshold = 0.9
|
||||
|
||||
[embedding]
|
||||
model = "mobilefacenet"
|
||||
distance_threshold = 0.6
|
||||
|
||||
[anti_spoofing]
|
||||
enabled = true
|
||||
depth_check = true
|
||||
liveness_model = true
|
||||
temporal_check = true
|
||||
min_score = 0.7
|
||||
|
||||
[tpm]
|
||||
enabled = true
|
||||
pcr_binding = false
|
||||
```
|
||||
|
||||
## IPC Protocol
|
||||
|
||||
The daemon communicates via Unix socket using JSON messages.
|
||||
|
||||
### Socket Location
|
||||
|
||||
`/run/linux-hello/auth.sock`
|
||||
|
||||
### Request Format
|
||||
|
||||
```json
|
||||
{"action": "authenticate", "user": "username"}
|
||||
{"action": "enroll", "user": "username", "label": "default", "frame_count": 5}
|
||||
{"action": "list", "user": "username"}
|
||||
{"action": "remove", "user": "username", "label": "default"}
|
||||
{"action": "ping"}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Authentication successful",
|
||||
"confidence": 0.95,
|
||||
"templates": ["default", "backup"]
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All operations return `Result<T, Error>` where `Error` is defined in `linux_hello_common::Error`:
|
||||
|
||||
```rust
|
||||
use linux_hello_common::{Error, Result};
|
||||
|
||||
match operation() {
|
||||
Ok(result) => { /* success */ }
|
||||
Err(Error::NoCameraFound) => { /* no IR camera */ }
|
||||
Err(Error::NoFaceDetected) => { /* face not visible */ }
|
||||
Err(Error::AuthenticationFailed) => { /* no match */ }
|
||||
Err(Error::UserNotEnrolled(user)) => { /* not enrolled */ }
|
||||
Err(e) => { /* other error */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Building Documentation
|
||||
|
||||
Generate HTML documentation:
|
||||
|
||||
```bash
|
||||
cargo doc --workspace --no-deps --open
|
||||
```
|
||||
|
||||
Documentation is generated at `target/doc/linux_hello_daemon/index.html`.
|
||||
|
||||
## See Also
|
||||
|
||||
- [README.md](../README.md) - Project overview and quick start
|
||||
- [BENCHMARKS.md](BENCHMARKS.md) - Performance benchmarks
|
||||
- Source code documentation: `cargo doc --open`
|
||||
289
docs/BENCHMARKS.md
Normal file
289
docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Linux Hello Performance Benchmarks
|
||||
|
||||
This document describes the performance benchmarks for the Linux Hello face authentication system. These benchmarks are used for optimization and regression testing.
|
||||
|
||||
## Overview
|
||||
|
||||
The benchmark suite measures performance of critical authentication pipeline components:
|
||||
|
||||
| Component | Description | Target Metric |
|
||||
|-----------|-------------|---------------|
|
||||
| Face Detection | Locate faces in camera frames | Frames per second |
|
||||
| Embedding Extraction | Extract facial features | Embeddings per second |
|
||||
| Template Matching | Compare embeddings (cosine similarity) | Comparisons per second |
|
||||
| Anti-Spoofing | Liveness detection pipeline | Latency (ms) |
|
||||
| Encryption/Decryption | AES-256-GCM operations | Throughput (MB/s) |
|
||||
| Secure Memory | Allocation, zeroization, constant-time ops | Overhead (ns) |
|
||||
|
||||
## Performance Goals
|
||||
|
||||
For a responsive authentication experience, the total authentication time should be under 100ms. This breaks down as follows:
|
||||
|
||||
| Stage | Target | Notes |
|
||||
|-------|--------|-------|
|
||||
| Frame Capture | <33ms | 30 FPS minimum |
|
||||
| Face Detection | <20ms | Per frame |
|
||||
| Embedding Extraction | <30ms | Per detected face |
|
||||
| Anti-Spoofing (per frame) | <15ms | Single frame analysis |
|
||||
| Template Matching | <5ms | Against up to 100 templates |
|
||||
| Encryption Round-trip | <10ms | For template storage/retrieval |
|
||||
| **Total Pipeline** | **<100ms** | Single-frame authentication |
|
||||
|
||||
### Additional Targets
|
||||
|
||||
- **Multi-frame anti-spoofing**: <150ms for 10-frame temporal analysis
|
||||
- **Secure memory operations**: <1% overhead vs non-secure operations
|
||||
- **Constant-time comparisons**: Timing variance <1% between match/no-match
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have Rust 1.75+ installed and the project dependencies:
|
||||
|
||||
```bash
|
||||
cd linux-hello
|
||||
cargo build --release -p linux-hello-daemon
|
||||
```
|
||||
|
||||
### Run All Benchmarks
|
||||
|
||||
```bash
|
||||
cargo bench -p linux-hello-daemon
|
||||
```
|
||||
|
||||
### Run Specific Benchmark Groups
|
||||
|
||||
```bash
|
||||
# Face detection only
|
||||
cargo bench -p linux-hello-daemon -- face_detection
|
||||
|
||||
# Template matching
|
||||
cargo bench -p linux-hello-daemon -- template_matching
|
||||
|
||||
# Encryption
|
||||
cargo bench -p linux-hello-daemon -- encryption
|
||||
|
||||
# Secure memory operations
|
||||
cargo bench -p linux-hello-daemon -- secure_memory
|
||||
|
||||
# Full authentication pipeline
|
||||
cargo bench -p linux-hello-daemon -- full_pipeline
|
||||
```
|
||||
|
||||
### Generate HTML Reports
|
||||
|
||||
Criterion automatically generates HTML reports in `target/criterion/`. Open `target/criterion/report/index.html` in a browser to view detailed results with graphs.
|
||||
|
||||
```bash
|
||||
# After running benchmarks
|
||||
firefox target/criterion/report/index.html
|
||||
```
|
||||
|
||||
### Compare Against Baseline
|
||||
|
||||
To track regressions, save a baseline and compare:
|
||||
|
||||
```bash
|
||||
# Save current results as baseline
|
||||
cargo bench -p linux-hello-daemon -- --save-baseline main
|
||||
|
||||
# After changes, compare against baseline
|
||||
cargo bench -p linux-hello-daemon -- --baseline main
|
||||
```
|
||||
|
||||
## Benchmark Descriptions
|
||||
|
||||
### Face Detection (`face_detection`)
|
||||
|
||||
Tests the face detection algorithms at common camera resolutions:
|
||||
- QVGA (320x240)
|
||||
- VGA (640x480)
|
||||
- 720p (1280x720)
|
||||
- 1080p (1920x1080)
|
||||
|
||||
**What it measures**:
|
||||
- `simple_detection`: Basic placeholder algorithm
|
||||
- `detector_trait`: Full FaceDetect trait implementation
|
||||
|
||||
### Embedding Extraction (`embedding_extraction`)
|
||||
|
||||
Tests embedding generation at various input sizes and output dimensions:
|
||||
- Face sizes: 64x64, 112x112, 160x160, 224x224
|
||||
- Embedding dimensions: 64, 128, 256, 512
|
||||
|
||||
**What it measures**:
|
||||
- Time to extract a normalized embedding vector from a face region
|
||||
|
||||
### Template Matching (`template_matching`)
|
||||
|
||||
Tests comparison operations:
|
||||
- Cosine similarity at different dimensions
|
||||
- Euclidean distance calculations
|
||||
- Matching against databases of 1-100 templates
|
||||
|
||||
**What it measures**:
|
||||
- Single comparison latency
|
||||
- Throughput when matching against template databases
|
||||
|
||||
### Anti-Spoofing (`anti_spoofing`)
|
||||
|
||||
Tests liveness detection components:
|
||||
- Single frame IR/depth/texture analysis
|
||||
- Full temporal pipeline (10 frames with movement/blink detection)
|
||||
|
||||
**What it measures**:
|
||||
- Per-frame analysis latency
|
||||
- Full pipeline latency for multi-frame analysis
|
||||
|
||||
### Encryption (`encryption`)
|
||||
|
||||
Tests AES-256-GCM encryption used for template storage:
|
||||
- Encrypt/decrypt at various data sizes
|
||||
- Round-trip (encrypt then decrypt)
|
||||
- PBKDF2 key derivation overhead
|
||||
|
||||
**What it measures**:
|
||||
- Throughput (bytes/second)
|
||||
- Latency for template-sized data
|
||||
|
||||
### Secure Memory (`secure_memory`)
|
||||
|
||||
Tests security-critical memory operations:
|
||||
- SecureEmbedding creation (with memory locking)
|
||||
- Constant-time cosine similarity
|
||||
- Secure byte comparison (SecureBytes)
|
||||
- Memory zeroization
|
||||
|
||||
**What it measures**:
|
||||
- Overhead vs non-secure operations
|
||||
- Timing consistency (for constant-time operations)
|
||||
|
||||
### Full Pipeline (`full_pipeline`)
|
||||
|
||||
Tests complete authentication flows:
|
||||
- `auth_pipeline_no_crypto`: Detection + extraction + matching
|
||||
- `auth_pipeline_with_antispoofing`: Full pipeline with liveness checks
|
||||
|
||||
**What it measures**:
|
||||
- End-to-end authentication latency
|
||||
|
||||
## Reference Results
|
||||
|
||||
Expected results on reference hardware (AMD Ryzen 7 5800X, 32GB RAM):
|
||||
|
||||
| Benchmark | Expected Time | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| Face detection (VGA) | ~50 us | Placeholder algorithm |
|
||||
| Embedding extraction (112x112) | ~100 us | Placeholder algorithm |
|
||||
| Cosine similarity (128-dim) | ~500 ns | SIMD-optimized |
|
||||
| Template matching (5 templates) | ~3 us | Linear scan |
|
||||
| Anti-spoofing (single frame) | ~2 ms | VGA resolution |
|
||||
| AES-GCM encrypt (512 bytes) | ~20 us | With PBKDF2 |
|
||||
| Secure memory zero (1KB) | ~500 ns | Volatile writes |
|
||||
| Constant-time eq (256 bytes) | ~300 ns | Using subtle crate |
|
||||
| Full pipeline (no crypto) | ~200 us | Detection + match |
|
||||
| Full pipeline (with anti-spoof) | ~2.5 ms | Complete auth |
|
||||
|
||||
**Note**: Production performance with ONNX models will differ significantly. These benchmarks use placeholder algorithms for testing infrastructure.
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Understanding Criterion Output
|
||||
|
||||
```
|
||||
template_matching/cosine_similarity/128
|
||||
time: [487.23 ns 489.12 ns 491.34 ns]
|
||||
thrpt: [2.0353 Melem/s 2.0444 Melem/s 2.0523 Melem/s]
|
||||
change: [-1.2% +0.3% +1.8%] (p = 0.72 > 0.05)
|
||||
No change in performance detected.
|
||||
```
|
||||
|
||||
- **time**: [lower bound, estimate, upper bound] with 95% confidence
|
||||
- **thrpt**: Throughput (operations per second)
|
||||
- **change**: Comparison vs previous run (if available)
|
||||
|
||||
### Performance Regressions
|
||||
|
||||
Criterion will flag significant regressions:
|
||||
- Performance degraded: >5% slower with high confidence
|
||||
- Performance improved: >5% faster with high confidence
|
||||
|
||||
Investigate regressions before merging code changes.
|
||||
|
||||
## Adding New Benchmarks
|
||||
|
||||
When adding new functionality, include appropriate benchmarks:
|
||||
|
||||
```rust
|
||||
fn bench_new_feature(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("new_feature");
|
||||
|
||||
// Set throughput for rate-based benchmarks
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
group.bench_function("operation_name", |b| {
|
||||
// Setup (not measured)
|
||||
let input = prepare_input();
|
||||
|
||||
b.iter(|| {
|
||||
// This code is measured
|
||||
black_box(your_function(black_box(&input)))
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// Add to criterion_group!
|
||||
criterion_group!(benches, ..., bench_new_feature);
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Benchmarks should be run in CI on performance-critical PRs:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions workflow
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run benchmarks
|
||||
run: cargo bench -p linux-hello-daemon -- --noplot
|
||||
- name: Store results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-results
|
||||
path: target/criterion/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### High Variance in Results
|
||||
|
||||
If benchmarks show high variance (wide confidence intervals):
|
||||
1. Close other applications
|
||||
2. Disable CPU frequency scaling: `sudo cpupower frequency-set -g performance`
|
||||
3. Increase sample size: `group.sample_size(200);`
|
||||
4. Run on an idle system
|
||||
|
||||
### Benchmarks Too Slow
|
||||
|
||||
For slow benchmarks, reduce sample size:
|
||||
|
||||
```rust
|
||||
group.sample_size(10); // Default is 100
|
||||
group.measurement_time(std::time::Duration::from_secs(5));
|
||||
```
|
||||
|
||||
### Memory Issues
|
||||
|
||||
If benchmarks fail with OOM or memory errors:
|
||||
1. Reduce iteration count
|
||||
2. Clean up large allocations in benchmark functions
|
||||
3. Check for memory leaks with valgrind
|
||||
|
||||
## License
|
||||
|
||||
These benchmarks are part of the Linux Hello project and are released under the GPL-3.0 license.
|
||||
97
kde-settings/CMakeLists.txt
Normal file
97
kde-settings/CMakeLists.txt
Normal file
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(kcm_linux_hello VERSION 1.0.0)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
# Find required packages
|
||||
find_package(ECM 6.0.0 REQUIRED NO_MODULE)
|
||||
set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH})
|
||||
|
||||
include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||
include(ECMQmlModule)
|
||||
include(FeatureSummary)
|
||||
|
||||
# KDE Frameworks 6
|
||||
set(KF_MIN_VERSION "6.0.0")
|
||||
set(QT_MIN_VERSION "6.6.0")
|
||||
|
||||
find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS
|
||||
Core
|
||||
Quick
|
||||
Qml
|
||||
DBus
|
||||
Widgets
|
||||
)
|
||||
|
||||
find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS
|
||||
CoreAddons
|
||||
I18n
|
||||
KCMUtils
|
||||
Config
|
||||
)
|
||||
|
||||
# Build the KCM plugin
|
||||
add_library(kcm_linux_hello MODULE)
|
||||
|
||||
target_sources(kcm_linux_hello PRIVATE
|
||||
src/kcm_linux_hello.cpp
|
||||
src/kcm_linux_hello.h
|
||||
src/dbus_client.cpp
|
||||
src/dbus_client.h
|
||||
)
|
||||
|
||||
target_link_libraries(kcm_linux_hello PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Quick
|
||||
Qt6::Qml
|
||||
Qt6::DBus
|
||||
Qt6::Widgets
|
||||
KF6::CoreAddons
|
||||
KF6::I18n
|
||||
KF6::KCMUtilsQuick
|
||||
KF6::ConfigCore
|
||||
)
|
||||
|
||||
# QML module for the UI
|
||||
ecm_add_qml_module(kcm_linux_hello URI "org.kde.kcm.linuxhello" VERSION 1.0
|
||||
GENERATE_PLUGIN_SOURCE
|
||||
DEPENDENCIES
|
||||
"QtQuick"
|
||||
"org.kde.kirigami"
|
||||
"org.kde.kcmutils"
|
||||
)
|
||||
|
||||
# Add QML files
|
||||
ecm_target_qml_sources(kcm_linux_hello SOURCES
|
||||
src/main.qml
|
||||
)
|
||||
|
||||
ecm_finalize_qml_module(kcm_linux_hello)
|
||||
|
||||
# Install the KCM plugin
|
||||
install(TARGETS kcm_linux_hello DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/kcms/systemsettings)
|
||||
|
||||
# Install metadata
|
||||
install(FILES src/metadata.json DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/kcms/systemsettings)
|
||||
|
||||
# Desktop file for System Settings integration
|
||||
configure_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/kcm_linux_hello.desktop.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/kcm_linux_hello.desktop
|
||||
@ONLY
|
||||
)
|
||||
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/kcm_linux_hello.desktop
|
||||
DESTINATION ${KDE_INSTALL_APPDIR}
|
||||
)
|
||||
|
||||
feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES)
|
||||
11
kde-settings/kcm_linux_hello.desktop.in
Normal file
11
kde-settings/kcm_linux_hello.desktop.in
Normal file
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Name=Linux Hello
|
||||
Comment=Configure facial authentication for login
|
||||
Icon=preferences-desktop-user-password
|
||||
Type=Application
|
||||
Exec=systemsettings kcm_linux_hello
|
||||
Categories=Qt;KDE;Security;Settings;
|
||||
|
||||
X-KDE-Keywords=face,facial,authentication,login,biometric,hello,security,recognition
|
||||
X-KDE-System-Settings-Parent-Category=security
|
||||
X-KDE-Weight=50
|
||||
408
kde-settings/src/dbus_client.cpp
Normal file
408
kde-settings/src/dbus_client.cpp
Normal file
@@ -0,0 +1,408 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
#include "dbus_client.h"
|
||||
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusConnectionInterface>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusPendingCall>
|
||||
#include <QDBusPendingReply>
|
||||
#include <QDBusReply>
|
||||
#include <QDebug>
|
||||
#include <QProcess>
|
||||
|
||||
#include <pwd.h>
|
||||
#include <unistd.h>
|
||||
|
||||
LinuxHelloDBusClient::LinuxHelloDBusClient(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
// Setup service watcher for daemon availability
|
||||
m_serviceWatcher = std::make_unique<QDBusServiceWatcher>(
|
||||
QString::fromLatin1(SERVICE_NAME),
|
||||
QDBusConnection::systemBus(),
|
||||
QDBusServiceWatcher::WatchForRegistration | QDBusServiceWatcher::WatchForUnregistration
|
||||
);
|
||||
|
||||
connect(m_serviceWatcher.get(), &QDBusServiceWatcher::serviceRegistered,
|
||||
this, &LinuxHelloDBusClient::onServiceRegistered);
|
||||
connect(m_serviceWatcher.get(), &QDBusServiceWatcher::serviceUnregistered,
|
||||
this, &LinuxHelloDBusClient::onServiceUnregistered);
|
||||
|
||||
// Check if service is already running and connect
|
||||
if (QDBusConnection::systemBus().interface()->isServiceRegistered(QString::fromLatin1(SERVICE_NAME))) {
|
||||
connectToService();
|
||||
}
|
||||
}
|
||||
|
||||
LinuxHelloDBusClient::~LinuxHelloDBusClient() = default;
|
||||
|
||||
// Property getters
|
||||
bool LinuxHelloDBusClient::daemonRunning() const { return m_daemonRunning; }
|
||||
bool LinuxHelloDBusClient::cameraAvailable() const { return m_cameraAvailable; }
|
||||
bool LinuxHelloDBusClient::tpmAvailable() const { return m_tpmAvailable; }
|
||||
bool LinuxHelloDBusClient::antiSpoofingEnabled() const { return m_antiSpoofingEnabled; }
|
||||
QString LinuxHelloDBusClient::daemonVersion() const { return m_daemonVersion; }
|
||||
int LinuxHelloDBusClient::enrolledCount() const { return m_enrolledCount; }
|
||||
|
||||
bool LinuxHelloDBusClient::enrollmentInProgress() const { return m_enrollmentInProgress; }
|
||||
int LinuxHelloDBusClient::enrollmentProgress() const { return m_enrollmentProgress; }
|
||||
QString LinuxHelloDBusClient::enrollmentMessage() const { return m_enrollmentMessage; }
|
||||
|
||||
QStringList LinuxHelloDBusClient::templates() const { return m_templates; }
|
||||
QString LinuxHelloDBusClient::lastError() const { return m_lastError; }
|
||||
|
||||
void LinuxHelloDBusClient::connectToService()
|
||||
{
|
||||
m_interface = std::make_unique<QDBusInterface>(
|
||||
QString::fromLatin1(SERVICE_NAME),
|
||||
QString::fromLatin1(OBJECT_PATH),
|
||||
QString::fromLatin1(INTERFACE_NAME),
|
||||
QDBusConnection::systemBus(),
|
||||
this
|
||||
);
|
||||
|
||||
if (m_interface->isValid()) {
|
||||
m_daemonRunning = true;
|
||||
Q_EMIT daemonRunningChanged();
|
||||
|
||||
setupSignalConnections();
|
||||
refreshStatus();
|
||||
refreshTemplates();
|
||||
} else {
|
||||
qWarning() << "Failed to create D-Bus interface:" << m_interface->lastError().message();
|
||||
m_interface.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::disconnectFromService()
|
||||
{
|
||||
m_interface.reset();
|
||||
m_daemonRunning = false;
|
||||
m_cameraAvailable = false;
|
||||
m_tpmAvailable = false;
|
||||
m_daemonVersion.clear();
|
||||
m_enrolledCount = 0;
|
||||
m_templates.clear();
|
||||
|
||||
Q_EMIT daemonRunningChanged();
|
||||
Q_EMIT cameraAvailableChanged();
|
||||
Q_EMIT tpmAvailableChanged();
|
||||
Q_EMIT daemonVersionChanged();
|
||||
Q_EMIT enrolledCountChanged();
|
||||
Q_EMIT templatesChanged();
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::setupSignalConnections()
|
||||
{
|
||||
if (!m_interface) return;
|
||||
|
||||
// Connect to daemon signals
|
||||
QDBusConnection::systemBus().connect(
|
||||
QString::fromLatin1(SERVICE_NAME),
|
||||
QString::fromLatin1(OBJECT_PATH),
|
||||
QString::fromLatin1(INTERFACE_NAME),
|
||||
QStringLiteral("EnrollmentProgress"),
|
||||
this,
|
||||
SLOT(onEnrollmentProgress(QString, uint, QString))
|
||||
);
|
||||
|
||||
QDBusConnection::systemBus().connect(
|
||||
QString::fromLatin1(SERVICE_NAME),
|
||||
QString::fromLatin1(OBJECT_PATH),
|
||||
QString::fromLatin1(INTERFACE_NAME),
|
||||
QStringLiteral("EnrollmentComplete"),
|
||||
this,
|
||||
SLOT(onEnrollmentComplete(QString, bool, QString))
|
||||
);
|
||||
|
||||
QDBusConnection::systemBus().connect(
|
||||
QString::fromLatin1(SERVICE_NAME),
|
||||
QString::fromLatin1(OBJECT_PATH),
|
||||
QString::fromLatin1(INTERFACE_NAME),
|
||||
QStringLiteral("Error"),
|
||||
this,
|
||||
SLOT(onDaemonError(QString, QString))
|
||||
);
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::onServiceRegistered(const QString &serviceName)
|
||||
{
|
||||
Q_UNUSED(serviceName)
|
||||
qDebug() << "Linux Hello daemon service registered";
|
||||
connectToService();
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::onServiceUnregistered(const QString &serviceName)
|
||||
{
|
||||
Q_UNUSED(serviceName)
|
||||
qDebug() << "Linux Hello daemon service unregistered";
|
||||
disconnectFromService();
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::refreshStatus()
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
m_daemonRunning = false;
|
||||
Q_EMIT daemonRunningChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get system status (camera_available, tpm_available, anti_spoofing_enabled, enrolled_count)
|
||||
QDBusMessage reply = m_interface->call(QStringLiteral("GetSystemStatus"));
|
||||
|
||||
if (reply.type() == QDBusMessage::ErrorMessage) {
|
||||
setLastError(reply.errorMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
QList<QVariant> args = reply.arguments();
|
||||
if (args.size() >= 4) {
|
||||
bool newCameraAvailable = args[0].toBool();
|
||||
bool newTpmAvailable = args[1].toBool();
|
||||
bool newAntiSpoofing = args[2].toBool();
|
||||
int newEnrolledCount = args[3].toInt();
|
||||
|
||||
if (m_cameraAvailable != newCameraAvailable) {
|
||||
m_cameraAvailable = newCameraAvailable;
|
||||
Q_EMIT cameraAvailableChanged();
|
||||
}
|
||||
if (m_tpmAvailable != newTpmAvailable) {
|
||||
m_tpmAvailable = newTpmAvailable;
|
||||
Q_EMIT tpmAvailableChanged();
|
||||
}
|
||||
if (m_antiSpoofingEnabled != newAntiSpoofing) {
|
||||
m_antiSpoofingEnabled = newAntiSpoofing;
|
||||
Q_EMIT antiSpoofingEnabledChanged();
|
||||
}
|
||||
if (m_enrolledCount != newEnrolledCount) {
|
||||
m_enrolledCount = newEnrolledCount;
|
||||
Q_EMIT enrolledCountChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// Get daemon version property
|
||||
QVariant versionVar = m_interface->property("Version");
|
||||
if (versionVar.isValid()) {
|
||||
QString newVersion = versionVar.toString();
|
||||
if (m_daemonVersion != newVersion) {
|
||||
m_daemonVersion = newVersion;
|
||||
Q_EMIT daemonVersionChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::refreshTemplates()
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString user = getCurrentUser();
|
||||
QDBusReply<QStringList> reply = m_interface->call(QStringLiteral("ListTemplates"), user);
|
||||
|
||||
if (reply.isValid()) {
|
||||
m_templates = reply.value();
|
||||
Q_EMIT templatesChanged();
|
||||
} else {
|
||||
setLastError(reply.error().message());
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::startEnrollment(const QString &label, int frameCount)
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
setLastError(tr("Daemon not running"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_enrollmentInProgress) {
|
||||
setLastError(tr("Enrollment already in progress"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString user = getCurrentUser();
|
||||
|
||||
// Start enrollment asynchronously
|
||||
QDBusPendingCall call = m_interface->asyncCall(
|
||||
QStringLiteral("EnrollStart"),
|
||||
user,
|
||||
label,
|
||||
static_cast<quint32>(frameCount)
|
||||
);
|
||||
|
||||
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call, this);
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher]() {
|
||||
QDBusPendingReply<> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
setLastError(reply.error().message());
|
||||
m_enrollmentInProgress = false;
|
||||
Q_EMIT enrollmentInProgressChanged();
|
||||
} else {
|
||||
m_enrollmentInProgress = true;
|
||||
m_enrollmentProgress = 0;
|
||||
m_enrollmentMessage = tr("Starting enrollment...");
|
||||
Q_EMIT enrollmentInProgressChanged();
|
||||
Q_EMIT enrollmentProgressChanged();
|
||||
Q_EMIT enrollmentMessageChanged();
|
||||
Q_EMIT enrollmentStarted();
|
||||
}
|
||||
watcher->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::cancelEnrollment()
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid() || !m_enrollmentInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
QDBusReply<void> reply = m_interface->call(QStringLiteral("EnrollCancel"));
|
||||
|
||||
if (reply.isValid()) {
|
||||
m_enrollmentInProgress = false;
|
||||
m_enrollmentProgress = 0;
|
||||
m_enrollmentMessage.clear();
|
||||
Q_EMIT enrollmentInProgressChanged();
|
||||
Q_EMIT enrollmentProgressChanged();
|
||||
Q_EMIT enrollmentMessageChanged();
|
||||
} else {
|
||||
setLastError(reply.error().message());
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::removeTemplate(const QString &label)
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
setLastError(tr("Daemon not running"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString user = getCurrentUser();
|
||||
QDBusReply<void> reply = m_interface->call(QStringLiteral("RemoveTemplate"), user, label);
|
||||
|
||||
if (reply.isValid()) {
|
||||
refreshTemplates();
|
||||
refreshStatus();
|
||||
Q_EMIT templateRemoved(label);
|
||||
} else {
|
||||
setLastError(reply.error().message());
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::removeAllTemplates()
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
setLastError(tr("Daemon not running"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString user = getCurrentUser();
|
||||
QDBusReply<void> reply = m_interface->call(QStringLiteral("RemoveTemplate"), user, QStringLiteral("*"));
|
||||
|
||||
if (reply.isValid()) {
|
||||
refreshTemplates();
|
||||
refreshStatus();
|
||||
Q_EMIT templateRemoved(QStringLiteral("*"));
|
||||
} else {
|
||||
setLastError(reply.error().message());
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::testAuthentication()
|
||||
{
|
||||
if (!m_interface || !m_interface->isValid()) {
|
||||
setLastError(tr("Daemon not running"));
|
||||
return;
|
||||
}
|
||||
|
||||
QString user = getCurrentUser();
|
||||
|
||||
QDBusPendingCall call = m_interface->asyncCall(QStringLiteral("Authenticate"), user);
|
||||
QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(call, this);
|
||||
|
||||
connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher]() {
|
||||
QDBusPendingReply<bool> reply = *watcher;
|
||||
if (reply.isError()) {
|
||||
Q_EMIT authenticationTested(false, reply.error().message());
|
||||
} else {
|
||||
bool success = reply.value();
|
||||
QString message = success ? tr("Authentication successful") : tr("Authentication failed");
|
||||
Q_EMIT authenticationTested(success, message);
|
||||
}
|
||||
watcher->deleteLater();
|
||||
});
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::setAntiSpoofingEnabled(bool enabled)
|
||||
{
|
||||
// Note: This would require writing to /etc/linux-hello/config.toml
|
||||
// which needs root privileges. In practice, this might use pkexec
|
||||
// or a privileged helper.
|
||||
Q_UNUSED(enabled)
|
||||
|
||||
// For now, emit a signal that the operation would need elevated privileges
|
||||
setLastError(tr("Changing anti-spoofing requires administrator privileges"));
|
||||
|
||||
// In a full implementation, you would use something like:
|
||||
// QProcess::startDetached("pkexec", {"linux-hello-config", "--set-anti-spoofing", enabled ? "true" : "false"});
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::setMatchingThreshold(double threshold)
|
||||
{
|
||||
// Similar to setAntiSpoofingEnabled, this requires root privileges
|
||||
Q_UNUSED(threshold)
|
||||
setLastError(tr("Changing threshold requires administrator privileges"));
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::onEnrollmentProgress(const QString &user, uint progress, const QString &message)
|
||||
{
|
||||
Q_UNUSED(user) // We only track our own enrollment
|
||||
|
||||
m_enrollmentProgress = static_cast<int>(progress);
|
||||
m_enrollmentMessage = message;
|
||||
|
||||
Q_EMIT enrollmentProgressChanged();
|
||||
Q_EMIT enrollmentMessageChanged();
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::onEnrollmentComplete(const QString &user, bool success, const QString &message)
|
||||
{
|
||||
Q_UNUSED(user)
|
||||
|
||||
m_enrollmentInProgress = false;
|
||||
m_enrollmentProgress = success ? 100 : 0;
|
||||
m_enrollmentMessage = message;
|
||||
|
||||
Q_EMIT enrollmentInProgressChanged();
|
||||
Q_EMIT enrollmentProgressChanged();
|
||||
Q_EMIT enrollmentMessageChanged();
|
||||
Q_EMIT enrollmentCompleted(success, message);
|
||||
|
||||
if (success) {
|
||||
refreshTemplates();
|
||||
refreshStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::onDaemonError(const QString &code, const QString &message)
|
||||
{
|
||||
qWarning() << "Daemon error:" << code << "-" << message;
|
||||
setLastError(QStringLiteral("%1: %2").arg(code, message));
|
||||
}
|
||||
|
||||
QString LinuxHelloDBusClient::getCurrentUser() const
|
||||
{
|
||||
struct passwd *pw = getpwuid(getuid());
|
||||
if (pw) {
|
||||
return QString::fromLocal8Bit(pw->pw_name);
|
||||
}
|
||||
return QString::fromLocal8Bit(qgetenv("USER"));
|
||||
}
|
||||
|
||||
void LinuxHelloDBusClient::setLastError(const QString &error)
|
||||
{
|
||||
m_lastError = error;
|
||||
Q_EMIT errorOccurred(error);
|
||||
}
|
||||
182
kde-settings/src/dbus_client.h
Normal file
182
kde-settings/src/dbus_client.h
Normal file
@@ -0,0 +1,182 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
#ifndef LINUX_HELLO_DBUS_CLIENT_H
|
||||
#define LINUX_HELLO_DBUS_CLIENT_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusInterface>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
#include <QDBusServiceWatcher>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* @brief D-Bus client for communicating with the Linux Hello daemon
|
||||
*
|
||||
* This class provides a Qt-friendly interface to the org.linuxhello.Daemon
|
||||
* D-Bus service for managing facial authentication.
|
||||
*/
|
||||
class LinuxHelloDBusClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
// System status properties
|
||||
Q_PROPERTY(bool daemonRunning READ daemonRunning NOTIFY daemonRunningChanged)
|
||||
Q_PROPERTY(bool cameraAvailable READ cameraAvailable NOTIFY cameraAvailableChanged)
|
||||
Q_PROPERTY(bool tpmAvailable READ tpmAvailable NOTIFY tpmAvailableChanged)
|
||||
Q_PROPERTY(bool antiSpoofingEnabled READ antiSpoofingEnabled NOTIFY antiSpoofingEnabledChanged)
|
||||
Q_PROPERTY(QString daemonVersion READ daemonVersion NOTIFY daemonVersionChanged)
|
||||
Q_PROPERTY(int enrolledCount READ enrolledCount NOTIFY enrolledCountChanged)
|
||||
|
||||
// Enrollment state
|
||||
Q_PROPERTY(bool enrollmentInProgress READ enrollmentInProgress NOTIFY enrollmentInProgressChanged)
|
||||
Q_PROPERTY(int enrollmentProgress READ enrollmentProgress NOTIFY enrollmentProgressChanged)
|
||||
Q_PROPERTY(QString enrollmentMessage READ enrollmentMessage NOTIFY enrollmentMessageChanged)
|
||||
|
||||
// Templates
|
||||
Q_PROPERTY(QStringList templates READ templates NOTIFY templatesChanged)
|
||||
|
||||
// Error state
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY errorOccurred)
|
||||
|
||||
public:
|
||||
explicit LinuxHelloDBusClient(QObject *parent = nullptr);
|
||||
~LinuxHelloDBusClient() override;
|
||||
|
||||
// Property getters
|
||||
bool daemonRunning() const;
|
||||
bool cameraAvailable() const;
|
||||
bool tpmAvailable() const;
|
||||
bool antiSpoofingEnabled() const;
|
||||
QString daemonVersion() const;
|
||||
int enrolledCount() const;
|
||||
|
||||
bool enrollmentInProgress() const;
|
||||
int enrollmentProgress() const;
|
||||
QString enrollmentMessage() const;
|
||||
|
||||
QStringList templates() const;
|
||||
QString lastError() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
/**
|
||||
* @brief Refresh the system status from the daemon
|
||||
*/
|
||||
void refreshStatus();
|
||||
|
||||
/**
|
||||
* @brief Refresh the list of enrolled templates
|
||||
*/
|
||||
void refreshTemplates();
|
||||
|
||||
/**
|
||||
* @brief Start enrollment for the current user
|
||||
* @param label Human-readable label for the template
|
||||
* @param frameCount Number of frames to capture (default: 5)
|
||||
*/
|
||||
void startEnrollment(const QString &label, int frameCount = 5);
|
||||
|
||||
/**
|
||||
* @brief Cancel an ongoing enrollment
|
||||
*/
|
||||
void cancelEnrollment();
|
||||
|
||||
/**
|
||||
* @brief Remove a template by label
|
||||
* @param label The template label to remove
|
||||
*/
|
||||
void removeTemplate(const QString &label);
|
||||
|
||||
/**
|
||||
* @brief Remove all templates for the current user
|
||||
*/
|
||||
void removeAllTemplates();
|
||||
|
||||
/**
|
||||
* @brief Test authentication for the current user
|
||||
*/
|
||||
void testAuthentication();
|
||||
|
||||
/**
|
||||
* @brief Set anti-spoofing enabled state
|
||||
* @param enabled Whether anti-spoofing should be enabled
|
||||
* @note This requires writing to the config file with root privileges
|
||||
*/
|
||||
void setAntiSpoofingEnabled(bool enabled);
|
||||
|
||||
/**
|
||||
* @brief Set the matching threshold
|
||||
* @param threshold The threshold value (0.0 - 1.0)
|
||||
*/
|
||||
void setMatchingThreshold(double threshold);
|
||||
|
||||
Q_SIGNALS:
|
||||
// Property change signals
|
||||
void daemonRunningChanged();
|
||||
void cameraAvailableChanged();
|
||||
void tpmAvailableChanged();
|
||||
void antiSpoofingEnabledChanged();
|
||||
void daemonVersionChanged();
|
||||
void enrolledCountChanged();
|
||||
|
||||
void enrollmentInProgressChanged();
|
||||
void enrollmentProgressChanged();
|
||||
void enrollmentMessageChanged();
|
||||
|
||||
void templatesChanged();
|
||||
|
||||
// Action result signals
|
||||
void enrollmentStarted();
|
||||
void enrollmentCompleted(bool success, const QString &message);
|
||||
void templateRemoved(const QString &label);
|
||||
void authenticationTested(bool success, const QString &message);
|
||||
void settingsUpdated();
|
||||
|
||||
// Error signal
|
||||
void errorOccurred(const QString &error);
|
||||
|
||||
private Q_SLOTS:
|
||||
void onServiceRegistered(const QString &serviceName);
|
||||
void onServiceUnregistered(const QString &serviceName);
|
||||
|
||||
// D-Bus signal handlers
|
||||
void onEnrollmentProgress(const QString &user, uint progress, const QString &message);
|
||||
void onEnrollmentComplete(const QString &user, bool success, const QString &message);
|
||||
void onDaemonError(const QString &code, const QString &message);
|
||||
|
||||
private:
|
||||
void connectToService();
|
||||
void disconnectFromService();
|
||||
void setupSignalConnections();
|
||||
QString getCurrentUser() const;
|
||||
void setLastError(const QString &error);
|
||||
|
||||
// D-Bus interface
|
||||
static constexpr const char* SERVICE_NAME = "org.linuxhello.Daemon";
|
||||
static constexpr const char* OBJECT_PATH = "/org/linuxhello/Manager";
|
||||
static constexpr const char* INTERFACE_NAME = "org.linuxhello.Manager";
|
||||
|
||||
std::unique_ptr<QDBusInterface> m_interface;
|
||||
std::unique_ptr<QDBusServiceWatcher> m_serviceWatcher;
|
||||
|
||||
// Cached state
|
||||
bool m_daemonRunning = false;
|
||||
bool m_cameraAvailable = false;
|
||||
bool m_tpmAvailable = false;
|
||||
bool m_antiSpoofingEnabled = true;
|
||||
QString m_daemonVersion;
|
||||
int m_enrolledCount = 0;
|
||||
|
||||
bool m_enrollmentInProgress = false;
|
||||
int m_enrollmentProgress = 0;
|
||||
QString m_enrollmentMessage;
|
||||
|
||||
QStringList m_templates;
|
||||
QString m_lastError;
|
||||
};
|
||||
|
||||
#endif // LINUX_HELLO_DBUS_CLIENT_H
|
||||
92
kde-settings/src/kcm_linux_hello.cpp
Normal file
92
kde-settings/src/kcm_linux_hello.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
#include "kcm_linux_hello.h"
|
||||
|
||||
#include <KLocalizedString>
|
||||
#include <KPluginFactory>
|
||||
|
||||
K_PLUGIN_CLASS_WITH_JSON(KcmLinuxHello, "metadata.json")
|
||||
|
||||
KcmLinuxHello::KcmLinuxHello(QObject *parent, const KPluginMetaData &data)
|
||||
: KQuickManagedConfigModule(parent, data)
|
||||
, m_client(new LinuxHelloDBusClient(this))
|
||||
{
|
||||
// Set up the module
|
||||
setButtons(Help | Default | Apply);
|
||||
|
||||
// Register the D-Bus client type for QML
|
||||
qmlRegisterUncreatableType<LinuxHelloDBusClient>(
|
||||
"org.kde.kcm.linuxhello",
|
||||
1, 0,
|
||||
"LinuxHelloDBusClient",
|
||||
QStringLiteral("LinuxHelloDBusClient is provided by the KCM")
|
||||
);
|
||||
|
||||
// Connect client signals for state tracking
|
||||
connect(m_client, &LinuxHelloDBusClient::antiSpoofingEnabledChanged,
|
||||
this, &KcmLinuxHello::settingsChanged);
|
||||
}
|
||||
|
||||
KcmLinuxHello::~KcmLinuxHello() = default;
|
||||
|
||||
LinuxHelloDBusClient *KcmLinuxHello::client() const
|
||||
{
|
||||
return m_client;
|
||||
}
|
||||
|
||||
bool KcmLinuxHello::loading() const
|
||||
{
|
||||
return m_loading;
|
||||
}
|
||||
|
||||
void KcmLinuxHello::setLoading(bool loading)
|
||||
{
|
||||
if (m_loading != loading) {
|
||||
m_loading = loading;
|
||||
Q_EMIT loadingChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void KcmLinuxHello::load()
|
||||
{
|
||||
setLoading(true);
|
||||
|
||||
// Refresh all data from the daemon
|
||||
m_client->refreshStatus();
|
||||
m_client->refreshTemplates();
|
||||
|
||||
setLoading(false);
|
||||
|
||||
KQuickManagedConfigModule::load();
|
||||
}
|
||||
|
||||
void KcmLinuxHello::save()
|
||||
{
|
||||
// Currently, settings changes (like anti-spoofing) require
|
||||
// root privileges and are handled separately via pkexec
|
||||
KQuickManagedConfigModule::save();
|
||||
}
|
||||
|
||||
void KcmLinuxHello::defaults()
|
||||
{
|
||||
// Reset to default configuration values
|
||||
// Anti-spoofing enabled by default
|
||||
// Threshold at default value (0.6)
|
||||
KQuickManagedConfigModule::defaults();
|
||||
}
|
||||
|
||||
bool KcmLinuxHello::isSaveNeeded() const
|
||||
{
|
||||
// For now, we don't track settings changes that need saving
|
||||
// as they require root privileges and are applied immediately
|
||||
return false;
|
||||
}
|
||||
|
||||
bool KcmLinuxHello::isDefaults() const
|
||||
{
|
||||
// Check if current settings match defaults
|
||||
return m_client->antiSpoofingEnabled();
|
||||
}
|
||||
|
||||
#include "kcm_linux_hello.moc"
|
||||
62
kde-settings/src/kcm_linux_hello.h
Normal file
62
kde-settings/src/kcm_linux_hello.h
Normal file
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
#ifndef KCM_LINUX_HELLO_H
|
||||
#define KCM_LINUX_HELLO_H
|
||||
|
||||
#include <KQuickManagedConfigModule>
|
||||
#include <QObject>
|
||||
|
||||
#include "dbus_client.h"
|
||||
|
||||
class KcmLinuxHello : public KQuickManagedConfigModule
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(LinuxHelloDBusClient* client READ client CONSTANT)
|
||||
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
|
||||
|
||||
public:
|
||||
explicit KcmLinuxHello(QObject *parent, const KPluginMetaData &data);
|
||||
~KcmLinuxHello() override;
|
||||
|
||||
LinuxHelloDBusClient *client() const;
|
||||
bool loading() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
/**
|
||||
* @brief Reload all data from the daemon
|
||||
*/
|
||||
void load() override;
|
||||
|
||||
/**
|
||||
* @brief Save any pending changes
|
||||
*/
|
||||
void save() override;
|
||||
|
||||
/**
|
||||
* @brief Reset to default settings
|
||||
*/
|
||||
void defaults() override;
|
||||
|
||||
/**
|
||||
* @brief Check if unsaved changes exist
|
||||
*/
|
||||
bool isSaveNeeded() const override;
|
||||
|
||||
/**
|
||||
* @brief Check if settings differ from defaults
|
||||
*/
|
||||
bool isDefaults() const override;
|
||||
|
||||
Q_SIGNALS:
|
||||
void loadingChanged();
|
||||
|
||||
private:
|
||||
LinuxHelloDBusClient *m_client;
|
||||
bool m_loading = false;
|
||||
|
||||
void setLoading(bool loading);
|
||||
};
|
||||
|
||||
#endif // KCM_LINUX_HELLO_H
|
||||
553
kde-settings/src/main.qml
Normal file
553
kde-settings/src/main.qml
Normal file
@@ -0,0 +1,553 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// SPDX-FileCopyrightText: 2024 Linux Hello Authors
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kcmutils as KCMUtils
|
||||
|
||||
KCMUtils.ScrollViewKCM {
|
||||
id: root
|
||||
|
||||
property var client: kcm.client
|
||||
|
||||
// Enrollment dialog state
|
||||
property bool showEnrollDialog: false
|
||||
property string enrollLabel: ""
|
||||
|
||||
implicitWidth: Kirigami.Units.gridUnit * 38
|
||||
implicitHeight: Kirigami.Units.gridUnit * 35
|
||||
|
||||
// Header action for refresh
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
icon.name: "view-refresh"
|
||||
text: i18n("Refresh")
|
||||
onTriggered: {
|
||||
client.refreshStatus()
|
||||
client.refreshTemplates()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Main content
|
||||
view: ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
// System Status Section
|
||||
Kirigami.FormLayout {
|
||||
id: statusForm
|
||||
Layout.fillWidth: true
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: i18n("System Status")
|
||||
}
|
||||
|
||||
// Daemon Status
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: i18n("Daemon:")
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
source: client.daemonRunning ? "dialog-ok-apply" : "dialog-error"
|
||||
color: client.daemonRunning ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: client.daemonRunning
|
||||
? i18n("Running (v%1)", client.daemonVersion || "unknown")
|
||||
: i18n("Not running")
|
||||
color: client.daemonRunning ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
|
||||
}
|
||||
}
|
||||
|
||||
// Camera Status
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: i18n("Camera:")
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
source: client.cameraAvailable ? "camera-web" : "camera-web-symbolic"
|
||||
color: client.cameraAvailable ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: client.cameraAvailable ? i18n("Available") : i18n("Not detected")
|
||||
color: client.cameraAvailable ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.negativeTextColor
|
||||
}
|
||||
}
|
||||
|
||||
// TPM Status
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: i18n("TPM:")
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
source: client.tpmAvailable ? "security-high" : "security-low"
|
||||
color: client.tpmAvailable ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
|
||||
implicitWidth: Kirigami.Units.iconSizes.small
|
||||
implicitHeight: Kirigami.Units.iconSizes.small
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: client.tpmAvailable
|
||||
? i18n("Available (secure storage enabled)")
|
||||
: i18n("Not available (using software encryption)")
|
||||
color: client.tpmAvailable ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrolled Faces Section
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: i18n("Enrolled Faces")
|
||||
}
|
||||
|
||||
// Face count summary
|
||||
QQC2.Label {
|
||||
Kirigami.FormData.label: i18n("Total enrolled:")
|
||||
text: i18np("%1 face template", "%1 face templates", client.templates.length)
|
||||
visible: client.templates.length > 0
|
||||
}
|
||||
|
||||
// Template list
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
visible: client.templates.length > 0
|
||||
|
||||
Repeater {
|
||||
model: client.templates
|
||||
|
||||
delegate: Kirigami.SwipeListItem {
|
||||
id: templateDelegate
|
||||
|
||||
required property string modelData
|
||||
required property int index
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
source: "user-identity"
|
||||
implicitWidth: Kirigami.Units.iconSizes.medium
|
||||
implicitHeight: Kirigami.Units.iconSizes.medium
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: templateDelegate.modelData
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Face template #%1", templateDelegate.index + 1)
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
icon.name: "edit-delete"
|
||||
text: i18n("Remove")
|
||||
onTriggered: {
|
||||
deleteConfirmDialog.templateLabel = templateDelegate.modelData
|
||||
deleteConfirmDialog.open()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
Kirigami.PlaceholderMessage {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: client.templates.length === 0 && client.daemonRunning
|
||||
|
||||
icon.name: "user-identity"
|
||||
text: i18n("No faces enrolled")
|
||||
explanation: i18n("Enroll your face to use facial authentication for login and authentication prompts.")
|
||||
}
|
||||
|
||||
// Daemon not running state
|
||||
Kirigami.PlaceholderMessage {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: !client.daemonRunning
|
||||
|
||||
icon.name: "dialog-warning"
|
||||
text: i18n("Daemon not running")
|
||||
explanation: i18n("The Linux Hello daemon is not running. Start the service to manage facial authentication.")
|
||||
|
||||
helpfulAction: Kirigami.Action {
|
||||
icon.name: "system-run"
|
||||
text: i18n("Start Daemon")
|
||||
onTriggered: {
|
||||
// This would typically use systemctl or similar
|
||||
console.log("Would start linux-hello daemon")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrollment Section
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: client.daemonRunning
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: i18n("Enrollment")
|
||||
}
|
||||
|
||||
// Enrollment progress (shown during enrollment)
|
||||
ColumnLayout {
|
||||
Kirigami.FormData.label: i18n("Progress:")
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
visible: client.enrollmentInProgress
|
||||
|
||||
QQC2.ProgressBar {
|
||||
Layout.fillWidth: true
|
||||
from: 0
|
||||
to: 100
|
||||
value: client.enrollmentProgress
|
||||
indeterminate: client.enrollmentProgress === 0
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: client.enrollmentMessage || i18n("Preparing...")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// Enrollment buttons
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: client.enrollmentInProgress ? "" : i18n("Actions:")
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
QQC2.Button {
|
||||
icon.name: "list-add"
|
||||
text: i18n("Enroll New Face")
|
||||
visible: !client.enrollmentInProgress
|
||||
enabled: client.cameraAvailable
|
||||
|
||||
onClicked: {
|
||||
enrollDialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Button {
|
||||
icon.name: "dialog-cancel"
|
||||
text: i18n("Cancel Enrollment")
|
||||
visible: client.enrollmentInProgress
|
||||
|
||||
onClicked: {
|
||||
client.cancelEnrollment()
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Button {
|
||||
icon.name: "security-high"
|
||||
text: i18n("Test Authentication")
|
||||
visible: !client.enrollmentInProgress && client.templates.length > 0
|
||||
enabled: client.cameraAvailable
|
||||
|
||||
onClicked: {
|
||||
client.testAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Button {
|
||||
icon.name: "edit-delete"
|
||||
text: i18n("Remove All")
|
||||
visible: !client.enrollmentInProgress && client.templates.length > 0
|
||||
|
||||
onClicked: {
|
||||
deleteAllConfirmDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Section
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: i18n("Security Settings")
|
||||
}
|
||||
|
||||
// Anti-spoofing toggle
|
||||
QQC2.Switch {
|
||||
id: antiSpoofingSwitch
|
||||
Kirigami.FormData.label: i18n("Anti-spoofing:")
|
||||
checked: client.antiSpoofingEnabled
|
||||
enabled: client.daemonRunning
|
||||
|
||||
onToggled: {
|
||||
if (checked !== client.antiSpoofingEnabled) {
|
||||
client.setAntiSpoofingEnabled(checked)
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.ToolTip.text: i18n("Enable liveness detection to prevent photo-based attacks")
|
||||
QQC2.ToolTip.visible: hovered
|
||||
QQC2.ToolTip.delay: 1000
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
||||
text: i18n("Anti-spoofing uses liveness detection to verify that a real person is present, protecting against attacks using photos or videos.")
|
||||
wrapMode: Text.WordWrap
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
// Threshold slider
|
||||
ColumnLayout {
|
||||
Kirigami.FormData.label: i18n("Matching threshold:")
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
enabled: client.daemonRunning
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("More lenient")
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
|
||||
QQC2.Slider {
|
||||
id: thresholdSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0.3
|
||||
to: 0.9
|
||||
value: 0.6
|
||||
stepSize: 0.05
|
||||
|
||||
onMoved: {
|
||||
// Would save threshold when released
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("More strict")
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: i18n("Current: %1", thresholdSlider.value.toFixed(2))
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
||||
text: i18n("A stricter threshold reduces false positives but may require better lighting. A more lenient threshold is more convenient but slightly less secure.")
|
||||
wrapMode: Text.WordWrap
|
||||
font: Kirigami.Theme.smallFont
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer to push content up
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
|
||||
// Enrollment Dialog
|
||||
Kirigami.Dialog {
|
||||
id: enrollDialog
|
||||
title: i18n("Enroll New Face")
|
||||
standardButtons: QQC2.Dialog.Ok | QQC2.Dialog.Cancel
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
|
||||
onAccepted: {
|
||||
if (labelField.text.trim().length > 0) {
|
||||
client.startEnrollment(labelField.text.trim(), 5)
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
labelField.text = i18n("My Face")
|
||||
labelField.selectAll()
|
||||
labelField.forceActiveFocus()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Enter a name for this face template:")
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
QQC2.TextField {
|
||||
id: labelField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: i18n("e.g., My Face, With Glasses")
|
||||
}
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
type: Kirigami.MessageType.Information
|
||||
text: i18n("During enrollment, look at the camera and slowly turn your head left and right to capture different angles.")
|
||||
visible: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Confirmation Dialog
|
||||
Kirigami.Dialog {
|
||||
id: deleteConfirmDialog
|
||||
title: i18n("Remove Face Template")
|
||||
standardButtons: QQC2.Dialog.Yes | QQC2.Dialog.No
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
|
||||
property string templateLabel: ""
|
||||
|
||||
onAccepted: {
|
||||
client.removeTemplate(templateLabel)
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
text: i18n("Are you sure you want to remove the face template \"%1\"?\n\nThis action cannot be undone.", deleteConfirmDialog.templateLabel)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// Delete All Confirmation Dialog
|
||||
Kirigami.Dialog {
|
||||
id: deleteAllConfirmDialog
|
||||
title: i18n("Remove All Face Templates")
|
||||
standardButtons: QQC2.Dialog.Yes | QQC2.Dialog.No
|
||||
padding: Kirigami.Units.largeSpacing
|
||||
|
||||
onAccepted: {
|
||||
client.removeAllTemplates()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
source: "dialog-warning"
|
||||
implicitWidth: Kirigami.Units.iconSizes.huge
|
||||
implicitHeight: Kirigami.Units.iconSizes.huge
|
||||
}
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18n("Are you sure you want to remove ALL enrolled face templates?\n\nYou will need to re-enroll to use facial authentication.")
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication result message
|
||||
Kirigami.InlineMessage {
|
||||
id: authResultMessage
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Kirigami.Units.largeSpacing
|
||||
|
||||
showCloseButton: true
|
||||
visible: false
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 5000
|
||||
onTriggered: authResultMessage.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
Kirigami.InlineMessage {
|
||||
id: errorMessage
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Kirigami.Units.largeSpacing
|
||||
|
||||
type: Kirigami.MessageType.Error
|
||||
showCloseButton: true
|
||||
visible: false
|
||||
}
|
||||
|
||||
// Connect to client signals
|
||||
Connections {
|
||||
target: client
|
||||
|
||||
function onAuthenticationTested(success, message) {
|
||||
authResultMessage.type = success ? Kirigami.MessageType.Positive : Kirigami.MessageType.Warning
|
||||
authResultMessage.text = message
|
||||
authResultMessage.visible = true
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
function onEnrollmentCompleted(success, message) {
|
||||
if (success) {
|
||||
authResultMessage.type = Kirigami.MessageType.Positive
|
||||
authResultMessage.text = i18n("Face enrolled successfully!")
|
||||
} else {
|
||||
authResultMessage.type = Kirigami.MessageType.Error
|
||||
authResultMessage.text = message
|
||||
}
|
||||
authResultMessage.visible = true
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
function onTemplateRemoved(label) {
|
||||
authResultMessage.type = Kirigami.MessageType.Information
|
||||
authResultMessage.text = label === "*"
|
||||
? i18n("All face templates removed")
|
||||
: i18n("Face template \"%1\" removed", label)
|
||||
authResultMessage.visible = true
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
function onErrorOccurred(error) {
|
||||
errorMessage.text = error
|
||||
errorMessage.visible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
24
kde-settings/src/metadata.json
Normal file
24
kde-settings/src/metadata.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"KPlugin": {
|
||||
"Id": "kcm_linux_hello",
|
||||
"Name": "Linux Hello",
|
||||
"Name[x-test]": "xxLinux Helloxx",
|
||||
"Description": "Configure facial authentication for login",
|
||||
"Description[x-test]": "xxConfigure facial authentication for loginxx",
|
||||
"Icon": "preferences-desktop-user-password",
|
||||
"Authors": [
|
||||
{
|
||||
"Name": "Linux Hello Authors",
|
||||
"Email": "linux-hello@example.org"
|
||||
}
|
||||
],
|
||||
"Category": "security",
|
||||
"License": "GPL-3.0-or-later",
|
||||
"Version": "1.0.0",
|
||||
"Website": "https://github.com/example/linux-hello"
|
||||
},
|
||||
"X-KDE-Keywords": "face,facial,authentication,login,biometric,hello,security,recognition,Windows Hello",
|
||||
"X-KDE-System-Settings-Parent-Category": "security",
|
||||
"X-KDE-Weight": 50,
|
||||
"X-Plasma-MainScript": "ui/main.qml"
|
||||
}
|
||||
@@ -1,11 +1,88 @@
|
||||
//! Configuration for Linux Hello
|
||||
//! Configuration Module for Linux Hello
|
||||
//!
|
||||
//! This module provides configuration structures for all Linux Hello components.
|
||||
//! Configuration is stored in TOML format and supports sensible defaults.
|
||||
//!
|
||||
//! # Configuration File Location
|
||||
//!
|
||||
//! The default configuration file is located at `/etc/linux-hello/config.toml`.
|
||||
//!
|
||||
//! # Configuration Sections
|
||||
//!
|
||||
//! - **general** - Logging and timeout settings
|
||||
//! - **camera** - Camera device selection and resolution
|
||||
//! - **detection** - Face detection model and thresholds
|
||||
//! - **embedding** - Face embedding extraction settings
|
||||
//! - **anti_spoofing** - Liveness detection configuration
|
||||
//! - **tpm** - TPM2 hardware security settings
|
||||
//!
|
||||
//! # Example Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [general]
|
||||
//! log_level = "info"
|
||||
//! timeout_seconds = 5
|
||||
//!
|
||||
//! [camera]
|
||||
//! device = "auto"
|
||||
//! ir_emitter = "auto"
|
||||
//! resolution = [640, 480]
|
||||
//! fps = 30
|
||||
//!
|
||||
//! [detection]
|
||||
//! model = "blazeface"
|
||||
//! min_face_size = 80
|
||||
//! confidence_threshold = 0.9
|
||||
//!
|
||||
//! [embedding]
|
||||
//! model = "mobilefacenet"
|
||||
//! distance_threshold = 0.6
|
||||
//!
|
||||
//! [anti_spoofing]
|
||||
//! enabled = true
|
||||
//! depth_check = true
|
||||
//! liveness_model = true
|
||||
//! temporal_check = true
|
||||
//! min_score = 0.7
|
||||
//!
|
||||
//! [tpm]
|
||||
//! enabled = true
|
||||
//! pcr_binding = false
|
||||
//! ```
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use linux_hello_common::Config;
|
||||
//!
|
||||
//! // Load from default location with fallback to defaults
|
||||
//! let config = Config::load_or_default();
|
||||
//!
|
||||
//! // Check settings
|
||||
//! if config.anti_spoofing.enabled {
|
||||
//! println!("Anti-spoofing is enabled");
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Main configuration structure
|
||||
/// Main configuration structure for Linux Hello.
|
||||
///
|
||||
/// This structure contains all configuration sections for the facial
|
||||
/// authentication system. All fields have sensible defaults.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_common::Config;
|
||||
///
|
||||
/// let config = Config::default();
|
||||
/// assert_eq!(config.general.timeout_seconds, 5);
|
||||
/// assert!(config.anti_spoofing.enabled);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
@@ -22,62 +99,140 @@ pub struct Config {
|
||||
pub tpm: TpmConfig,
|
||||
}
|
||||
|
||||
/// General system configuration.
|
||||
///
|
||||
/// Controls logging verbosity and authentication timeout.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralConfig {
|
||||
/// Log level: "error", "warn", "info", "debug", or "trace".
|
||||
/// Default: "info"
|
||||
#[serde(default = "default_log_level")]
|
||||
pub log_level: String,
|
||||
/// Authentication timeout in seconds. If face detection or matching
|
||||
/// takes longer than this, authentication fails.
|
||||
/// Default: 5
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_seconds: u32,
|
||||
}
|
||||
|
||||
/// Camera hardware configuration.
|
||||
///
|
||||
/// Specifies which camera device to use and capture parameters.
|
||||
/// Set `device` and `ir_emitter` to "auto" for automatic detection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CameraConfig {
|
||||
/// Camera device path (e.g., "/dev/video0") or "auto" for automatic detection.
|
||||
/// Auto-detection prefers IR cameras over regular webcams.
|
||||
/// Default: "auto"
|
||||
#[serde(default = "default_auto")]
|
||||
pub device: String,
|
||||
/// IR emitter control path or "auto" for automatic detection.
|
||||
/// Controls the infrared LED for illumination during capture.
|
||||
/// Default: "auto"
|
||||
#[serde(default = "default_auto")]
|
||||
pub ir_emitter: String,
|
||||
/// Capture resolution as [width, height] in pixels.
|
||||
/// Default: [640, 480]
|
||||
#[serde(default = "default_resolution")]
|
||||
pub resolution: [u32; 2],
|
||||
/// Target frame rate in frames per second.
|
||||
/// Default: 30
|
||||
#[serde(default = "default_fps")]
|
||||
pub fps: u32,
|
||||
}
|
||||
|
||||
/// Face detection configuration.
|
||||
///
|
||||
/// Controls the face detection model and sensitivity thresholds.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectionConfig {
|
||||
/// Face detection model to use: "blazeface" or "mtcnn".
|
||||
/// Default: "blazeface"
|
||||
#[serde(default = "default_model")]
|
||||
pub model: String,
|
||||
/// Minimum face size in pixels for detection.
|
||||
/// Faces smaller than this are ignored.
|
||||
/// Default: 80
|
||||
#[serde(default = "default_min_face_size")]
|
||||
pub min_face_size: u32,
|
||||
/// Minimum confidence score (0.0-1.0) for accepting a face detection.
|
||||
/// Higher values reduce false positives but may miss valid faces.
|
||||
/// Default: 0.9
|
||||
#[serde(default = "default_confidence_threshold")]
|
||||
pub confidence_threshold: f32,
|
||||
}
|
||||
|
||||
/// Face embedding extraction configuration.
|
||||
///
|
||||
/// Controls the neural network model used to generate face embeddings
|
||||
/// and the distance threshold for matching.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddingConfig {
|
||||
/// Embedding extraction model: "mobilefacenet" or "arcface".
|
||||
/// Default: "mobilefacenet"
|
||||
#[serde(default = "default_embedding_model")]
|
||||
pub model: String,
|
||||
/// Maximum cosine distance (0.0-2.0) for a successful match.
|
||||
/// Lower values are more strict. A value of 0.6 means embeddings
|
||||
/// must be at least 70% similar.
|
||||
/// Default: 0.6
|
||||
#[serde(default = "default_distance_threshold")]
|
||||
pub distance_threshold: f32,
|
||||
}
|
||||
|
||||
/// Anti-spoofing and liveness detection configuration.
|
||||
///
|
||||
/// Controls multiple detection methods to prevent authentication attacks
|
||||
/// using photos, videos, or masks. Each method can be individually enabled.
|
||||
///
|
||||
/// # Security Considerations
|
||||
///
|
||||
/// For maximum security, enable all detection methods. However, this may
|
||||
/// increase authentication time. Adjust based on your security requirements.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AntiSpoofingConfig {
|
||||
/// Master switch to enable/disable all anti-spoofing checks.
|
||||
/// Default: true
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Enable depth estimation to detect flat images (photos/screens).
|
||||
/// Default: true
|
||||
#[serde(default = "default_true")]
|
||||
pub depth_check: bool,
|
||||
/// Enable ML-based liveness detection model.
|
||||
/// Default: true
|
||||
#[serde(default = "default_true")]
|
||||
pub liveness_model: bool,
|
||||
/// Enable temporal analysis (micro-movements, blink detection).
|
||||
/// Requires multiple frames and increases authentication time.
|
||||
/// Default: true
|
||||
#[serde(default = "default_true")]
|
||||
pub temporal_check: bool,
|
||||
/// Minimum combined liveness score (0.0-1.0) to pass anti-spoofing.
|
||||
/// Default: 0.7
|
||||
#[serde(default = "default_min_score")]
|
||||
pub min_score: f32,
|
||||
}
|
||||
|
||||
/// TPM2 (Trusted Platform Module) configuration.
|
||||
///
|
||||
/// When enabled, face templates are encrypted using keys bound to the
|
||||
/// TPM hardware, making them inaccessible on other machines.
|
||||
///
|
||||
/// # Hardware Requirements
|
||||
///
|
||||
/// Requires a TPM 2.0 chip. Most modern laptops include one.
|
||||
/// Falls back to software encryption if TPM is unavailable.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TpmConfig {
|
||||
/// Enable TPM-based encryption for face templates.
|
||||
/// Default: true
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Bind encryption keys to PCR (Platform Configuration Register) values.
|
||||
/// If enabled, templates become inaccessible if the boot configuration changes.
|
||||
/// Provides additional security but may require re-enrollment after BIOS updates.
|
||||
/// Default: false
|
||||
#[serde(default)]
|
||||
pub pcr_binding: bool,
|
||||
}
|
||||
|
||||
@@ -1,51 +1,130 @@
|
||||
//! Error types for Linux Hello
|
||||
//! Error Types for Linux Hello
|
||||
//!
|
||||
//! This module defines all error types used throughout the Linux Hello system.
|
||||
//! Errors are designed to be informative while not leaking sensitive information.
|
||||
//!
|
||||
//! # Error Categories
|
||||
//!
|
||||
//! - **Camera errors** - Hardware access, IR emitter control, device not found
|
||||
//! - **Detection errors** - Face detection failures, multiple faces, no face
|
||||
//! - **Authentication errors** - Template matching failures, user not enrolled
|
||||
//! - **Storage errors** - Configuration, serialization, I/O failures
|
||||
//! - **Security errors** - TPM failures, encryption errors
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use linux_hello_common::{Error, Result};
|
||||
//!
|
||||
//! fn authenticate_user(user: &str) -> Result<bool> {
|
||||
//! // Simulate checking if user is enrolled
|
||||
//! if user.is_empty() {
|
||||
//! return Err(Error::UserNotEnrolled("unknown".to_string()));
|
||||
//! }
|
||||
//! Ok(true)
|
||||
//! }
|
||||
//!
|
||||
//! match authenticate_user("") {
|
||||
//! Ok(true) => println!("Authenticated!"),
|
||||
//! Ok(false) => println!("Authentication failed"),
|
||||
//! Err(Error::UserNotEnrolled(user)) => println!("User {} not enrolled", user),
|
||||
//! Err(e) => println!("Error: {}", e),
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Main error type for Linux Hello
|
||||
/// Main error type for Linux Hello.
|
||||
///
|
||||
/// All operations in Linux Hello return this error type or wrap it
|
||||
/// in a [`Result`]. Error messages are designed to be user-friendly
|
||||
/// without exposing internal implementation details.
|
||||
///
|
||||
/// # Security Note
|
||||
///
|
||||
/// Error messages intentionally avoid including sensitive information
|
||||
/// like internal paths or cryptographic details to prevent information disclosure.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
/// Camera hardware access error.
|
||||
/// Includes the underlying system error message.
|
||||
#[error("Camera error: {0}")]
|
||||
Camera(String),
|
||||
|
||||
/// No suitable IR camera was detected on the system.
|
||||
/// Ensure an IR camera is connected and accessible.
|
||||
#[error("No IR camera found")]
|
||||
NoCameraFound,
|
||||
|
||||
/// Failed to control the IR emitter (LED).
|
||||
/// The emitter may be in use by another process.
|
||||
#[error("IR emitter control failed: {0}")]
|
||||
IrEmitter(String),
|
||||
|
||||
/// Face detection failed during processing.
|
||||
/// May indicate a model loading issue or corrupted frame.
|
||||
#[error("Face detection error: {0}")]
|
||||
Detection(String),
|
||||
|
||||
/// No face was detected in the captured frame.
|
||||
/// Ensure face is visible and properly lit.
|
||||
#[error("No face detected in frame")]
|
||||
NoFaceDetected,
|
||||
|
||||
/// Multiple faces were detected when only one is expected.
|
||||
/// For security, authentication requires exactly one face.
|
||||
#[error("Multiple faces detected")]
|
||||
MultipleFacesDetected,
|
||||
|
||||
/// Failed to load an ML model (detection or embedding).
|
||||
#[error("Model loading error: {0}")]
|
||||
ModelLoad(String),
|
||||
|
||||
/// Configuration file parsing or validation error.
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// TPM (Trusted Platform Module) operation failed.
|
||||
/// May indicate TPM is unavailable or key access was denied.
|
||||
#[error("TPM error: {0}")]
|
||||
Tpm(String),
|
||||
|
||||
/// Face did not match any enrolled template.
|
||||
/// Generic error to prevent information disclosure.
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
/// The specified user has no enrolled face templates.
|
||||
#[error("User not enrolled: {0}")]
|
||||
UserNotEnrolled(String),
|
||||
|
||||
/// File system or network I/O error.
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// JSON/TOML serialization or deserialization error.
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// D-Bus communication error with system services.
|
||||
#[error("D-Bus error: {0}")]
|
||||
Dbus(String),
|
||||
}
|
||||
|
||||
/// Result type alias for Linux Hello
|
||||
/// Result type alias for Linux Hello operations.
|
||||
///
|
||||
/// This is a convenience alias for `std::result::Result<T, Error>`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_common::Result;
|
||||
///
|
||||
/// fn do_something() -> Result<()> {
|
||||
/// // ... operation that might fail
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
//! Linux Hello Common Library
|
||||
//!
|
||||
//! Shared types, configuration, and error handling for the Linux Hello
|
||||
//! facial authentication system.
|
||||
//! This crate provides shared types, configuration, and error handling for the
|
||||
//! Linux Hello facial authentication system. It serves as the foundation for
|
||||
//! both the daemon and CLI components.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Linux Hello is a facial authentication system for Linux, similar to Windows Hello.
|
||||
//! It uses IR camera technology combined with anti-spoofing measures to provide
|
||||
//! secure biometric authentication.
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - [`config`] - Configuration structures for all system components
|
||||
//! - [`error`] - Error types and result aliases
|
||||
//! - [`template`] - Face template storage and management
|
||||
//!
|
||||
//! # Security Model
|
||||
//!
|
||||
//! The system implements a layered security approach:
|
||||
//!
|
||||
//! 1. **IR Camera Verification** - Uses infrared cameras to prevent photo attacks
|
||||
//! 2. **Anti-Spoofing** - Multiple liveness detection methods (depth, texture, blink)
|
||||
//! 3. **Encrypted Storage** - Templates are encrypted at rest using AES-256-GCM
|
||||
//! 4. **TPM Integration** - Hardware-bound encryption when TPM2 is available
|
||||
//! 5. **Memory Protection** - Sensitive data is zeroized on drop
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use linux_hello_common::{Config, TemplateStore, Result};
|
||||
//!
|
||||
//! fn main() -> Result<()> {
|
||||
//! // Load configuration
|
||||
//! let config = Config::load_or_default();
|
||||
//!
|
||||
//! // Initialize template storage
|
||||
//! let store = TemplateStore::new(TemplateStore::default_path());
|
||||
//! store.initialize()?;
|
||||
//!
|
||||
//! // Check if user is enrolled
|
||||
//! if store.is_enrolled("john") {
|
||||
//! println!("User john is enrolled");
|
||||
//! }
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
@@ -9,4 +54,4 @@ pub mod template;
|
||||
|
||||
pub use config::Config;
|
||||
pub use error::{Error, Result};
|
||||
pub use template::{FaceTemplate, TemplateStore};
|
||||
pub use template::{FaceTemplate, TemplateStore};
|
||||
@@ -1,7 +1,57 @@
|
||||
//! Template Storage Module
|
||||
//! Face Template Storage Module
|
||||
//!
|
||||
//! Handles storage and retrieval of face templates (embeddings) for enrolled users.
|
||||
//! Currently uses unencrypted file-based storage. TPM encryption will be added in Phase 3.
|
||||
//! This module handles storage and retrieval of face templates (embeddings) for
|
||||
//! enrolled users. Templates are stored as JSON files organized by user.
|
||||
//!
|
||||
//! # Storage Layout
|
||||
//!
|
||||
//! Templates are stored in a hierarchical structure:
|
||||
//!
|
||||
//! ```text
|
||||
//! /var/lib/linux-hello/templates/
|
||||
//! john/
|
||||
//! default.json
|
||||
//! backup.json
|
||||
//! alice/
|
||||
//! default.json
|
||||
//! ```
|
||||
//!
|
||||
//! # Security Considerations
|
||||
//!
|
||||
//! - This module provides unencrypted storage for templates
|
||||
//! - For production use, combine with `SecureTemplateStore` from `linux-hello-daemon` for encryption
|
||||
//! - Templates contain biometric data and should be protected
|
||||
//! - Directory permissions should be restricted (0700)
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use linux_hello_common::{FaceTemplate, TemplateStore};
|
||||
//!
|
||||
//! // Create and initialize store
|
||||
//! let store = TemplateStore::new("/var/lib/linux-hello/templates");
|
||||
//! store.initialize().expect("Failed to create template directory");
|
||||
//!
|
||||
//! // Create a template
|
||||
//! let template = FaceTemplate {
|
||||
//! user: "john".to_string(),
|
||||
//! label: "default".to_string(),
|
||||
//! embedding: vec![0.1, 0.2, 0.3], // Actual embeddings are 128-512 dimensions
|
||||
//! enrolled_at: 1234567890,
|
||||
//! frame_count: 5,
|
||||
//! };
|
||||
//!
|
||||
//! // Store the template
|
||||
//! store.store(&template).expect("Failed to store template");
|
||||
//!
|
||||
//! // Check enrollment status
|
||||
//! if store.is_enrolled("john") {
|
||||
//! println!("User john is enrolled");
|
||||
//! }
|
||||
//!
|
||||
//! // Load template for matching
|
||||
//! let loaded = store.load("john", "default").expect("Template not found");
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -9,57 +59,131 @@ use std::fs;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A face template (embedding vector) for a user
|
||||
/// A face template containing the embedding vector for a user.
|
||||
///
|
||||
/// Templates are created during enrollment by capturing multiple frames,
|
||||
/// extracting face embeddings, and averaging them for robustness.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// - `user` - System username this template belongs to
|
||||
/// - `label` - Identifier for multiple enrollments (e.g., "default", "glasses")
|
||||
/// - `embedding` - Normalized face embedding vector (typically 128 or 512 dimensions)
|
||||
/// - `enrolled_at` - Unix timestamp when the template was created
|
||||
/// - `frame_count` - Number of frames used to generate the averaged embedding
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_common::FaceTemplate;
|
||||
///
|
||||
/// let template = FaceTemplate {
|
||||
/// user: "alice".to_string(),
|
||||
/// label: "default".to_string(),
|
||||
/// embedding: vec![0.1, 0.2, 0.3, 0.4], // Simplified example
|
||||
/// enrolled_at: std::time::SystemTime::now()
|
||||
/// .duration_since(std::time::UNIX_EPOCH)
|
||||
/// .unwrap()
|
||||
/// .as_secs(),
|
||||
/// frame_count: 5,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FaceTemplate {
|
||||
/// User identifier
|
||||
/// System username this template belongs to.
|
||||
pub user: String,
|
||||
/// Label for this enrollment (e.g., "default", "backup")
|
||||
/// Label for this enrollment (e.g., "default", "glasses", "backup").
|
||||
/// Users can have multiple templates with different labels.
|
||||
pub label: String,
|
||||
/// Face embedding vector (normalized)
|
||||
/// Normalized face embedding vector.
|
||||
/// Typically 128 dimensions (MobileFaceNet) or 512 dimensions (ArcFace).
|
||||
pub embedding: Vec<f32>,
|
||||
/// Timestamp when enrolled
|
||||
/// Unix timestamp when this template was enrolled.
|
||||
pub enrolled_at: u64,
|
||||
/// Number of frames used to generate this template
|
||||
/// Number of frames averaged to create this template.
|
||||
/// More frames generally produce more robust templates.
|
||||
pub frame_count: u32,
|
||||
}
|
||||
|
||||
/// Template storage manager
|
||||
/// File-based template storage manager.
|
||||
///
|
||||
/// Manages face templates on the filesystem with operations for storing,
|
||||
/// loading, listing, and removing templates.
|
||||
///
|
||||
/// # Thread Safety
|
||||
///
|
||||
/// This struct is not thread-safe. For concurrent access, use external
|
||||
/// synchronization or wrap in a `Mutex`.
|
||||
pub struct TemplateStore {
|
||||
/// Base directory for template storage
|
||||
base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TemplateStore {
|
||||
/// Create a new template store
|
||||
/// Create a new template store at the specified path.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_path` - Directory where templates will be stored
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_common::TemplateStore;
|
||||
///
|
||||
/// let store = TemplateStore::new("/var/lib/linux-hello/templates");
|
||||
/// ```
|
||||
pub fn new<P: AsRef<Path>>(base_path: P) -> Self {
|
||||
Self {
|
||||
base_path: base_path.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default template storage path
|
||||
/// Get the default template storage path.
|
||||
///
|
||||
/// Returns `/var/lib/linux-hello/templates`.
|
||||
pub fn default_path() -> PathBuf {
|
||||
PathBuf::from("/var/lib/linux-hello/templates")
|
||||
}
|
||||
|
||||
/// Initialize the template store (create directories if needed)
|
||||
/// Initialize the template store by creating the storage directory.
|
||||
///
|
||||
/// This must be called before storing templates. Creates the directory
|
||||
/// with all parent directories if they don't exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the directory cannot be created (permission denied, etc.).
|
||||
pub fn initialize(&self) -> Result<()> {
|
||||
fs::create_dir_all(&self.base_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the path for a user's template directory
|
||||
/// Get the path for a user's template directory.
|
||||
fn user_path(&self, user: &str) -> PathBuf {
|
||||
self.base_path.join(user)
|
||||
}
|
||||
|
||||
/// Get the path for a specific template file
|
||||
/// Get the path for a specific template file.
|
||||
fn template_path(&self, user: &str, label: &str) -> PathBuf {
|
||||
self.user_path(user).join(format!("{}.json", label))
|
||||
}
|
||||
|
||||
/// Store a template for a user
|
||||
/// Store a face template for a user.
|
||||
///
|
||||
/// Creates the user's directory if it doesn't exist and writes the
|
||||
/// template as a JSON file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `template` - The face template to store
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The directory cannot be created
|
||||
/// - The file cannot be written
|
||||
/// - JSON serialization fails
|
||||
pub fn store(&self, template: &FaceTemplate) -> Result<()> {
|
||||
let user_dir = self.user_path(&template.user);
|
||||
fs::create_dir_all(&user_dir)?;
|
||||
@@ -208,7 +332,6 @@ impl TemplateStore {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,7 @@ path = "src/main.rs"
|
||||
[features]
|
||||
default = []
|
||||
tpm = ["tss-esapi"]
|
||||
onnx = ["ort", "ndarray"]
|
||||
|
||||
[dependencies]
|
||||
linux-hello-common = { path = "../linux-hello-common" }
|
||||
@@ -33,13 +34,23 @@ image.workspace = true
|
||||
# Security
|
||||
zeroize.workspace = true
|
||||
libc = "0.2"
|
||||
subtle = "2.5"
|
||||
|
||||
# Cryptography (for software TPM fallback)
|
||||
aes-gcm = "0.10"
|
||||
rand = "0.8"
|
||||
pbkdf2 = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
# D-Bus support
|
||||
zbus = { version = "4.0", features = ["tokio"] }
|
||||
|
||||
# TPM2 (optional)
|
||||
tss-esapi = { workspace = true, optional = true }
|
||||
|
||||
# ML inference - temporarily disabled until ort 2.0 stable
|
||||
# ort.workspace = true
|
||||
# ndarray.workspace = true
|
||||
# ML inference (enabled via 'onnx' feature)
|
||||
ort = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
|
||||
# Camera (Linux-only)
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
@@ -47,3 +58,8 @@ v4l.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "benchmarks"
|
||||
harness = false
|
||||
|
||||
648
linux-hello-daemon/benches/benchmarks.rs
Normal file
648
linux-hello-daemon/benches/benchmarks.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
//! Performance Benchmarks for Linux Hello
|
||||
//!
|
||||
//! This module contains benchmarks for critical authentication pipeline components:
|
||||
//! - Face detection
|
||||
//! - Embedding extraction
|
||||
//! - Template matching (cosine similarity)
|
||||
//! - Anti-spoofing checks
|
||||
//! - Encryption/decryption (AES-GCM)
|
||||
//! - Secure memory operations
|
||||
//!
|
||||
//! Run with: cargo bench -p linux-hello-daemon
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId, Throughput};
|
||||
use image::GrayImage;
|
||||
|
||||
use linux_hello_daemon::detection::{detect_face_simple, SimpleFaceDetector, FaceDetect};
|
||||
use linux_hello_daemon::embedding::{
|
||||
cosine_similarity, euclidean_distance, PlaceholderEmbeddingExtractor, EmbeddingExtractor,
|
||||
};
|
||||
use linux_hello_daemon::matching::match_template;
|
||||
use linux_hello_daemon::anti_spoofing::{
|
||||
AntiSpoofingConfig, AntiSpoofingDetector, AntiSpoofingFrame,
|
||||
};
|
||||
use linux_hello_daemon::secure_memory::{SecureEmbedding, SecureBytes, memory_protection};
|
||||
use linux_hello_common::FaceTemplate;
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Generation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a synthetic grayscale image with realistic noise
|
||||
fn generate_test_image(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut image = Vec::with_capacity((width * height) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
// Generate a pattern that simulates face-like brightness distribution
|
||||
let center_x = width as f32 / 2.0;
|
||||
let center_y = height as f32 / 2.0;
|
||||
let dx = (x as f32 - center_x) / center_x;
|
||||
let dy = (y as f32 - center_y) / center_y;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Face-like brightness: brighter in center, darker at edges
|
||||
let base = (180.0 - dist * 80.0).max(50.0);
|
||||
// Add some noise
|
||||
let noise = ((x * 17 + y * 31) % 20) as f32 - 10.0;
|
||||
let pixel = (base + noise).clamp(0.0, 255.0) as u8;
|
||||
image.push(pixel);
|
||||
}
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
/// Generate a normalized embedding vector
|
||||
fn generate_test_embedding(dimension: usize) -> Vec<f32> {
|
||||
let mut embedding: Vec<f32> = (0..dimension)
|
||||
.map(|i| ((i as f32 * 0.1).sin() + 0.5) / dimension as f32)
|
||||
.collect();
|
||||
|
||||
// Normalize
|
||||
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for val in &mut embedding {
|
||||
*val /= norm;
|
||||
}
|
||||
}
|
||||
embedding
|
||||
}
|
||||
|
||||
/// Generate test face templates
|
||||
fn generate_test_templates(count: usize, dimension: usize) -> Vec<FaceTemplate> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let mut embedding = generate_test_embedding(dimension);
|
||||
// Add slight variation to each template
|
||||
for (j, val) in embedding.iter_mut().enumerate() {
|
||||
*val += (i as f32 * 0.01 + j as f32 * 0.001).sin() * 0.1;
|
||||
}
|
||||
// Re-normalize
|
||||
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
for val in &mut embedding {
|
||||
*val /= norm;
|
||||
}
|
||||
|
||||
FaceTemplate {
|
||||
user: format!("user_{}", i),
|
||||
label: "default".to_string(),
|
||||
embedding,
|
||||
enrolled_at: 1234567890 + i as u64,
|
||||
frame_count: 5,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Face Detection Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
fn bench_face_detection(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("face_detection");
|
||||
|
||||
// Test at common camera resolutions
|
||||
let resolutions = [
|
||||
(320, 240, "QVGA"),
|
||||
(640, 480, "VGA"),
|
||||
(1280, 720, "720p"),
|
||||
(1920, 1080, "1080p"),
|
||||
];
|
||||
|
||||
for (width, height, name) in resolutions {
|
||||
let image = generate_test_image(width, height);
|
||||
let _pixels = (width * height) as u64;
|
||||
|
||||
group.throughput(Throughput::Elements(1)); // 1 frame per iteration
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("simple_detection", name),
|
||||
&(image.clone(), width, height),
|
||||
|b, (img, w, h)| {
|
||||
b.iter(|| {
|
||||
detect_face_simple(black_box(img), black_box(*w), black_box(*h))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// SimpleFaceDetector (trait implementation)
|
||||
let detector = SimpleFaceDetector::new(0.3);
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("detector_trait", name),
|
||||
&(image.clone(), width, height),
|
||||
|b, (img, w, h)| {
|
||||
b.iter(|| {
|
||||
detector.detect(black_box(img), black_box(*w), black_box(*h))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Embedding Extraction Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
fn bench_embedding_extraction(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("embedding_extraction");
|
||||
|
||||
let face_sizes = [
|
||||
(64, 64, "64x64"),
|
||||
(112, 112, "112x112"),
|
||||
(160, 160, "160x160"),
|
||||
(224, 224, "224x224"),
|
||||
];
|
||||
|
||||
let extractor = PlaceholderEmbeddingExtractor::new(128);
|
||||
|
||||
for (width, height, name) in face_sizes {
|
||||
let image_data = generate_test_image(width, height);
|
||||
let face_image = GrayImage::from_raw(width, height, image_data)
|
||||
.expect("Failed to create test image");
|
||||
|
||||
group.throughput(Throughput::Elements(1)); // 1 embedding per iteration
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("placeholder_extractor", name),
|
||||
&face_image,
|
||||
|b, img| {
|
||||
b.iter(|| {
|
||||
extractor.extract(black_box(img))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Also benchmark different embedding dimensions
|
||||
let dimensions = [64, 128, 256, 512];
|
||||
let face_image = GrayImage::from_raw(112, 112, generate_test_image(112, 112))
|
||||
.expect("Failed to create test image");
|
||||
|
||||
for dim in dimensions {
|
||||
let extractor = PlaceholderEmbeddingExtractor::new(dim);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("dimension", dim),
|
||||
&face_image,
|
||||
|b, img| {
|
||||
b.iter(|| {
|
||||
extractor.extract(black_box(img))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Template Matching Benchmarks (Cosine Similarity)
|
||||
// ============================================================================
|
||||
|
||||
fn bench_template_matching(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("template_matching");
|
||||
|
||||
// Benchmark cosine similarity at different dimensions
|
||||
let dimensions = [64, 128, 256, 512, 1024];
|
||||
|
||||
for dim in dimensions {
|
||||
let emb_a = generate_test_embedding(dim);
|
||||
let emb_b = generate_test_embedding(dim);
|
||||
|
||||
group.throughput(Throughput::Elements(1)); // 1 comparison per iteration
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("cosine_similarity", dim),
|
||||
&(emb_a.clone(), emb_b.clone()),
|
||||
|b, (a, bb)| {
|
||||
b.iter(|| {
|
||||
cosine_similarity(black_box(a), black_box(bb))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("euclidean_distance", dim),
|
||||
&(emb_a.clone(), emb_b.clone()),
|
||||
|b, (a, bb)| {
|
||||
b.iter(|| {
|
||||
euclidean_distance(black_box(a), black_box(bb))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Benchmark matching against template databases of different sizes
|
||||
let template_counts = [1, 5, 10, 50, 100];
|
||||
let query = generate_test_embedding(128);
|
||||
|
||||
for count in template_counts {
|
||||
let templates = generate_test_templates(count, 128);
|
||||
|
||||
group.throughput(Throughput::Elements(count as u64)); // N comparisons
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("match_against_n_templates", count),
|
||||
&(query.clone(), templates),
|
||||
|b, (q, tmpl)| {
|
||||
b.iter(|| {
|
||||
match_template(black_box(q), black_box(tmpl), 0.4)
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Anti-Spoofing Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
fn bench_anti_spoofing(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("anti_spoofing");
|
||||
|
||||
let resolutions = [
|
||||
(320, 240, "QVGA"),
|
||||
(640, 480, "VGA"),
|
||||
];
|
||||
|
||||
for (width, height, name) in resolutions {
|
||||
let pixels = generate_test_image(width, height);
|
||||
let frame = AntiSpoofingFrame {
|
||||
pixels: pixels.clone(),
|
||||
width,
|
||||
height,
|
||||
is_ir: true,
|
||||
face_bbox: Some((width / 4, height / 4, width / 2, height / 2)),
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
// Single frame check
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("single_frame_check", name),
|
||||
&frame,
|
||||
|b, f| {
|
||||
let config = AntiSpoofingConfig::default();
|
||||
let mut detector = AntiSpoofingDetector::new(config);
|
||||
b.iter(|| {
|
||||
detector.reset();
|
||||
detector.check_frame(black_box(f))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Full pipeline with temporal analysis
|
||||
let width = 640;
|
||||
let height = 480;
|
||||
let frames: Vec<_> = (0..10)
|
||||
.map(|i| {
|
||||
let pixels = generate_test_image(width, height);
|
||||
AntiSpoofingFrame {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
is_ir: true,
|
||||
face_bbox: Some((width / 4 + i, height / 4, width / 2, height / 2)),
|
||||
timestamp_ms: i as u64 * 100,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
group.throughput(Throughput::Elements(10)); // 10 frames
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("full_pipeline", "10_frames"),
|
||||
&frames,
|
||||
|b, frames| {
|
||||
let mut config = AntiSpoofingConfig::default();
|
||||
config.enable_movement_check = true;
|
||||
config.enable_blink_check = true;
|
||||
|
||||
b.iter(|| {
|
||||
let mut detector = AntiSpoofingDetector::new(config.clone());
|
||||
let mut last_result = None;
|
||||
for frame in frames {
|
||||
last_result = Some(detector.check_frame(black_box(frame)));
|
||||
}
|
||||
last_result
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Encryption/Decryption Benchmarks (AES-GCM)
|
||||
// ============================================================================
|
||||
|
||||
fn bench_encryption(c: &mut Criterion) {
|
||||
use linux_hello_daemon::tpm::SoftwareTpmFallback;
|
||||
use linux_hello_daemon::tpm::TpmStorage;
|
||||
|
||||
let mut group = c.benchmark_group("encryption");
|
||||
|
||||
// Initialize software TPM fallback in temp directory
|
||||
let temp_dir = std::env::temp_dir().join("linux-hello-bench-keys");
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
|
||||
let mut storage = SoftwareTpmFallback::new(&temp_dir);
|
||||
if storage.initialize().is_err() {
|
||||
// Skip encryption benchmarks if we can't initialize
|
||||
eprintln!("Warning: Could not initialize encryption storage for benchmarks");
|
||||
group.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Test with different data sizes (embedding sizes)
|
||||
let data_sizes = [
|
||||
(128 * 4, "128_floats"), // 128-dim embedding
|
||||
(256 * 4, "256_floats"), // 256-dim embedding
|
||||
(512 * 4, "512_floats"), // 512-dim embedding
|
||||
(1024 * 4, "1024_floats"), // 1024-dim embedding
|
||||
];
|
||||
|
||||
for (size, name) in data_sizes {
|
||||
let plaintext: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
|
||||
|
||||
group.throughput(Throughput::Bytes(size as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("encrypt", name),
|
||||
&plaintext,
|
||||
|b, data| {
|
||||
b.iter(|| {
|
||||
storage.encrypt("bench_user", black_box(data))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Encrypt once for decrypt benchmark
|
||||
let encrypted = storage.encrypt("bench_user", &plaintext)
|
||||
.expect("Encryption failed");
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("decrypt", name),
|
||||
&encrypted,
|
||||
|b, enc| {
|
||||
b.iter(|| {
|
||||
storage.decrypt("bench_user", black_box(enc))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Round-trip benchmark
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("round_trip", name),
|
||||
&plaintext,
|
||||
|b, data| {
|
||||
b.iter(|| {
|
||||
let enc = storage.encrypt("bench_user", black_box(data)).unwrap();
|
||||
storage.decrypt("bench_user", black_box(&enc))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Secure Memory Operation Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
fn bench_secure_memory(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("secure_memory");
|
||||
|
||||
// SecureEmbedding creation and operations
|
||||
let dimensions = [128, 256, 512];
|
||||
|
||||
for dim in dimensions {
|
||||
let data = generate_test_embedding(dim);
|
||||
|
||||
group.throughput(Throughput::Elements(dim as u64));
|
||||
|
||||
// SecureEmbedding creation (includes memory locking attempt)
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("secure_embedding_create", dim),
|
||||
&data,
|
||||
|b, d| {
|
||||
b.iter(|| {
|
||||
SecureEmbedding::new(black_box(d.clone()))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Secure cosine similarity
|
||||
let secure_a = SecureEmbedding::new(data.clone());
|
||||
let secure_b = SecureEmbedding::new(generate_test_embedding(dim));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("secure_cosine_similarity", dim),
|
||||
&(secure_a.clone(), secure_b.clone()),
|
||||
|b, (a, bb)| {
|
||||
b.iter(|| {
|
||||
a.cosine_similarity(black_box(bb))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Serialization/deserialization
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("secure_to_bytes", dim),
|
||||
&secure_a,
|
||||
|b, emb| {
|
||||
b.iter(|| {
|
||||
emb.to_bytes()
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let bytes = secure_a.to_bytes();
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("secure_from_bytes", dim),
|
||||
&bytes,
|
||||
|b, data| {
|
||||
b.iter(|| {
|
||||
SecureEmbedding::from_bytes(black_box(data))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// SecureBytes constant-time comparison
|
||||
let byte_sizes = [64, 128, 256, 512, 1024];
|
||||
|
||||
for size in byte_sizes {
|
||||
let bytes_a = SecureBytes::new((0..size).map(|i| (i % 256) as u8).collect());
|
||||
let bytes_b = SecureBytes::new((0..size).map(|i| (i % 256) as u8).collect());
|
||||
let bytes_diff = SecureBytes::new((0..size).map(|i| ((i + 1) % 256) as u8).collect());
|
||||
|
||||
group.throughput(Throughput::Bytes(size as u64));
|
||||
|
||||
// Equal bytes comparison
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("constant_time_eq_match", size),
|
||||
&(bytes_a.clone(), bytes_b.clone()),
|
||||
|b, (a, bb)| {
|
||||
b.iter(|| {
|
||||
a.constant_time_eq(black_box(bb))
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Different bytes comparison (should take same time)
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("constant_time_eq_differ", size),
|
||||
&(bytes_a.clone(), bytes_diff.clone()),
|
||||
|b, (a, d)| {
|
||||
b.iter(|| {
|
||||
a.constant_time_eq(black_box(d))
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Memory zeroization
|
||||
for size in byte_sizes {
|
||||
group.throughput(Throughput::Bytes(size as u64));
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("secure_zero", size),
|
||||
&size,
|
||||
|b, &sz| {
|
||||
let mut buffer: Vec<u8> = (0..sz).map(|i| (i % 256) as u8).collect();
|
||||
b.iter(|| {
|
||||
memory_protection::secure_zero(black_box(&mut buffer));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Full Pipeline Benchmark (End-to-End)
|
||||
// ============================================================================
|
||||
|
||||
fn bench_full_pipeline(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("full_pipeline");
|
||||
group.sample_size(50); // Fewer samples for slower benchmarks
|
||||
|
||||
// Simulate complete authentication flow
|
||||
let width = 640;
|
||||
let height = 480;
|
||||
let image_data = generate_test_image(width, height);
|
||||
let templates = generate_test_templates(5, 128);
|
||||
|
||||
group.throughput(Throughput::Elements(1)); // 1 authentication attempt
|
||||
|
||||
group.bench_function("auth_pipeline_no_crypto", |b| {
|
||||
let detector = SimpleFaceDetector::new(0.3);
|
||||
let extractor = PlaceholderEmbeddingExtractor::new(128);
|
||||
|
||||
b.iter(|| {
|
||||
// Step 1: Face detection
|
||||
let detections = detector.detect(
|
||||
black_box(&image_data),
|
||||
black_box(width),
|
||||
black_box(height)
|
||||
).unwrap();
|
||||
|
||||
if let Some(detection) = detections.first() {
|
||||
// Step 2: Extract face region (simulated)
|
||||
let (_x, _y, w, h) = detection.to_pixels(width, height);
|
||||
let face_image = GrayImage::from_raw(w, h, vec![128u8; (w * h) as usize])
|
||||
.unwrap_or_else(|| GrayImage::new(w, h));
|
||||
|
||||
// Step 3: Extract embedding
|
||||
let embedding = extractor.extract(&face_image).unwrap();
|
||||
|
||||
// Step 4: Template matching
|
||||
let result = match_template(&embedding, &templates, 0.4);
|
||||
|
||||
black_box(Some(result))
|
||||
} else {
|
||||
black_box(None)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// With anti-spoofing
|
||||
group.bench_function("auth_pipeline_with_antispoofing", |b| {
|
||||
let detector = SimpleFaceDetector::new(0.3);
|
||||
let extractor = PlaceholderEmbeddingExtractor::new(128);
|
||||
let config = AntiSpoofingConfig::default();
|
||||
|
||||
b.iter(|| {
|
||||
let mut spoof_detector = AntiSpoofingDetector::new(config.clone());
|
||||
|
||||
// Step 1: Face detection
|
||||
let detections = detector.detect(
|
||||
black_box(&image_data),
|
||||
black_box(width),
|
||||
black_box(height)
|
||||
).unwrap();
|
||||
|
||||
if let Some(detection) = detections.first() {
|
||||
// Step 2: Anti-spoofing check
|
||||
let frame = AntiSpoofingFrame {
|
||||
pixels: image_data.clone(),
|
||||
width,
|
||||
height,
|
||||
is_ir: true,
|
||||
face_bbox: Some(detection.to_pixels(width, height)),
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
let liveness = spoof_detector.check_frame(&frame).unwrap();
|
||||
|
||||
if liveness.is_live {
|
||||
// Step 3: Extract face region
|
||||
let (_, _, w, h) = detection.to_pixels(width, height);
|
||||
let face_image = GrayImage::from_raw(w, h, vec![128u8; (w * h) as usize])
|
||||
.unwrap_or_else(|| GrayImage::new(w, h));
|
||||
|
||||
// Step 4: Extract embedding
|
||||
let embedding = extractor.extract(&face_image).unwrap();
|
||||
|
||||
// Step 5: Template matching
|
||||
let result = match_template(&embedding, &templates, 0.4);
|
||||
|
||||
black_box(Some(result))
|
||||
} else {
|
||||
black_box(None)
|
||||
}
|
||||
} else {
|
||||
black_box(None)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Criterion Configuration and Main
|
||||
// ============================================================================
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_face_detection,
|
||||
bench_embedding_extraction,
|
||||
bench_template_matching,
|
||||
bench_anti_spoofing,
|
||||
bench_encryption,
|
||||
bench_secure_memory,
|
||||
bench_full_pipeline,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,19 +1,64 @@
|
||||
//! Anti-Spoofing Module
|
||||
//!
|
||||
//! Provides liveness detection to prevent authentication attacks
|
||||
//! using photos, videos, or masks.
|
||||
//! This module provides liveness detection to prevent authentication attacks
|
||||
//! using photos, videos, or masks. It is a critical security component.
|
||||
//!
|
||||
//! Detection methods:
|
||||
//! - IR light analysis (presence/pattern verification)
|
||||
//! - Depth estimation from IR stereo or structured light
|
||||
//! - Temporal micro-movement analysis
|
||||
//! - Texture analysis for screen/paper detection
|
||||
//! - Eye blink detection
|
||||
//! # Overview
|
||||
//!
|
||||
//! # Scoring
|
||||
//! Anti-spoofing validates that the face in front of the camera is a real,
|
||||
//! live person rather than a photograph, video, or 3D mask. Multiple
|
||||
//! detection methods are combined for robust protection.
|
||||
//!
|
||||
//! # Detection Methods
|
||||
//!
|
||||
//! | Method | Description | Weight |
|
||||
//! |--------|-------------|--------|
|
||||
//! | IR Check | Analyzes IR reflection patterns | 1.5x |
|
||||
//! | Depth Check | Estimates 3D structure from gradients | 1.2x |
|
||||
//! | Texture Check | Detects screen/paper patterns (LBP) | 1.0x |
|
||||
//! | Blink Check | Monitors eye region for natural blinks | 0.8x |
|
||||
//! | Movement Check | Analyzes micro-movements over time | 0.8x |
|
||||
//!
|
||||
//! # Scoring System
|
||||
//!
|
||||
//! Each method returns a score between 0.0 (definitely fake) and 1.0
|
||||
//! (definitely real). Scores are combined using weighted averaging.
|
||||
//! (definitely real). Scores are combined using weighted averaging:
|
||||
//!
|
||||
//! ```text
|
||||
//! final_score = sum(score_i * weight_i) / sum(weight_i)
|
||||
//! ```
|
||||
//!
|
||||
//! Authentication passes if `final_score >= threshold` (default: 0.7).
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::anti_spoofing::{
|
||||
//! AntiSpoofingDetector, AntiSpoofingConfig, AntiSpoofingFrame
|
||||
//! };
|
||||
//!
|
||||
//! let config = AntiSpoofingConfig::default();
|
||||
//! let mut detector = AntiSpoofingDetector::new(config);
|
||||
//!
|
||||
//! // Process multiple frames
|
||||
//! for frame_data in frames {
|
||||
//! let frame = AntiSpoofingFrame {
|
||||
//! pixels: frame_data,
|
||||
//! width: 640,
|
||||
//! height: 480,
|
||||
//! is_ir: true,
|
||||
//! face_bbox: Some((100, 100, 200, 200)),
|
||||
//! timestamp_ms: 0,
|
||||
//! };
|
||||
//!
|
||||
//! let result = detector.check_frame(&frame)?;
|
||||
//! if result.is_live {
|
||||
//! println!("Liveness confirmed: {:.2}", result.score);
|
||||
//! } else {
|
||||
//! println!("Spoofing detected: {}", result.rejection_reason.unwrap());
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -197,6 +197,7 @@ mod tests {
|
||||
let controls = scan_emitter_controls("/dev/video0");
|
||||
// On non-Linux, should return mock control
|
||||
// On Linux, depends on hardware
|
||||
assert!(controls.len() >= 0);
|
||||
// Just verify the function returns without panic
|
||||
let _ = controls.len();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,49 @@
|
||||
//! Camera Interface Module
|
||||
//!
|
||||
//! Handles V4L2 camera enumeration, frame capture, and IR camera detection.
|
||||
//! This module provides camera enumeration, frame capture, and IR camera detection
|
||||
//! for the Linux Hello facial authentication system.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Linux Hello requires an infrared (IR) camera for secure authentication. IR cameras
|
||||
//! are preferred because they:
|
||||
//!
|
||||
//! - Work in low light conditions
|
||||
//! - Are harder to spoof with photos (IR reflects differently from screens)
|
||||
//! - Provide consistent imaging regardless of ambient lighting
|
||||
//!
|
||||
//! # Camera Detection
|
||||
//!
|
||||
//! The module automatically detects IR cameras by checking:
|
||||
//! - Device names containing "IR", "Infrared", or "Windows Hello"
|
||||
//! - V4L2 capabilities and supported formats
|
||||
//! - Known IR camera vendor/product IDs
|
||||
//!
|
||||
//! # Platform Support
|
||||
//!
|
||||
//! - **Linux** - Full V4L2 support via the `linux` submodule
|
||||
//! - **Other platforms** - Mock camera for development and testing
|
||||
//!
|
||||
//! # Example: Enumerate Cameras
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::camera::{enumerate_cameras, Camera};
|
||||
//!
|
||||
//! // Find all available cameras
|
||||
//! let cameras = enumerate_cameras().expect("Failed to enumerate cameras");
|
||||
//!
|
||||
//! for camera in &cameras {
|
||||
//! println!("Found: {} (IR: {})", camera.name, camera.is_ir);
|
||||
//! }
|
||||
//!
|
||||
//! // Find the first IR camera
|
||||
//! if let Some(ir_cam) = cameras.iter().find(|c| c.is_ir) {
|
||||
//! let mut camera = Camera::open(&ir_cam.device_path)?;
|
||||
//! camera.start()?;
|
||||
//! let frame = camera.capture_frame()?;
|
||||
//! println!("Captured {}x{} frame", frame.width, frame.height);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
@@ -14,17 +57,38 @@ pub use linux::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use ir_emitter::IrEmitterControl;
|
||||
|
||||
/// Represents a detected camera device
|
||||
/// Information about a detected camera device.
|
||||
///
|
||||
/// This structure contains metadata about a camera, including its device path,
|
||||
/// name, whether it's an IR camera, and supported resolutions.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::CameraInfo;
|
||||
///
|
||||
/// let info = CameraInfo {
|
||||
/// device_path: "/dev/video0".to_string(),
|
||||
/// name: "Integrated IR Camera".to_string(),
|
||||
/// is_ir: true,
|
||||
/// resolutions: vec![(640, 480), (1280, 720)],
|
||||
/// };
|
||||
///
|
||||
/// if info.is_ir {
|
||||
/// println!("Found IR camera: {}", info.name);
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Public API, fields may be used by external code
|
||||
pub struct CameraInfo {
|
||||
/// Device path (e.g., /dev/video0)
|
||||
/// Device path (e.g., "/dev/video0" on Linux).
|
||||
pub device_path: String,
|
||||
/// Human-readable name
|
||||
/// Human-readable camera name from the driver.
|
||||
pub name: String,
|
||||
/// Whether this appears to be an IR camera
|
||||
/// Whether this camera appears to be an IR (infrared) camera.
|
||||
/// Detected based on name patterns and capabilities.
|
||||
pub is_ir: bool,
|
||||
/// Supported resolutions
|
||||
/// List of supported resolutions as (width, height) pairs.
|
||||
pub resolutions: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
@@ -40,33 +104,68 @@ impl std::fmt::Display for CameraInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured video frame
|
||||
/// A captured video frame from the camera.
|
||||
///
|
||||
/// Contains the raw pixel data along with metadata about dimensions,
|
||||
/// format, and timing. Used throughout the authentication pipeline.
|
||||
///
|
||||
/// # Memory Layout
|
||||
///
|
||||
/// The `data` field contains raw pixel bytes. For grayscale images,
|
||||
/// this is one byte per pixel in row-major order. For YUYV, pixels
|
||||
/// are packed as Y0 U Y1 V (4 bytes per 2 pixels).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::{Frame, PixelFormat};
|
||||
///
|
||||
/// let frame = Frame {
|
||||
/// data: vec![128; 640 * 480], // 640x480 grayscale
|
||||
/// width: 640,
|
||||
/// height: 480,
|
||||
/// format: PixelFormat::Grey,
|
||||
/// timestamp_us: 0,
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(frame.data.len(), (frame.width * frame.height) as usize);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)] // Public API, used by camera implementations
|
||||
pub struct Frame {
|
||||
/// Raw frame data
|
||||
/// Raw pixel data in the specified format.
|
||||
pub data: Vec<u8>,
|
||||
/// Frame width in pixels
|
||||
/// Frame width in pixels.
|
||||
pub width: u32,
|
||||
/// Frame height in pixels
|
||||
/// Frame height in pixels.
|
||||
pub height: u32,
|
||||
/// Pixel format
|
||||
/// Pixel format of the data.
|
||||
pub format: PixelFormat,
|
||||
/// Timestamp in microseconds
|
||||
/// Timestamp in microseconds since capture start.
|
||||
/// Useful for temporal analysis and frame timing.
|
||||
pub timestamp_us: u64,
|
||||
}
|
||||
|
||||
/// Supported pixel formats
|
||||
/// Supported pixel formats for camera frames.
|
||||
///
|
||||
/// IR cameras typically output grayscale (Grey) or YUYV formats.
|
||||
/// The face detection pipeline works best with grayscale input.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)] // Public API, variants used by camera implementations
|
||||
pub enum PixelFormat {
|
||||
/// 8-bit grayscale (Y8/GREY)
|
||||
/// 8-bit grayscale (Y8/GREY).
|
||||
/// One byte per pixel, values 0-255 represent brightness.
|
||||
/// Preferred format for IR cameras and face detection.
|
||||
Grey,
|
||||
/// YUYV (packed YUV 4:2:2)
|
||||
/// YUYV (packed YUV 4:2:2).
|
||||
/// Two bytes per pixel on average (Y0 U Y1 V for each pixel pair).
|
||||
/// Common format for USB webcams.
|
||||
Yuyv,
|
||||
/// MJPEG compressed
|
||||
/// MJPEG compressed.
|
||||
/// Variable-length JPEG frames. Requires decompression before processing.
|
||||
Mjpeg,
|
||||
/// Unknown format
|
||||
/// Unknown or unsupported format.
|
||||
/// Frames with this format cannot be processed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
||||
147
linux-hello-daemon/src/dbus_server.rs
Normal file
147
linux-hello-daemon/src/dbus_server.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! D-Bus Server Bootstrap
|
||||
//!
|
||||
//! Handles connection to the system bus, service name registration,
|
||||
//! and serving the org.linuxhello.Manager interface.
|
||||
|
||||
use linux_hello_common::{Config, Result, Error};
|
||||
use tracing::{error, info};
|
||||
use zbus::connection::Builder;
|
||||
use zbus::Connection;
|
||||
|
||||
use crate::auth::AuthService;
|
||||
use crate::dbus_service::LinuxHelloManager;
|
||||
|
||||
/// D-Bus service name
|
||||
pub const SERVICE_NAME: &str = "org.linuxhello.Daemon";
|
||||
|
||||
/// D-Bus object path
|
||||
pub const OBJECT_PATH: &str = "/org/linuxhello/Manager";
|
||||
|
||||
/// D-Bus server for Linux Hello
|
||||
pub struct DbusServer {
|
||||
connection: Option<Connection>,
|
||||
}
|
||||
|
||||
impl DbusServer {
|
||||
/// Create a new D-Bus server instance
|
||||
pub fn new() -> Self {
|
||||
Self { connection: None }
|
||||
}
|
||||
|
||||
/// Start the D-Bus server
|
||||
///
|
||||
/// This connects to the system bus, registers the service name,
|
||||
/// and serves the interface at the specified object path.
|
||||
pub async fn start(&mut self, auth_service: AuthService, config: Config) -> Result<()> {
|
||||
info!("Starting D-Bus server...");
|
||||
|
||||
// Create the interface implementation
|
||||
let manager = LinuxHelloManager::new(auth_service, config);
|
||||
|
||||
// Update initial status
|
||||
manager.update_status().await;
|
||||
|
||||
// Build connection to system bus
|
||||
let connection = Builder::system()
|
||||
.map_err(|e| Error::Dbus(format!("Failed to create connection builder: {}", e)))?
|
||||
.name(SERVICE_NAME)
|
||||
.map_err(|e| Error::Dbus(format!("Failed to set service name: {}", e)))?
|
||||
.serve_at(OBJECT_PATH, manager)
|
||||
.map_err(|e| Error::Dbus(format!("Failed to serve interface: {}", e)))?
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| Error::Dbus(format!("Failed to connect to system bus: {}", e)))?;
|
||||
|
||||
info!("D-Bus server connected to system bus");
|
||||
info!(" Service name: {}", SERVICE_NAME);
|
||||
info!(" Object path: {}", OBJECT_PATH);
|
||||
|
||||
self.connection = Some(connection);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the D-Bus server indefinitely
|
||||
///
|
||||
/// This should be called after `start()` to keep the server running.
|
||||
/// The server will handle incoming method calls, property access,
|
||||
/// and emit signals as needed.
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
if self.connection.is_none() {
|
||||
return Err(Error::Dbus("D-Bus server not started".to_string()));
|
||||
}
|
||||
|
||||
info!("D-Bus server running, waiting for requests...");
|
||||
|
||||
// The connection is kept alive by zbus internally.
|
||||
// We just need to wait indefinitely.
|
||||
// In production, this would be cancelled by a shutdown signal.
|
||||
std::future::pending::<()>().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the connection (if connected)
|
||||
pub fn connection(&self) -> Option<&Connection> {
|
||||
self.connection.as_ref()
|
||||
}
|
||||
|
||||
/// Check if the server is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.connection.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DbusServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the D-Bus service and run it
|
||||
///
|
||||
/// This is a convenience function that creates a server, starts it,
|
||||
/// and runs it indefinitely.
|
||||
pub async fn run_dbus_service(auth_service: AuthService, config: Config) -> Result<()> {
|
||||
let mut server = DbusServer::new();
|
||||
|
||||
if let Err(e) = server.start(auth_service, config).await {
|
||||
error!("Failed to start D-Bus server: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
server.run().await
|
||||
}
|
||||
|
||||
/// Try to connect to system bus (for testing availability)
|
||||
pub async fn check_system_bus_available() -> bool {
|
||||
match Connection::system().await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!("System bus not available: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_service_name() {
|
||||
assert_eq!(SERVICE_NAME, "org.linuxhello.Daemon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_path() {
|
||||
assert_eq!(OBJECT_PATH, "/org/linuxhello/Manager");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_default() {
|
||||
let server = DbusServer::default();
|
||||
assert!(!server.is_connected());
|
||||
assert!(server.connection().is_none());
|
||||
}
|
||||
}
|
||||
373
linux-hello-daemon/src/dbus_service.rs
Normal file
373
linux-hello-daemon/src/dbus_service.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! D-Bus Service Interface
|
||||
//!
|
||||
//! Implements the org.linuxhello.Manager D-Bus interface for facial authentication.
|
||||
//! This provides an alternative to Unix socket IPC for client applications.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zbus::{interface, SignalContext};
|
||||
|
||||
use linux_hello_common::{Config, TemplateStore};
|
||||
|
||||
use crate::auth::AuthService;
|
||||
|
||||
/// Version string for the daemon
|
||||
pub const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// System status information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemStatus {
|
||||
pub camera_available: bool,
|
||||
pub tpm_available: bool,
|
||||
pub anti_spoofing_enabled: bool,
|
||||
pub enrolled_users_count: u32,
|
||||
}
|
||||
|
||||
/// D-Bus interface implementation for Linux Hello Manager
|
||||
pub struct LinuxHelloManager {
|
||||
auth_service: Arc<AuthService>,
|
||||
config: Arc<Config>,
|
||||
status: Arc<RwLock<SystemStatus>>,
|
||||
/// Track if enrollment is in progress
|
||||
enrollment_active: Arc<RwLock<Option<EnrollmentState>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EnrollmentState {
|
||||
user: String,
|
||||
label: String,
|
||||
frames_captured: u32,
|
||||
frames_total: u32,
|
||||
}
|
||||
|
||||
impl LinuxHelloManager {
|
||||
/// Create a new D-Bus interface instance
|
||||
pub fn new(auth_service: AuthService, config: Config) -> Self {
|
||||
let anti_spoofing_enabled = config.anti_spoofing.enabled;
|
||||
|
||||
Self {
|
||||
auth_service: Arc::new(auth_service),
|
||||
config: Arc::new(config),
|
||||
status: Arc::new(RwLock::new(SystemStatus {
|
||||
camera_available: false,
|
||||
tpm_available: false,
|
||||
anti_spoofing_enabled,
|
||||
enrolled_users_count: 0,
|
||||
})),
|
||||
enrollment_active: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update system status (called during initialization)
|
||||
pub async fn update_status(&self) {
|
||||
let camera_available = self.check_camera_available();
|
||||
let tpm_available = self.check_tpm_available();
|
||||
let enrolled_count = self.count_enrolled_users();
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.camera_available = camera_available;
|
||||
status.tpm_available = tpm_available;
|
||||
status.enrolled_users_count = enrolled_count;
|
||||
}
|
||||
|
||||
fn check_camera_available(&self) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::camera::enumerate_cameras;
|
||||
enumerate_cameras().map(|c| !c.is_empty()).unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn check_tpm_available(&self) -> bool {
|
||||
// Check if TPM device exists
|
||||
std::path::Path::new("/dev/tpm0").exists()
|
||||
|| std::path::Path::new("/dev/tpmrm0").exists()
|
||||
}
|
||||
|
||||
fn count_enrolled_users(&self) -> u32 {
|
||||
let store = TemplateStore::new(TemplateStore::default_path());
|
||||
// Use list_users() to count enrolled users
|
||||
store.list_users().map(|users| users.len() as u32).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// D-Bus error type
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbusError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Convert a linux_hello_common::Error to a zbus::fdo::Error
|
||||
/// This is a helper function to avoid orphan rule violations
|
||||
fn to_dbus_error(err: linux_hello_common::Error) -> zbus::fdo::Error {
|
||||
zbus::fdo::Error::Failed(err.to_string())
|
||||
}
|
||||
|
||||
#[interface(name = "org.linuxhello.Manager")]
|
||||
impl LinuxHelloManager {
|
||||
// ==================== Methods ====================
|
||||
|
||||
/// Authenticate a user using facial recognition
|
||||
///
|
||||
/// Returns true if authentication succeeded, false otherwise.
|
||||
/// Throws an error if user is not enrolled or camera is unavailable.
|
||||
async fn authenticate(&self, user: &str) -> zbus::fdo::Result<bool> {
|
||||
tracing::info!("D-Bus: Authenticate request for user: {}", user);
|
||||
|
||||
let result = self.auth_service.authenticate(user).await;
|
||||
|
||||
match result {
|
||||
Ok(success) => {
|
||||
tracing::info!("D-Bus: Authentication result for {}: {}", user, success);
|
||||
Ok(success)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("D-Bus: Authentication error for {}: {}", user, e);
|
||||
Err(to_dbus_error(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start enrollment for a user
|
||||
///
|
||||
/// This initiates the enrollment process. Progress will be reported
|
||||
/// via the EnrollmentProgress signal.
|
||||
async fn enroll_start(
|
||||
&self,
|
||||
#[zbus(signal_context)] ctx: SignalContext<'_>,
|
||||
user: &str,
|
||||
label: &str,
|
||||
frame_count: u32,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
tracing::info!("D-Bus: EnrollStart for user: {}, label: {}", user, label);
|
||||
|
||||
// Check if enrollment is already in progress
|
||||
{
|
||||
let active = self.enrollment_active.read().await;
|
||||
if active.is_some() {
|
||||
return Err(zbus::fdo::Error::Failed(
|
||||
"Enrollment already in progress".to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Set enrollment state
|
||||
{
|
||||
let mut active = self.enrollment_active.write().await;
|
||||
*active = Some(EnrollmentState {
|
||||
user: user.to_string(),
|
||||
label: label.to_string(),
|
||||
frames_captured: 0,
|
||||
frames_total: frame_count,
|
||||
});
|
||||
}
|
||||
|
||||
let user_owned = user.to_string();
|
||||
let label_owned = label.to_string();
|
||||
let auth_service = self.auth_service.clone();
|
||||
let enrollment_active = self.enrollment_active.clone();
|
||||
let ctx_path = ctx.path().to_owned();
|
||||
let ctx_conn = ctx.connection().clone();
|
||||
|
||||
// Spawn enrollment task
|
||||
tokio::spawn(async move {
|
||||
// Simulate progress updates (in real implementation, this would be
|
||||
// integrated with frame capture)
|
||||
for i in 1..=frame_count {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Update state
|
||||
{
|
||||
let mut active = enrollment_active.write().await;
|
||||
if let Some(ref mut state) = *active {
|
||||
state.frames_captured = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Send progress signal
|
||||
let progress = (i as f64 / frame_count as f64 * 100.0) as u32;
|
||||
if let Ok(iface_ref) = ctx_conn
|
||||
.object_server()
|
||||
.interface::<_, LinuxHelloManager>(&ctx_path)
|
||||
.await
|
||||
{
|
||||
let _ = LinuxHelloManager::enrollment_progress(
|
||||
iface_ref.signal_context(),
|
||||
&user_owned,
|
||||
progress,
|
||||
&format!("Capturing frame {}/{}", i, frame_count),
|
||||
).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform actual enrollment
|
||||
let result = auth_service.enroll(&user_owned, &label_owned, frame_count).await;
|
||||
|
||||
// Clear enrollment state
|
||||
{
|
||||
let mut active = enrollment_active.write().await;
|
||||
*active = None;
|
||||
}
|
||||
|
||||
// Send completion signal
|
||||
if let Ok(iface_ref) = ctx_conn
|
||||
.object_server()
|
||||
.interface::<_, LinuxHelloManager>(&ctx_path)
|
||||
.await
|
||||
{
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = LinuxHelloManager::enrollment_complete(
|
||||
iface_ref.signal_context(),
|
||||
&user_owned,
|
||||
true,
|
||||
"Enrollment successful",
|
||||
).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = LinuxHelloManager::enrollment_complete(
|
||||
iface_ref.signal_context(),
|
||||
&user_owned,
|
||||
false,
|
||||
&format!("Enrollment failed: {}", e),
|
||||
).await;
|
||||
let _ = LinuxHelloManager::error(
|
||||
iface_ref.signal_context(),
|
||||
"enrollment_failed",
|
||||
&e.to_string(),
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an ongoing enrollment
|
||||
async fn enroll_cancel(&self) -> zbus::fdo::Result<()> {
|
||||
tracing::info!("D-Bus: EnrollCancel");
|
||||
|
||||
let mut active = self.enrollment_active.write().await;
|
||||
if active.is_none() {
|
||||
return Err(zbus::fdo::Error::Failed(
|
||||
"No enrollment in progress".to_string()
|
||||
));
|
||||
}
|
||||
|
||||
*active = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all enrolled templates for a user
|
||||
async fn list_templates(&self, user: &str) -> zbus::fdo::Result<Vec<String>> {
|
||||
tracing::info!("D-Bus: ListTemplates for user: {}", user);
|
||||
|
||||
let store = TemplateStore::new(TemplateStore::default_path());
|
||||
store.list_templates(user).map_err(to_dbus_error)
|
||||
}
|
||||
|
||||
/// Remove a specific template or all templates for a user
|
||||
async fn remove_template(
|
||||
&self,
|
||||
user: &str,
|
||||
label: &str,
|
||||
) -> zbus::fdo::Result<()> {
|
||||
tracing::info!("D-Bus: RemoveTemplate for user: {}, label: {}", user, label);
|
||||
|
||||
let store = TemplateStore::new(TemplateStore::default_path());
|
||||
|
||||
if label == "*" {
|
||||
store.remove_all(user).map_err(to_dbus_error)
|
||||
} else {
|
||||
store.remove(user, label).map_err(to_dbus_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get comprehensive system status
|
||||
async fn get_system_status(&self) -> zbus::fdo::Result<(bool, bool, bool, u32)> {
|
||||
tracing::info!("D-Bus: GetSystemStatus");
|
||||
|
||||
// Refresh status
|
||||
self.update_status().await;
|
||||
|
||||
let status = self.status.read().await;
|
||||
Ok((
|
||||
status.camera_available,
|
||||
status.tpm_available,
|
||||
status.anti_spoofing_enabled,
|
||||
status.enrolled_users_count,
|
||||
))
|
||||
}
|
||||
|
||||
// ==================== Properties ====================
|
||||
|
||||
/// Daemon version
|
||||
#[zbus(property)]
|
||||
async fn version(&self) -> String {
|
||||
DAEMON_VERSION.to_string()
|
||||
}
|
||||
|
||||
/// Whether a camera is available
|
||||
#[zbus(property)]
|
||||
async fn camera_available(&self) -> bool {
|
||||
let status = self.status.read().await;
|
||||
status.camera_available
|
||||
}
|
||||
|
||||
/// Whether TPM is available
|
||||
#[zbus(property)]
|
||||
async fn tpm_available(&self) -> bool {
|
||||
let status = self.status.read().await;
|
||||
status.tpm_available
|
||||
}
|
||||
|
||||
/// Whether anti-spoofing is enabled
|
||||
#[zbus(property)]
|
||||
async fn anti_spoofing_enabled(&self) -> bool {
|
||||
self.config.anti_spoofing.enabled
|
||||
}
|
||||
|
||||
// ==================== Signals ====================
|
||||
|
||||
/// Emitted during enrollment to report progress
|
||||
#[zbus(signal)]
|
||||
async fn enrollment_progress(
|
||||
ctx: &SignalContext<'_>,
|
||||
user: &str,
|
||||
progress: u32,
|
||||
message: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Emitted when enrollment completes (success or failure)
|
||||
#[zbus(signal)]
|
||||
async fn enrollment_complete(
|
||||
ctx: &SignalContext<'_>,
|
||||
user: &str,
|
||||
success: bool,
|
||||
message: &str,
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Emitted when an error occurs
|
||||
#[zbus(signal)]
|
||||
async fn error(
|
||||
ctx: &SignalContext<'_>,
|
||||
code: &str,
|
||||
message: &str,
|
||||
) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_daemon_version() {
|
||||
assert!(!DAEMON_VERSION.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,110 @@
|
||||
//! Face Detection Module
|
||||
//!
|
||||
//! Face detection types and simple fallback detection.
|
||||
//! ONNX-based detection will be added once models are available.
|
||||
//! This module provides face detection functionality for the authentication pipeline.
|
||||
//! It includes types for representing detected faces and traits for implementing
|
||||
//! different detection backends.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Face detection is the first ML step in the authentication pipeline. It locates
|
||||
//! faces in camera frames and provides bounding boxes for subsequent processing.
|
||||
//!
|
||||
//! # Coordinate System
|
||||
//!
|
||||
//! All coordinates are normalized to the range [0, 1] for resolution independence:
|
||||
//! - (0, 0) is the top-left corner
|
||||
//! - (1, 1) is the bottom-right corner
|
||||
//! - Use [`FaceDetection::to_pixels`] to convert to pixel coordinates
|
||||
//!
|
||||
//! # Detection Backends
|
||||
//!
|
||||
//! The module supports multiple detection backends via the [`FaceDetect`] trait:
|
||||
//!
|
||||
//! - [`SimpleFaceDetector`] - Basic detection for testing (no ML model required)
|
||||
//! - ONNX-based detectors (planned) - BlazeFace, MTCNN, RetinaFace
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use linux_hello_daemon::{FaceDetection, FaceDetect, SimpleFaceDetector};
|
||||
//!
|
||||
//! // Create a detector
|
||||
//! let detector = SimpleFaceDetector::new(0.5);
|
||||
//!
|
||||
//! // Detect faces in a grayscale image
|
||||
//! let image = vec![128u8; 640 * 480];
|
||||
//! let detections = detector.detect(&image, 640, 480).unwrap();
|
||||
//!
|
||||
//! for face in &detections {
|
||||
//! let (x, y, w, h) = face.to_pixels(640, 480);
|
||||
//! println!("Face at ({}, {}) size {}x{}, confidence: {:.2}",
|
||||
//! x, y, w, h, face.confidence);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::Result;
|
||||
|
||||
/// Detected face bounding box
|
||||
/// A detected face with bounding box and confidence score.
|
||||
///
|
||||
/// Coordinates are normalized to [0, 1] for resolution independence.
|
||||
/// Use [`to_pixels`](Self::to_pixels) to convert to actual pixel coordinates.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::FaceDetection;
|
||||
///
|
||||
/// let detection = FaceDetection {
|
||||
/// x: 0.25, // Face starts at 25% from left
|
||||
/// y: 0.1, // Face starts at 10% from top
|
||||
/// width: 0.5, // Face is 50% of image width
|
||||
/// height: 0.8, // Face is 80% of image height
|
||||
/// confidence: 0.95,
|
||||
/// };
|
||||
///
|
||||
/// // Convert to 640x480 pixel coordinates
|
||||
/// let (px, py, pw, ph) = detection.to_pixels(640, 480);
|
||||
/// assert_eq!((px, py, pw, ph), (160, 48, 320, 384));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // Public API, fields used by detection methods
|
||||
pub struct FaceDetection {
|
||||
/// X coordinate of top-left corner (0-1 normalized)
|
||||
/// X coordinate of top-left corner (0.0-1.0 normalized).
|
||||
pub x: f32,
|
||||
/// Y coordinate of top-left corner (0-1 normalized)
|
||||
/// Y coordinate of top-left corner (0.0-1.0 normalized).
|
||||
pub y: f32,
|
||||
/// Width of bounding box (0-1 normalized)
|
||||
/// Width of bounding box (0.0-1.0 normalized).
|
||||
pub width: f32,
|
||||
/// Height of bounding box (0-1 normalized)
|
||||
/// Height of bounding box (0.0-1.0 normalized).
|
||||
pub height: f32,
|
||||
/// Detection confidence score (0-1)
|
||||
/// Detection confidence score (0.0-1.0).
|
||||
/// Higher values indicate more confident detections.
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
impl FaceDetection {
|
||||
/// Convert normalized coordinates to pixel coordinates
|
||||
///
|
||||
/// Public API - used by face region extraction
|
||||
/// Convert normalized coordinates to pixel coordinates.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `img_width` - Width of the image in pixels
|
||||
/// * `img_height` - Height of the image in pixels
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A tuple of (x, y, width, height) in pixel coordinates.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::FaceDetection;
|
||||
///
|
||||
/// let det = FaceDetection {
|
||||
/// x: 0.5, y: 0.5, width: 0.25, height: 0.25, confidence: 0.9
|
||||
/// };
|
||||
/// let (x, y, w, h) = det.to_pixels(100, 100);
|
||||
/// assert_eq!((x, y, w, h), (50, 50, 25, 25));
|
||||
/// ```
|
||||
#[allow(dead_code)] // Public API, used by auth service
|
||||
pub fn to_pixels(&self, img_width: u32, img_height: u32) -> (u32, u32, u32, u32) {
|
||||
let x = (self.x * img_width as f32) as u32;
|
||||
@@ -35,17 +115,56 @@ impl FaceDetection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Face detector trait for different backends
|
||||
///
|
||||
/// Public API - trait for extensible face detection backends
|
||||
/// Trait for face detection backends.
|
||||
///
|
||||
/// Implement this trait to add support for different face detection models
|
||||
/// or algorithms. All implementations should return normalized coordinates.
|
||||
///
|
||||
/// # Implementing a Custom Detector
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use linux_hello_daemon::{FaceDetect, FaceDetection};
|
||||
/// use linux_hello_common::Result;
|
||||
///
|
||||
/// struct MyDetector {
|
||||
/// model: OnnxModel,
|
||||
/// }
|
||||
///
|
||||
/// impl FaceDetect for MyDetector {
|
||||
/// fn detect(&self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<FaceDetection>> {
|
||||
/// // Run model inference and return detections
|
||||
/// let detections = self.model.run(image_data, width, height)?;
|
||||
/// Ok(detections)
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(dead_code)] // Public API trait
|
||||
pub trait FaceDetect {
|
||||
/// Detect faces in a grayscale image
|
||||
/// Detect faces in a grayscale image.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `image_data` - Raw grayscale pixel data (one byte per pixel)
|
||||
/// * `width` - Image width in pixels
|
||||
/// * `height` - Image height in pixels
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of detected faces with normalized coordinates and confidence scores.
|
||||
/// Returns an empty vector if no faces are detected.
|
||||
fn detect(&self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<FaceDetection>>;
|
||||
}
|
||||
|
||||
/// Simple face detection using image processing (no ML)
|
||||
/// Used as fallback or for testing
|
||||
/// Simple face detection using basic image analysis.
|
||||
///
|
||||
/// This is a placeholder implementation that assumes a centered face
|
||||
/// if the image has reasonable contrast. It is intended for testing only
|
||||
/// and should not be used in production.
|
||||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// Returns a centered face detection if the image mean brightness
|
||||
/// is between 30 and 225 (indicating reasonable contrast).
|
||||
pub fn detect_face_simple(image_data: &[u8], _width: u32, _height: u32) -> Option<FaceDetection> {
|
||||
// Very simple centered face assumption for testing
|
||||
// In production, this would use proper CV techniques
|
||||
|
||||
@@ -1,24 +1,111 @@
|
||||
//! Face Embedding Module
|
||||
//! Face Embedding Extraction Module
|
||||
//!
|
||||
//! Extracts face embeddings from detected faces. Currently uses a placeholder
|
||||
//! implementation. ONNX model integration will be added when models are available.
|
||||
//! This module extracts face embeddings (feature vectors) from detected faces.
|
||||
//! Embeddings are compact numerical representations that capture facial features
|
||||
//! for identity matching.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Face embeddings are the core of facial recognition. They transform a face image
|
||||
//! into a fixed-length vector where:
|
||||
//!
|
||||
//! - Similar faces have embeddings with small distances
|
||||
//! - Different faces have embeddings with large distances
|
||||
//!
|
||||
//! # Embedding Properties
|
||||
//!
|
||||
//! - **Dimension**: Typically 128 (MobileFaceNet) or 512 (ArcFace)
|
||||
//! - **Normalized**: Embeddings have unit length (L2 norm = 1)
|
||||
//! - **Metric**: Use cosine similarity or Euclidean distance for comparison
|
||||
//!
|
||||
//! # Distance Functions
|
||||
//!
|
||||
//! This module provides two distance metrics:
|
||||
//!
|
||||
//! - [`cosine_similarity`] - Returns 1.0 for identical embeddings, -1.0 for opposite
|
||||
//! - [`euclidean_distance`] - Returns 0.0 for identical embeddings
|
||||
//!
|
||||
//! Use [`similarity_to_distance`] to convert cosine similarity to a distance metric.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use linux_hello_daemon::{
|
||||
//! EmbeddingExtractor, PlaceholderEmbeddingExtractor,
|
||||
//! cosine_similarity, euclidean_distance,
|
||||
//! };
|
||||
//! use image::GrayImage;
|
||||
//!
|
||||
//! // Create an extractor
|
||||
//! let extractor = PlaceholderEmbeddingExtractor::new(128);
|
||||
//!
|
||||
//! // Extract embedding from a face image
|
||||
//! let face = GrayImage::new(112, 112);
|
||||
//! let embedding = extractor.extract(&face).unwrap();
|
||||
//! assert_eq!(embedding.len(), 128);
|
||||
//!
|
||||
//! // Compare embeddings
|
||||
//! let same_embedding = embedding.clone();
|
||||
//! let similarity = cosine_similarity(&embedding, &same_embedding);
|
||||
//! assert!((similarity - 1.0).abs() < 0.01); // Identical vectors
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::Result;
|
||||
use image::GrayImage;
|
||||
|
||||
/// Face embedding extractor trait
|
||||
/// Trait for face embedding extraction backends.
|
||||
///
|
||||
/// Implement this trait to add support for different embedding models
|
||||
/// like MobileFaceNet, ArcFace, or FaceNet.
|
||||
///
|
||||
/// # Output Requirements
|
||||
///
|
||||
/// Implementations should return normalized embeddings (L2 norm = 1.0)
|
||||
/// for consistent distance calculations.
|
||||
///
|
||||
/// # Example Implementation
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use linux_hello_daemon::EmbeddingExtractor;
|
||||
/// use image::GrayImage;
|
||||
///
|
||||
/// struct OnnxEmbeddingExtractor {
|
||||
/// model: OnnxModel,
|
||||
/// }
|
||||
///
|
||||
/// impl EmbeddingExtractor for OnnxEmbeddingExtractor {
|
||||
/// fn extract(&self, face_image: &GrayImage) -> Result<Vec<f32>> {
|
||||
/// let input = preprocess(face_image);
|
||||
/// let embedding = self.model.run(input)?;
|
||||
/// Ok(normalize(embedding))
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait EmbeddingExtractor {
|
||||
/// Extract embedding from a face region
|
||||
/// Extract a face embedding from a grayscale face image.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `face_image` - Cropped and aligned face image (typically 112x112 or 160x160)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A normalized embedding vector (L2 norm approximately 1.0).
|
||||
fn extract(&self, face_image: &GrayImage) -> Result<Vec<f32>>;
|
||||
}
|
||||
|
||||
/// Placeholder embedding extractor
|
||||
///
|
||||
/// Uses simple image statistics as a placeholder embedding.
|
||||
/// In production, this would use an ONNX model (MobileFaceNet, ArcFace, etc.)
|
||||
/// Placeholder embedding extractor for testing.
|
||||
///
|
||||
/// Uses simple image statistics to generate a pseudo-embedding.
|
||||
/// **Not suitable for production** - use ONNX-based extractors for real authentication.
|
||||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// Computes image statistics (mean, variance, histogram) and creates a
|
||||
/// feature vector that is then normalized to unit length.
|
||||
#[derive(Clone)]
|
||||
pub struct PlaceholderEmbeddingExtractor {
|
||||
/// Embedding dimension
|
||||
/// Output embedding dimension (typically 128 or 512).
|
||||
pub dimension: usize,
|
||||
}
|
||||
|
||||
@@ -101,7 +188,37 @@ impl EmbeddingExtractor for PlaceholderEmbeddingExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute cosine similarity between two embeddings
|
||||
/// Compute cosine similarity between two embeddings.
|
||||
///
|
||||
/// Cosine similarity measures the angle between two vectors, regardless of magnitude.
|
||||
/// Returns a value between -1.0 and 1.0:
|
||||
///
|
||||
/// - `1.0`: Identical directions (same person)
|
||||
/// - `0.0`: Orthogonal (unrelated)
|
||||
/// - `-1.0`: Opposite directions
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `a` - First embedding vector
|
||||
/// * `b` - Second embedding vector
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Cosine similarity value. Returns 0.0 if vectors have different lengths
|
||||
/// or if either vector has zero magnitude.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::cosine_similarity;
|
||||
///
|
||||
/// let a = vec![1.0, 0.0, 0.0];
|
||||
/// let b = vec![1.0, 0.0, 0.0];
|
||||
/// assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001); // Identical
|
||||
///
|
||||
/// let c = vec![0.0, 1.0, 0.0];
|
||||
/// assert!(cosine_similarity(&a, &c).abs() < 0.001); // Orthogonal
|
||||
/// ```
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() {
|
||||
return 0.0;
|
||||
@@ -118,7 +235,31 @@ pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
dot_product / (norm_a * norm_b)
|
||||
}
|
||||
|
||||
/// Compute Euclidean distance between two embeddings
|
||||
/// Compute Euclidean distance between two embeddings.
|
||||
///
|
||||
/// Euclidean distance is the straight-line distance between two points
|
||||
/// in n-dimensional space.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `a` - First embedding vector
|
||||
/// * `b` - Second embedding vector
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The L2 distance between vectors. Returns `f32::MAX` if vectors have
|
||||
/// different lengths.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::euclidean_distance;
|
||||
///
|
||||
/// let a = vec![0.0, 0.0];
|
||||
/// let b = vec![3.0, 4.0];
|
||||
/// let dist = euclidean_distance(&a, &b);
|
||||
/// assert!((dist - 5.0).abs() < 0.001); // Classic 3-4-5 triangle
|
||||
/// ```
|
||||
pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() {
|
||||
return f32::MAX;
|
||||
@@ -136,10 +277,34 @@ pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 {
|
||||
sum_sq_diff.sqrt()
|
||||
}
|
||||
|
||||
/// Convert cosine similarity to distance (for thresholding)
|
||||
///
|
||||
/// Cosine similarity ranges from -1 to 1, where 1 is identical.
|
||||
/// This converts it to a distance metric where 0 is identical.
|
||||
/// Convert cosine similarity to distance (for thresholding).
|
||||
///
|
||||
/// Cosine similarity ranges from -1 to 1, where 1 means identical.
|
||||
/// This function converts it to a distance metric where 0 means identical.
|
||||
///
|
||||
/// # Formula
|
||||
///
|
||||
/// `distance = 1.0 - similarity`
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `similarity` - Cosine similarity value (-1.0 to 1.0)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Distance value (0.0 to 2.0), where 0.0 indicates identical embeddings.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::similarity_to_distance;
|
||||
///
|
||||
/// // Identical vectors (similarity = 1.0) -> distance = 0.0
|
||||
/// assert_eq!(similarity_to_distance(1.0), 0.0);
|
||||
///
|
||||
/// // Orthogonal vectors (similarity = 0.0) -> distance = 1.0
|
||||
/// assert_eq!(similarity_to_distance(0.0), 1.0);
|
||||
/// ```
|
||||
pub fn similarity_to_distance(similarity: f32) -> f32 {
|
||||
1.0 - similarity
|
||||
}
|
||||
|
||||
@@ -1,16 +1,243 @@
|
||||
//! IPC Module
|
||||
//! IPC (Inter-Process Communication) Module
|
||||
//!
|
||||
//! Handles communication between the daemon and PAM module via Unix sockets.
|
||||
//! This module handles communication between the Linux Hello daemon and the
|
||||
//! PAM module (or CLI) via Unix domain sockets.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! The daemon exposes a Unix socket at `/run/linux-hello/auth.sock` that accepts
|
||||
//! JSON-formatted requests for authentication, enrollment, and management operations.
|
||||
//!
|
||||
//! # Security Features
|
||||
//!
|
||||
//! | Feature | Description |
|
||||
//! |---------|-------------|
|
||||
//! | Socket Permissions | Restricted to owner only (0o600) |
|
||||
//! | Peer Credentials | SO_PEERCRED verifies client identity |
|
||||
//! | Authorization | Operations validated against user permissions |
|
||||
//! | Rate Limiting | Prevents brute-force and DoS attacks |
|
||||
//! | Message Validation | Size limits prevent memory exhaustion |
|
||||
//!
|
||||
//! # Request Types
|
||||
//!
|
||||
//! - `authenticate` - Verify face against enrolled templates
|
||||
//! - `enroll` - Capture and store a new face template
|
||||
//! - `list` - List enrolled templates for a user
|
||||
//! - `remove` - Remove templates for a user
|
||||
//! - `ping` - Health check
|
||||
//!
|
||||
//! # Example: Client Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::ipc::{IpcClient, IpcRequest};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let client = IpcClient::default();
|
||||
//!
|
||||
//! // Check if daemon is running
|
||||
//! if client.ping().await.unwrap() {
|
||||
//! println!("Daemon is running");
|
||||
//! }
|
||||
//!
|
||||
//! // Authenticate a user
|
||||
//! let response = client.authenticate("alice").await.unwrap();
|
||||
//! if response.success {
|
||||
//! println!("Authentication successful!");
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Authorization Model
|
||||
//!
|
||||
//! - **Root (UID 0)**: Can perform any operation on any user
|
||||
//! - **Regular users**: Can only perform operations on their own account
|
||||
//! - **Authentication**: PAM module runs as root during login
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use linux_hello_common::error::{Error, Result};
|
||||
|
||||
// Security constants
|
||||
/// Maximum message size (64KB) to prevent memory exhaustion attacks
|
||||
const MAX_MESSAGE_SIZE: usize = 64 * 1024;
|
||||
/// Maximum connections per second from a single peer
|
||||
const MAX_CONNECTIONS_PER_SECOND: u32 = 10;
|
||||
/// Rate limit window duration
|
||||
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1);
|
||||
/// Backoff duration after rate limit exceeded
|
||||
const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Peer credentials from SO_PEERCRED
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PeerCredentials {
|
||||
pub pid: i32,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
}
|
||||
|
||||
impl PeerCredentials {
|
||||
/// Get peer credentials from a Unix stream using SO_PEERCRED
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn from_stream(stream: &UnixStream) -> Result<Self> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let fd = stream.as_raw_fd();
|
||||
let mut ucred: libc::ucred = unsafe { std::mem::zeroed() };
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
|
||||
let ret = unsafe {
|
||||
libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
&mut ucred as *mut _ as *mut libc::c_void,
|
||||
&mut len,
|
||||
)
|
||||
};
|
||||
|
||||
if ret != 0 {
|
||||
return Err(Error::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
pid: ucred.pid,
|
||||
uid: ucred.uid,
|
||||
gid: ucred.gid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the peer is root (UID 0)
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.uid == 0
|
||||
}
|
||||
|
||||
/// Check if the peer can operate on the target user
|
||||
/// Returns true if peer is root OR peer UID matches target user's UID
|
||||
pub fn can_operate_on_user(&self, target_username: &str) -> bool {
|
||||
if self.is_root() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Look up the target user's UID
|
||||
match Self::get_uid_for_username(target_username) {
|
||||
Some(target_uid) => self.uid == target_uid,
|
||||
None => false, // User doesn't exist, deny access
|
||||
}
|
||||
}
|
||||
|
||||
/// Get UID for a username using libc
|
||||
fn get_uid_for_username(username: &str) -> Option<u32> {
|
||||
use std::ffi::CString;
|
||||
|
||||
let c_username = CString::new(username).ok()?;
|
||||
let passwd = unsafe { libc::getpwnam(c_username.as_ptr()) };
|
||||
|
||||
if passwd.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { (*passwd).pw_uid })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiter for tracking connection attempts per peer
|
||||
#[derive(Debug)]
|
||||
pub struct RateLimiter {
|
||||
/// Map of peer UID to (connection count, window start, backoff until)
|
||||
connections: HashMap<u32, (u32, Instant, Option<Instant>)>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a connection from the given UID should be allowed
|
||||
/// Returns Ok(()) if allowed, Err with message if rate limited
|
||||
pub fn check_rate_limit(&mut self, uid: u32) -> std::result::Result<(), String> {
|
||||
let now = Instant::now();
|
||||
|
||||
// Clean up old entries periodically
|
||||
self.cleanup_old_entries(now);
|
||||
|
||||
let entry = self.connections.entry(uid).or_insert((0, now, None));
|
||||
|
||||
// Check if in backoff period
|
||||
if let Some(backoff_until) = entry.2 {
|
||||
if now < backoff_until {
|
||||
let remaining = backoff_until.duration_since(now);
|
||||
return Err(format!(
|
||||
"Rate limited. Try again in {} seconds",
|
||||
remaining.as_secs()
|
||||
));
|
||||
}
|
||||
// Backoff period expired, reset
|
||||
entry.2 = None;
|
||||
}
|
||||
|
||||
// Check if we're in a new window
|
||||
if now.duration_since(entry.1) > RATE_LIMIT_WINDOW {
|
||||
entry.0 = 0;
|
||||
entry.1 = now;
|
||||
}
|
||||
|
||||
entry.0 += 1;
|
||||
|
||||
if entry.0 > MAX_CONNECTIONS_PER_SECOND {
|
||||
// Enter backoff period
|
||||
entry.2 = Some(now + RATE_LIMIT_BACKOFF);
|
||||
return Err(format!(
|
||||
"Rate limit exceeded. Backing off for {} seconds",
|
||||
RATE_LIMIT_BACKOFF.as_secs()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a failed authentication attempt for exponential backoff
|
||||
pub fn record_failure(&mut self, uid: u32) {
|
||||
let now = Instant::now();
|
||||
let entry = self.connections.entry(uid).or_insert((0, now, None));
|
||||
|
||||
// Apply exponential backoff on failures
|
||||
let current_backoff = entry.2.map(|b| b.duration_since(now)).unwrap_or(Duration::ZERO);
|
||||
let new_backoff = if current_backoff == Duration::ZERO {
|
||||
RATE_LIMIT_BACKOFF
|
||||
} else {
|
||||
// Double the backoff, max 60 seconds
|
||||
std::cmp::min(current_backoff * 2, Duration::from_secs(60))
|
||||
};
|
||||
entry.2 = Some(now + new_backoff);
|
||||
}
|
||||
|
||||
/// Clean up entries older than 60 seconds to prevent memory growth
|
||||
fn cleanup_old_entries(&mut self, now: Instant) {
|
||||
self.connections.retain(|_, (_, window_start, backoff)| {
|
||||
let window_active = now.duration_since(*window_start) < Duration::from_secs(60);
|
||||
let backoff_active = backoff.map(|b| now < b).unwrap_or(false);
|
||||
window_active || backoff_active
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// IPC message types
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action")]
|
||||
@@ -75,6 +302,7 @@ pub struct IpcServer {
|
||||
enroll_handler: Option<EnrollHandler>,
|
||||
list_handler: Option<ListHandler>,
|
||||
remove_handler: Option<RemoveHandler>,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
@@ -86,6 +314,7 @@ impl IpcServer {
|
||||
enroll_handler: None,
|
||||
list_handler: None,
|
||||
remove_handler: None,
|
||||
rate_limiter: Arc::new(Mutex::new(RateLimiter::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +367,14 @@ impl IpcServer {
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&self.socket_path)?;
|
||||
|
||||
// Set socket permissions (readable/writable by owner and group)
|
||||
|
||||
// Set socket permissions - SECURITY: owner only (0o600) to prevent unauthorized access
|
||||
// This is more restrictive than the previous 0o660 which allowed group access
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&self.socket_path)?.permissions();
|
||||
perms.set_mode(0o660);
|
||||
perms.set_mode(0o600);
|
||||
std::fs::set_permissions(&self.socket_path, perms)?;
|
||||
}
|
||||
|
||||
@@ -153,10 +383,32 @@ impl IpcServer {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _addr)) => {
|
||||
// Get peer credentials for authentication and rate limiting
|
||||
let peer_creds = match PeerCredentials::from_stream(&stream) {
|
||||
Ok(creds) => creds,
|
||||
Err(e) => {
|
||||
warn!("Failed to get peer credentials: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit before processing
|
||||
{
|
||||
let mut rate_limiter = self.rate_limiter.lock().await;
|
||||
if let Err(msg) = rate_limiter.check_rate_limit(peer_creds.uid) {
|
||||
warn!("Rate limited connection from UID {}: {}", peer_creds.uid, msg);
|
||||
// Send rate limit response and close connection
|
||||
let _ = Self::send_error_response(stream, &msg).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let auth_handler = self.auth_handler.clone();
|
||||
let enroll_handler = self.enroll_handler.clone();
|
||||
let list_handler = self.list_handler.clone();
|
||||
let remove_handler = self.remove_handler.clone();
|
||||
let rate_limiter = self.rate_limiter.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::handle_client(
|
||||
stream,
|
||||
@@ -164,6 +416,8 @@ impl IpcServer {
|
||||
enroll_handler,
|
||||
list_handler,
|
||||
remove_handler,
|
||||
peer_creds,
|
||||
rate_limiter,
|
||||
).await {
|
||||
warn!("Error handling client: {}", e);
|
||||
}
|
||||
@@ -176,47 +430,105 @@ impl IpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an error response to the client
|
||||
async fn send_error_response(mut stream: UnixStream, message: &str) -> Result<()> {
|
||||
let response = IpcResponse {
|
||||
success: false,
|
||||
message: Some(message.to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
};
|
||||
let response_json = serde_json::to_string(&response)
|
||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||
stream.write_all(response_json.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
mut stream: UnixStream,
|
||||
auth_handler: Option<AuthHandler>,
|
||||
enroll_handler: Option<EnrollHandler>,
|
||||
list_handler: Option<ListHandler>,
|
||||
remove_handler: Option<RemoveHandler>,
|
||||
peer_creds: PeerCredentials,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
) -> Result<()> {
|
||||
let mut buffer = vec![0u8; 4096];
|
||||
// SECURITY: Read message with size validation
|
||||
// First, read up to MAX_MESSAGE_SIZE bytes
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_SIZE];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
|
||||
|
||||
if n == 0 {
|
||||
return Ok(()); // Connection closed
|
||||
}
|
||||
|
||||
// SECURITY: Validate message size
|
||||
if n >= MAX_MESSAGE_SIZE {
|
||||
warn!(
|
||||
"Message size limit exceeded from UID {} (PID {}), rejecting",
|
||||
peer_creds.uid, peer_creds.pid
|
||||
);
|
||||
let response = IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!(
|
||||
"Message too large. Maximum size is {} bytes",
|
||||
MAX_MESSAGE_SIZE
|
||||
)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
};
|
||||
let response_json = serde_json::to_string(&response)
|
||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||
stream.write_all(response_json.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let request_str = String::from_utf8_lossy(&buffer[..n]);
|
||||
let request: IpcRequest = serde_json::from_str(&request_str)
|
||||
.map_err(|e| Error::Serialization(format!("Invalid request: {}", e)))?;
|
||||
|
||||
info!(
|
||||
"IPC request from UID {} (PID {}): {:?}",
|
||||
peer_creds.uid, peer_creds.pid, request
|
||||
);
|
||||
|
||||
let response = match request {
|
||||
IpcRequest::Authenticate { user } => {
|
||||
// Authentication requests are allowed from any authenticated connection
|
||||
// The PAM module runs as root when performing authentication
|
||||
match auth_handler {
|
||||
Some(ref h) => {
|
||||
match h(user).await {
|
||||
match h(user.clone()).await {
|
||||
Ok(true) => IpcResponse {
|
||||
success: true,
|
||||
message: Some("Authentication successful".to_string()),
|
||||
confidence: Some(1.0),
|
||||
templates: None,
|
||||
},
|
||||
Ok(false) => IpcResponse {
|
||||
success: false,
|
||||
message: Some("Authentication failed".to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
Ok(false) => {
|
||||
// Record failed authentication for rate limiting
|
||||
let mut limiter = rate_limiter.lock().await;
|
||||
limiter.record_failure(peer_creds.uid);
|
||||
IpcResponse {
|
||||
success: false,
|
||||
message: Some("Authentication failed".to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Record failure for rate limiting
|
||||
let mut limiter = rate_limiter.lock().await;
|
||||
limiter.record_failure(peer_creds.uid);
|
||||
IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
@@ -228,81 +540,147 @@ impl IpcServer {
|
||||
}
|
||||
}
|
||||
IpcRequest::Enroll { user, label, frame_count } => {
|
||||
match enroll_handler {
|
||||
Some(ref h) => {
|
||||
match h(user.clone(), label.clone(), frame_count).await {
|
||||
Ok(()) => IpcResponse {
|
||||
success: true,
|
||||
message: Some(format!("Enrollment successful for user: {}", user)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Enrollment failed: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
// SECURITY: Authorization check for enrollment
|
||||
// Only root or the user themselves can enroll faces
|
||||
if !peer_creds.can_operate_on_user(&user) {
|
||||
warn!(
|
||||
"Unauthorized enrollment attempt: UID {} tried to enroll user '{}'",
|
||||
peer_creds.uid, user
|
||||
);
|
||||
IpcResponse {
|
||||
success: false,
|
||||
message: Some("Enrollment handler not set".to_string()),
|
||||
message: Some(format!(
|
||||
"Permission denied: only root or user '{}' can enroll faces for this account",
|
||||
user
|
||||
)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match enroll_handler {
|
||||
Some(ref h) => {
|
||||
match h(user.clone(), label.clone(), frame_count).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Enrollment successful for user '{}' by UID {}",
|
||||
user, peer_creds.uid
|
||||
);
|
||||
IpcResponse {
|
||||
success: true,
|
||||
message: Some(format!("Enrollment successful for user: {}", user)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
}
|
||||
}
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Enrollment failed: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
success: false,
|
||||
message: Some("Enrollment handler not set".to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
IpcRequest::List { user } => {
|
||||
match list_handler {
|
||||
Some(ref h) => {
|
||||
match h(user).await {
|
||||
Ok(templates) => IpcResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
confidence: None,
|
||||
templates: Some(templates),
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
// SECURITY: Authorization check for listing templates
|
||||
// Only root or the user themselves can list their templates
|
||||
if !peer_creds.can_operate_on_user(&user) {
|
||||
warn!(
|
||||
"Unauthorized list attempt: UID {} tried to list templates for user '{}'",
|
||||
peer_creds.uid, user
|
||||
);
|
||||
IpcResponse {
|
||||
success: false,
|
||||
message: Some("List handler not set".to_string()),
|
||||
message: Some(format!(
|
||||
"Permission denied: only root or user '{}' can list templates for this account",
|
||||
user
|
||||
)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match list_handler {
|
||||
Some(ref h) => {
|
||||
match h(user).await {
|
||||
Ok(templates) => IpcResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
confidence: None,
|
||||
templates: Some(templates),
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
success: false,
|
||||
message: Some("List handler not set".to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
IpcRequest::Remove { user, label, all } => {
|
||||
match remove_handler {
|
||||
Some(ref h) => {
|
||||
match h(user.clone(), label, all).await {
|
||||
Ok(()) => IpcResponse {
|
||||
success: true,
|
||||
message: Some(format!("Templates removed for user: {}", user)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
// SECURITY: Authorization check for template removal
|
||||
// Only root or the user themselves can remove their templates
|
||||
if !peer_creds.can_operate_on_user(&user) {
|
||||
warn!(
|
||||
"Unauthorized remove attempt: UID {} tried to remove templates for user '{}'",
|
||||
peer_creds.uid, user
|
||||
);
|
||||
IpcResponse {
|
||||
success: false,
|
||||
message: Some("Remove handler not set".to_string()),
|
||||
message: Some(format!(
|
||||
"Permission denied: only root or user '{}' can remove templates for this account",
|
||||
user
|
||||
)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match remove_handler {
|
||||
Some(ref h) => {
|
||||
match h(user.clone(), label, all).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Templates removed for user '{}' by UID {}",
|
||||
user, peer_creds.uid
|
||||
);
|
||||
IpcResponse {
|
||||
success: true,
|
||||
message: Some(format!("Templates removed for user: {}", user)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
}
|
||||
}
|
||||
Err(e) => IpcResponse {
|
||||
success: false,
|
||||
message: Some(format!("Error: {}", e)),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
None => IpcResponse {
|
||||
success: false,
|
||||
message: Some("Remove handler not set".to_string()),
|
||||
confidence: None,
|
||||
templates: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
IpcRequest::Ping => IpcResponse {
|
||||
@@ -315,7 +693,7 @@ impl IpcServer {
|
||||
|
||||
let response_json = serde_json::to_string(&response)
|
||||
.map_err(|e| Error::Serialization(e.to_string()))?;
|
||||
|
||||
|
||||
stream.write_all(response_json.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
|
||||
@@ -1,11 +1,87 @@
|
||||
//! Linux Hello Daemon Library
|
||||
//!
|
||||
//! Core functionality for camera capture and face detection.
|
||||
//! Re-exported for use by the CLI tool.
|
||||
//! This crate provides the core functionality for the Linux Hello facial
|
||||
//! authentication system, including camera capture, face detection, embedding
|
||||
//! extraction, template matching, and anti-spoofing.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The daemon is structured in a pipeline architecture:
|
||||
//!
|
||||
//! ```text
|
||||
//! Camera Capture -> Face Detection -> Anti-Spoofing -> Embedding Extraction -> Template Matching
|
||||
//! | | | | |
|
||||
//! camera/ detection/ anti_spoofing/ embedding/ matching/
|
||||
//! ```
|
||||
//!
|
||||
//! # Modules
|
||||
//!
|
||||
//! - [`camera`] - V4L2 camera enumeration and frame capture
|
||||
//! - [`detection`] - Face detection using ML models
|
||||
//! - [`anti_spoofing`] - Liveness detection to prevent photo/video attacks
|
||||
//! - [`embedding`] - Face embedding extraction from detected faces
|
||||
//! - [`matching`] - Template matching using distance metrics
|
||||
//! - [`secure_memory`] - Memory-safe containers for sensitive data
|
||||
//! - [`tpm`] - TPM2 hardware encryption for templates
|
||||
//! - [`ipc`] - Unix socket communication with PAM module
|
||||
//! - [`dbus_server`] - D-Bus service for system integration
|
||||
//! - [`auth`] - High-level authentication service
|
||||
//! - `onnx` - ONNX model integration (requires `onnx` feature)
|
||||
//!
|
||||
//! # Feature Flags
|
||||
//!
|
||||
//! - `onnx` - Enable ONNX model-based face detection and embedding extraction
|
||||
//! - `tpm` - Enable TPM-based secure key storage
|
||||
//!
|
||||
//! # Security Features
|
||||
//!
|
||||
//! The daemon implements multiple security layers:
|
||||
//!
|
||||
//! 1. **IR Camera Requirement** - Only infrared cameras are accepted
|
||||
//! 2. **Anti-Spoofing** - Multiple liveness checks (depth, texture, movement)
|
||||
//! 3. **Secure Memory** - Embeddings are zeroized on drop
|
||||
//! 4. **TPM Encryption** - Templates encrypted with hardware-bound keys
|
||||
//! 5. **IPC Authorization** - Socket permissions and peer credential checks
|
||||
//! 6. **Rate Limiting** - Protection against brute-force attacks
|
||||
//!
|
||||
//! # Example: Authentication Flow
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::{
|
||||
//! Camera, enumerate_cameras, FaceDetection, FaceDetect,
|
||||
//! SimpleFaceDetector, PlaceholderEmbeddingExtractor,
|
||||
//! EmbeddingExtractor, match_template, MatchResult,
|
||||
//! };
|
||||
//! use linux_hello_common::{Config, TemplateStore};
|
||||
//!
|
||||
//! // 1. Find IR camera
|
||||
//! let cameras = enumerate_cameras().expect("Failed to enumerate cameras");
|
||||
//! let ir_camera = cameras.iter().find(|c| c.is_ir).expect("No IR camera");
|
||||
//!
|
||||
//! // 2. Capture frame
|
||||
//! let mut camera = Camera::open(&ir_camera.device_path).expect("Failed to open camera");
|
||||
//! camera.start().expect("Failed to start capture");
|
||||
//! let frame = camera.capture_frame().expect("Failed to capture frame");
|
||||
//!
|
||||
//! // 3. Detect face
|
||||
//! let detector = SimpleFaceDetector::new(0.5);
|
||||
//! let detections = detector.detect(&frame.data, frame.width, frame.height)
|
||||
//! .expect("Detection failed");
|
||||
//!
|
||||
//! // 4. Extract embedding and match against stored templates
|
||||
//! // ... (see auth module for complete flow)
|
||||
//! ```
|
||||
//!
|
||||
//! # Platform Support
|
||||
//!
|
||||
//! - **Linux** - Full support with V4L2 camera access
|
||||
//! - **Other platforms** - Mock camera for development/testing
|
||||
|
||||
pub mod anti_spoofing;
|
||||
pub mod auth;
|
||||
pub mod camera;
|
||||
pub mod dbus_server;
|
||||
pub mod dbus_service;
|
||||
pub mod detection;
|
||||
pub mod embedding;
|
||||
pub mod ipc;
|
||||
@@ -14,18 +90,76 @@ pub mod secure_memory;
|
||||
pub mod secure_template_store;
|
||||
pub mod tpm;
|
||||
|
||||
/// ONNX model integration for face detection and embedding extraction.
|
||||
///
|
||||
/// This module provides high-accuracy face recognition using ONNX Runtime
|
||||
/// with models like RetinaFace (detection) and MobileFaceNet (embedding).
|
||||
///
|
||||
/// # Feature Flag
|
||||
///
|
||||
/// Enable with the `onnx` feature:
|
||||
///
|
||||
/// ```toml
|
||||
/// [dependencies]
|
||||
/// linux-hello-daemon = { version = "0.1", features = ["onnx"] }
|
||||
/// ```
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use linux_hello_daemon::onnx::{OnnxPipeline, OnnxModelConfig};
|
||||
///
|
||||
/// // Load models
|
||||
/// let pipeline = OnnxPipeline::load(
|
||||
/// "models/retinaface.onnx",
|
||||
/// "models/mobilefacenet.onnx",
|
||||
/// )?;
|
||||
///
|
||||
/// // Process frame
|
||||
/// let embedding = pipeline.process_best_face(&frame_data, width, height)?;
|
||||
/// ```
|
||||
#[cfg(feature = "onnx")]
|
||||
pub mod onnx;
|
||||
|
||||
// Re-export anti-spoofing types
|
||||
pub use anti_spoofing::{AntiSpoofingConfig, AntiSpoofingDetector, LivenessResult};
|
||||
|
||||
// Re-export secure memory types
|
||||
pub use secure_memory::{SecureBytes, SecureEmbedding};
|
||||
|
||||
// Re-export secure template store
|
||||
pub use secure_template_store::SecureTemplateStore;
|
||||
|
||||
// Re-export camera types
|
||||
pub use camera::{CameraInfo, Frame, PixelFormat};
|
||||
|
||||
// Re-export detection types
|
||||
pub use detection::{FaceDetection, FaceDetect, detect_face_simple, SimpleFaceDetector};
|
||||
|
||||
// Re-export embedding types and functions
|
||||
pub use embedding::{
|
||||
cosine_similarity, euclidean_distance, EmbeddingExtractor, PlaceholderEmbeddingExtractor,
|
||||
similarity_to_distance,
|
||||
};
|
||||
|
||||
// Re-export matching types and functions
|
||||
pub use matching::{average_embeddings, match_template, MatchResult};
|
||||
|
||||
// Re-export IPC types
|
||||
pub use ipc::{IpcClient, IpcRequest, IpcResponse, IpcServer};
|
||||
|
||||
// Re-export D-Bus types
|
||||
pub use dbus_server::{DbusServer, run_dbus_service, check_system_bus_available, SERVICE_NAME, OBJECT_PATH};
|
||||
pub use dbus_service::LinuxHelloManager;
|
||||
|
||||
// Linux-specific camera exports
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use camera::{enumerate_cameras, Camera};
|
||||
|
||||
// ONNX model exports (when feature enabled)
|
||||
#[cfg(feature = "onnx")]
|
||||
pub use onnx::{
|
||||
OnnxFaceDetector, OnnxEmbeddingExtractor, FaceAligner,
|
||||
OnnxPipeline, OnnxModelConfig, DetectionWithLandmarks,
|
||||
REFERENCE_LANDMARKS_112,
|
||||
};
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
//!
|
||||
//! Main daemon process for face authentication. Handles camera capture,
|
||||
//! face detection, anti-spoofing checks, and template matching.
|
||||
//!
|
||||
//! The daemon provides two communication interfaces:
|
||||
//! - IPC (Unix socket): For PAM module authentication
|
||||
//! - D-Bus: For desktop applications and system integration
|
||||
|
||||
mod camera;
|
||||
mod detection;
|
||||
|
||||
use linux_hello_common::{Config, Result, TemplateStore};
|
||||
use linux_hello_daemon::auth::AuthService;
|
||||
use linux_hello_daemon::dbus_server::{DbusServer, check_system_bus_available};
|
||||
use linux_hello_daemon::ipc::IpcServer;
|
||||
use tracing::{error, info, Level};
|
||||
use tracing::{error, info, warn, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -40,7 +45,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Camera enumeration failed: {}", e);
|
||||
warn!("Camera enumeration failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +66,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Start IPC server
|
||||
let mut ipc_server = IpcServer::new(IpcServer::default_socket_path());
|
||||
|
||||
|
||||
// Set authentication handler
|
||||
let auth_service_for_auth = auth_service.clone();
|
||||
ipc_server.set_auth_handler(move |user| {
|
||||
@@ -102,13 +107,40 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize D-Bus server (optional - will fail gracefully if system bus unavailable)
|
||||
let dbus_enabled = check_system_bus_available().await;
|
||||
let mut dbus_server = DbusServer::new();
|
||||
|
||||
if dbus_enabled {
|
||||
match dbus_server.start(auth_service.clone(), config.clone()).await {
|
||||
Ok(()) => {
|
||||
info!("D-Bus server started successfully");
|
||||
info!(" Service: org.linuxhello.Daemon");
|
||||
info!(" Object path: /org/linuxhello/Manager");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to start D-Bus server: {}", e);
|
||||
warn!("D-Bus interface will not be available");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("System D-Bus not available, skipping D-Bus server");
|
||||
}
|
||||
|
||||
info!("Linux Hello Daemon ready");
|
||||
info!("Listening for authentication requests...");
|
||||
if dbus_enabled && dbus_server.is_connected() {
|
||||
info!(" - IPC: {}", IpcServer::default_socket_path().display());
|
||||
info!(" - D-Bus: org.linuxhello.Daemon");
|
||||
} else {
|
||||
info!(" - IPC: {}", IpcServer::default_socket_path().display());
|
||||
}
|
||||
|
||||
// Start IPC server in background
|
||||
// Start IPC server as a task
|
||||
let ipc_future = ipc_server.start();
|
||||
|
||||
// Wait for shutdown signal
|
||||
|
||||
// Wait for shutdown signal or server error
|
||||
// Both IPC and D-Bus run concurrently using tokio
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Shutdown signal received");
|
||||
|
||||
@@ -1,24 +1,111 @@
|
||||
//! Template Matching Module
|
||||
//!
|
||||
//! Matches face embeddings against stored templates using distance metrics.
|
||||
//! This module matches face embeddings against stored templates to verify identity.
|
||||
//! It is the final step in the authentication pipeline.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Template matching compares a newly extracted face embedding against previously
|
||||
//! stored templates. If the distance is below the configured threshold, the
|
||||
//! user is authenticated.
|
||||
//!
|
||||
//! # Matching Algorithm
|
||||
//!
|
||||
//! 1. Compute cosine similarity between probe embedding and each stored template
|
||||
//! 2. Convert similarity to distance: `distance = 1.0 - similarity`
|
||||
//! 3. Compare best distance against threshold
|
||||
//! 4. Return match result with confidence information
|
||||
//!
|
||||
//! # Security Considerations
|
||||
//!
|
||||
//! - **Threshold Selection**: Lower thresholds are more secure but may reject valid users
|
||||
//! - **Multiple Templates**: Users can enroll multiple templates for different conditions
|
||||
//! - **Template Quality**: Better enrollment produces more accurate matching
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use linux_hello_daemon::{match_template, MatchResult};
|
||||
//! use linux_hello_common::FaceTemplate;
|
||||
//!
|
||||
//! // Create a stored template
|
||||
//! let template = FaceTemplate {
|
||||
//! user: "alice".to_string(),
|
||||
//! label: "default".to_string(),
|
||||
//! embedding: vec![1.0, 0.0, 0.0], // Simplified example
|
||||
//! enrolled_at: 0,
|
||||
//! frame_count: 1,
|
||||
//! };
|
||||
//!
|
||||
//! // Match a probe embedding
|
||||
//! let probe = vec![1.0, 0.0, 0.0]; // Identical to template
|
||||
//! let result = match_template(&probe, &[template], 0.5);
|
||||
//!
|
||||
//! assert!(result.matched);
|
||||
//! assert!(result.best_similarity > 0.99);
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::{FaceTemplate, Result};
|
||||
use crate::embedding::{cosine_similarity, similarity_to_distance};
|
||||
|
||||
/// Result of matching against templates
|
||||
/// Result of matching a probe embedding against stored templates.
|
||||
///
|
||||
/// Contains information about whether a match was found, the best
|
||||
/// similarity score, and which template matched (if any).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchResult {
|
||||
/// Whether a match was found
|
||||
/// Whether the probe matched any stored template.
|
||||
pub matched: bool,
|
||||
/// Best similarity score (cosine similarity, 0-1)
|
||||
/// Highest cosine similarity score among all templates (0.0-1.0).
|
||||
/// Higher values indicate closer matches.
|
||||
pub best_similarity: f32,
|
||||
/// Distance threshold used
|
||||
/// The distance threshold used for matching.
|
||||
/// A match occurs when `1.0 - best_similarity <= distance_threshold`.
|
||||
pub distance_threshold: f32,
|
||||
/// Matched template label (if matched)
|
||||
/// Label of the matched template, if any.
|
||||
/// `None` if no match was found.
|
||||
pub matched_label: Option<String>,
|
||||
}
|
||||
|
||||
/// Match a face embedding against stored templates
|
||||
/// Match a face embedding against stored templates.
|
||||
///
|
||||
/// Compares the probe embedding against all provided templates and returns
|
||||
/// the best match result.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `embedding` - The probe embedding to match (from current face)
|
||||
/// * `templates` - Stored templates to match against
|
||||
/// * `distance_threshold` - Maximum cosine distance for a match (0.0-2.0)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A [`MatchResult`] indicating whether a match was found and match details.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::match_template;
|
||||
/// use linux_hello_common::FaceTemplate;
|
||||
///
|
||||
/// let templates = vec![
|
||||
/// FaceTemplate {
|
||||
/// user: "alice".to_string(),
|
||||
/// label: "default".to_string(),
|
||||
/// embedding: vec![1.0, 0.0, 0.0],
|
||||
/// enrolled_at: 0,
|
||||
/// frame_count: 1,
|
||||
/// },
|
||||
/// ];
|
||||
///
|
||||
/// // Exact match
|
||||
/// let result = match_template(&vec![1.0, 0.0, 0.0], &templates, 0.5);
|
||||
/// assert!(result.matched);
|
||||
///
|
||||
/// // No match (different vector)
|
||||
/// let result = match_template(&vec![0.0, 0.0, 1.0], &templates, 0.1);
|
||||
/// assert!(!result.matched);
|
||||
/// ```
|
||||
pub fn match_template(
|
||||
embedding: &[f32],
|
||||
templates: &[FaceTemplate],
|
||||
@@ -57,7 +144,40 @@ pub fn match_template(
|
||||
}
|
||||
}
|
||||
|
||||
/// Average multiple embeddings (for enrollment)
|
||||
/// Average multiple embeddings to create a robust template.
|
||||
///
|
||||
/// During enrollment, multiple face frames are captured and their embeddings
|
||||
/// averaged. This produces a more robust template that handles natural
|
||||
/// facial variations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `embeddings` - Vector of embeddings to average (must all have same dimension)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A normalized average embedding. Returns an error if:
|
||||
/// - Input is empty
|
||||
/// - Embeddings have different dimensions
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use linux_hello_daemon::average_embeddings;
|
||||
///
|
||||
/// let embeddings = vec![
|
||||
/// vec![1.0, 0.0, 0.0],
|
||||
/// vec![0.0, 1.0, 0.0],
|
||||
/// vec![0.0, 0.0, 1.0],
|
||||
/// ];
|
||||
///
|
||||
/// let avg = average_embeddings(&embeddings).unwrap();
|
||||
/// assert_eq!(avg.len(), 3);
|
||||
///
|
||||
/// // Result is normalized
|
||||
/// let norm: f32 = avg.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
/// assert!((norm - 1.0).abs() < 0.01);
|
||||
/// ```
|
||||
pub fn average_embeddings(embeddings: &[Vec<f32>]) -> Result<Vec<f32>> {
|
||||
if embeddings.is_empty() {
|
||||
return Err(linux_hello_common::Error::Detection(
|
||||
|
||||
451
linux-hello-daemon/src/onnx/alignment.rs
Normal file
451
linux-hello-daemon/src/onnx/alignment.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
//! Face Alignment Module
|
||||
//!
|
||||
//! Provides utilities for face alignment using detected facial landmarks.
|
||||
//! Alignment normalizes face images to a standard pose for embedding extraction.
|
||||
//!
|
||||
//! # Standard Face Normalization
|
||||
//!
|
||||
//! The standard aligned face is 112x112 pixels with landmarks at fixed positions:
|
||||
//! - Left eye center: (38.29, 51.70)
|
||||
//! - Right eye center: (73.53, 51.50)
|
||||
//! - Nose tip: (56.03, 71.74)
|
||||
//! - Left mouth corner: (41.55, 92.37)
|
||||
//! - Right mouth corner: (70.73, 92.20)
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! Uses similarity transformation (rotation, scale, translation) estimated from
|
||||
//! facial landmarks to warp the face to the reference positions.
|
||||
|
||||
use linux_hello_common::{Error, Result};
|
||||
|
||||
/// Reference landmark positions for 112x112 aligned face (ArcFace standard)
|
||||
pub const REFERENCE_LANDMARKS_112: [[f32; 2]; 5] = [
|
||||
[38.2946, 51.6963], // Left eye center
|
||||
[73.5318, 51.5014], // Right eye center
|
||||
[56.0252, 71.7366], // Nose tip
|
||||
[41.5493, 92.3655], // Left mouth corner
|
||||
[70.7299, 92.2041], // Right mouth corner
|
||||
];
|
||||
|
||||
/// Reference landmark positions for 96x96 aligned face (alternative format)
|
||||
pub const REFERENCE_LANDMARKS_96: [[f32; 2]; 5] = [
|
||||
[30.2946, 51.6963],
|
||||
[65.5318, 51.5014],
|
||||
[48.0252, 71.7366],
|
||||
[33.5493, 92.3655],
|
||||
[62.7299, 92.2041],
|
||||
];
|
||||
|
||||
/// Face alignment utility
|
||||
///
|
||||
/// Aligns detected faces using 5-point landmarks to produce normalized
|
||||
/// face images suitable for embedding extraction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FaceAligner {
|
||||
/// Output image width
|
||||
output_width: u32,
|
||||
/// Output image height
|
||||
output_height: u32,
|
||||
/// Reference landmarks for target alignment
|
||||
reference_landmarks: [[f32; 2]; 5],
|
||||
}
|
||||
|
||||
impl Default for FaceAligner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceAligner {
|
||||
/// Create a new face aligner with standard 112x112 output
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
output_width: 112,
|
||||
output_height: 112,
|
||||
reference_landmarks: REFERENCE_LANDMARKS_112,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create aligner with custom output size
|
||||
pub fn with_size(width: u32, height: u32) -> Self {
|
||||
// Scale reference landmarks to new size
|
||||
let scale_x = width as f32 / 112.0;
|
||||
let scale_y = height as f32 / 112.0;
|
||||
|
||||
let mut reference = REFERENCE_LANDMARKS_112;
|
||||
for lm in &mut reference {
|
||||
lm[0] *= scale_x;
|
||||
lm[1] *= scale_y;
|
||||
}
|
||||
|
||||
Self {
|
||||
output_width: width,
|
||||
output_height: height,
|
||||
reference_landmarks: reference,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the output dimensions
|
||||
pub fn output_size(&self) -> (u32, u32) {
|
||||
(self.output_width, self.output_height)
|
||||
}
|
||||
|
||||
/// Align a face using detected landmarks
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `image_data` - Grayscale image data
|
||||
/// * `width` - Image width
|
||||
/// * `height` - Image height
|
||||
/// * `landmarks` - 5-point facial landmarks in pixel coordinates
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Aligned face image as grayscale bytes (output_width x output_height)
|
||||
pub fn align(
|
||||
&self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
landmarks: &[[f32; 2]; 5],
|
||||
) -> Result<Vec<u8>> {
|
||||
// Validate input
|
||||
let expected_size = (width * height) as usize;
|
||||
if image_data.len() != expected_size {
|
||||
return Err(Error::Detection(format!(
|
||||
"Image data size mismatch: expected {}, got {}",
|
||||
expected_size,
|
||||
image_data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Estimate similarity transformation matrix
|
||||
let transform = self.estimate_similarity_transform(landmarks)?;
|
||||
|
||||
// Apply transformation to produce aligned image
|
||||
let aligned = self.warp_affine(image_data, width, height, &transform);
|
||||
|
||||
Ok(aligned)
|
||||
}
|
||||
|
||||
/// Estimate similarity transformation from source to reference landmarks
|
||||
///
|
||||
/// Uses least squares to find the best rotation, scale, and translation
|
||||
/// that maps source landmarks to reference landmarks.
|
||||
fn estimate_similarity_transform(
|
||||
&self,
|
||||
src_landmarks: &[[f32; 2]; 5],
|
||||
) -> Result<SimilarityTransform> {
|
||||
// Compute centroids
|
||||
let (src_cx, src_cy) = self.centroid(src_landmarks);
|
||||
let (ref_cx, ref_cy) = self.centroid(&self.reference_landmarks);
|
||||
|
||||
// Center the points
|
||||
let mut src_centered = [[0.0f32; 2]; 5];
|
||||
let mut ref_centered = [[0.0f32; 2]; 5];
|
||||
|
||||
for i in 0..5 {
|
||||
src_centered[i][0] = src_landmarks[i][0] - src_cx;
|
||||
src_centered[i][1] = src_landmarks[i][1] - src_cy;
|
||||
ref_centered[i][0] = self.reference_landmarks[i][0] - ref_cx;
|
||||
ref_centered[i][1] = self.reference_landmarks[i][1] - ref_cy;
|
||||
}
|
||||
|
||||
// Compute scale and rotation using SVD-like approach
|
||||
// For 2D similarity transform: ref = scale * R * src + t
|
||||
// Where R is rotation matrix
|
||||
|
||||
let mut num_a = 0.0f32;
|
||||
let mut num_b = 0.0f32;
|
||||
let mut denom = 0.0f32;
|
||||
|
||||
for i in 0..5 {
|
||||
let sx = src_centered[i][0];
|
||||
let sy = src_centered[i][1];
|
||||
let rx = ref_centered[i][0];
|
||||
let ry = ref_centered[i][1];
|
||||
|
||||
// For similarity transform: rx = a*sx - b*sy, ry = b*sx + a*sy
|
||||
// where a = scale*cos(theta), b = scale*sin(theta)
|
||||
num_a += sx * rx + sy * ry;
|
||||
num_b += sx * ry - sy * rx;
|
||||
denom += sx * sx + sy * sy;
|
||||
}
|
||||
|
||||
if denom < 1e-6 {
|
||||
return Err(Error::Detection("Degenerate landmarks".to_string()));
|
||||
}
|
||||
|
||||
let a = num_a / denom;
|
||||
let b = num_b / denom;
|
||||
|
||||
// Translation: ref_centroid = [a, -b; b, a] * src_centroid + t
|
||||
let tx = ref_cx - (a * src_cx - b * src_cy);
|
||||
let ty = ref_cy - (b * src_cx + a * src_cy);
|
||||
|
||||
Ok(SimilarityTransform { a, b, tx, ty })
|
||||
}
|
||||
|
||||
/// Compute centroid of landmarks
|
||||
fn centroid(&self, landmarks: &[[f32; 2]; 5]) -> (f32, f32) {
|
||||
let sum_x: f32 = landmarks.iter().map(|p| p[0]).sum();
|
||||
let sum_y: f32 = landmarks.iter().map(|p| p[1]).sum();
|
||||
(sum_x / 5.0, sum_y / 5.0)
|
||||
}
|
||||
|
||||
/// Apply affine warp to image
|
||||
fn warp_affine(
|
||||
&self,
|
||||
src: &[u8],
|
||||
src_width: u32,
|
||||
src_height: u32,
|
||||
transform: &SimilarityTransform,
|
||||
) -> Vec<u8> {
|
||||
let out_size = (self.output_width * self.output_height) as usize;
|
||||
let mut dst = vec![0u8; out_size];
|
||||
|
||||
// Compute inverse transformation for backward mapping
|
||||
// Forward: dst = [a, -b; b, a] * src + [tx, ty]
|
||||
// Inverse: src = [a, b; -b, a] / (a^2 + b^2) * (dst - [tx, ty])
|
||||
let det = transform.a * transform.a + transform.b * transform.b;
|
||||
if det < 1e-10 {
|
||||
return dst; // Return black image if transform is degenerate
|
||||
}
|
||||
|
||||
let inv_a = transform.a / det;
|
||||
let inv_b = transform.b / det;
|
||||
|
||||
for dst_y in 0..self.output_height {
|
||||
for dst_x in 0..self.output_width {
|
||||
// Map destination pixel to source
|
||||
let dx = dst_x as f32 - transform.tx;
|
||||
let dy = dst_y as f32 - transform.ty;
|
||||
|
||||
let src_x = inv_a * dx + inv_b * dy;
|
||||
let src_y = -inv_b * dx + inv_a * dy;
|
||||
|
||||
// Bilinear interpolation
|
||||
let pixel = self.bilinear_sample(src, src_width, src_height, src_x, src_y);
|
||||
|
||||
let dst_idx = (dst_y * self.output_width + dst_x) as usize;
|
||||
dst[dst_idx] = pixel;
|
||||
}
|
||||
}
|
||||
|
||||
dst
|
||||
}
|
||||
|
||||
/// Sample image with bilinear interpolation
|
||||
fn bilinear_sample(&self, src: &[u8], width: u32, height: u32, x: f32, y: f32) -> u8 {
|
||||
// Boundary check
|
||||
if x < 0.0 || y < 0.0 || x >= (width - 1) as f32 || y >= (height - 1) as f32 {
|
||||
return 0; // Black for out-of-bounds
|
||||
}
|
||||
|
||||
let x0 = x.floor() as u32;
|
||||
let y0 = y.floor() as u32;
|
||||
let x1 = x0 + 1;
|
||||
let y1 = y0 + 1;
|
||||
|
||||
let fx = x - x0 as f32;
|
||||
let fy = y - y0 as f32;
|
||||
|
||||
// Get four neighboring pixels
|
||||
let idx00 = (y0 * width + x0) as usize;
|
||||
let idx01 = (y0 * width + x1) as usize;
|
||||
let idx10 = (y1 * width + x0) as usize;
|
||||
let idx11 = (y1 * width + x1) as usize;
|
||||
|
||||
let p00 = src.get(idx00).copied().unwrap_or(0) as f32;
|
||||
let p01 = src.get(idx01).copied().unwrap_or(0) as f32;
|
||||
let p10 = src.get(idx10).copied().unwrap_or(0) as f32;
|
||||
let p11 = src.get(idx11).copied().unwrap_or(0) as f32;
|
||||
|
||||
// Bilinear interpolation
|
||||
let value = p00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ p01 * fx * (1.0 - fy)
|
||||
+ p10 * (1.0 - fx) * fy
|
||||
+ p11 * fx * fy;
|
||||
|
||||
value.clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// Simple crop-based alignment (fallback when landmarks are unavailable)
|
||||
///
|
||||
/// Crops face region and resizes to standard size without geometric correction.
|
||||
pub fn simple_crop(
|
||||
&self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
face_x: u32,
|
||||
face_y: u32,
|
||||
face_width: u32,
|
||||
face_height: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
// Add margin around face (20% on each side)
|
||||
let margin_x = face_width / 5;
|
||||
let margin_y = face_height / 5;
|
||||
|
||||
let crop_x = face_x.saturating_sub(margin_x);
|
||||
let crop_y = face_y.saturating_sub(margin_y);
|
||||
let crop_w = (face_width + 2 * margin_x).min(width - crop_x);
|
||||
let crop_h = (face_height + 2 * margin_y).min(height - crop_y);
|
||||
|
||||
// Extract and resize the crop
|
||||
let mut output = vec![0u8; (self.output_width * self.output_height) as usize];
|
||||
|
||||
let scale_x = crop_w as f32 / self.output_width as f32;
|
||||
let scale_y = crop_h as f32 / self.output_height as f32;
|
||||
|
||||
for out_y in 0..self.output_height {
|
||||
for out_x in 0..self.output_width {
|
||||
let src_x = crop_x as f32 + out_x as f32 * scale_x;
|
||||
let src_y = crop_y as f32 + out_y as f32 * scale_y;
|
||||
|
||||
let pixel = self.bilinear_sample(image_data, width, height, src_x, src_y);
|
||||
output[(out_y * self.output_width + out_x) as usize] = pixel;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// 2D similarity transformation parameters
|
||||
///
|
||||
/// Represents: [x'] = [a -b] [x] + [tx]
|
||||
/// [y'] [b a] [y] [ty]
|
||||
///
|
||||
/// Where a = scale * cos(theta), b = scale * sin(theta)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SimilarityTransform {
|
||||
a: f32,
|
||||
b: f32,
|
||||
tx: f32,
|
||||
ty: f32,
|
||||
}
|
||||
|
||||
impl SimilarityTransform {
|
||||
/// Get the scale factor
|
||||
#[allow(dead_code)]
|
||||
pub fn scale(&self) -> f32 {
|
||||
(self.a * self.a + self.b * self.b).sqrt()
|
||||
}
|
||||
|
||||
/// Get the rotation angle in radians
|
||||
#[allow(dead_code)]
|
||||
pub fn angle(&self) -> f32 {
|
||||
self.b.atan2(self.a)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_aligner_creation() {
|
||||
let aligner = FaceAligner::new();
|
||||
assert_eq!(aligner.output_size(), (112, 112));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_size() {
|
||||
let aligner = FaceAligner::with_size(224, 224);
|
||||
assert_eq!(aligner.output_size(), (224, 224));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_centroid() {
|
||||
let aligner = FaceAligner::new();
|
||||
let landmarks = [
|
||||
[0.0, 0.0],
|
||||
[10.0, 0.0],
|
||||
[5.0, 5.0],
|
||||
[0.0, 10.0],
|
||||
[10.0, 10.0],
|
||||
];
|
||||
let (cx, cy) = aligner.centroid(&landmarks);
|
||||
assert!((cx - 5.0).abs() < 0.01);
|
||||
assert!((cy - 5.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_transform() {
|
||||
let aligner = FaceAligner::new();
|
||||
|
||||
// If source landmarks match reference, transform should be near-identity
|
||||
// (with translation to match reference centroid)
|
||||
let landmarks = REFERENCE_LANDMARKS_112;
|
||||
let transform = aligner.estimate_similarity_transform(&landmarks).unwrap();
|
||||
|
||||
// Scale should be approximately 1
|
||||
let scale = transform.scale();
|
||||
assert!((scale - 1.0).abs() < 0.01, "Scale should be ~1, got {}", scale);
|
||||
|
||||
// Angle should be approximately 0
|
||||
let angle = transform.angle();
|
||||
assert!(angle.abs() < 0.01, "Angle should be ~0, got {}", angle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_crop() {
|
||||
let aligner = FaceAligner::new();
|
||||
|
||||
// Create a simple test image
|
||||
let width = 200u32;
|
||||
let height = 200u32;
|
||||
let image: Vec<u8> = (0..(width * height))
|
||||
.map(|i| ((i % 256) as u8))
|
||||
.collect();
|
||||
|
||||
let result = aligner.simple_crop(&image, width, height, 50, 50, 100, 100);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let aligned = result.unwrap();
|
||||
assert_eq!(aligned.len(), (112 * 112) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_align_preserves_size() {
|
||||
let aligner = FaceAligner::new();
|
||||
|
||||
let width = 640u32;
|
||||
let height = 480u32;
|
||||
let image = vec![128u8; (width * height) as usize];
|
||||
|
||||
// Use reference landmarks scaled to image size
|
||||
let landmarks = [
|
||||
[200.0, 150.0],
|
||||
[300.0, 150.0],
|
||||
[250.0, 200.0],
|
||||
[210.0, 250.0],
|
||||
[290.0, 250.0],
|
||||
];
|
||||
|
||||
let result = aligner.align(&image, width, height, &landmarks);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let aligned = result.unwrap();
|
||||
assert_eq!(aligned.len(), (112 * 112) as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bilinear_interpolation() {
|
||||
let aligner = FaceAligner::new();
|
||||
|
||||
// 2x2 test image with known values
|
||||
let image = vec![0u8, 100, 100, 200];
|
||||
|
||||
// Sample at center should give average
|
||||
let center = aligner.bilinear_sample(&image, 2, 2, 0.5, 0.5);
|
||||
// Expected: (0 + 100 + 100 + 200) / 4 = 100
|
||||
assert!((center as i32 - 100).abs() < 5);
|
||||
|
||||
// Sample at corner should give exact value
|
||||
let corner = aligner.bilinear_sample(&image, 2, 2, 0.0, 0.0);
|
||||
assert_eq!(corner, 0);
|
||||
}
|
||||
}
|
||||
838
linux-hello-daemon/src/onnx/detector.rs
Normal file
838
linux-hello-daemon/src/onnx/detector.rs
Normal file
@@ -0,0 +1,838 @@
|
||||
//! ONNX Face Detector using RetinaFace/BlazeFace
|
||||
//!
|
||||
//! This module provides face detection using ONNX models such as RetinaFace or BlazeFace,
|
||||
//! which output both bounding boxes and 5-point facial landmarks.
|
||||
//!
|
||||
//! # Model Specifications
|
||||
//!
|
||||
//! ## RetinaFace Model
|
||||
//!
|
||||
//! **Input:**
|
||||
//! - Name: `input` or `data`
|
||||
//! - Shape: `[1, 3, H, W]` (NCHW format)
|
||||
//! - Type: `float32`
|
||||
//! - Preprocessing:
|
||||
//! - RGB channel order
|
||||
//! - Normalize: `(pixel - 127.5) / 128.0` (range [-1, 1])
|
||||
//! - Common sizes: 640x640, 320x320
|
||||
//!
|
||||
//! **Outputs:**
|
||||
//! - `bbox` or `loc`: Bounding box deltas `[1, num_anchors, 4]`
|
||||
//! - `conf` or `cls`: Classification scores `[1, num_anchors, 2]` (background, face)
|
||||
//! - `landmark` or `ldmk`: Landmark offsets `[1, num_anchors, 10]` (5 points x 2 coords)
|
||||
//!
|
||||
//! ## BlazeFace Model
|
||||
//!
|
||||
//! **Input:**
|
||||
//! - Shape: `[1, 3, 128, 128]` or `[1, 3, 256, 256]`
|
||||
//! - Same preprocessing as RetinaFace
|
||||
//!
|
||||
//! **Outputs:**
|
||||
//! - Boxes and scores in different format
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! let detector = OnnxFaceDetector::load("retinaface.onnx")?;
|
||||
//! let detections = detector.detect(&image_data, 640, 480)?;
|
||||
//!
|
||||
//! for det in detections {
|
||||
//! println!("Face at ({}, {}) with confidence {}", det.x, det.y, det.confidence);
|
||||
//! println!("Landmarks: {:?}", det.landmarks);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::{Error, Result};
|
||||
use crate::detection::FaceDetection;
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
use ort::{session::Session, value::TensorRef};
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
use ndarray::Array4;
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
type OnnxTensor<'a> = TensorRef<'a, f32>;
|
||||
|
||||
/// Face detection result with landmarks
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionWithLandmarks {
|
||||
/// Base detection (bounding box + confidence)
|
||||
pub detection: FaceDetection,
|
||||
/// 5-point facial landmarks in normalized coordinates (0-1)
|
||||
///
|
||||
/// Order:
|
||||
/// - [0]: Left eye center
|
||||
/// - [1]: Right eye center
|
||||
/// - [2]: Nose tip
|
||||
/// - [3]: Left mouth corner
|
||||
/// - [4]: Right mouth corner
|
||||
pub landmarks: [[f32; 2]; 5],
|
||||
}
|
||||
|
||||
impl DetectionWithLandmarks {
|
||||
/// Convert landmarks to pixel coordinates
|
||||
pub fn landmarks_to_pixels(&self, img_width: u32, img_height: u32) -> [[f32; 2]; 5] {
|
||||
let mut result = [[0.0; 2]; 5];
|
||||
for (i, lm) in self.landmarks.iter().enumerate() {
|
||||
result[i][0] = lm[0] * img_width as f32;
|
||||
result[i][1] = lm[1] * img_height as f32;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchor box configuration for RetinaFace
|
||||
#[derive(Debug, Clone)]
|
||||
struct AnchorConfig {
|
||||
/// Feature map strides
|
||||
strides: Vec<u32>,
|
||||
/// Anchor sizes per stride level
|
||||
anchor_sizes: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Default for AnchorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
strides: vec![8, 16, 32],
|
||||
anchor_sizes: vec![
|
||||
vec![16.0, 32.0], // Stride 8
|
||||
vec![64.0, 128.0], // Stride 16
|
||||
vec![256.0, 512.0], // Stride 32
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-computed anchor box
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Anchor {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
}
|
||||
|
||||
/// ONNX-based face detector using RetinaFace or BlazeFace
|
||||
///
|
||||
/// This detector provides:
|
||||
/// - High accuracy face detection
|
||||
/// - 5-point facial landmarks for alignment
|
||||
/// - Multi-scale detection for faces of various sizes
|
||||
///
|
||||
/// # Model Requirements
|
||||
///
|
||||
/// The model should be a RetinaFace variant exported to ONNX format.
|
||||
/// Recommended models:
|
||||
/// - `retinaface_mnet025_v2.onnx` - MobileNet-0.25 backbone (fast, small)
|
||||
/// - `retinaface_r50_v1.onnx` - ResNet-50 backbone (accurate, larger)
|
||||
/// - `blazeface.onnx` - BlazeFace (very fast, good for real-time)
|
||||
pub struct OnnxFaceDetector {
|
||||
/// ONNX runtime session
|
||||
#[cfg(feature = "onnx")]
|
||||
session: Session,
|
||||
|
||||
/// Model input width
|
||||
input_width: u32,
|
||||
/// Model input height
|
||||
input_height: u32,
|
||||
/// Detection confidence threshold
|
||||
confidence_threshold: f32,
|
||||
/// Non-maximum suppression IoU threshold
|
||||
nms_threshold: f32,
|
||||
/// Pre-computed anchors
|
||||
anchors: Vec<Anchor>,
|
||||
/// Whether model is loaded (for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
model_loaded: bool,
|
||||
}
|
||||
|
||||
impl OnnxFaceDetector {
|
||||
/// Default model path relative to data directory
|
||||
pub const DEFAULT_MODEL_PATH: &'static str = "models/retinaface.onnx";
|
||||
|
||||
/// Load a RetinaFace ONNX model from file
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `model_path` - Path to the ONNX model file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `OnnxFaceDetector` instance
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the model cannot be loaded
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn load<P: AsRef<std::path::Path>>(model_path: P) -> Result<Self> {
|
||||
let session = Session::builder()
|
||||
.map_err(|e| Error::Detection(format!("Failed to create session builder: {}", e)))?
|
||||
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set optimization level: {}", e)))?
|
||||
.with_intra_threads(4)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set thread count: {}", e)))?
|
||||
.commit_from_file(model_path.as_ref())
|
||||
.map_err(|e| Error::Detection(format!("Failed to load ONNX model: {}", e)))?;
|
||||
|
||||
// Determine input size from model
|
||||
let (input_width, input_height) = Self::get_input_size(&session)?;
|
||||
|
||||
// Generate anchors for the input size
|
||||
let anchors = Self::generate_anchors(input_width, input_height, &AnchorConfig::default());
|
||||
|
||||
Ok(Self {
|
||||
session,
|
||||
input_width,
|
||||
input_height,
|
||||
confidence_threshold: 0.5,
|
||||
nms_threshold: 0.4,
|
||||
anchors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a RetinaFace ONNX model (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn load<P: AsRef<std::path::Path>>(model_path: P) -> Result<Self> {
|
||||
let input_width = 640;
|
||||
let input_height = 640;
|
||||
let anchors = Self::generate_anchors(input_width, input_height, &AnchorConfig::default());
|
||||
|
||||
Ok(Self {
|
||||
input_width,
|
||||
input_height,
|
||||
confidence_threshold: 0.5,
|
||||
nms_threshold: 0.4,
|
||||
anchors,
|
||||
model_loaded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load model with custom configuration
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn load_with_config<P: AsRef<std::path::Path>>(
|
||||
model_path: P,
|
||||
config: &super::OnnxModelConfig,
|
||||
) -> Result<Self> {
|
||||
let mut builder = Session::builder()
|
||||
.map_err(|e| Error::Detection(format!("Failed to create session builder: {}", e)))?
|
||||
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set optimization level: {}", e)))?;
|
||||
|
||||
if config.num_threads > 0 {
|
||||
builder = builder
|
||||
.with_intra_threads(config.num_threads)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set thread count: {}", e)))?;
|
||||
}
|
||||
|
||||
let session = builder
|
||||
.commit_from_file(model_path.as_ref())
|
||||
.map_err(|e| Error::Detection(format!("Failed to load ONNX model: {}", e)))?;
|
||||
|
||||
let (input_width, input_height) = (
|
||||
config.detection_input_size.0,
|
||||
config.detection_input_size.1,
|
||||
);
|
||||
|
||||
let anchors = Self::generate_anchors(input_width, input_height, &AnchorConfig::default());
|
||||
|
||||
Ok(Self {
|
||||
session,
|
||||
input_width,
|
||||
input_height,
|
||||
confidence_threshold: 0.5,
|
||||
nms_threshold: 0.4,
|
||||
anchors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load model with custom configuration (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn load_with_config<P: AsRef<std::path::Path>>(
|
||||
model_path: P,
|
||||
config: &super::OnnxModelConfig,
|
||||
) -> Result<Self> {
|
||||
let (input_width, input_height) = config.detection_input_size;
|
||||
let anchors = Self::generate_anchors(input_width, input_height, &AnchorConfig::default());
|
||||
|
||||
Ok(Self {
|
||||
input_width,
|
||||
input_height,
|
||||
confidence_threshold: 0.5,
|
||||
nms_threshold: 0.4,
|
||||
anchors,
|
||||
model_loaded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get input size from model
|
||||
#[cfg(feature = "onnx")]
|
||||
fn get_input_size(session: &Session) -> Result<(u32, u32)> {
|
||||
use ort::value::ValueType;
|
||||
|
||||
let inputs = session.inputs();
|
||||
if inputs.is_empty() {
|
||||
return Err(Error::Detection("Model has no inputs".to_string()));
|
||||
}
|
||||
|
||||
let input = &inputs[0];
|
||||
if let ValueType::Tensor { shape, .. } = input.dtype() {
|
||||
// NCHW format: [N, C, H, W]
|
||||
let dims: &[i64] = shape;
|
||||
if dims.len() >= 4 {
|
||||
return Ok((dims[3].max(1) as u32, dims[2].max(1) as u32));
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 640x640
|
||||
Ok((640, 640))
|
||||
}
|
||||
|
||||
/// Set the confidence threshold for detections
|
||||
pub fn set_confidence_threshold(&mut self, threshold: f32) {
|
||||
self.confidence_threshold = threshold.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Set the NMS IoU threshold
|
||||
pub fn set_nms_threshold(&mut self, threshold: f32) {
|
||||
self.nms_threshold = threshold.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Get the model input size
|
||||
pub fn input_size(&self) -> (u32, u32) {
|
||||
(self.input_width, self.input_height)
|
||||
}
|
||||
|
||||
/// Detect faces with landmarks
|
||||
///
|
||||
/// Returns face detections including 5-point facial landmarks
|
||||
/// suitable for face alignment.
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn detect_with_landmarks(
|
||||
&mut self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<DetectionWithLandmarks>> {
|
||||
// Validate input
|
||||
let expected_size = (width * height) as usize;
|
||||
if image_data.len() != expected_size {
|
||||
return Err(Error::Detection(format!(
|
||||
"Image size mismatch: expected {}, got {}",
|
||||
expected_size,
|
||||
image_data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Preprocess image - returns owned Array4
|
||||
let tensor_data = self.preprocess(image_data, width, height)?;
|
||||
|
||||
// Create tensor reference using shape and slice (compatible with ort 2.0 API)
|
||||
let shape: Vec<i64> = tensor_data.shape().iter().map(|&x| x as i64).collect();
|
||||
let slice = tensor_data.as_slice()
|
||||
.ok_or_else(|| Error::Detection("Array not contiguous".to_string()))?;
|
||||
let input_tensor = TensorRef::from_array_view((shape, slice))
|
||||
.map_err(|e| Error::Detection(format!("Failed to create tensor: {}", e)))?;
|
||||
|
||||
// Run inference
|
||||
let outputs = self.session
|
||||
.run(ort::inputs![input_tensor])
|
||||
.map_err(|e| Error::Detection(format!("Inference failed: {}", e)))?;
|
||||
|
||||
// Extract data from outputs immediately (before dropping outputs)
|
||||
// This releases the mutable borrow on session
|
||||
let (loc_data, conf_data, landm_data) = Self::extract_output_data(&outputs)?;
|
||||
drop(outputs); // Explicitly drop to release the session borrow
|
||||
|
||||
// Post-process using extracted data
|
||||
let mut detections = self.decode_detections(
|
||||
&loc_data, &conf_data, landm_data.as_deref(), width, height
|
||||
);
|
||||
|
||||
// Apply NMS
|
||||
detections = self.nms(detections);
|
||||
|
||||
Ok(detections)
|
||||
}
|
||||
|
||||
/// Detect faces with landmarks (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn detect_with_landmarks(
|
||||
&mut self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Vec<DetectionWithLandmarks>> {
|
||||
if !self.model_loaded {
|
||||
return Err(Error::Detection(
|
||||
"ONNX models not loaded (onnx feature not enabled)".to_string()
|
||||
));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Preprocess image for model input
|
||||
///
|
||||
/// Returns an owned Array4 that can be used to create a TensorRef
|
||||
#[cfg(feature = "onnx")]
|
||||
fn preprocess(&self, image_data: &[u8], width: u32, height: u32) -> Result<Array4<f32>> {
|
||||
// Resize image to model input size
|
||||
let resized = self.resize_bilinear(image_data, width, height, self.input_width, self.input_height);
|
||||
|
||||
// Convert to NCHW float32 tensor with normalization
|
||||
let mut tensor_data = Array4::<f32>::zeros((
|
||||
1,
|
||||
3,
|
||||
self.input_height as usize,
|
||||
self.input_width as usize,
|
||||
));
|
||||
|
||||
for y in 0..self.input_height as usize {
|
||||
for x in 0..self.input_width as usize {
|
||||
let pixel = resized[y * self.input_width as usize + x] as f32;
|
||||
// Normalize to [-1, 1] range: (pixel - 127.5) / 128.0
|
||||
let normalized = (pixel - 127.5) / 128.0;
|
||||
|
||||
// Replicate grayscale to RGB channels
|
||||
tensor_data[[0, 0, y, x]] = normalized;
|
||||
tensor_data[[0, 1, y, x]] = normalized;
|
||||
tensor_data[[0, 2, y, x]] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tensor_data)
|
||||
}
|
||||
|
||||
/// Extract raw data from model outputs
|
||||
#[cfg(feature = "onnx")]
|
||||
fn extract_output_data(
|
||||
outputs: &ort::session::SessionOutputs,
|
||||
) -> Result<(Vec<f32>, Vec<f32>, Option<Vec<f32>>)> {
|
||||
// Get output tensors - try different naming conventions
|
||||
let loc = outputs.get("loc")
|
||||
.or_else(|| outputs.get("bbox"))
|
||||
.or_else(|| outputs.get("boxes"));
|
||||
|
||||
let conf = outputs.get("conf")
|
||||
.or_else(|| outputs.get("cls"))
|
||||
.or_else(|| outputs.get("scores"));
|
||||
|
||||
let landm = outputs.get("landm")
|
||||
.or_else(|| outputs.get("landmark"))
|
||||
.or_else(|| outputs.get("landmarks"));
|
||||
|
||||
let (loc, conf) = match (loc, conf) {
|
||||
(Some(l), Some(c)) => (l, c),
|
||||
_ => return Err(Error::Detection("Missing required outputs".to_string())),
|
||||
};
|
||||
|
||||
// Extract tensors - try_extract_tensor returns (Shape, &[T])
|
||||
let (_, loc_slice) = loc
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| Error::Detection(format!("Failed to extract loc tensor: {}", e)))?;
|
||||
|
||||
let (_, conf_slice) = conf
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| Error::Detection(format!("Failed to extract conf tensor: {}", e)))?;
|
||||
|
||||
let landm_result = landm.map(|l| {
|
||||
l.try_extract_tensor::<f32>().ok()
|
||||
}).flatten();
|
||||
|
||||
let loc_data: Vec<f32> = loc_slice.to_vec();
|
||||
let conf_data: Vec<f32> = conf_slice.to_vec();
|
||||
let landm_data: Option<Vec<f32>> = landm_result.map(|(_, data)| data.to_vec());
|
||||
|
||||
Ok((loc_data, conf_data, landm_data))
|
||||
}
|
||||
|
||||
/// Decode detections from extracted output data
|
||||
#[cfg(feature = "onnx")]
|
||||
fn decode_detections(
|
||||
&self,
|
||||
loc_data: &[f32],
|
||||
conf_data: &[f32],
|
||||
landm_data: Option<&[f32]>,
|
||||
orig_width: u32,
|
||||
orig_height: u32,
|
||||
) -> Vec<DetectionWithLandmarks> {
|
||||
let mut detections = Vec::new();
|
||||
|
||||
// Scale factors from model input to original image
|
||||
let scale_x = orig_width as f32 / self.input_width as f32;
|
||||
let scale_y = orig_height as f32 / self.input_height as f32;
|
||||
|
||||
// Process each anchor
|
||||
for (i, anchor) in self.anchors.iter().enumerate() {
|
||||
// Get confidence score (assuming [background, face] format)
|
||||
let conf_idx = i * 2 + 1; // Face class
|
||||
if conf_idx >= conf_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply softmax to get probability
|
||||
let bg_score = conf_data.get(i * 2).copied().unwrap_or(0.0);
|
||||
let face_score = conf_data[conf_idx];
|
||||
let confidence = Self::softmax(bg_score, face_score);
|
||||
|
||||
if confidence < self.confidence_threshold {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decode bounding box
|
||||
let loc_idx = i * 4;
|
||||
if loc_idx + 3 >= loc_data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let dx = loc_data[loc_idx];
|
||||
let dy = loc_data[loc_idx + 1];
|
||||
let dw = loc_data[loc_idx + 2];
|
||||
let dh = loc_data[loc_idx + 3];
|
||||
|
||||
// Decode from anchor offsets
|
||||
let cx = anchor.cx + dx * 0.1 * anchor.width;
|
||||
let cy = anchor.cy + dy * 0.1 * anchor.height;
|
||||
let w = anchor.width * (dw * 0.2).exp();
|
||||
let h = anchor.height * (dh * 0.2).exp();
|
||||
|
||||
// Convert to normalized coordinates in original image space
|
||||
let x = ((cx - w / 2.0) * scale_x).max(0.0) / orig_width as f32;
|
||||
let y = ((cy - h / 2.0) * scale_y).max(0.0) / orig_height as f32;
|
||||
let width = (w * scale_x) / orig_width as f32;
|
||||
let height = (h * scale_y) / orig_height as f32;
|
||||
|
||||
// Decode landmarks if available
|
||||
let landmarks = if let Some(ref lm_data) = landm_data {
|
||||
let lm_idx = i * 10;
|
||||
if lm_idx + 9 < lm_data.len() {
|
||||
let mut lms = [[0.0f32; 2]; 5];
|
||||
for j in 0..5 {
|
||||
let lx = anchor.cx + lm_data[lm_idx + j * 2] * 0.1 * anchor.width;
|
||||
let ly = anchor.cy + lm_data[lm_idx + j * 2 + 1] * 0.1 * anchor.height;
|
||||
lms[j][0] = (lx * scale_x) / orig_width as f32;
|
||||
lms[j][1] = (ly * scale_y) / orig_height as f32;
|
||||
}
|
||||
lms
|
||||
} else {
|
||||
Self::estimate_landmarks(x, y, width, height)
|
||||
}
|
||||
} else {
|
||||
Self::estimate_landmarks(x, y, width, height)
|
||||
};
|
||||
|
||||
detections.push(DetectionWithLandmarks {
|
||||
detection: FaceDetection {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
confidence,
|
||||
},
|
||||
landmarks,
|
||||
});
|
||||
}
|
||||
|
||||
detections
|
||||
}
|
||||
|
||||
/// Softmax for two values
|
||||
#[cfg(feature = "onnx")]
|
||||
fn softmax(bg: f32, face: f32) -> f32 {
|
||||
let max = bg.max(face);
|
||||
let exp_bg = (bg - max).exp();
|
||||
let exp_face = (face - max).exp();
|
||||
exp_face / (exp_bg + exp_face)
|
||||
}
|
||||
|
||||
/// Estimate landmarks from bounding box (fallback when not provided)
|
||||
fn estimate_landmarks(x: f32, y: f32, w: f32, h: f32) -> [[f32; 2]; 5] {
|
||||
// Standard facial landmark positions as fraction of face box
|
||||
[
|
||||
[x + w * 0.3, y + h * 0.35], // Left eye
|
||||
[x + w * 0.7, y + h * 0.35], // Right eye
|
||||
[x + w * 0.5, y + h * 0.55], // Nose
|
||||
[x + w * 0.35, y + h * 0.75], // Left mouth
|
||||
[x + w * 0.65, y + h * 0.75], // Right mouth
|
||||
]
|
||||
}
|
||||
|
||||
/// Generate anchors for RetinaFace
|
||||
fn generate_anchors(input_width: u32, input_height: u32, config: &AnchorConfig) -> Vec<Anchor> {
|
||||
let mut anchors = Vec::new();
|
||||
|
||||
for (level, &stride) in config.strides.iter().enumerate() {
|
||||
let feat_h = input_height / stride;
|
||||
let feat_w = input_width / stride;
|
||||
|
||||
for y in 0..feat_h {
|
||||
for x in 0..feat_w {
|
||||
let cx = (x as f32 + 0.5) * stride as f32;
|
||||
let cy = (y as f32 + 0.5) * stride as f32;
|
||||
|
||||
for &size in &config.anchor_sizes[level] {
|
||||
anchors.push(Anchor {
|
||||
cx,
|
||||
cy,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchors
|
||||
}
|
||||
|
||||
/// Apply non-maximum suppression to remove overlapping detections
|
||||
fn nms(&self, mut detections: Vec<DetectionWithLandmarks>) -> Vec<DetectionWithLandmarks> {
|
||||
// Sort by confidence (descending)
|
||||
detections.sort_by(|a, b| {
|
||||
b.detection.confidence
|
||||
.partial_cmp(&a.detection.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let mut keep = vec![true; detections.len()];
|
||||
let mut result = Vec::new();
|
||||
|
||||
for i in 0..detections.len() {
|
||||
if !keep[i] {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(detections[i].clone());
|
||||
|
||||
for j in (i + 1)..detections.len() {
|
||||
if !keep[j] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let iou = Self::iou(&detections[i].detection, &detections[j].detection);
|
||||
if iou > self.nms_threshold {
|
||||
keep[j] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Calculate Intersection over Union (IoU) between two boxes
|
||||
fn iou(a: &FaceDetection, b: &FaceDetection) -> f32 {
|
||||
let x1 = a.x.max(b.x);
|
||||
let y1 = a.y.max(b.y);
|
||||
let x2 = (a.x + a.width).min(b.x + b.width);
|
||||
let y2 = (a.y + a.height).min(b.y + b.height);
|
||||
|
||||
if x2 <= x1 || y2 <= y1 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let intersection = (x2 - x1) * (y2 - y1);
|
||||
let area_a = a.width * a.height;
|
||||
let area_b = b.width * b.height;
|
||||
let union = area_a + area_b - intersection;
|
||||
|
||||
if union > 0.0 {
|
||||
intersection / union
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize image using bilinear interpolation
|
||||
#[cfg(feature = "onnx")]
|
||||
fn resize_bilinear(
|
||||
&self,
|
||||
src: &[u8],
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
dst_w: u32,
|
||||
dst_h: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut dst = vec![0u8; (dst_w * dst_h) as usize];
|
||||
|
||||
let scale_x = src_w as f32 / dst_w as f32;
|
||||
let scale_y = src_h as f32 / dst_h as f32;
|
||||
|
||||
for y in 0..dst_h {
|
||||
for x in 0..dst_w {
|
||||
let src_x = (x as f32 + 0.5) * scale_x - 0.5;
|
||||
let src_y = (y as f32 + 0.5) * scale_y - 0.5;
|
||||
|
||||
let x0 = src_x.floor().max(0.0) as u32;
|
||||
let y0 = src_y.floor().max(0.0) as u32;
|
||||
let x1 = (x0 + 1).min(src_w - 1);
|
||||
let y1 = (y0 + 1).min(src_h - 1);
|
||||
|
||||
let fx = src_x - x0 as f32;
|
||||
let fy = src_y - y0 as f32;
|
||||
|
||||
let p00 = src[(y0 * src_w + x0) as usize] as f32;
|
||||
let p01 = src[(y0 * src_w + x1) as usize] as f32;
|
||||
let p10 = src[(y1 * src_w + x0) as usize] as f32;
|
||||
let p11 = src[(y1 * src_w + x1) as usize] as f32;
|
||||
|
||||
let value = p00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ p01 * fx * (1.0 - fy)
|
||||
+ p10 * (1.0 - fx) * fy
|
||||
+ p11 * fx * fy;
|
||||
|
||||
dst[(y * dst_w + x) as usize] = value.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
dst
|
||||
}
|
||||
}
|
||||
|
||||
// Note: FaceDetect trait requires &self but ort 2.0 session.run() requires &mut self.
|
||||
// We don't implement FaceDetect for OnnxFaceDetector - use detect_with_landmarks() directly.
|
||||
// This allows the more efficient direct API while maintaining type safety.
|
||||
|
||||
impl OnnxFaceDetector {
|
||||
/// Detect faces (convenience wrapper around detect_with_landmarks)
|
||||
///
|
||||
/// Returns only bounding boxes without landmarks.
|
||||
pub fn detect(&mut self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<FaceDetection>> {
|
||||
let detections = self.detect_with_landmarks(image_data, width, height)?;
|
||||
Ok(detections.into_iter().map(|d| d.detection).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detector_creation() {
|
||||
let detector = OnnxFaceDetector::load("nonexistent.onnx");
|
||||
#[cfg(feature = "onnx")]
|
||||
assert!(detector.is_err());
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
{
|
||||
assert!(detector.is_ok());
|
||||
let det = detector.unwrap();
|
||||
assert!(!det.model_loaded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
fn test_unloaded_returns_error() {
|
||||
let detector = OnnxFaceDetector::load("test.onnx").unwrap();
|
||||
let result = detector.detect_with_landmarks(&[128; 100], 10, 10);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not loaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iou_calculation() {
|
||||
let a = FaceDetection {
|
||||
x: 0.0, y: 0.0, width: 0.5, height: 0.5, confidence: 1.0
|
||||
};
|
||||
let b = FaceDetection {
|
||||
x: 0.25, y: 0.25, width: 0.5, height: 0.5, confidence: 1.0
|
||||
};
|
||||
|
||||
let iou = OnnxFaceDetector::iou(&a, &b);
|
||||
// Intersection: 0.25 * 0.25 = 0.0625
|
||||
// Union: 0.25 + 0.25 - 0.0625 = 0.4375
|
||||
// IoU: 0.0625 / 0.4375 = ~0.143
|
||||
assert!(iou > 0.1 && iou < 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_landmarks_to_pixels() {
|
||||
let det = DetectionWithLandmarks {
|
||||
detection: FaceDetection {
|
||||
x: 0.0, y: 0.0, width: 1.0, height: 1.0, confidence: 1.0
|
||||
},
|
||||
landmarks: [
|
||||
[0.5, 0.3],
|
||||
[0.7, 0.3],
|
||||
[0.6, 0.5],
|
||||
[0.4, 0.7],
|
||||
[0.8, 0.7],
|
||||
],
|
||||
};
|
||||
|
||||
let pixels = det.landmarks_to_pixels(100, 100);
|
||||
assert_eq!(pixels[0], [50.0, 30.0]);
|
||||
assert_eq!(pixels[2], [60.0, 50.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anchor_generation() {
|
||||
let anchors = OnnxFaceDetector::generate_anchors(640, 640, &AnchorConfig::default());
|
||||
// Should have anchors at multiple scales
|
||||
assert!(!anchors.is_empty());
|
||||
|
||||
// Check that anchors cover the image
|
||||
let min_cx = anchors.iter().map(|a| a.cx).fold(f32::INFINITY, f32::min);
|
||||
let max_cx = anchors.iter().map(|a| a.cx).fold(f32::NEG_INFINITY, f32::max);
|
||||
assert!(min_cx < 50.0);
|
||||
assert!(max_cx > 590.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_estimate_landmarks() {
|
||||
let landmarks = OnnxFaceDetector::estimate_landmarks(0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// Check landmark positions are within face box
|
||||
for lm in &landmarks {
|
||||
assert!(lm[0] >= 0.0 && lm[0] <= 1.0);
|
||||
assert!(lm[1] >= 0.0 && lm[1] <= 1.0);
|
||||
}
|
||||
|
||||
// Eyes should be above nose
|
||||
assert!(landmarks[0][1] < landmarks[2][1]);
|
||||
assert!(landmarks[1][1] < landmarks[2][1]);
|
||||
|
||||
// Mouth corners should be below nose
|
||||
assert!(landmarks[3][1] > landmarks[2][1]);
|
||||
assert!(landmarks[4][1] > landmarks[2][1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nms() {
|
||||
let detector = OnnxFaceDetector::load("test.onnx");
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
{
|
||||
let detector = detector.unwrap();
|
||||
|
||||
let detections = vec![
|
||||
DetectionWithLandmarks {
|
||||
detection: FaceDetection {
|
||||
x: 0.0, y: 0.0, width: 0.5, height: 0.5, confidence: 0.9
|
||||
},
|
||||
landmarks: [[0.0; 2]; 5],
|
||||
},
|
||||
DetectionWithLandmarks {
|
||||
detection: FaceDetection {
|
||||
x: 0.1, y: 0.1, width: 0.5, height: 0.5, confidence: 0.8
|
||||
},
|
||||
landmarks: [[0.0; 2]; 5],
|
||||
},
|
||||
DetectionWithLandmarks {
|
||||
detection: FaceDetection {
|
||||
x: 0.8, y: 0.8, width: 0.2, height: 0.2, confidence: 0.7
|
||||
},
|
||||
landmarks: [[0.0; 2]; 5],
|
||||
},
|
||||
];
|
||||
|
||||
let result = detector.nms(detections);
|
||||
|
||||
// Should keep first and third (non-overlapping), suppress second
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!((result[0].detection.confidence - 0.9).abs() < 0.01);
|
||||
assert!((result[1].detection.confidence - 0.7).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
422
linux-hello-daemon/src/onnx/embedding.rs
Normal file
422
linux-hello-daemon/src/onnx/embedding.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
//! ONNX Face Embedding Extractor
|
||||
//!
|
||||
//! This module provides face embedding extraction using ONNX models such as
|
||||
//! MobileFaceNet or ArcFace.
|
||||
//!
|
||||
//! # Model Specifications
|
||||
//!
|
||||
//! ## MobileFaceNet
|
||||
//!
|
||||
//! **Input:**
|
||||
//! - Name: `input` or `data`
|
||||
//! - Shape: `[1, 3, 112, 112]` (NCHW format)
|
||||
//! - Type: `float32`
|
||||
//! - Preprocessing:
|
||||
//! - RGB channel order
|
||||
//! - Normalize: `(pixel - 127.5) / 128.0` (range [-1, 1])
|
||||
//!
|
||||
//! **Output:**
|
||||
//! - Name: `embedding` or `output`
|
||||
//! - Shape: `[1, 128]` or `[1, 512]` depending on model variant
|
||||
//! - Type: `float32`
|
||||
//! - Post-processing: L2 normalize for cosine similarity matching
|
||||
//!
|
||||
//! ## ArcFace (ResNet-based)
|
||||
//!
|
||||
//! **Input:**
|
||||
//! - Shape: `[1, 3, 112, 112]`
|
||||
//! - Same preprocessing as MobileFaceNet
|
||||
//!
|
||||
//! **Output:**
|
||||
//! - Shape: `[1, 512]`
|
||||
//! - 512-dimensional embedding vector
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! let extractor = OnnxEmbeddingExtractor::load("mobilefacenet.onnx")?;
|
||||
//! let embedding = extractor.extract(&aligned_face)?;
|
||||
//!
|
||||
//! // Compare embeddings
|
||||
//! let similarity = cosine_similarity(&embedding1, &embedding2);
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::{Error, Result};
|
||||
use image::GrayImage;
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
use ort::{session::Session, value::TensorRef};
|
||||
|
||||
#[cfg(feature = "onnx")]
|
||||
use ndarray::Array4;
|
||||
|
||||
// Note: We don't implement the EmbeddingExtractor trait because ort 2.0 session.run()
|
||||
// requires &mut self, but the trait uses &self.
|
||||
|
||||
/// ONNX-based face embedding extractor
|
||||
///
|
||||
/// Extracts face embeddings using models like MobileFaceNet or ArcFace.
|
||||
/// The extracted embeddings can be compared using cosine similarity
|
||||
/// for face verification and identification.
|
||||
///
|
||||
/// # Model Requirements
|
||||
///
|
||||
/// The model should be an ONNX face recognition model with:
|
||||
/// - Input: `[1, 3, 112, 112]` RGB float32 image
|
||||
/// - Output: `[1, D]` embedding vector (D = 128 or 512)
|
||||
///
|
||||
/// Recommended models:
|
||||
/// - `mobilefacenet.onnx` - 128-dim, fast, suitable for edge devices
|
||||
/// - `arcface_r100.onnx` - 512-dim, high accuracy, larger
|
||||
pub struct OnnxEmbeddingExtractor {
|
||||
/// ONNX runtime session
|
||||
#[cfg(feature = "onnx")]
|
||||
session: Session,
|
||||
|
||||
/// Embedding dimension
|
||||
embedding_dim: usize,
|
||||
|
||||
/// Input image size (width, height)
|
||||
input_size: (u32, u32),
|
||||
|
||||
/// Whether model is loaded (for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
model_loaded: bool,
|
||||
}
|
||||
|
||||
impl OnnxEmbeddingExtractor {
|
||||
/// Default model path relative to data directory
|
||||
pub const DEFAULT_MODEL_PATH: &'static str = "models/mobilefacenet.onnx";
|
||||
|
||||
/// Load embedding model from file
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `model_path` - Path to the ONNX model file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `OnnxEmbeddingExtractor` instance
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the model cannot be loaded or has invalid format
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn load<P: AsRef<std::path::Path>>(model_path: P) -> Result<Self> {
|
||||
let session = Session::builder()
|
||||
.map_err(|e| Error::Detection(format!("Failed to create session builder: {}", e)))?
|
||||
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set optimization level: {}", e)))?
|
||||
.with_intra_threads(4)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set thread count: {}", e)))?
|
||||
.commit_from_file(model_path.as_ref())
|
||||
.map_err(|e| Error::Detection(format!("Failed to load ONNX model: {}", e)))?;
|
||||
|
||||
// Determine embedding dimension from output shape
|
||||
let embedding_dim = Self::get_embedding_dim(&session)?;
|
||||
|
||||
Ok(Self {
|
||||
session,
|
||||
embedding_dim,
|
||||
input_size: (112, 112),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load embedding model (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn load<P: AsRef<std::path::Path>>(model_path: P) -> Result<Self> {
|
||||
Ok(Self {
|
||||
embedding_dim: 128,
|
||||
input_size: (112, 112),
|
||||
model_loaded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load model with custom configuration
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn load_with_config<P: AsRef<std::path::Path>>(
|
||||
model_path: P,
|
||||
config: &super::OnnxModelConfig,
|
||||
) -> Result<Self> {
|
||||
let mut builder = Session::builder()
|
||||
.map_err(|e| Error::Detection(format!("Failed to create session builder: {}", e)))?
|
||||
.with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set optimization level: {}", e)))?;
|
||||
|
||||
// Set thread count if specified
|
||||
if config.num_threads > 0 {
|
||||
builder = builder
|
||||
.with_intra_threads(config.num_threads)
|
||||
.map_err(|e| Error::Detection(format!("Failed to set thread count: {}", e)))?;
|
||||
}
|
||||
|
||||
let session = builder
|
||||
.commit_from_file(model_path.as_ref())
|
||||
.map_err(|e| Error::Detection(format!("Failed to load ONNX model: {}", e)))?;
|
||||
|
||||
let embedding_dim = Self::get_embedding_dim(&session)?;
|
||||
|
||||
Ok(Self {
|
||||
session,
|
||||
embedding_dim,
|
||||
input_size: config.embedding_input_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load model with custom configuration (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn load_with_config<P: AsRef<std::path::Path>>(
|
||||
model_path: P,
|
||||
config: &super::OnnxModelConfig,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
embedding_dim: 128,
|
||||
input_size: config.embedding_input_size,
|
||||
model_loaded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the embedding dimension
|
||||
pub fn embedding_dimension(&self) -> usize {
|
||||
self.embedding_dim
|
||||
}
|
||||
|
||||
/// Get the expected input size
|
||||
pub fn input_size(&self) -> (u32, u32) {
|
||||
self.input_size
|
||||
}
|
||||
|
||||
/// Extract embedding from raw grayscale image bytes
|
||||
///
|
||||
/// The image should be an aligned face of the correct input size.
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn extract_from_bytes(&mut self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
|
||||
// Validate input size
|
||||
let expected_pixels = (width * height) as usize;
|
||||
if image_data.len() != expected_pixels {
|
||||
return Err(Error::Detection(format!(
|
||||
"Image size mismatch: expected {}x{}={} pixels, got {}",
|
||||
width, height, expected_pixels, image_data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Preprocess: resize if needed and convert to NCHW float tensor
|
||||
let tensor_data = self.preprocess(image_data, width, height)?;
|
||||
|
||||
// Create tensor reference using shape and slice (compatible with ort 2.0 API)
|
||||
let shape: Vec<i64> = tensor_data.shape().iter().map(|&x| x as i64).collect();
|
||||
let slice = tensor_data.as_slice()
|
||||
.ok_or_else(|| Error::Detection("Array not contiguous".to_string()))?;
|
||||
let input_tensor = TensorRef::from_array_view((shape, slice))
|
||||
.map_err(|e| Error::Detection(format!("Failed to create tensor: {}", e)))?;
|
||||
|
||||
// Run inference
|
||||
let outputs = self.session
|
||||
.run(ort::inputs![input_tensor])
|
||||
.map_err(|e| Error::Detection(format!("Inference failed: {}", e)))?;
|
||||
|
||||
// Extract raw embedding data from outputs (must be done before dropping outputs)
|
||||
let raw_embedding = Self::extract_embedding_data(&outputs)?;
|
||||
|
||||
// L2 normalize the embedding
|
||||
let embedding = Self::normalize_embedding(raw_embedding);
|
||||
|
||||
Ok(embedding)
|
||||
}
|
||||
|
||||
/// Extract embedding from raw grayscale image bytes (stub for non-onnx builds)
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn extract_from_bytes(&mut self, image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
|
||||
if !self.model_loaded {
|
||||
return Err(Error::Detection(
|
||||
"ONNX models not loaded (onnx feature not enabled)".to_string()
|
||||
));
|
||||
}
|
||||
Ok(vec![0.0; self.embedding_dim])
|
||||
}
|
||||
|
||||
/// Preprocess image for model input
|
||||
///
|
||||
/// Returns an owned Array4 that can be used to create a TensorRef
|
||||
#[cfg(feature = "onnx")]
|
||||
fn preprocess(&self, image_data: &[u8], width: u32, height: u32) -> Result<Array4<f32>> {
|
||||
let (target_w, target_h) = self.input_size;
|
||||
|
||||
// Resize image if needed
|
||||
let resized = if width != target_w || height != target_h {
|
||||
self.resize_bilinear(image_data, width, height, target_w, target_h)
|
||||
} else {
|
||||
image_data.to_vec()
|
||||
};
|
||||
|
||||
// Convert to NCHW float32 tensor with normalization
|
||||
// Input: grayscale HW, Output: [1, 3, H, W] float32
|
||||
let mut tensor_data = Array4::<f32>::zeros((1, 3, target_h as usize, target_w as usize));
|
||||
|
||||
for y in 0..target_h as usize {
|
||||
for x in 0..target_w as usize {
|
||||
let pixel = resized[y * target_w as usize + x] as f32;
|
||||
// Normalize to [-1, 1] range
|
||||
let normalized = (pixel - 127.5) / 128.0;
|
||||
|
||||
// Replicate grayscale to RGB channels
|
||||
tensor_data[[0, 0, y, x]] = normalized;
|
||||
tensor_data[[0, 1, y, x]] = normalized;
|
||||
tensor_data[[0, 2, y, x]] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tensor_data)
|
||||
}
|
||||
|
||||
/// Extract raw embedding data from model outputs (static function to avoid borrow issues)
|
||||
#[cfg(feature = "onnx")]
|
||||
fn extract_embedding_data(outputs: &ort::session::SessionOutputs) -> Result<Vec<f32>> {
|
||||
// Get the first output (embedding)
|
||||
let output = outputs.get("output")
|
||||
.or_else(|| outputs.get("embedding"))
|
||||
.or_else(|| outputs.get("fc1"))
|
||||
.ok_or_else(|| Error::Detection("No embedding output found".to_string()))?;
|
||||
|
||||
// try_extract_tensor returns (Shape, &[T])
|
||||
let (_, data_slice) = output
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| Error::Detection(format!("Failed to extract tensor: {}", e)))?;
|
||||
|
||||
Ok(data_slice.to_vec())
|
||||
}
|
||||
|
||||
/// L2 normalize an embedding vector
|
||||
fn normalize_embedding(embedding: Vec<f32>) -> Vec<f32> {
|
||||
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
embedding.iter().map(|x| x / norm).collect()
|
||||
} else {
|
||||
embedding
|
||||
}
|
||||
}
|
||||
|
||||
/// Get embedding dimension from model output
|
||||
#[cfg(feature = "onnx")]
|
||||
fn get_embedding_dim(session: &Session) -> Result<usize> {
|
||||
use ort::value::ValueType;
|
||||
|
||||
let outputs = session.outputs();
|
||||
if outputs.is_empty() {
|
||||
return Err(Error::Detection("Model has no outputs".to_string()));
|
||||
}
|
||||
|
||||
// Try to get dimension from output shape
|
||||
// Common output names: "output", "embedding", "fc1"
|
||||
for output in outputs {
|
||||
if let ValueType::Tensor { shape, .. } = output.dtype() {
|
||||
let dims: &[i64] = shape;
|
||||
// Embedding is typically [1, D] or [D]
|
||||
if dims.len() == 2 {
|
||||
return Ok(dims[1].max(1) as usize);
|
||||
} else if dims.len() == 1 {
|
||||
return Ok(dims[0].max(1) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 128 if we can't determine
|
||||
Ok(128)
|
||||
}
|
||||
|
||||
/// Resize image using bilinear interpolation
|
||||
#[cfg(feature = "onnx")]
|
||||
fn resize_bilinear(
|
||||
&self,
|
||||
src: &[u8],
|
||||
src_w: u32,
|
||||
src_h: u32,
|
||||
dst_w: u32,
|
||||
dst_h: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut dst = vec![0u8; (dst_w * dst_h) as usize];
|
||||
|
||||
let scale_x = src_w as f32 / dst_w as f32;
|
||||
let scale_y = src_h as f32 / dst_h as f32;
|
||||
|
||||
for y in 0..dst_h {
|
||||
for x in 0..dst_w {
|
||||
let src_x = (x as f32 + 0.5) * scale_x - 0.5;
|
||||
let src_y = (y as f32 + 0.5) * scale_y - 0.5;
|
||||
|
||||
let x0 = src_x.floor().max(0.0) as u32;
|
||||
let y0 = src_y.floor().max(0.0) as u32;
|
||||
let x1 = (x0 + 1).min(src_w - 1);
|
||||
let y1 = (y0 + 1).min(src_h - 1);
|
||||
|
||||
let fx = src_x - x0 as f32;
|
||||
let fy = src_y - y0 as f32;
|
||||
|
||||
let p00 = src[(y0 * src_w + x0) as usize] as f32;
|
||||
let p01 = src[(y0 * src_w + x1) as usize] as f32;
|
||||
let p10 = src[(y1 * src_w + x0) as usize] as f32;
|
||||
let p11 = src[(y1 * src_w + x1) as usize] as f32;
|
||||
|
||||
let value = p00 * (1.0 - fx) * (1.0 - fy)
|
||||
+ p01 * fx * (1.0 - fy)
|
||||
+ p10 * (1.0 - fx) * fy
|
||||
+ p11 * fx * fy;
|
||||
|
||||
dst[(y * dst_w + x) as usize] = value.clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
dst
|
||||
}
|
||||
}
|
||||
|
||||
// Note: EmbeddingExtractor trait requires &self but ort 2.0 session.run() requires &mut self.
|
||||
// We don't implement EmbeddingExtractor for OnnxEmbeddingExtractor - use extract_from_bytes() directly.
|
||||
|
||||
impl OnnxEmbeddingExtractor {
|
||||
/// Extract from GrayImage (convenience wrapper)
|
||||
pub fn extract(&mut self, face_image: &GrayImage) -> Result<Vec<f32>> {
|
||||
let (width, height) = face_image.dimensions();
|
||||
self.extract_from_bytes(face_image.as_raw(), width, height)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extractor_creation() {
|
||||
let extractor = OnnxEmbeddingExtractor::load("nonexistent.onnx");
|
||||
#[cfg(feature = "onnx")]
|
||||
assert!(extractor.is_err());
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
{
|
||||
assert!(extractor.is_ok());
|
||||
let ext = extractor.unwrap();
|
||||
assert_eq!(ext.embedding_dimension(), 128);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
fn test_unloaded_returns_error() {
|
||||
let extractor = OnnxEmbeddingExtractor::load("test.onnx").unwrap();
|
||||
let result = extractor.extract_from_bytes(&[128; 112 * 112], 112, 112);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("not loaded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_size() {
|
||||
let extractor = OnnxEmbeddingExtractor::load("test.onnx");
|
||||
#[cfg(not(feature = "onnx"))]
|
||||
{
|
||||
let ext = extractor.unwrap();
|
||||
assert_eq!(ext.input_size(), (112, 112));
|
||||
}
|
||||
}
|
||||
}
|
||||
277
linux-hello-daemon/src/onnx/mod.rs
Normal file
277
linux-hello-daemon/src/onnx/mod.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! ONNX Model Integration Module
|
||||
//!
|
||||
//! This module provides ONNX-based face detection and embedding extraction
|
||||
//! using the `ort` crate (ONNX Runtime).
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The ONNX pipeline consists of:
|
||||
//! 1. **Detection** - RetinaFace/BlazeFace model for face detection with landmarks
|
||||
//! 2. **Alignment** - Affine transformation using 5-point landmarks
|
||||
//! 3. **Embedding** - MobileFaceNet/ArcFace for 128/512-dimensional face embeddings
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::onnx::{OnnxFaceDetector, OnnxEmbeddingExtractor, FaceAligner};
|
||||
//!
|
||||
//! // Load models
|
||||
//! let detector = OnnxFaceDetector::load("/path/to/retinaface.onnx")?;
|
||||
//! let aligner = FaceAligner::new();
|
||||
//! let extractor = OnnxEmbeddingExtractor::load("/path/to/mobilefacenet.onnx")?;
|
||||
//!
|
||||
//! // Process frame
|
||||
//! let detections = detector.detect_with_landmarks(&image_data, width, height)?;
|
||||
//! for det in detections {
|
||||
//! let landmarks_px = det.landmarks_to_pixels(width, height);
|
||||
//! let aligned = aligner.align(&image_data, width, height, &landmarks_px)?;
|
||||
//! let embedding = extractor.extract_from_bytes(&aligned, 112, 112)?;
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Feature Flag
|
||||
//!
|
||||
//! This module is only available when the `onnx` feature is enabled:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [dependencies]
|
||||
//! linux-hello-daemon = { version = "0.1", features = ["onnx"] }
|
||||
//! ```
|
||||
//!
|
||||
//! Without the feature enabled, stub implementations are provided that return
|
||||
//! errors indicating the models are not loaded.
|
||||
//!
|
||||
//! # Model Files
|
||||
//!
|
||||
//! Models should be placed in the `models/` directory:
|
||||
//! - `models/retinaface.onnx` - Face detection model
|
||||
//! - `models/mobilefacenet.onnx` - Embedding extraction model
|
||||
//!
|
||||
//! See `models/README.md` for download instructions and model specifications.
|
||||
|
||||
mod alignment;
|
||||
mod detector;
|
||||
mod embedding;
|
||||
|
||||
pub use alignment::{FaceAligner, REFERENCE_LANDMARKS_112, REFERENCE_LANDMARKS_96};
|
||||
pub use detector::{OnnxFaceDetector, DetectionWithLandmarks};
|
||||
pub use embedding::OnnxEmbeddingExtractor;
|
||||
|
||||
/// ONNX model configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OnnxModelConfig {
|
||||
/// Number of inference threads (0 = auto)
|
||||
pub num_threads: usize,
|
||||
/// Enable GPU acceleration if available
|
||||
pub use_gpu: bool,
|
||||
/// Input image size for detection model (width, height)
|
||||
pub detection_input_size: (u32, u32),
|
||||
/// Input image size for embedding model (width, height)
|
||||
pub embedding_input_size: (u32, u32),
|
||||
}
|
||||
|
||||
impl Default for OnnxModelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
num_threads: 0, // Auto-detect
|
||||
use_gpu: false, // CPU-only by default for compatibility
|
||||
detection_input_size: (640, 640),
|
||||
embedding_input_size: (112, 112),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnnxModelConfig {
|
||||
/// Create configuration optimized for speed (smaller input size)
|
||||
pub fn fast() -> Self {
|
||||
Self {
|
||||
num_threads: 4,
|
||||
use_gpu: false,
|
||||
detection_input_size: (320, 320),
|
||||
embedding_input_size: (112, 112),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create configuration optimized for accuracy (larger input size)
|
||||
pub fn accurate() -> Self {
|
||||
Self {
|
||||
num_threads: 0,
|
||||
use_gpu: false,
|
||||
detection_input_size: (640, 640),
|
||||
embedding_input_size: (112, 112),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create configuration for GPU acceleration
|
||||
#[cfg(feature = "onnx")]
|
||||
pub fn with_gpu() -> Self {
|
||||
Self {
|
||||
num_threads: 0,
|
||||
use_gpu: true,
|
||||
detection_input_size: (640, 640),
|
||||
embedding_input_size: (112, 112),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete ONNX face recognition pipeline
|
||||
///
|
||||
/// Combines detection, alignment, and embedding extraction into a single
|
||||
/// easy-to-use interface.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let pipeline = OnnxPipeline::load(
|
||||
/// "models/retinaface.onnx",
|
||||
/// "models/mobilefacenet.onnx",
|
||||
/// )?;
|
||||
///
|
||||
/// let embeddings = pipeline.process_frame(&image_data, width, height)?;
|
||||
/// ```
|
||||
pub struct OnnxPipeline {
|
||||
/// Face detector
|
||||
pub detector: OnnxFaceDetector,
|
||||
/// Face aligner
|
||||
pub aligner: FaceAligner,
|
||||
/// Embedding extractor
|
||||
pub extractor: OnnxEmbeddingExtractor,
|
||||
/// Minimum detection confidence
|
||||
pub min_confidence: f32,
|
||||
}
|
||||
|
||||
impl OnnxPipeline {
|
||||
/// Load pipeline with default configuration
|
||||
pub fn load<P: AsRef<std::path::Path>>(
|
||||
detector_path: P,
|
||||
embedding_path: P,
|
||||
) -> linux_hello_common::Result<Self> {
|
||||
Self::load_with_config(detector_path, embedding_path, &OnnxModelConfig::default())
|
||||
}
|
||||
|
||||
/// Load pipeline with custom configuration
|
||||
pub fn load_with_config<P: AsRef<std::path::Path>>(
|
||||
detector_path: P,
|
||||
embedding_path: P,
|
||||
config: &OnnxModelConfig,
|
||||
) -> linux_hello_common::Result<Self> {
|
||||
let detector = OnnxFaceDetector::load_with_config(&detector_path, config)?;
|
||||
let extractor = OnnxEmbeddingExtractor::load_with_config(&embedding_path, config)?;
|
||||
let aligner = FaceAligner::with_size(
|
||||
config.embedding_input_size.0,
|
||||
config.embedding_input_size.1,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
detector,
|
||||
aligner,
|
||||
extractor,
|
||||
min_confidence: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set minimum detection confidence threshold
|
||||
pub fn set_min_confidence(&mut self, confidence: f32) {
|
||||
self.min_confidence = confidence.clamp(0.0, 1.0);
|
||||
self.detector.set_confidence_threshold(self.min_confidence);
|
||||
}
|
||||
|
||||
/// Process a frame and extract face embeddings
|
||||
///
|
||||
/// Returns a vector of (detection, embedding) pairs for each detected face.
|
||||
pub fn process_frame(
|
||||
&mut self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> linux_hello_common::Result<Vec<(DetectionWithLandmarks, Vec<f32>)>> {
|
||||
// Detect faces
|
||||
let detections = self.detector.detect_with_landmarks(image_data, width, height)?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for detection in detections {
|
||||
if detection.detection.confidence < self.min_confidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert landmarks to pixel coordinates
|
||||
let landmarks_px = detection.landmarks_to_pixels(width, height);
|
||||
|
||||
// Align face
|
||||
let aligned = self.aligner.align(image_data, width, height, &landmarks_px)?;
|
||||
|
||||
// Extract embedding
|
||||
let (align_w, align_h) = self.aligner.output_size();
|
||||
let embedding = self.extractor.extract_from_bytes(&aligned, align_w, align_h)?;
|
||||
|
||||
results.push((detection, embedding));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Process a frame and return only the best face embedding
|
||||
///
|
||||
/// Selects the face with highest confidence.
|
||||
pub fn process_best_face(
|
||||
&mut self,
|
||||
image_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> linux_hello_common::Result<Option<Vec<f32>>> {
|
||||
let results = self.process_frame(image_data, width, height)?;
|
||||
|
||||
// Find the detection with highest confidence
|
||||
let best = results
|
||||
.into_iter()
|
||||
.max_by(|a, b| {
|
||||
a.0.detection.confidence
|
||||
.partial_cmp(&b.0.detection.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
Ok(best.map(|(_, embedding)| embedding))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = OnnxModelConfig::default();
|
||||
assert_eq!(config.num_threads, 0);
|
||||
assert!(!config.use_gpu);
|
||||
assert_eq!(config.detection_input_size, (640, 640));
|
||||
assert_eq!(config.embedding_input_size, (112, 112));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_fast() {
|
||||
let config = OnnxModelConfig::fast();
|
||||
assert_eq!(config.detection_input_size, (320, 320));
|
||||
assert_eq!(config.num_threads, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_accurate() {
|
||||
let config = OnnxModelConfig::accurate();
|
||||
assert_eq!(config.detection_input_size, (640, 640));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reference_landmarks() {
|
||||
// Verify reference landmarks are in expected positions
|
||||
// Left eye should be left of right eye
|
||||
assert!(REFERENCE_LANDMARKS_112[0][0] < REFERENCE_LANDMARKS_112[1][0]);
|
||||
|
||||
// Eyes should be above mouth
|
||||
assert!(REFERENCE_LANDMARKS_112[0][1] < REFERENCE_LANDMARKS_112[3][1]);
|
||||
|
||||
// Nose should be between eyes and mouth
|
||||
assert!(REFERENCE_LANDMARKS_112[2][1] > REFERENCE_LANDMARKS_112[0][1]);
|
||||
assert!(REFERENCE_LANDMARKS_112[2][1] < REFERENCE_LANDMARKS_112[3][1]);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,51 @@
|
||||
//! Secure Memory Module
|
||||
//!
|
||||
//! Provides secure handling of sensitive data like embeddings
|
||||
//! and templates. Key features:
|
||||
//! This module provides secure handling of sensitive biometric data like face
|
||||
//! embeddings and templates. It implements defense-in-depth memory protection.
|
||||
//!
|
||||
//! - Automatic zeroization on drop
|
||||
//! - Memory locking to prevent swapping
|
||||
//! - Secure comparison (constant-time where possible)
|
||||
//! - Protection against memory dumps
|
||||
//! # Security Features
|
||||
//!
|
||||
//! | Feature | Description |
|
||||
//! |---------|-------------|
|
||||
//! | Zeroization | Data is securely erased when dropped |
|
||||
//! | Memory Locking | Data is locked in RAM to prevent swapping |
|
||||
//! | Constant-Time Comparison | Prevents timing attacks |
|
||||
//! | Debug Redaction | Sensitive data is hidden in debug output |
|
||||
//!
|
||||
//! # Types
|
||||
//!
|
||||
//! - [`SecureEmbedding`] - Container for face embedding vectors
|
||||
//! - [`SecureBytes`] - Container for raw sensitive bytes
|
||||
//! - [`ZeroizeGuard`] - RAII guard for automatic zeroization
|
||||
//!
|
||||
//! # Memory Protection
|
||||
//!
|
||||
//! On Linux, the module uses `mlock()` to prevent the kernel from swapping
|
||||
//! sensitive data to disk. This is a best-effort operation that may fail
|
||||
//! if the process lacks `CAP_IPC_LOCK` capability.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use linux_hello_daemon::{SecureEmbedding, SecureBytes};
|
||||
//!
|
||||
//! // Create a secure embedding
|
||||
//! let embedding = SecureEmbedding::new(vec![0.1, 0.2, 0.3]);
|
||||
//! assert_eq!(embedding.len(), 3);
|
||||
//!
|
||||
//! // Constant-time comparison
|
||||
//! let bytes1 = SecureBytes::new(vec![1, 2, 3, 4]);
|
||||
//! let bytes2 = SecureBytes::new(vec![1, 2, 3, 4]);
|
||||
//! assert!(bytes1.constant_time_eq(&bytes2));
|
||||
//!
|
||||
//! // Debug output is redacted
|
||||
//! let debug = format!("{:?}", embedding);
|
||||
//! assert!(debug.contains("REDACTED"));
|
||||
//! ```
|
||||
|
||||
use linux_hello_common::{Error, Result};
|
||||
use std::fmt;
|
||||
use subtle::{Choice, ConstantTimeEq};
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
/// Secure container for face embedding data
|
||||
@@ -23,8 +59,38 @@ pub struct SecureEmbedding {
|
||||
|
||||
impl SecureEmbedding {
|
||||
/// Create a new secure embedding from raw data
|
||||
///
|
||||
/// Attempts to lock the memory to prevent swapping.
|
||||
pub fn new(data: Vec<f32>) -> Self {
|
||||
Self { data }
|
||||
let embedding = Self { data };
|
||||
// Attempt to lock memory - failure is not fatal but logged
|
||||
embedding.try_lock_memory();
|
||||
embedding
|
||||
}
|
||||
|
||||
/// Attempt to lock the embedding data in memory to prevent swapping
|
||||
fn try_lock_memory(&self) {
|
||||
// Convert f32 slice to byte slice for mlock
|
||||
let byte_slice = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
self.data.as_ptr() as *const u8,
|
||||
self.data.len() * std::mem::size_of::<f32>(),
|
||||
)
|
||||
};
|
||||
if let Err(e) = memory_protection::lock_memory(byte_slice) {
|
||||
tracing::warn!("Failed to lock SecureEmbedding memory: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock memory when dropping (called by Drop implementation)
|
||||
fn try_unlock_memory(&self) {
|
||||
let byte_slice = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
self.data.as_ptr() as *const u8,
|
||||
self.data.len() * std::mem::size_of::<f32>(),
|
||||
)
|
||||
};
|
||||
let _ = memory_protection::unlock_memory(byte_slice);
|
||||
}
|
||||
|
||||
/// Get the embedding dimension
|
||||
@@ -46,10 +112,20 @@ impl SecureEmbedding {
|
||||
}
|
||||
|
||||
/// Calculate cosine similarity with another embedding
|
||||
///
|
||||
///
|
||||
/// Returns a value between -1.0 and 1.0
|
||||
///
|
||||
/// # Security Note
|
||||
/// This implementation processes all elements in constant time relative to
|
||||
/// the maximum length of both embeddings to prevent timing side-channels.
|
||||
/// The length comparison result is computed in constant time.
|
||||
pub fn cosine_similarity(&self, other: &SecureEmbedding) -> f32 {
|
||||
if self.len() != other.len() || self.is_empty() {
|
||||
let self_len = self.len();
|
||||
let other_len = other.len();
|
||||
let max_len = self_len.max(other_len);
|
||||
|
||||
// Handle empty case - still do dummy work to maintain constant time
|
||||
if max_len == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@@ -57,14 +133,29 @@ impl SecureEmbedding {
|
||||
let mut norm_a = 0.0f32;
|
||||
let mut norm_b = 0.0f32;
|
||||
|
||||
for (a, b) in self.data.iter().zip(other.data.iter()) {
|
||||
// Process all elements up to max_len to ensure constant-time operation
|
||||
// Use conditional selection to avoid branching based on actual lengths
|
||||
for i in 0..max_len {
|
||||
// Use 0.0 for out-of-bounds access (constant-time selection)
|
||||
let a = if i < self_len { self.data[i] } else { 0.0 };
|
||||
let b = if i < other_len { other.data[i] } else { 0.0 };
|
||||
|
||||
dot_product += a * b;
|
||||
norm_a += a * a;
|
||||
norm_b += b * b;
|
||||
}
|
||||
|
||||
// Compute whether lengths match using constant-time comparison
|
||||
// We use subtle's ConstantTimeEq for the length check
|
||||
let lengths_match: Choice = self_len.ct_eq(&other_len);
|
||||
|
||||
let denominator = (norm_a.sqrt() * norm_b.sqrt()).max(f32::EPSILON);
|
||||
dot_product / denominator
|
||||
let similarity = dot_product / denominator;
|
||||
|
||||
// Return 0.0 if lengths don't match, otherwise return similarity
|
||||
// This selection is constant-time using subtle's Choice
|
||||
let zero = 0.0f32;
|
||||
if bool::from(lengths_match) { similarity } else { zero }
|
||||
}
|
||||
|
||||
/// Calculate Euclidean distance with another embedding
|
||||
@@ -144,16 +235,44 @@ impl SecureBytes {
|
||||
}
|
||||
|
||||
/// Constant-time comparison to prevent timing attacks
|
||||
///
|
||||
/// # Security Note
|
||||
/// This implementation is truly constant-time:
|
||||
/// - Processes all bytes up to the maximum length of both inputs
|
||||
/// - Uses the `subtle` crate for constant-time operations
|
||||
/// - Takes the same time regardless of:
|
||||
/// - Whether lengths match
|
||||
/// - How many bytes match
|
||||
/// - The position of first difference
|
||||
pub fn constant_time_eq(&self, other: &SecureBytes) -> bool {
|
||||
if self.len() != other.len() {
|
||||
return false;
|
||||
let self_len = self.len();
|
||||
let other_len = other.len();
|
||||
let max_len = self_len.max(other_len);
|
||||
|
||||
// Start with length comparison using constant-time equality
|
||||
let lengths_match: Choice = self_len.ct_eq(&other_len);
|
||||
|
||||
// XOR accumulator for byte differences
|
||||
let mut differences: u8 = 0;
|
||||
|
||||
// Process all bytes up to max_len to ensure constant-time operation
|
||||
// regardless of actual lengths
|
||||
for i in 0..max_len {
|
||||
// Use 0xFF for out-of-bounds access (ensures mismatch if lengths differ)
|
||||
// This is constant-time because we always do the comparison
|
||||
let a = if i < self_len { self.data[i] } else { 0xFF };
|
||||
let b = if i < other_len { other.data[i] } else { 0x00 };
|
||||
|
||||
differences |= a ^ b;
|
||||
}
|
||||
|
||||
let mut result: u8 = 0;
|
||||
for (a, b) in self.data.iter().zip(other.data.iter()) {
|
||||
result |= a ^ b;
|
||||
}
|
||||
result == 0
|
||||
// Both conditions must be true:
|
||||
// 1. Lengths must match (constant-time check)
|
||||
// 2. All bytes must be equal (constant-time accumulation)
|
||||
let bytes_match: Choice = differences.ct_eq(&0u8);
|
||||
|
||||
// Combine both conditions using constant-time AND
|
||||
bool::from(lengths_match & bytes_match)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,11 +442,50 @@ mod tests {
|
||||
let bytes1 = SecureBytes::new(vec![1, 2, 3, 4]);
|
||||
let bytes2 = SecureBytes::new(vec![1, 2, 3, 4]);
|
||||
let bytes3 = SecureBytes::new(vec![1, 2, 3, 5]);
|
||||
|
||||
|
||||
assert!(bytes1.constant_time_eq(&bytes2));
|
||||
assert!(!bytes1.constant_time_eq(&bytes3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secure_bytes_constant_time_eq_length_mismatch() {
|
||||
// Test that length mismatches are handled correctly
|
||||
let short = SecureBytes::new(vec![1, 2, 3]);
|
||||
let long = SecureBytes::new(vec![1, 2, 3, 4, 5]);
|
||||
let same_prefix = SecureBytes::new(vec![1, 2, 3, 0, 0]);
|
||||
|
||||
// Different lengths should never match
|
||||
assert!(!short.constant_time_eq(&long));
|
||||
assert!(!long.constant_time_eq(&short));
|
||||
|
||||
// Even if one is a prefix of the other
|
||||
assert!(!short.constant_time_eq(&same_prefix));
|
||||
|
||||
// Empty cases
|
||||
let empty1 = SecureBytes::new(vec![]);
|
||||
let empty2 = SecureBytes::new(vec![]);
|
||||
let non_empty = SecureBytes::new(vec![1]);
|
||||
|
||||
assert!(empty1.constant_time_eq(&empty2));
|
||||
assert!(!empty1.constant_time_eq(&non_empty));
|
||||
assert!(!non_empty.constant_time_eq(&empty1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secure_embedding_cosine_similarity_length_mismatch() {
|
||||
// Test that length mismatches return 0.0
|
||||
let short = SecureEmbedding::new(vec![1.0, 0.0]);
|
||||
let long = SecureEmbedding::new(vec![1.0, 0.0, 0.0]);
|
||||
|
||||
assert_eq!(short.cosine_similarity(&long), 0.0);
|
||||
assert_eq!(long.cosine_similarity(&short), 0.0);
|
||||
|
||||
// Empty embeddings
|
||||
let empty = SecureEmbedding::new(vec![]);
|
||||
assert_eq!(empty.cosine_similarity(&short), 0.0);
|
||||
assert_eq!(short.cosine_similarity(&empty), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secure_zero() {
|
||||
let mut data = vec![1u8, 2, 3, 4, 5];
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
//! TPM2 Storage Module
|
||||
//!
|
||||
//! Provides secure storage for face templates using TPM2.
|
||||
//! Templates are encrypted with TPM-bound keys, making them
|
||||
//! inaccessible without the specific TPM hardware.
|
||||
//! This module provides secure storage for face templates using TPM2 (Trusted
|
||||
//! Platform Module). Templates are encrypted with hardware-bound keys, making
|
||||
//! them inaccessible without the specific TPM hardware.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! TPM provides hardware-rooted security for biometric data:
|
||||
//!
|
||||
//! - Keys cannot be extracted from the TPM
|
||||
//! - Encrypted data is bound to the specific machine
|
||||
//! - Optional PCR binding ties data to boot configuration
|
||||
//!
|
||||
//! # Key Hierarchy
|
||||
//!
|
||||
//! Key Hierarchy:
|
||||
//! ```text
|
||||
//! TPM Storage Root Key (SRK)
|
||||
//! └── Linux Hello Primary Key (sealed to PCRs)
|
||||
//! └── User Template Encryption Key (per-user)
|
||||
//! └── Encrypted face template
|
||||
//! ```
|
||||
//!
|
||||
//! # Software Fallback
|
||||
//!
|
||||
//! When TPM is not available, the module falls back to software encryption:
|
||||
//!
|
||||
//! - AES-256-GCM for authenticated encryption
|
||||
//! - PBKDF2-HMAC-SHA256 for key derivation (600,000 iterations)
|
||||
//! - Cryptographically secure random for IV/salt
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use linux_hello_daemon::tpm::{TpmStorage, SoftwareTpmFallback, get_tpm_storage};
|
||||
//!
|
||||
//! // Get appropriate storage (TPM or fallback)
|
||||
//! let mut storage = get_tpm_storage();
|
||||
//!
|
||||
//! // Initialize storage
|
||||
//! storage.initialize()?;
|
||||
//!
|
||||
//! // Encrypt a template
|
||||
//! let plaintext = b"embedding data...";
|
||||
//! let encrypted = storage.encrypt("alice", plaintext)?;
|
||||
//!
|
||||
//! // Decrypt later
|
||||
//! let decrypted = storage.decrypt("alice", &encrypted)?;
|
||||
//! assert_eq!(decrypted, plaintext);
|
||||
//! ```
|
||||
//!
|
||||
//! # Security Considerations
|
||||
//!
|
||||
//! - TPM encryption provides stronger security than software fallback
|
||||
//! - PCR binding can prevent decryption after boot config changes
|
||||
//! - Software fallback is still secure but not hardware-bound
|
||||
|
||||
use linux_hello_common::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,10 +68,12 @@ pub const PRIMARY_KEY_HANDLE: u32 = 0x81000001;
|
||||
/// Encrypted template data structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptedTemplate {
|
||||
/// Encrypted embedding data
|
||||
/// Encrypted embedding data (includes AES-GCM authentication tag)
|
||||
pub ciphertext: Vec<u8>,
|
||||
/// Initialization vector
|
||||
/// Initialization vector (nonce) - 12 bytes for AES-GCM
|
||||
pub iv: Vec<u8>,
|
||||
/// Salt used for key derivation - 32 bytes
|
||||
pub salt: Vec<u8>,
|
||||
/// Key handle used for encryption
|
||||
pub key_handle: u32,
|
||||
/// Whether this template is TPM-encrypted
|
||||
@@ -56,14 +101,27 @@ pub trait TpmStorage {
|
||||
fn remove_user_key(&mut self, user: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// PBKDF2 iteration count - OWASP recommends at least 600,000 for SHA-256
|
||||
const PBKDF2_ITERATIONS: u32 = 600_000;
|
||||
|
||||
/// Salt size in bytes (256 bits)
|
||||
const SALT_SIZE: usize = 32;
|
||||
|
||||
/// AES-GCM nonce size (96 bits as recommended by NIST)
|
||||
const NONCE_SIZE: usize = 12;
|
||||
|
||||
/// Software-only fallback encryption (when TPM is not available)
|
||||
///
|
||||
/// WARNING: This is NOT secure for production use. It provides
|
||||
/// basic encryption for development/testing without TPM hardware.
|
||||
///
|
||||
/// This implementation uses cryptographically secure algorithms:
|
||||
/// - AES-256-GCM for authenticated encryption
|
||||
/// - PBKDF2-HMAC-SHA256 for key derivation
|
||||
/// - Cryptographically secure random number generation for IV and salt
|
||||
#[derive(Debug)]
|
||||
pub struct SoftwareTpmFallback {
|
||||
key_path: PathBuf,
|
||||
initialized: bool,
|
||||
/// Master secret loaded from key file (used as password input to PBKDF2)
|
||||
master_secret: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SoftwareTpmFallback {
|
||||
@@ -71,6 +129,7 @@ impl SoftwareTpmFallback {
|
||||
Self {
|
||||
key_path: key_path.as_ref().to_path_buf(),
|
||||
initialized: false,
|
||||
master_secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,30 +142,111 @@ impl SoftwareTpmFallback {
|
||||
self.key_path.join(format!("{}.key", user))
|
||||
}
|
||||
|
||||
/// Simple XOR-based encryption (placeholder - NOT SECURE)
|
||||
/// In production, use proper AES-GCM with derived keys
|
||||
fn xor_encrypt(&self, data: &[u8], key: &[u8]) -> Vec<u8> {
|
||||
data.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &byte)| byte ^ key[i % key.len()])
|
||||
.collect()
|
||||
/// Get path to master secret file
|
||||
fn master_secret_path(&self) -> PathBuf {
|
||||
self.key_path.join("master.secret")
|
||||
}
|
||||
|
||||
/// Generate a pseudo-random key from user identifier
|
||||
/// WARNING: This is NOT cryptographically secure
|
||||
fn derive_key(&self, user: &str) -> Vec<u8> {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut key = Vec::with_capacity(32);
|
||||
for i in 0..4 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
user.hash(&mut hasher);
|
||||
i.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
key.extend_from_slice(&hash.to_le_bytes());
|
||||
/// Generate or load the master secret
|
||||
fn ensure_master_secret(&mut self) -> Result<()> {
|
||||
use rand::RngCore;
|
||||
|
||||
if self.master_secret.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
key
|
||||
|
||||
let secret_path = self.master_secret_path();
|
||||
|
||||
if secret_path.exists() {
|
||||
// Load existing master secret
|
||||
let secret = std::fs::read(&secret_path)?;
|
||||
if secret.len() != 32 {
|
||||
return Err(Error::Tpm("Invalid master secret file".to_string()));
|
||||
}
|
||||
self.master_secret = Some(secret);
|
||||
} else {
|
||||
// Generate new master secret using cryptographically secure RNG
|
||||
let mut secret = vec![0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut secret);
|
||||
|
||||
// Write with restricted permissions
|
||||
std::fs::write(&secret_path, &secret)?;
|
||||
|
||||
// Set file permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&secret_path, perms)?;
|
||||
}
|
||||
|
||||
self.master_secret = Some(secret);
|
||||
debug!("Generated new master secret");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive an AES-256 key using PBKDF2-HMAC-SHA256
|
||||
///
|
||||
/// The key is derived from: master_secret || user_identifier
|
||||
/// This ensures each user gets a unique key while still requiring
|
||||
/// knowledge of the master secret.
|
||||
fn derive_key(&self, user: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha2::Sha256;
|
||||
|
||||
let master = self.master_secret.as_ref()
|
||||
.ok_or_else(|| Error::Tpm("Master secret not initialized".to_string()))?;
|
||||
|
||||
// Combine master secret with user identifier as password
|
||||
let mut password = master.clone();
|
||||
password.extend_from_slice(user.as_bytes());
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
pbkdf2_hmac::<Sha256>(&password, salt, PBKDF2_ITERATIONS, &mut key);
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt data using AES-256-GCM
|
||||
fn aes_gcm_encrypt(&self, plaintext: &[u8], key: &[u8; 32], nonce: &[u8; NONCE_SIZE]) -> Result<Vec<u8>> {
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| Error::Tpm(format!("Failed to create cipher: {}", e)))?;
|
||||
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
|
||||
cipher.encrypt(nonce, plaintext)
|
||||
.map_err(|e| Error::Tpm(format!("Encryption failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Decrypt data using AES-256-GCM
|
||||
fn aes_gcm_decrypt(&self, ciphertext: &[u8], key: &[u8; 32], nonce: &[u8]) -> Result<Vec<u8>> {
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| Error::Tpm(format!("Failed to create cipher: {}", e)))?;
|
||||
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
|
||||
cipher.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| Error::Tpm(format!("Decryption failed (authentication error): {}", e)))
|
||||
}
|
||||
|
||||
/// Generate cryptographically secure random bytes
|
||||
fn generate_random_bytes<const N: usize>() -> [u8; N] {
|
||||
use rand::RngCore;
|
||||
let mut bytes = [0u8; N];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,21 +258,38 @@ impl TpmStorage for SoftwareTpmFallback {
|
||||
|
||||
fn initialize(&mut self) -> Result<()> {
|
||||
std::fs::create_dir_all(&self.key_path)?;
|
||||
|
||||
// Set directory permissions to 0700 (owner only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o700);
|
||||
std::fs::set_permissions(&self.key_path, perms)?;
|
||||
}
|
||||
|
||||
// Generate or load master secret
|
||||
self.ensure_master_secret()?;
|
||||
|
||||
self.initialized = true;
|
||||
warn!("Using software TPM fallback - templates are NOT securely encrypted");
|
||||
warn!("Using software TPM fallback - no hardware TPM protection");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encrypt(&self, user: &str, plaintext: &[u8]) -> Result<EncryptedTemplate> {
|
||||
let key = self.derive_key(user);
|
||||
let iv: Vec<u8> = (0..16).map(|i| ((i * 17 + 42) % 256) as u8).collect();
|
||||
|
||||
// XOR with key (NOT SECURE - placeholder only)
|
||||
let ciphertext = self.xor_encrypt(plaintext, &key);
|
||||
|
||||
// Generate cryptographically secure random salt and nonce
|
||||
let salt: [u8; SALT_SIZE] = Self::generate_random_bytes();
|
||||
let nonce: [u8; NONCE_SIZE] = Self::generate_random_bytes();
|
||||
|
||||
// Derive key using PBKDF2-HMAC-SHA256
|
||||
let key = self.derive_key(user, &salt)?;
|
||||
|
||||
// Encrypt using AES-256-GCM (provides both confidentiality and authenticity)
|
||||
let ciphertext = self.aes_gcm_encrypt(plaintext, &key, &nonce)?;
|
||||
|
||||
Ok(EncryptedTemplate {
|
||||
ciphertext,
|
||||
iv,
|
||||
iv: nonce.to_vec(),
|
||||
salt: salt.to_vec(),
|
||||
key_handle: 0, // No TPM handle for software fallback
|
||||
tpm_encrypted: false,
|
||||
})
|
||||
@@ -144,27 +301,53 @@ impl TpmStorage for SoftwareTpmFallback {
|
||||
"Cannot decrypt TPM-encrypted template without TPM".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let key = self.derive_key(user);
|
||||
let plaintext = self.xor_encrypt(&encrypted.ciphertext, &key);
|
||||
|
||||
|
||||
// Validate salt and IV sizes
|
||||
if encrypted.salt.len() != SALT_SIZE {
|
||||
return Err(Error::Tpm(format!(
|
||||
"Invalid salt size: expected {}, got {}",
|
||||
SALT_SIZE, encrypted.salt.len()
|
||||
)));
|
||||
}
|
||||
if encrypted.iv.len() != NONCE_SIZE {
|
||||
return Err(Error::Tpm(format!(
|
||||
"Invalid IV/nonce size: expected {}, got {}",
|
||||
NONCE_SIZE, encrypted.iv.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Derive key using the stored salt
|
||||
let key = self.derive_key(user, &encrypted.salt)?;
|
||||
|
||||
// Decrypt using AES-256-GCM (will fail if data was tampered with)
|
||||
let plaintext = self.aes_gcm_decrypt(&encrypted.ciphertext, &key, &encrypted.iv)?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
fn create_user_key(&mut self, user: &str) -> Result<()> {
|
||||
let key_path = self.user_key_path(user);
|
||||
let _key = self.derive_key(user); // Derived but not stored in software fallback
|
||||
|
||||
// Store key metadata (not the actual key for software fallback)
|
||||
let metadata = format!("user={}\ncreated={}", user,
|
||||
|
||||
// Store key metadata (the actual key is derived on-demand using PBKDF2)
|
||||
let metadata = format!("user={}\ncreated={}",
|
||||
user,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
);
|
||||
|
||||
|
||||
std::fs::write(&key_path, metadata)?;
|
||||
debug!("Created software key for user: {}", user);
|
||||
|
||||
// Set file permissions to 0600 (owner read/write only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(&key_path, perms)?;
|
||||
}
|
||||
|
||||
debug!("Created software key metadata for user: {}", user);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -367,16 +550,18 @@ mod tests {
|
||||
fn test_encrypted_template_serialization() {
|
||||
let template = EncryptedTemplate {
|
||||
ciphertext: vec![1, 2, 3, 4, 5],
|
||||
iv: vec![10, 20, 30, 40],
|
||||
iv: vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120], // 12 bytes for AES-GCM
|
||||
salt: vec![0u8; 32], // 32 bytes for PBKDF2 salt
|
||||
key_handle: PRIMARY_KEY_HANDLE,
|
||||
tpm_encrypted: false,
|
||||
};
|
||||
|
||||
|
||||
let json = serde_json::to_string(&template).unwrap();
|
||||
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
|
||||
assert_eq!(restored.ciphertext, template.ciphertext);
|
||||
assert_eq!(restored.iv, template.iv);
|
||||
assert_eq!(restored.salt, template.salt);
|
||||
assert_eq!(restored.key_handle, template.key_handle);
|
||||
}
|
||||
}
|
||||
|
||||
453
linux-hello-daemon/tests/onnx_integration.rs
Normal file
453
linux-hello-daemon/tests/onnx_integration.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
//! Integration tests for ONNX face recognition pipeline
|
||||
//!
|
||||
//! These tests require the `onnx` feature to be enabled:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test --features onnx
|
||||
//! ```
|
||||
//!
|
||||
//! Note: Tests that load actual ONNX models are marked with `#[ignore]` by default
|
||||
//! since they require model files to be present. Run with:
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test --features onnx -- --ignored
|
||||
//! ```
|
||||
|
||||
#![cfg(feature = "onnx")]
|
||||
|
||||
use linux_hello_daemon::onnx::{
|
||||
FaceAligner, OnnxEmbeddingExtractor, OnnxFaceDetector, OnnxModelConfig, OnnxPipeline,
|
||||
REFERENCE_LANDMARKS_112,
|
||||
};
|
||||
use linux_hello_daemon::{FaceDetect, EmbeddingExtractor};
|
||||
use std::path::Path;
|
||||
|
||||
/// Model directory path
|
||||
const MODEL_DIR: &str = "../models";
|
||||
|
||||
/// Test image dimensions
|
||||
const TEST_WIDTH: u32 = 640;
|
||||
const TEST_HEIGHT: u32 = 480;
|
||||
|
||||
/// Create a synthetic test image with face-like pattern
|
||||
fn create_test_image(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut image = vec![128u8; (width * height) as usize];
|
||||
|
||||
// Create a simple face-like pattern in the center
|
||||
let face_x = width / 4;
|
||||
let face_y = height / 4;
|
||||
let face_w = width / 2;
|
||||
let face_h = height / 2;
|
||||
|
||||
// Draw face region (lighter)
|
||||
for y in face_y..(face_y + face_h) {
|
||||
for x in face_x..(face_x + face_w) {
|
||||
let idx = (y * width + x) as usize;
|
||||
image[idx] = 180;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw "eyes" (darker spots)
|
||||
let eye_y = face_y + face_h / 4;
|
||||
let left_eye_x = face_x + face_w / 3;
|
||||
let right_eye_x = face_x + 2 * face_w / 3;
|
||||
let eye_radius = face_w / 10;
|
||||
|
||||
for dy in 0..eye_radius {
|
||||
for dx in 0..eye_radius {
|
||||
let idx_l = ((eye_y + dy) * width + left_eye_x + dx) as usize;
|
||||
let idx_r = ((eye_y + dy) * width + right_eye_x + dx) as usize;
|
||||
if idx_l < image.len() {
|
||||
image[idx_l] = 60;
|
||||
}
|
||||
if idx_r < image.len() {
|
||||
image[idx_r] = 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
image
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Unit Tests (no model files required)
|
||||
// =============================================================================
|
||||
|
||||
mod alignment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_aligner_default_size() {
|
||||
let aligner = FaceAligner::new();
|
||||
assert_eq!(aligner.output_size(), (112, 112));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aligner_custom_size() {
|
||||
let aligner = FaceAligner::with_size(224, 224);
|
||||
assert_eq!(aligner.output_size(), (224, 224));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_align_produces_correct_output_size() {
|
||||
let aligner = FaceAligner::new();
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
|
||||
// Create fake landmarks in pixel coordinates
|
||||
let landmarks = [
|
||||
[200.0, 150.0], // Left eye
|
||||
[300.0, 150.0], // Right eye
|
||||
[250.0, 200.0], // Nose
|
||||
[210.0, 250.0], // Left mouth
|
||||
[290.0, 250.0], // Right mouth
|
||||
];
|
||||
|
||||
let result = aligner.align(&image, TEST_WIDTH, TEST_HEIGHT, &landmarks);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let aligned = result.unwrap();
|
||||
assert_eq!(aligned.len(), 112 * 112);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_crop_fallback() {
|
||||
let aligner = FaceAligner::new();
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
|
||||
let result = aligner.simple_crop(
|
||||
&image,
|
||||
TEST_WIDTH,
|
||||
TEST_HEIGHT,
|
||||
100, // face_x
|
||||
100, // face_y
|
||||
200, // face_width
|
||||
200, // face_height
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let cropped = result.unwrap();
|
||||
assert_eq!(cropped.len(), 112 * 112);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reference_landmarks_validity() {
|
||||
// Left eye should be left of right eye
|
||||
assert!(REFERENCE_LANDMARKS_112[0][0] < REFERENCE_LANDMARKS_112[1][0]);
|
||||
|
||||
// Eyes should be at similar height
|
||||
let eye_y_diff = (REFERENCE_LANDMARKS_112[0][1] - REFERENCE_LANDMARKS_112[1][1]).abs();
|
||||
assert!(eye_y_diff < 1.0);
|
||||
|
||||
// Nose should be below eyes
|
||||
assert!(REFERENCE_LANDMARKS_112[2][1] > REFERENCE_LANDMARKS_112[0][1]);
|
||||
|
||||
// Mouth corners should be below nose
|
||||
assert!(REFERENCE_LANDMARKS_112[3][1] > REFERENCE_LANDMARKS_112[2][1]);
|
||||
assert!(REFERENCE_LANDMARKS_112[4][1] > REFERENCE_LANDMARKS_112[2][1]);
|
||||
}
|
||||
}
|
||||
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = OnnxModelConfig::default();
|
||||
assert_eq!(config.num_threads, 0);
|
||||
assert!(!config.use_gpu);
|
||||
assert_eq!(config.detection_input_size, (640, 640));
|
||||
assert_eq!(config.embedding_input_size, (112, 112));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fast_config() {
|
||||
let config = OnnxModelConfig::fast();
|
||||
assert_eq!(config.detection_input_size, (320, 320));
|
||||
assert_eq!(config.num_threads, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accurate_config() {
|
||||
let config = OnnxModelConfig::accurate();
|
||||
assert_eq!(config.detection_input_size, (640, 640));
|
||||
}
|
||||
}
|
||||
|
||||
mod detector_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detector_stub_without_model() {
|
||||
// Without actual model, detector should be created but return errors on use
|
||||
let detector = OnnxFaceDetector::load("nonexistent.onnx");
|
||||
|
||||
// On non-onnx builds, this returns a stub
|
||||
// On onnx builds, this returns an error because file doesn't exist
|
||||
// Both behaviors are acceptable
|
||||
if let Ok(det) = detector {
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
let result = det.detect(&image, TEST_WIDTH, TEST_HEIGHT);
|
||||
// Should fail because model not actually loaded
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detector_input_size_accessors() {
|
||||
if let Ok(detector) = OnnxFaceDetector::load("test.onnx") {
|
||||
let (w, h) = detector.input_size();
|
||||
assert!(w > 0);
|
||||
assert!(h > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod embedding_tests {
|
||||
use super::*;
|
||||
use image::GrayImage;
|
||||
|
||||
#[test]
|
||||
fn test_embedding_stub_without_model() {
|
||||
let extractor = OnnxEmbeddingExtractor::load("nonexistent.onnx");
|
||||
|
||||
if let Ok(ext) = extractor {
|
||||
let face = GrayImage::from_raw(112, 112, vec![128u8; 112 * 112]).unwrap();
|
||||
let result = ext.extract(&face);
|
||||
// Should fail because model not actually loaded
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_dimension() {
|
||||
if let Ok(extractor) = OnnxEmbeddingExtractor::load("test.onnx") {
|
||||
assert!(extractor.embedding_dimension() > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Integration Tests (require model files)
|
||||
// =============================================================================
|
||||
|
||||
mod integration_with_models {
|
||||
use super::*;
|
||||
|
||||
fn model_path(name: &str) -> String {
|
||||
format!("{}/{}", MODEL_DIR, name)
|
||||
}
|
||||
|
||||
fn models_available() -> bool {
|
||||
Path::new(&model_path("retinaface.onnx")).exists()
|
||||
&& Path::new(&model_path("mobilefacenet.onnx")).exists()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_load_detection_model() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = OnnxFaceDetector::load(model_path("retinaface.onnx"));
|
||||
assert!(result.is_ok(), "Failed to load detection model: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_load_embedding_model() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let result = OnnxEmbeddingExtractor::load(model_path("mobilefacenet.onnx"));
|
||||
assert!(result.is_ok(), "Failed to load embedding model: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_detection_on_synthetic_image() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let detector = OnnxFaceDetector::load(model_path("retinaface.onnx"))
|
||||
.expect("Failed to load detector");
|
||||
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
let detections = detector.detect(&image, TEST_WIDTH, TEST_HEIGHT);
|
||||
|
||||
assert!(detections.is_ok(), "Detection failed: {:?}", detections.err());
|
||||
// Note: synthetic image may or may not trigger detections
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_embedding_extraction() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let extractor = OnnxEmbeddingExtractor::load(model_path("mobilefacenet.onnx"))
|
||||
.expect("Failed to load extractor");
|
||||
|
||||
// Create aligned face image
|
||||
let face_data = vec![128u8; 112 * 112];
|
||||
let result = extractor.extract_from_bytes(&face_data, 112, 112);
|
||||
|
||||
assert!(result.is_ok(), "Embedding extraction failed: {:?}", result.err());
|
||||
|
||||
let embedding = result.unwrap();
|
||||
assert_eq!(embedding.len(), extractor.embedding_dimension());
|
||||
|
||||
// Check embedding is normalized (L2 norm should be ~1)
|
||||
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 0.1, "Embedding not normalized: norm = {}", norm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_full_pipeline() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let pipeline = OnnxPipeline::load(
|
||||
model_path("retinaface.onnx"),
|
||||
model_path("mobilefacenet.onnx"),
|
||||
)
|
||||
.expect("Failed to load pipeline");
|
||||
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
let results = pipeline.process_frame(&image, TEST_WIDTH, TEST_HEIGHT);
|
||||
|
||||
assert!(results.is_ok(), "Pipeline processing failed: {:?}", results.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_embedding_consistency() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let extractor = OnnxEmbeddingExtractor::load(model_path("mobilefacenet.onnx"))
|
||||
.expect("Failed to load extractor");
|
||||
|
||||
// Same face should produce similar embeddings
|
||||
let face_data = vec![128u8; 112 * 112];
|
||||
|
||||
let embedding1 = extractor.extract_from_bytes(&face_data, 112, 112)
|
||||
.expect("First extraction failed");
|
||||
let embedding2 = extractor.extract_from_bytes(&face_data, 112, 112)
|
||||
.expect("Second extraction failed");
|
||||
|
||||
// Compute cosine similarity
|
||||
let dot: f32 = embedding1.iter().zip(&embedding2).map(|(a, b)| a * b).sum();
|
||||
let norm1: f32 = embedding1.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm2: f32 = embedding2.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let similarity = dot / (norm1 * norm2);
|
||||
|
||||
// Same input should give identical output (similarity = 1.0)
|
||||
assert!(
|
||||
(similarity - 1.0).abs() < 0.001,
|
||||
"Same input gave different embeddings: similarity = {}",
|
||||
similarity
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires ONNX model files to be present"]
|
||||
fn test_different_faces_produce_different_embeddings() {
|
||||
if !models_available() {
|
||||
eprintln!("Skipping: model files not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let extractor = OnnxEmbeddingExtractor::load(model_path("mobilefacenet.onnx"))
|
||||
.expect("Failed to load extractor");
|
||||
|
||||
// Two different "faces"
|
||||
let face1 = vec![100u8; 112 * 112];
|
||||
let face2 = vec![200u8; 112 * 112];
|
||||
|
||||
let embedding1 = extractor.extract_from_bytes(&face1, 112, 112)
|
||||
.expect("First extraction failed");
|
||||
let embedding2 = extractor.extract_from_bytes(&face2, 112, 112)
|
||||
.expect("Second extraction failed");
|
||||
|
||||
// Compute cosine similarity
|
||||
let dot: f32 = embedding1.iter().zip(&embedding2).map(|(a, b)| a * b).sum();
|
||||
let norm1: f32 = embedding1.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm2: f32 = embedding2.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let similarity = dot / (norm1 * norm2);
|
||||
|
||||
// Different inputs should produce different embeddings
|
||||
assert!(
|
||||
similarity < 0.99,
|
||||
"Different inputs gave too similar embeddings: similarity = {}",
|
||||
similarity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Benchmark-style tests (optional, for performance tracking)
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod benchmarks {
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn test_alignment_performance() {
|
||||
let aligner = FaceAligner::new();
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
let landmarks = [
|
||||
[200.0, 150.0],
|
||||
[300.0, 150.0],
|
||||
[250.0, 200.0],
|
||||
[210.0, 250.0],
|
||||
[290.0, 250.0],
|
||||
];
|
||||
|
||||
let iterations = 100;
|
||||
let start = Instant::now();
|
||||
|
||||
for _ in 0..iterations {
|
||||
let _ = aligner.align(&image, TEST_WIDTH, TEST_HEIGHT, &landmarks);
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let avg_ms = elapsed.as_millis() as f64 / iterations as f64;
|
||||
|
||||
println!("Face alignment: {:.2}ms per iteration", avg_ms);
|
||||
assert!(avg_ms < 50.0, "Alignment too slow: {}ms", avg_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_crop_performance() {
|
||||
let aligner = FaceAligner::new();
|
||||
let image = create_test_image(TEST_WIDTH, TEST_HEIGHT);
|
||||
|
||||
let iterations = 100;
|
||||
let start = Instant::now();
|
||||
|
||||
for _ in 0..iterations {
|
||||
let _ = aligner.simple_crop(&image, TEST_WIDTH, TEST_HEIGHT, 100, 100, 200, 200);
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let avg_ms = elapsed.as_millis() as f64 / iterations as f64;
|
||||
|
||||
println!("Simple crop: {:.2}ms per iteration", avg_ms);
|
||||
assert!(avg_ms < 20.0, "Simple crop too slow: {}ms", avg_ms);
|
||||
}
|
||||
}
|
||||
40
linux-hello-settings/Cargo.toml
Normal file
40
linux-hello-settings/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "linux-hello-settings"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "GNOME Settings application for Linux Hello facial authentication"
|
||||
|
||||
[[bin]]
|
||||
name = "linux-hello-settings"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# GTK4 and Adwaita
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
libadwaita = { version = "0.7", features = ["v1_4"] }
|
||||
glib = "0.20"
|
||||
|
||||
# D-Bus
|
||||
zbus = { version = "4.0", features = ["tokio"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
|
||||
# Shared types
|
||||
linux-hello-common = { path = "../linux-hello-common" }
|
||||
|
||||
# Error handling and serialization
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
# Date/time formatting
|
||||
chrono = "0.4"
|
||||
|
||||
# Logging
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
285
linux-hello-settings/src/dbus_client.rs
Normal file
285
linux-hello-settings/src/dbus_client.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! D-Bus client for communicating with the Linux Hello daemon.
|
||||
//!
|
||||
//! This module provides a client interface to the org.linuxhello.Daemon
|
||||
//! D-Bus service for managing facial authentication templates.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zbus::{proxy, Connection, Result as ZbusResult};
|
||||
|
||||
/// Status information about the Linux Hello system
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SystemStatus {
|
||||
/// Whether a camera is available
|
||||
pub camera_available: bool,
|
||||
/// Camera device path (e.g., /dev/video0)
|
||||
pub camera_device: Option<String>,
|
||||
/// Whether TPM is available for secure storage
|
||||
pub tpm_available: bool,
|
||||
/// Whether anti-spoofing (liveness detection) is enabled
|
||||
pub anti_spoofing_enabled: bool,
|
||||
/// Daemon version
|
||||
pub version: String,
|
||||
/// Whether the daemon is running
|
||||
pub daemon_running: bool,
|
||||
}
|
||||
|
||||
/// Information about an enrolled face template
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateInfo {
|
||||
/// Unique identifier for the template
|
||||
pub id: String,
|
||||
/// User-provided label for the template
|
||||
pub label: String,
|
||||
/// Username associated with this template
|
||||
pub username: String,
|
||||
/// Timestamp when the template was created (Unix epoch seconds)
|
||||
pub created_at: i64,
|
||||
/// Timestamp of last successful authentication (Unix epoch seconds)
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// Enrollment progress information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnrollmentProgress {
|
||||
/// Current step in the enrollment process
|
||||
pub step: u32,
|
||||
/// Total number of steps
|
||||
pub total_steps: u32,
|
||||
/// Human-readable status message
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// D-Bus proxy for the Linux Hello Daemon
|
||||
#[proxy(
|
||||
interface = "org.linuxhello.Daemon",
|
||||
default_service = "org.linuxhello.Daemon",
|
||||
default_path = "/org/linuxhello/Daemon"
|
||||
)]
|
||||
trait LinuxHelloDaemon {
|
||||
/// Get the current system status
|
||||
fn get_status(&self) -> ZbusResult<String>;
|
||||
|
||||
/// List all enrolled templates for the current user
|
||||
fn list_templates(&self) -> ZbusResult<String>;
|
||||
|
||||
/// Start a new enrollment session
|
||||
/// Returns a session ID
|
||||
fn start_enrollment(&self, label: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Cancel an ongoing enrollment session
|
||||
fn cancel_enrollment(&self, session_id: &str) -> ZbusResult<()>;
|
||||
|
||||
/// Get enrollment progress for a session
|
||||
fn get_enrollment_progress(&self, session_id: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Complete and save the enrollment
|
||||
fn finish_enrollment(&self, session_id: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Remove an enrolled template by ID
|
||||
fn remove_template(&self, template_id: &str) -> ZbusResult<()>;
|
||||
|
||||
/// Test authentication with enrolled templates
|
||||
/// Returns the matched template ID or empty string if no match
|
||||
fn test_authentication(&self) -> ZbusResult<String>;
|
||||
|
||||
/// Signal emitted when enrollment progress updates
|
||||
#[zbus(signal)]
|
||||
fn enrollment_progress(&self, session_id: &str, step: u32, total: u32, message: &str)
|
||||
-> ZbusResult<()>;
|
||||
|
||||
/// Signal emitted when enrollment completes
|
||||
#[zbus(signal)]
|
||||
fn enrollment_complete(&self, session_id: &str, success: bool, message: &str)
|
||||
-> ZbusResult<()>;
|
||||
}
|
||||
|
||||
/// Client for communicating with the Linux Hello daemon
|
||||
#[derive(Clone)]
|
||||
pub struct DaemonClient {
|
||||
connection: Arc<Mutex<Option<Connection>>>,
|
||||
}
|
||||
|
||||
impl DaemonClient {
|
||||
/// Create a new daemon client
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the D-Bus system bus
|
||||
pub async fn connect(&self) -> Result<(), DaemonError> {
|
||||
let conn = Connection::system()
|
||||
.await
|
||||
.map_err(|e| DaemonError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
let mut guard = self.connection.lock().await;
|
||||
*guard = Some(conn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if connected to the daemon
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.connection.lock().await.is_some()
|
||||
}
|
||||
|
||||
/// Get the D-Bus proxy for the daemon
|
||||
async fn get_proxy(&self) -> Result<LinuxHelloDaemonProxy<'static>, DaemonError> {
|
||||
let guard = self.connection.lock().await;
|
||||
let conn = guard
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NotConnected)?
|
||||
.clone();
|
||||
|
||||
LinuxHelloDaemonProxy::new(&conn)
|
||||
.await
|
||||
.map_err(|e| DaemonError::ProxyError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get the current system status
|
||||
pub async fn get_status(&self) -> Result<SystemStatus, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
match proxy.get_status().await {
|
||||
Ok(json) => {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// If daemon is not running, return a default status
|
||||
if e.to_string().contains("org.freedesktop.DBus.Error.ServiceUnknown")
|
||||
|| e.to_string().contains("org.freedesktop.DBus.Error.NameHasNoOwner")
|
||||
{
|
||||
Ok(SystemStatus {
|
||||
daemon_running: false,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Err(DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all enrolled templates
|
||||
pub async fn list_templates(&self) -> Result<Vec<TemplateInfo>, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
match proxy.list_templates().await {
|
||||
Ok(json) => {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("org.freedesktop.DBus.Error.ServiceUnknown")
|
||||
|| e.to_string().contains("org.freedesktop.DBus.Error.NameHasNoOwner")
|
||||
{
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Err(DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new enrollment session
|
||||
pub async fn start_enrollment(&self, label: &str) -> Result<String, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.start_enrollment(label)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Cancel an ongoing enrollment session
|
||||
pub async fn cancel_enrollment(&self, session_id: &str) -> Result<(), DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.cancel_enrollment(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get enrollment progress
|
||||
pub async fn get_enrollment_progress(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<EnrollmentProgress, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
let json = proxy
|
||||
.get_enrollment_progress(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))?;
|
||||
|
||||
serde_json::from_str(&json).map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Complete and save the enrollment
|
||||
pub async fn finish_enrollment(&self, session_id: &str) -> Result<String, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.finish_enrollment(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove an enrolled template
|
||||
pub async fn remove_template(&self, template_id: &str) -> Result<(), DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.remove_template(template_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Test authentication
|
||||
pub async fn test_authentication(&self) -> Result<Option<String>, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
let result = proxy
|
||||
.test_authentication()
|
||||
.await
|
||||
.map_err(|e| DaemonError::DbusError(e.to_string()))?;
|
||||
|
||||
if result.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DaemonClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when communicating with the daemon
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum DaemonError {
|
||||
#[error("Failed to connect to D-Bus: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("Not connected to daemon")]
|
||||
NotConnected,
|
||||
|
||||
#[error("Failed to create D-Bus proxy: {0}")]
|
||||
ProxyError(String),
|
||||
|
||||
#[error("D-Bus error: {0}")]
|
||||
DbusError(String),
|
||||
|
||||
#[error("Failed to parse response: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Enrollment error: {0}")]
|
||||
EnrollmentError(String),
|
||||
}
|
||||
478
linux-hello-settings/src/enrollment.rs
Normal file
478
linux-hello-settings/src/enrollment.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
//! Enrollment Dialog
|
||||
//!
|
||||
//! Provides a dialog for enrolling new face templates with camera preview
|
||||
//! and step-by-step guidance through the enrollment process.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::glib;
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dbus_client::DaemonClient;
|
||||
|
||||
/// Enrollment states
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnrollmentState {
|
||||
/// Initial state, ready to start
|
||||
Ready,
|
||||
/// Enrollment in progress
|
||||
InProgress,
|
||||
/// Enrollment completed successfully
|
||||
Completed,
|
||||
/// Enrollment failed
|
||||
Failed,
|
||||
/// Enrollment cancelled
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Dialog for enrolling a new face template
|
||||
pub struct EnrollmentDialog {
|
||||
dialog: adw::Dialog,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
state: Rc<RefCell<EnrollmentState>>,
|
||||
session_id: Rc<RefCell<Option<String>>>,
|
||||
// UI elements
|
||||
label_entry: adw::EntryRow,
|
||||
start_button: gtk4::Button,
|
||||
cancel_button: gtk4::Button,
|
||||
status_page: adw::StatusPage,
|
||||
progress_bar: gtk4::ProgressBar,
|
||||
instruction_label: gtk4::Label,
|
||||
// Callbacks
|
||||
on_completed: Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
}
|
||||
|
||||
impl EnrollmentDialog {
|
||||
/// Create a new enrollment dialog
|
||||
pub fn new(parent: &adw::ApplicationWindow, client: Arc<Mutex<DaemonClient>>) -> Self {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Enroll Face")
|
||||
.content_width(450)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
// Create header bar
|
||||
let header = adw::HeaderBar::builder()
|
||||
.show_start_title_buttons(false)
|
||||
.show_end_title_buttons(false)
|
||||
.build();
|
||||
|
||||
let cancel_button = gtk4::Button::builder()
|
||||
.label("Cancel")
|
||||
.build();
|
||||
header.pack_start(&cancel_button);
|
||||
|
||||
let start_button = gtk4::Button::builder()
|
||||
.label("Start Enrollment")
|
||||
.css_classes(["suggested-action"])
|
||||
.sensitive(false)
|
||||
.build();
|
||||
header.pack_end(&start_button);
|
||||
|
||||
// Main content
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
content.append(&header);
|
||||
|
||||
// Instructions group
|
||||
let instructions_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
|
||||
let instructions_row = adw::ActionRow::builder()
|
||||
.title("How to Enroll")
|
||||
.subtitle("Position your face in the camera view and follow the instructions")
|
||||
.build();
|
||||
let info_icon = gtk4::Image::from_icon_name("dialog-information-symbolic");
|
||||
instructions_row.add_prefix(&info_icon);
|
||||
instructions_group.add(&instructions_row);
|
||||
|
||||
content.append(&instructions_group);
|
||||
|
||||
// Label input group
|
||||
let label_group = adw::PreferencesGroup::builder()
|
||||
.title("Face Label")
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
|
||||
let label_entry = adw::EntryRow::builder()
|
||||
.title("Label")
|
||||
.text("default")
|
||||
.build();
|
||||
label_group.add(&label_entry);
|
||||
|
||||
content.append(&label_group);
|
||||
|
||||
// Status/Camera preview area
|
||||
let status_page = adw::StatusPage::builder()
|
||||
.icon_name("camera-video-symbolic")
|
||||
.title("Ready to Enroll")
|
||||
.description("Press 'Start Enrollment' to begin capturing your face")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
content.append(&status_page);
|
||||
|
||||
// Progress section
|
||||
let progress_box = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let instruction_label = gtk4::Label::builder()
|
||||
.label("Look directly at the camera")
|
||||
.css_classes(["dim-label"])
|
||||
.visible(false)
|
||||
.build();
|
||||
progress_box.append(&instruction_label);
|
||||
|
||||
let progress_bar = gtk4::ProgressBar::builder()
|
||||
.show_text(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
progress_box.append(&progress_bar);
|
||||
|
||||
content.append(&progress_box);
|
||||
|
||||
// Tips section
|
||||
let tips_group = adw::PreferencesGroup::builder()
|
||||
.title("Tips for Best Results")
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let tips = [
|
||||
("face-smile-symbolic", "Good lighting", "Ensure your face is well-lit"),
|
||||
("view-reveal-symbolic", "Clear view", "Remove glasses if possible"),
|
||||
("object-rotate-right-symbolic", "Multiple angles", "Slowly turn your head when prompted"),
|
||||
];
|
||||
|
||||
for (icon, title, subtitle) in tips {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
let icon_widget = gtk4::Image::from_icon_name(icon);
|
||||
row.add_prefix(&icon_widget);
|
||||
tips_group.add(&row);
|
||||
}
|
||||
|
||||
content.append(&tips_group);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
|
||||
let enrollment_dialog = Self {
|
||||
dialog,
|
||||
client,
|
||||
state: Rc::new(RefCell::new(EnrollmentState::Ready)),
|
||||
session_id: Rc::new(RefCell::new(None)),
|
||||
label_entry,
|
||||
start_button,
|
||||
cancel_button,
|
||||
status_page,
|
||||
progress_bar,
|
||||
instruction_label,
|
||||
on_completed: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
enrollment_dialog.connect_signals(parent);
|
||||
enrollment_dialog.validate_input();
|
||||
|
||||
enrollment_dialog
|
||||
}
|
||||
|
||||
/// Connect UI signals
|
||||
fn connect_signals(&self, parent: &adw::ApplicationWindow) {
|
||||
// Label entry validation
|
||||
let this = self.clone_weak();
|
||||
self.label_entry.connect_changed(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
dialog.validate_input();
|
||||
}
|
||||
});
|
||||
|
||||
// Start button
|
||||
let this = self.clone_weak();
|
||||
self.start_button.connect_clicked(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
dialog.start_enrollment();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
let this = self.clone_weak();
|
||||
let dialog = self.dialog.clone();
|
||||
self.cancel_button.connect_clicked(move |_| {
|
||||
if let Some(d) = this.upgrade() {
|
||||
d.cancel_enrollment();
|
||||
}
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
// Dialog close
|
||||
let this = self.clone_weak();
|
||||
self.dialog.connect_closed(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
// Cancel any ongoing enrollment
|
||||
if *dialog.state.borrow() == EnrollmentState::InProgress {
|
||||
dialog.cancel_enrollment();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate input and update button sensitivity
|
||||
fn validate_input(&self) {
|
||||
let label = self.label_entry.text();
|
||||
let valid = !label.is_empty() && label.len() <= 64;
|
||||
self.start_button.set_sensitive(valid && *self.state.borrow() == EnrollmentState::Ready);
|
||||
}
|
||||
|
||||
/// Create a weak reference for callbacks
|
||||
fn clone_weak(&self) -> WeakEnrollmentDialog {
|
||||
WeakEnrollmentDialog {
|
||||
dialog: self.dialog.downgrade(),
|
||||
client: self.client.clone(),
|
||||
state: self.state.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
label_entry: self.label_entry.downgrade(),
|
||||
start_button: self.start_button.downgrade(),
|
||||
cancel_button: self.cancel_button.downgrade(),
|
||||
status_page: self.status_page.downgrade(),
|
||||
progress_bar: self.progress_bar.downgrade(),
|
||||
instruction_label: self.instruction_label.downgrade(),
|
||||
on_completed: self.on_completed.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the enrollment process
|
||||
fn start_enrollment(&self) {
|
||||
let label = self.label_entry.text().to_string();
|
||||
|
||||
*self.state.borrow_mut() = EnrollmentState::InProgress;
|
||||
|
||||
// Update UI
|
||||
self.start_button.set_sensitive(false);
|
||||
self.start_button.set_label("Enrolling...");
|
||||
self.label_entry.set_sensitive(false);
|
||||
self.progress_bar.set_visible(true);
|
||||
self.progress_bar.set_fraction(0.0);
|
||||
self.progress_bar.set_text(Some("Starting..."));
|
||||
self.instruction_label.set_visible(true);
|
||||
|
||||
self.status_page.set_icon_name(Some("camera-video-symbolic"));
|
||||
self.status_page.set_title("Enrolling...");
|
||||
self.status_page.set_description(Some("Please look at the camera"));
|
||||
|
||||
let client = self.client.clone();
|
||||
let state = self.state.clone();
|
||||
let session_id = self.session_id.clone();
|
||||
let progress_bar = self.progress_bar.clone();
|
||||
let instruction_label = self.instruction_label.clone();
|
||||
let status_page = self.status_page.clone();
|
||||
let start_button = self.start_button.clone();
|
||||
let label_entry = self.label_entry.clone();
|
||||
let on_completed = self.on_completed.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
|
||||
// Start enrollment session
|
||||
match client_guard.start_enrollment(&label).await {
|
||||
Ok(sid) => {
|
||||
tracing::info!("Started enrollment session: {}", sid);
|
||||
*session_id.borrow_mut() = Some(sid.clone());
|
||||
|
||||
// Poll for progress
|
||||
let instructions = [
|
||||
"Look straight at the camera",
|
||||
"Slowly turn your head left",
|
||||
"Slowly turn your head right",
|
||||
"Tilt your head slightly up",
|
||||
"Tilt your head slightly down",
|
||||
"Processing...",
|
||||
];
|
||||
|
||||
for (i, instruction) in instructions.iter().enumerate() {
|
||||
if *state.borrow() != EnrollmentState::InProgress {
|
||||
break;
|
||||
}
|
||||
|
||||
let progress = (i + 1) as f64 / instructions.len() as f64;
|
||||
let instruction = instruction.to_string();
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
move || {
|
||||
progress_bar.set_fraction(progress);
|
||||
progress_bar.set_text(Some(&format!("{}%", (progress * 100.0) as u32)));
|
||||
instruction_label.set_label(&instruction);
|
||||
}
|
||||
));
|
||||
|
||||
// Simulate capture delay (in real app, this would be based on daemon signals)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(800)).await;
|
||||
}
|
||||
|
||||
// Finish enrollment
|
||||
if *state.borrow() == EnrollmentState::InProgress {
|
||||
match client_guard.finish_enrollment(&sid).await {
|
||||
Ok(template_id) => {
|
||||
tracing::info!("Enrollment completed, template ID: {}", template_id);
|
||||
*state.borrow_mut() = EnrollmentState::Completed;
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] status_page,
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
#[strong] start_button,
|
||||
#[strong] on_completed,
|
||||
move || {
|
||||
status_page.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
status_page.set_title("Enrollment Complete");
|
||||
status_page.set_description(Some("Your face has been enrolled successfully"));
|
||||
progress_bar.set_visible(false);
|
||||
instruction_label.set_visible(false);
|
||||
start_button.set_label("Done");
|
||||
start_button.set_sensitive(true);
|
||||
start_button.remove_css_class("suggested-action");
|
||||
start_button.add_css_class("success");
|
||||
|
||||
// Trigger callback
|
||||
if let Some(callback) = on_completed.borrow().as_ref() {
|
||||
callback(true);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
handle_enrollment_error(&state, &status_page, &progress_bar, &instruction_label, &start_button, &label_entry, &on_completed, &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
handle_enrollment_error(&state, &status_page, &progress_bar, &instruction_label, &start_button, &label_entry, &on_completed, &e.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing enrollment
|
||||
fn cancel_enrollment(&self) {
|
||||
if *self.state.borrow() == EnrollmentState::InProgress {
|
||||
*self.state.borrow_mut() = EnrollmentState::Cancelled;
|
||||
|
||||
if let Some(sid) = self.session_id.borrow().as_ref() {
|
||||
let client = self.client.clone();
|
||||
let sid = sid.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
let _ = client_guard.cancel_enrollment(&sid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect callback for enrollment completion
|
||||
pub fn connect_completed<F: Fn(bool) + 'static>(&self, callback: F) {
|
||||
*self.on_completed.borrow_mut() = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Present the dialog
|
||||
pub fn present(&self) {
|
||||
if let Some(root) = self.dialog.root() {
|
||||
if let Some(window) = root.downcast_ref::<gtk4::Window>() {
|
||||
self.dialog.present(Some(window));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle enrollment error
|
||||
fn handle_enrollment_error(
|
||||
state: &Rc<RefCell<EnrollmentState>>,
|
||||
status_page: &adw::StatusPage,
|
||||
progress_bar: >k4::ProgressBar,
|
||||
instruction_label: >k4::Label,
|
||||
start_button: >k4::Button,
|
||||
label_entry: &adw::EntryRow,
|
||||
on_completed: &Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
error: &str,
|
||||
) {
|
||||
tracing::error!("Enrollment failed: {}", error);
|
||||
*state.borrow_mut() = EnrollmentState::Failed;
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] status_page,
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
#[strong] start_button,
|
||||
#[strong] label_entry,
|
||||
#[strong] on_completed,
|
||||
#[strong] error,
|
||||
move || {
|
||||
status_page.set_icon_name(Some("dialog-error-symbolic"));
|
||||
status_page.set_title("Enrollment Failed");
|
||||
status_page.set_description(Some(&error));
|
||||
progress_bar.set_visible(false);
|
||||
instruction_label.set_visible(false);
|
||||
start_button.set_label("Retry");
|
||||
start_button.set_sensitive(true);
|
||||
label_entry.set_sensitive(true);
|
||||
|
||||
// Trigger callback
|
||||
if let Some(callback) = on_completed.borrow().as_ref() {
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// Weak reference to enrollment dialog for callbacks
|
||||
struct WeakEnrollmentDialog {
|
||||
dialog: glib::WeakRef<adw::Dialog>,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
state: Rc<RefCell<EnrollmentState>>,
|
||||
session_id: Rc<RefCell<Option<String>>>,
|
||||
label_entry: glib::WeakRef<adw::EntryRow>,
|
||||
start_button: glib::WeakRef<gtk4::Button>,
|
||||
cancel_button: glib::WeakRef<gtk4::Button>,
|
||||
status_page: glib::WeakRef<adw::StatusPage>,
|
||||
progress_bar: glib::WeakRef<gtk4::ProgressBar>,
|
||||
instruction_label: glib::WeakRef<gtk4::Label>,
|
||||
on_completed: Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
}
|
||||
|
||||
impl WeakEnrollmentDialog {
|
||||
fn upgrade(&self) -> Option<EnrollmentDialog> {
|
||||
Some(EnrollmentDialog {
|
||||
dialog: self.dialog.upgrade()?,
|
||||
client: self.client.clone(),
|
||||
state: self.state.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
label_entry: self.label_entry.upgrade()?,
|
||||
start_button: self.start_button.upgrade()?,
|
||||
cancel_button: self.cancel_button.upgrade()?,
|
||||
status_page: self.status_page.upgrade()?,
|
||||
progress_bar: self.progress_bar.upgrade()?,
|
||||
instruction_label: self.instruction_label.upgrade()?,
|
||||
on_completed: self.on_completed.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
51
linux-hello-settings/src/main.rs
Normal file
51
linux-hello-settings/src/main.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Linux Hello Settings - GNOME Settings Application
|
||||
//!
|
||||
//! A GTK4/libadwaita application for managing Linux Hello facial authentication.
|
||||
//! Provides UI for enrolling faces, managing templates, and configuring settings.
|
||||
|
||||
mod dbus_client;
|
||||
mod enrollment;
|
||||
mod templates;
|
||||
mod window;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
use libadwaita as adw;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use window::SettingsWindow;
|
||||
|
||||
const APP_ID: &str = "org.linuxhello.Settings";
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::from_default_env()
|
||||
.add_directive("linux_hello_settings=info".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting Linux Hello Settings");
|
||||
|
||||
// Create and run the application
|
||||
let app = adw::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.flags(gio::ApplicationFlags::FLAGS_NONE)
|
||||
.build();
|
||||
|
||||
app.connect_startup(|_| {
|
||||
// Load CSS if needed
|
||||
tracing::debug!("Application startup");
|
||||
});
|
||||
|
||||
app.connect_activate(build_ui);
|
||||
|
||||
app.run()
|
||||
}
|
||||
|
||||
fn build_ui(app: &adw::Application) {
|
||||
// Create and present the main window
|
||||
let window = SettingsWindow::new(app);
|
||||
window.present();
|
||||
}
|
||||
178
linux-hello-settings/src/templates.rs
Normal file
178
linux-hello-settings/src/templates.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! Template List Widget
|
||||
//!
|
||||
//! Provides a widget for displaying and managing enrolled face templates.
|
||||
//! Supports viewing template details and removing templates.
|
||||
|
||||
use crate::dbus_client::TemplateInfo;
|
||||
|
||||
/// Template list box containing enrolled templates
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateListBox {
|
||||
/// List of templates
|
||||
templates: Vec<TemplateInfo>,
|
||||
}
|
||||
|
||||
impl TemplateListBox {
|
||||
/// Create a new template list box
|
||||
pub fn new(templates: Vec<TemplateInfo>) -> Self {
|
||||
Self { templates }
|
||||
}
|
||||
|
||||
/// Get the list of templates
|
||||
pub fn templates(&self) -> &[TemplateInfo] {
|
||||
&self.templates
|
||||
}
|
||||
|
||||
/// Find a template by ID
|
||||
pub fn find_by_id(&self, id: &str) -> Option<&TemplateInfo> {
|
||||
self.templates.iter().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
/// Find a template by label
|
||||
pub fn find_by_label(&self, label: &str) -> Option<&TemplateInfo> {
|
||||
self.templates.iter().find(|t| t.label == label)
|
||||
}
|
||||
|
||||
/// Check if any templates are enrolled
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.templates.is_empty()
|
||||
}
|
||||
|
||||
/// Get the count of enrolled templates
|
||||
pub fn count(&self) -> usize {
|
||||
self.templates.len()
|
||||
}
|
||||
|
||||
/// Update the template list
|
||||
pub fn update(&mut self, templates: Vec<TemplateInfo>) {
|
||||
self.templates = templates;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateListBox {
|
||||
fn default() -> Self {
|
||||
Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Template display model for UI binding
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateDisplayModel {
|
||||
/// Template ID
|
||||
pub id: String,
|
||||
/// Display label
|
||||
pub label: String,
|
||||
/// Username
|
||||
pub username: String,
|
||||
/// Formatted creation date
|
||||
pub created_date: String,
|
||||
/// Formatted last used date
|
||||
pub last_used_date: String,
|
||||
/// Whether this template has been used recently
|
||||
pub recently_used: bool,
|
||||
}
|
||||
|
||||
impl TemplateDisplayModel {
|
||||
/// Create a display model from template info
|
||||
pub fn from_template(template: &TemplateInfo) -> Self {
|
||||
use chrono::{DateTime, Duration, Local, Utc};
|
||||
|
||||
let created_date = DateTime::<Utc>::from_timestamp(template.created_at, 0)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.map(|dt| dt.format("%B %d, %Y").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let (last_used_date, recently_used) = if let Some(ts) = template.last_used {
|
||||
let dt = DateTime::<Utc>::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.with_timezone(&Local));
|
||||
|
||||
let date_str = dt
|
||||
.as_ref()
|
||||
.map(|d| d.format("%B %d, %Y at %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
// Check if used within the last 24 hours
|
||||
let recent = dt
|
||||
.map(|d| {
|
||||
let now = Local::now();
|
||||
now.signed_duration_since(d) < Duration::hours(24)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
(date_str, recent)
|
||||
} else {
|
||||
("Never".to_string(), false)
|
||||
};
|
||||
|
||||
Self {
|
||||
id: template.id.clone(),
|
||||
label: template.label.clone(),
|
||||
username: template.username.clone(),
|
||||
created_date,
|
||||
last_used_date,
|
||||
recently_used,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Template action types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TemplateAction {
|
||||
/// View template details
|
||||
View,
|
||||
/// Remove template
|
||||
Remove,
|
||||
/// Test authentication with template
|
||||
Test,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_template(id: &str, label: &str) -> TemplateInfo {
|
||||
TemplateInfo {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
username: "testuser".to_string(),
|
||||
created_at: 1704067200, // 2024-01-01 00:00:00 UTC
|
||||
last_used: Some(1704153600), // 2024-01-02 00:00:00 UTC
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_list_box() {
|
||||
let templates = vec![
|
||||
create_test_template("1", "default"),
|
||||
create_test_template("2", "backup"),
|
||||
];
|
||||
|
||||
let list = TemplateListBox::new(templates);
|
||||
|
||||
assert!(!list.is_empty());
|
||||
assert_eq!(list.count(), 2);
|
||||
assert!(list.find_by_id("1").is_some());
|
||||
assert!(list.find_by_label("backup").is_some());
|
||||
assert!(list.find_by_id("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_list() {
|
||||
let list = TemplateListBox::default();
|
||||
|
||||
assert!(list.is_empty());
|
||||
assert_eq!(list.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_display_model() {
|
||||
let template = create_test_template("test-id", "Test Face");
|
||||
let model = TemplateDisplayModel::from_template(&template);
|
||||
|
||||
assert_eq!(model.id, "test-id");
|
||||
assert_eq!(model.label, "Test Face");
|
||||
assert_eq!(model.username, "testuser");
|
||||
assert!(!model.created_date.is_empty());
|
||||
assert_ne!(model.last_used_date, "Never");
|
||||
}
|
||||
}
|
||||
597
linux-hello-settings/src/window.rs
Normal file
597
linux-hello-settings/src/window.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
//! Main Settings Window
|
||||
//!
|
||||
//! Contains the primary UI for Linux Hello Settings following GNOME HIG.
|
||||
//! Includes status display, enrollment controls, template management, and settings.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dbus_client::{DaemonClient, SystemStatus, TemplateInfo};
|
||||
use crate::enrollment::EnrollmentDialog;
|
||||
use crate::templates::TemplateListBox;
|
||||
|
||||
/// Main settings window for Linux Hello
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsWindow {
|
||||
pub window: adw::ApplicationWindow,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
// Status widgets
|
||||
daemon_status_row: adw::ActionRow,
|
||||
camera_status_row: adw::ActionRow,
|
||||
tpm_status_row: adw::ActionRow,
|
||||
// Enrollment widgets
|
||||
enroll_button: gtk4::Button,
|
||||
enrollment_progress: gtk4::ProgressBar,
|
||||
// Template list
|
||||
template_list: Rc<RefCell<Option<TemplateListBox>>>,
|
||||
templates_group: adw::PreferencesGroup,
|
||||
// Settings widgets
|
||||
anti_spoofing_switch: adw::SwitchRow,
|
||||
confidence_spin: adw::SpinRow,
|
||||
}
|
||||
|
||||
impl SettingsWindow {
|
||||
/// Create a new settings window
|
||||
pub fn new(app: &adw::Application) -> Self {
|
||||
let client = Arc::new(Mutex::new(DaemonClient::new()));
|
||||
|
||||
// Create the main window
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Linux Hello Settings")
|
||||
.default_width(600)
|
||||
.default_height(700)
|
||||
.build();
|
||||
|
||||
// Create header bar
|
||||
let header = adw::HeaderBar::new();
|
||||
|
||||
// Refresh button in header
|
||||
let refresh_button = gtk4::Button::from_icon_name("view-refresh-symbolic");
|
||||
refresh_button.set_tooltip_text(Some("Refresh status"));
|
||||
header.pack_end(&refresh_button);
|
||||
|
||||
// Create main content with scrollable view
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.build();
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
|
||||
// === Status Section ===
|
||||
let status_group = adw::PreferencesGroup::builder()
|
||||
.title("System Status")
|
||||
.description("Current status of Linux Hello components")
|
||||
.build();
|
||||
|
||||
let daemon_status_row = adw::ActionRow::builder()
|
||||
.title("Daemon")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let daemon_icon = gtk4::Image::from_icon_name("emblem-synchronizing-symbolic");
|
||||
daemon_status_row.add_prefix(&daemon_icon);
|
||||
status_group.add(&daemon_status_row);
|
||||
|
||||
let camera_status_row = adw::ActionRow::builder()
|
||||
.title("Camera")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let camera_icon = gtk4::Image::from_icon_name("camera-video-symbolic");
|
||||
camera_status_row.add_prefix(&camera_icon);
|
||||
status_group.add(&camera_status_row);
|
||||
|
||||
let tpm_status_row = adw::ActionRow::builder()
|
||||
.title("TPM Security")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let tpm_icon = gtk4::Image::from_icon_name("security-high-symbolic");
|
||||
tpm_status_row.add_prefix(&tpm_icon);
|
||||
status_group.add(&tpm_status_row);
|
||||
|
||||
content.append(&status_group);
|
||||
|
||||
// === Enrollment Section ===
|
||||
let enrollment_group = adw::PreferencesGroup::builder()
|
||||
.title("Face Enrollment")
|
||||
.description("Register your face for authentication")
|
||||
.build();
|
||||
|
||||
// Enrollment row with button
|
||||
let enroll_row = adw::ActionRow::builder()
|
||||
.title("Enroll New Face")
|
||||
.subtitle("Add a new face template for authentication")
|
||||
.build();
|
||||
let enroll_icon = gtk4::Image::from_icon_name("contact-new-symbolic");
|
||||
enroll_row.add_prefix(&enroll_icon);
|
||||
|
||||
let enroll_button = gtk4::Button::builder()
|
||||
.label("Enroll")
|
||||
.valign(gtk4::Align::Center)
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
enroll_row.add_suffix(&enroll_button);
|
||||
enroll_row.set_activatable_widget(Some(&enroll_button));
|
||||
enrollment_group.add(&enroll_row);
|
||||
|
||||
// Progress bar (hidden by default)
|
||||
let enrollment_progress = gtk4::ProgressBar::builder()
|
||||
.visible(false)
|
||||
.show_text(true)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
enrollment_group.add(&enrollment_progress);
|
||||
|
||||
content.append(&enrollment_group);
|
||||
|
||||
// === Templates Section ===
|
||||
let templates_group = adw::PreferencesGroup::builder()
|
||||
.title("Enrolled Faces")
|
||||
.description("Manage your enrolled face templates")
|
||||
.build();
|
||||
|
||||
// Placeholder when no templates
|
||||
let no_templates_row = adw::ActionRow::builder()
|
||||
.title("No faces enrolled")
|
||||
.subtitle("Enroll a face to enable facial authentication")
|
||||
.build();
|
||||
let empty_icon = gtk4::Image::from_icon_name("face-uncertain-symbolic");
|
||||
no_templates_row.add_prefix(&empty_icon);
|
||||
templates_group.add(&no_templates_row);
|
||||
|
||||
content.append(&templates_group);
|
||||
|
||||
// === Settings Section ===
|
||||
let settings_group = adw::PreferencesGroup::builder()
|
||||
.title("Settings")
|
||||
.description("Configure authentication behavior")
|
||||
.build();
|
||||
|
||||
// Anti-spoofing toggle
|
||||
let anti_spoofing_switch = adw::SwitchRow::builder()
|
||||
.title("Anti-Spoofing")
|
||||
.subtitle("Detect and reject spoofing attempts (photos, videos)")
|
||||
.active(true)
|
||||
.build();
|
||||
let spoof_icon = gtk4::Image::from_icon_name("security-medium-symbolic");
|
||||
anti_spoofing_switch.add_prefix(&spoof_icon);
|
||||
settings_group.add(&anti_spoofing_switch);
|
||||
|
||||
// Confidence threshold
|
||||
let confidence_adjustment = gtk4::Adjustment::new(
|
||||
0.9, // value
|
||||
0.5, // lower
|
||||
1.0, // upper
|
||||
0.05, // step_increment
|
||||
0.1, // page_increment
|
||||
0.0, // page_size
|
||||
);
|
||||
let confidence_spin = adw::SpinRow::builder()
|
||||
.title("Confidence Threshold")
|
||||
.subtitle("Minimum confidence level for successful authentication")
|
||||
.adjustment(&confidence_adjustment)
|
||||
.digits(2)
|
||||
.build();
|
||||
let conf_icon = gtk4::Image::from_icon_name("dialog-information-symbolic");
|
||||
confidence_spin.add_prefix(&conf_icon);
|
||||
settings_group.add(&confidence_spin);
|
||||
|
||||
content.append(&settings_group);
|
||||
|
||||
// === About Section ===
|
||||
let about_group = adw::PreferencesGroup::new();
|
||||
|
||||
let about_row = adw::ActionRow::builder()
|
||||
.title("About Linux Hello")
|
||||
.subtitle("Version 0.1.0")
|
||||
.activatable(true)
|
||||
.build();
|
||||
let about_icon = gtk4::Image::from_icon_name("help-about-symbolic");
|
||||
about_row.add_prefix(&about_icon);
|
||||
let chevron = gtk4::Image::from_icon_name("go-next-symbolic");
|
||||
about_row.add_suffix(&chevron);
|
||||
about_group.add(&about_row);
|
||||
|
||||
content.append(&about_group);
|
||||
|
||||
// Assemble the UI
|
||||
clamp.set_child(Some(&content));
|
||||
scroll.set_child(Some(&clamp));
|
||||
|
||||
let main_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
main_box.append(&header);
|
||||
main_box.append(&scroll);
|
||||
|
||||
window.set_content(Some(&main_box));
|
||||
|
||||
let settings_window = Self {
|
||||
window,
|
||||
client,
|
||||
daemon_status_row,
|
||||
camera_status_row,
|
||||
tpm_status_row,
|
||||
enroll_button,
|
||||
enrollment_progress,
|
||||
template_list: Rc::new(RefCell::new(None)),
|
||||
templates_group,
|
||||
anti_spoofing_switch,
|
||||
confidence_spin,
|
||||
};
|
||||
|
||||
// Connect signals
|
||||
settings_window.connect_signals(&refresh_button, &about_row);
|
||||
|
||||
// Initial status refresh
|
||||
settings_window.refresh_status();
|
||||
|
||||
settings_window
|
||||
}
|
||||
|
||||
/// Connect UI signals
|
||||
fn connect_signals(&self, refresh_button: >k4::Button, about_row: &adw::ActionRow) {
|
||||
// Refresh button
|
||||
let this = self.clone();
|
||||
refresh_button.connect_clicked(move |_| {
|
||||
this.refresh_status();
|
||||
});
|
||||
|
||||
// Enroll button
|
||||
let this = self.clone();
|
||||
self.enroll_button.connect_clicked(move |_| {
|
||||
this.show_enrollment_dialog();
|
||||
});
|
||||
|
||||
// About row
|
||||
let window = self.window.clone();
|
||||
about_row.connect_activated(move |_| {
|
||||
show_about_dialog(&window);
|
||||
});
|
||||
|
||||
// Anti-spoofing switch
|
||||
let this = self.clone();
|
||||
self.anti_spoofing_switch.connect_active_notify(move |switch| {
|
||||
let enabled = switch.is_active();
|
||||
tracing::info!("Anti-spoofing toggled: {}", enabled);
|
||||
this.save_settings();
|
||||
});
|
||||
|
||||
// Confidence threshold
|
||||
let this = self.clone();
|
||||
self.confidence_spin.connect_value_notify(move |spin| {
|
||||
let value = spin.value();
|
||||
tracing::info!("Confidence threshold changed: {}", value);
|
||||
this.save_settings();
|
||||
});
|
||||
}
|
||||
|
||||
/// Present the window
|
||||
pub fn present(&self) {
|
||||
self.window.present();
|
||||
}
|
||||
|
||||
/// Refresh system status from daemon
|
||||
fn refresh_status(&self) {
|
||||
let client = self.client.clone();
|
||||
let daemon_row = self.daemon_status_row.clone();
|
||||
let camera_row = self.camera_status_row.clone();
|
||||
let tpm_row = self.tpm_status_row.clone();
|
||||
let templates_group = self.templates_group.clone();
|
||||
let template_list = self.template_list.clone();
|
||||
let enroll_button = self.enroll_button.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
// Connect to daemon
|
||||
let mut client_guard = client.lock().await;
|
||||
if !client_guard.is_connected().await {
|
||||
if let Err(e) = client_guard.connect().await {
|
||||
tracing::warn!("Failed to connect to daemon: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get status
|
||||
let status = client_guard.get_status().await.unwrap_or_default();
|
||||
|
||||
// Update daemon status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] daemon_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.daemon_running {
|
||||
daemon_row.set_subtitle("Running");
|
||||
add_status_indicator(&daemon_row, true);
|
||||
} else {
|
||||
daemon_row.set_subtitle("Not running");
|
||||
add_status_indicator(&daemon_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update camera status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] camera_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.camera_available {
|
||||
let device = status.camera_device.as_deref().unwrap_or("Available");
|
||||
camera_row.set_subtitle(device);
|
||||
add_status_indicator(&camera_row, true);
|
||||
} else {
|
||||
camera_row.set_subtitle("Not available");
|
||||
add_status_indicator(&camera_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update TPM status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] tpm_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.tpm_available {
|
||||
tpm_row.set_subtitle("Available - Secure storage enabled");
|
||||
add_status_indicator(&tpm_row, true);
|
||||
} else {
|
||||
tpm_row.set_subtitle("Not available - Using file storage");
|
||||
add_status_indicator(&tpm_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update enroll button sensitivity
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] enroll_button,
|
||||
#[strong] status,
|
||||
move || {
|
||||
enroll_button.set_sensitive(status.daemon_running && status.camera_available);
|
||||
}
|
||||
));
|
||||
|
||||
// Get templates
|
||||
let templates = client_guard.list_templates().await.unwrap_or_default();
|
||||
drop(client_guard);
|
||||
|
||||
// Update templates list
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] templates_group,
|
||||
#[strong] template_list,
|
||||
move || {
|
||||
update_templates_list(&templates_group, &template_list, templates);
|
||||
}
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Show enrollment dialog
|
||||
fn show_enrollment_dialog(&self) {
|
||||
let dialog = EnrollmentDialog::new(&self.window, self.client.clone());
|
||||
|
||||
let this = self.clone();
|
||||
dialog.connect_completed(move |success| {
|
||||
if success {
|
||||
this.refresh_status();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
/// Remove a template
|
||||
pub fn remove_template(&self, template_id: &str) {
|
||||
let client = self.client.clone();
|
||||
let template_id = template_id.to_string();
|
||||
let this = self.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
match client_guard.remove_template(&template_id).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("Removed template: {}", template_id);
|
||||
drop(client_guard);
|
||||
glib::idle_add_local_once(move || {
|
||||
this.refresh_status();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove template: {}", e);
|
||||
// Show error toast
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Save settings to configuration
|
||||
fn save_settings(&self) {
|
||||
// In a real implementation, this would save to the config file
|
||||
// or communicate with the daemon to update settings
|
||||
tracing::debug!(
|
||||
"Settings: anti_spoofing={}, confidence={}",
|
||||
self.anti_spoofing_switch.is_active(),
|
||||
self.confidence_spin.value()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a status indicator icon to a row
|
||||
fn add_status_indicator(row: &adw::ActionRow, success: bool) {
|
||||
// Remove existing suffix indicators
|
||||
if let Some(first_child) = row.first_child() {
|
||||
let mut child = first_child;
|
||||
loop {
|
||||
let next = child.next_sibling();
|
||||
if child.css_classes().contains(&"status-indicator".into()) {
|
||||
// Can't easily remove - GTK4 limitation
|
||||
}
|
||||
match next {
|
||||
Some(c) => child = c,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let icon_name = if success {
|
||||
"emblem-ok-symbolic"
|
||||
} else {
|
||||
"dialog-warning-symbolic"
|
||||
};
|
||||
|
||||
let indicator = gtk4::Image::from_icon_name(icon_name);
|
||||
indicator.add_css_class("status-indicator");
|
||||
if success {
|
||||
indicator.add_css_class("success");
|
||||
} else {
|
||||
indicator.add_css_class("warning");
|
||||
}
|
||||
row.add_suffix(&indicator);
|
||||
}
|
||||
|
||||
/// Update the templates list
|
||||
fn update_templates_list(
|
||||
group: &adw::PreferencesGroup,
|
||||
template_list_cell: &Rc<RefCell<Option<TemplateListBox>>>,
|
||||
templates: Vec<TemplateInfo>,
|
||||
) {
|
||||
// Clear existing rows (workaround: recreate the list)
|
||||
// In GTK4/libadwaita, we need to remove rows individually
|
||||
|
||||
// Remove all children from the group
|
||||
while let Some(child) = group.first_child() {
|
||||
if let Some(row) = child.downcast_ref::<adw::PreferencesRow>() {
|
||||
group.remove(row);
|
||||
} else {
|
||||
// Skip non-row children (header, etc.)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if templates.is_empty() {
|
||||
let no_templates_row = adw::ActionRow::builder()
|
||||
.title("No faces enrolled")
|
||||
.subtitle("Enroll a face to enable facial authentication")
|
||||
.build();
|
||||
let empty_icon = gtk4::Image::from_icon_name("face-uncertain-symbolic");
|
||||
no_templates_row.add_prefix(&empty_icon);
|
||||
group.add(&no_templates_row);
|
||||
} else {
|
||||
for template in templates {
|
||||
let row = create_template_row(&template);
|
||||
group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
*template_list_cell.borrow_mut() = Some(TemplateListBox::new(templates));
|
||||
}
|
||||
|
||||
/// Create a row for a template
|
||||
fn create_template_row(template: &TemplateInfo) -> adw::ActionRow {
|
||||
let subtitle = format!(
|
||||
"Created: {} | Last used: {}",
|
||||
format_timestamp(template.created_at),
|
||||
template.last_used.map(format_timestamp).unwrap_or_else(|| "Never".to_string())
|
||||
);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&template.label)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
|
||||
let face_icon = gtk4::Image::from_icon_name("avatar-default-symbolic");
|
||||
row.add_prefix(&face_icon);
|
||||
|
||||
// Delete button
|
||||
let delete_button = gtk4::Button::from_icon_name("user-trash-symbolic");
|
||||
delete_button.set_valign(gtk4::Align::Center);
|
||||
delete_button.add_css_class("flat");
|
||||
delete_button.set_tooltip_text(Some("Remove this face"));
|
||||
|
||||
let template_id = template.id.clone();
|
||||
delete_button.connect_clicked(move |button| {
|
||||
// Show confirmation dialog
|
||||
if let Some(window) = button.root().and_then(|r| r.downcast::<gtk4::Window>().ok()) {
|
||||
show_delete_confirmation(&window, &template_id);
|
||||
}
|
||||
});
|
||||
|
||||
row.add_suffix(&delete_button);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp for display
|
||||
fn format_timestamp(timestamp: i64) -> String {
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
|
||||
let datetime = DateTime::<Utc>::from_timestamp(timestamp, 0)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
datetime
|
||||
}
|
||||
|
||||
/// Show delete confirmation dialog
|
||||
fn show_delete_confirmation(window: >k4::Window, template_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove Face?")
|
||||
.body("This will remove the enrolled face template. You will need to enroll again to use facial authentication.")
|
||||
.build();
|
||||
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("delete", "Remove"),
|
||||
]);
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let template_id = template_id.to_string();
|
||||
let win = window.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "delete" {
|
||||
// Emit signal or call remove method
|
||||
if let Some(settings_window) = win.data::<SettingsWindow>("settings-window") {
|
||||
settings_window.remove_template(&template_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// Show the about dialog
|
||||
fn show_about_dialog(window: &adw::ApplicationWindow) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("Linux Hello")
|
||||
.application_icon("org.linuxhello.Settings")
|
||||
.developer_name("Linux Hello Contributors")
|
||||
.version("0.1.0")
|
||||
.website("https://github.com/linux-hello/linux-hello")
|
||||
.issue_url("https://github.com/linux-hello/linux-hello/issues")
|
||||
.license_type(gtk4::License::Gpl30)
|
||||
.comments("Facial authentication for Linux, inspired by Windows Hello")
|
||||
.build();
|
||||
|
||||
about.add_credit_section(Some("Contributors"), &[
|
||||
"Linux Hello Team",
|
||||
]);
|
||||
|
||||
about.present(Some(window));
|
||||
}
|
||||
260
models/README.md
260
models/README.md
@@ -1,41 +1,249 @@
|
||||
# Face Detection Models
|
||||
# Face Recognition ONNX Models
|
||||
|
||||
This directory contains ONNX model files for face detection and embedding.
|
||||
This directory contains ONNX model files for face detection and embedding extraction
|
||||
used by Linux Hello's facial authentication system.
|
||||
|
||||
## Required Models
|
||||
|
||||
### BlazeFace (Face Detection)
|
||||
- **File**: `blazeface.onnx`
|
||||
- **Purpose**: Fast face detection
|
||||
- **Input**: RGB image [1, 3, 128, 128]
|
||||
- **Output**: Bounding boxes and confidence scores
|
||||
### 1. Face Detection Model
|
||||
|
||||
Download from: https://github.com/onnx/models/tree/main/vision/body_analysis/ultraface
|
||||
**Recommended: RetinaFace**
|
||||
|
||||
### MobileFaceNet (Face Embedding)
|
||||
- **File**: `mobilefacenet.onnx`
|
||||
- **Purpose**: Face feature extraction
|
||||
- **Input**: Aligned face [1, 3, 112, 112]
|
||||
- **Output**: 128-dimensional embedding
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| File | `retinaface.onnx` |
|
||||
| Purpose | Face detection with 5-point landmarks |
|
||||
| Input Shape | `[1, 3, 640, 640]` (NCHW, RGB) |
|
||||
| Input Range | `[-1, 1]` normalized: `(pixel - 127.5) / 128.0` |
|
||||
| Outputs | `loc`, `conf`, `landm` tensors |
|
||||
|
||||
Download from: https://github.com/onnx/models
|
||||
**Alternative: BlazeFace**
|
||||
|
||||
## Model Conversion
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| File | `blazeface.onnx` |
|
||||
| Purpose | Fast face detection |
|
||||
| Input Shape | `[1, 3, 128, 128]` or `[1, 3, 256, 256]` |
|
||||
| Use Case | Real-time detection on low-power devices |
|
||||
|
||||
If you have models in other formats, convert to ONNX using:
|
||||
### 2. Face Embedding Model
|
||||
|
||||
**Recommended: MobileFaceNet**
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| File | `mobilefacenet.onnx` |
|
||||
| Purpose | Face embedding extraction |
|
||||
| Input Shape | `[1, 3, 112, 112]` (NCHW, RGB) |
|
||||
| Input Range | `[-1, 1]` normalized: `(pixel - 127.5) / 128.0` |
|
||||
| Output Shape | `[1, 128]` or `[1, 512]` |
|
||||
| Output | L2-normalized embedding vector |
|
||||
|
||||
**Alternative: ArcFace**
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| File | `arcface.onnx` |
|
||||
| Purpose | High-accuracy face embedding |
|
||||
| Input Shape | `[1, 3, 112, 112]` |
|
||||
| Output Shape | `[1, 512]` |
|
||||
| Use Case | Higher accuracy at cost of larger model |
|
||||
|
||||
## Download Instructions
|
||||
|
||||
### Option 1: From ONNX Model Zoo
|
||||
|
||||
```bash
|
||||
# From TensorFlow
|
||||
python -m tf2onnx.convert --saved-model ./model --output model.onnx
|
||||
# RetinaFace (face detection)
|
||||
wget https://github.com/onnx/models/raw/main/vision/body_analysis/ultraface/models/version-RFB-640.onnx \
|
||||
-O retinaface.onnx
|
||||
|
||||
# From PyTorch
|
||||
import torch
|
||||
torch.onnx.export(model, dummy_input, "model.onnx")
|
||||
# Note: MobileFaceNet may need to be converted from other frameworks
|
||||
```
|
||||
|
||||
## License
|
||||
### Option 2: From InsightFace
|
||||
|
||||
Please ensure you comply with the licenses of any models you download:
|
||||
- BlazeFace: Apache 2.0
|
||||
- MobileFaceNet: MIT
|
||||
- ArcFace: MIT
|
||||
```bash
|
||||
# Clone InsightFace model repository
|
||||
git clone https://github.com/deepinsight/insightface.git
|
||||
cd insightface/model_zoo
|
||||
|
||||
# Download and extract models
|
||||
# See: https://github.com/deepinsight/insightface/tree/master/model_zoo
|
||||
```
|
||||
|
||||
### Option 3: Convert from PyTorch/TensorFlow
|
||||
|
||||
**From PyTorch:**
|
||||
|
||||
```python
|
||||
import torch
|
||||
import torch.onnx
|
||||
|
||||
# Load your trained model
|
||||
model = YourFaceModel()
|
||||
model.load_state_dict(torch.load('model.pth'))
|
||||
model.eval()
|
||||
|
||||
# Export to ONNX
|
||||
dummy_input = torch.randn(1, 3, 112, 112)
|
||||
torch.onnx.export(
|
||||
model,
|
||||
dummy_input,
|
||||
"model.onnx",
|
||||
input_names=['input'],
|
||||
output_names=['embedding'],
|
||||
dynamic_axes={'input': {0: 'batch'}, 'embedding': {0: 'batch'}}
|
||||
)
|
||||
```
|
||||
|
||||
**From TensorFlow:**
|
||||
|
||||
```bash
|
||||
pip install tf2onnx
|
||||
|
||||
python -m tf2onnx.convert \
|
||||
--saved-model ./saved_model \
|
||||
--output model.onnx \
|
||||
--opset 13
|
||||
```
|
||||
|
||||
## Model Specifications
|
||||
|
||||
### RetinaFace Output Format
|
||||
|
||||
The RetinaFace model outputs three tensors:
|
||||
|
||||
1. **loc** (bounding boxes): `[1, num_anchors, 4]`
|
||||
- Format: `[dx, dy, dw, dh]` offsets from anchor boxes
|
||||
- Decode: `cx = anchor_cx + dx * 0.1 * anchor_w`
|
||||
|
||||
2. **conf** (confidence): `[1, num_anchors, 2]`
|
||||
- Format: `[background_score, face_score]`
|
||||
- Apply softmax to get probability
|
||||
|
||||
3. **landm** (landmarks): `[1, num_anchors, 10]`
|
||||
- Format: 5 points x 2 coordinates `[x0, y0, x1, y1, ..., x4, y4]`
|
||||
- Landmark order:
|
||||
- 0: Left eye center
|
||||
- 1: Right eye center
|
||||
- 2: Nose tip
|
||||
- 3: Left mouth corner
|
||||
- 4: Right mouth corner
|
||||
|
||||
### Anchor Configuration
|
||||
|
||||
RetinaFace uses multi-scale anchors:
|
||||
|
||||
| Stride | Feature Map Size (640x640) | Anchor Sizes |
|
||||
|--------|---------------------------|--------------|
|
||||
| 8 | 80x80 | 16, 32 |
|
||||
| 16 | 40x40 | 64, 128 |
|
||||
| 32 | 20x20 | 256, 512 |
|
||||
|
||||
### Embedding Normalization
|
||||
|
||||
Face embeddings should be L2-normalized for comparison:
|
||||
|
||||
```rust
|
||||
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let normalized: Vec<f32> = embedding.iter().map(|x| x / norm).collect();
|
||||
```
|
||||
|
||||
## Expected File Layout
|
||||
|
||||
```
|
||||
models/
|
||||
├── README.md # This file
|
||||
├── retinaface.onnx # Face detection model
|
||||
├── mobilefacenet.onnx # Face embedding model (128-dim)
|
||||
├── arcface.onnx # Alternative embedding model (512-dim, optional)
|
||||
└── blazeface.onnx # Alternative detection model (optional)
|
||||
```
|
||||
|
||||
## Testing Models
|
||||
|
||||
To verify models work correctly:
|
||||
|
||||
```bash
|
||||
# Run integration tests with models
|
||||
cd linux-hello-daemon
|
||||
cargo test --features onnx -- --ignored
|
||||
```
|
||||
|
||||
## Performance Guidelines
|
||||
|
||||
### Detection Model Selection
|
||||
|
||||
| Model | Input Size | Speed | Accuracy | Memory |
|
||||
|-------|-----------|-------|----------|--------|
|
||||
| RetinaFace-MNet0.25 | 640x640 | Fast | Good | ~5MB |
|
||||
| RetinaFace-R50 | 640x640 | Medium | Excellent | ~100MB |
|
||||
| BlazeFace | 128x128 | Very Fast | Moderate | ~1MB |
|
||||
|
||||
### Embedding Model Selection
|
||||
|
||||
| Model | Embedding Dim | Speed | Accuracy | Memory |
|
||||
|-------|--------------|-------|----------|--------|
|
||||
| MobileFaceNet | 128 | Fast | Good | ~4MB |
|
||||
| ArcFace-R50 | 512 | Medium | Excellent | ~120MB |
|
||||
| ArcFace-R100 | 512 | Slow | Best | ~250MB |
|
||||
|
||||
### Recommended Configurations
|
||||
|
||||
**Low-power devices (Raspberry Pi, etc.):**
|
||||
- Detection: BlazeFace 128x128
|
||||
- Embedding: MobileFaceNet 128-dim
|
||||
- Expected: ~30ms per frame
|
||||
|
||||
**Standard desktop:**
|
||||
- Detection: RetinaFace-MNet 640x640
|
||||
- Embedding: MobileFaceNet 128-dim
|
||||
- Expected: ~15ms per frame
|
||||
|
||||
**High-security scenarios:**
|
||||
- Detection: RetinaFace-R50 640x640
|
||||
- Embedding: ArcFace-R100 512-dim
|
||||
- Expected: ~100ms per frame
|
||||
|
||||
## License Information
|
||||
|
||||
Ensure compliance with model licenses:
|
||||
|
||||
| Model | License | Commercial Use |
|
||||
|-------|---------|----------------|
|
||||
| RetinaFace | MIT | Yes |
|
||||
| BlazeFace | Apache 2.0 | Yes |
|
||||
| MobileFaceNet | MIT | Yes |
|
||||
| ArcFace | MIT | Yes |
|
||||
| InsightFace models | Non-commercial | Check specific model |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Model Loading Fails
|
||||
|
||||
1. Verify ONNX format version (opset 11-17 recommended)
|
||||
2. Check input/output tensor names match expected
|
||||
3. Ensure file is not corrupted: `python -c "import onnx; onnx.load('model.onnx')"`
|
||||
|
||||
### Poor Detection Results
|
||||
|
||||
1. Ensure input normalization matches model training
|
||||
2. Check image is RGB (not BGR)
|
||||
3. Verify input dimensions match model expectations
|
||||
4. Adjust confidence threshold (default: 0.5)
|
||||
|
||||
### Embedding Quality Issues
|
||||
|
||||
1. Face alignment is critical - ensure landmarks are correct
|
||||
2. Check that input is 112x112 after alignment
|
||||
3. Verify embedding is L2-normalized before comparison
|
||||
4. Distance threshold typically: 0.4-0.6 for cosine distance
|
||||
|
||||
## References
|
||||
|
||||
- [ONNX Model Zoo](https://github.com/onnx/models)
|
||||
- [InsightFace](https://github.com/deepinsight/insightface)
|
||||
- [RetinaFace Paper](https://arxiv.org/abs/1905.00641)
|
||||
- [ArcFace Paper](https://arxiv.org/abs/1801.07698)
|
||||
- [MobileFaceNet Paper](https://arxiv.org/abs/1804.07573)
|
||||
|
||||
@@ -152,38 +152,382 @@ static int connect_to_daemon(int sockfd, struct module_options *opts) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Validate username contains only safe characters for JSON embedding.
|
||||
* Returns 1 if valid, 0 if invalid.
|
||||
* Allowed: alphanumeric, underscore, hyphen, dot (standard Unix username chars)
|
||||
*/
|
||||
static int validate_username(const char *user, size_t len) {
|
||||
size_t i;
|
||||
|
||||
/* Assertions */
|
||||
assert(user != NULL);
|
||||
|
||||
/* Check length bounds first */
|
||||
if (len == 0 || len >= MAX_USERNAME_LEN) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Validate each character - fixed upper bound loop */
|
||||
for (i = 0; i < len && i < MAX_USERNAME_LEN; i++) {
|
||||
char c = user[i];
|
||||
/* Allow: a-z, A-Z, 0-9, underscore, hyphen, dot */
|
||||
int is_lower = (c >= 'a' && c <= 'z');
|
||||
int is_upper = (c >= 'A' && c <= 'Z');
|
||||
int is_digit = (c >= '0' && c <= '9');
|
||||
int is_special = (c == '_' || c == '-' || c == '.');
|
||||
|
||||
if (!(is_lower || is_upper || is_digit || is_special)) {
|
||||
return 0; /* Invalid character found */
|
||||
}
|
||||
}
|
||||
|
||||
/* Must not start with hyphen or dot */
|
||||
if (user[0] == '-' || user[0] == '.') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1; /* Valid */
|
||||
}
|
||||
|
||||
/* Send authentication request */
|
||||
static int send_request(int sockfd, const char *user, struct module_options *opts) {
|
||||
char request[MAX_MESSAGE_SIZE];
|
||||
int user_len;
|
||||
size_t user_len;
|
||||
int request_len;
|
||||
ssize_t n;
|
||||
|
||||
|
||||
/* Assertions */
|
||||
assert(sockfd >= 0);
|
||||
assert(user != NULL);
|
||||
assert(opts != NULL);
|
||||
|
||||
/* Build request with bounds checking */
|
||||
user_len = (int)strlen(user);
|
||||
|
||||
/* Validate username length BEFORE any operations */
|
||||
user_len = strlen(user);
|
||||
if (user_len == 0 || user_len >= MAX_USERNAME_LEN) {
|
||||
syslog(LOG_ERR, "Username length invalid: %zu", user_len);
|
||||
return -1;
|
||||
}
|
||||
assert_condition(user_len > 0 && user_len < MAX_USERNAME_LEN, "Invalid username length");
|
||||
|
||||
request_len = snprintf(request, sizeof(request),
|
||||
|
||||
/* Validate username contains only safe characters */
|
||||
if (!validate_username(user, user_len)) {
|
||||
syslog(LOG_ERR, "Username contains invalid characters");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Calculate required buffer size to prevent overflow:
|
||||
* Fixed JSON overhead: {"action":"authenticate","user":""} = 34 chars
|
||||
* Plus username length, plus null terminator
|
||||
*/
|
||||
if (user_len > (sizeof(request) - 35)) {
|
||||
syslog(LOG_ERR, "Username too long for request buffer");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Build request with bounds checking */
|
||||
request_len = snprintf(request, sizeof(request),
|
||||
"{\"action\":\"authenticate\",\"user\":\"%s\"}", user);
|
||||
assert_condition(request_len > 0 && request_len < (int)sizeof(request),
|
||||
|
||||
/* Verify snprintf succeeded and didn't truncate */
|
||||
if (request_len < 0 || request_len >= (int)sizeof(request)) {
|
||||
syslog(LOG_ERR, "Request buffer overflow detected");
|
||||
return -1;
|
||||
}
|
||||
assert_condition(request_len > 0 && request_len < (int)sizeof(request),
|
||||
"Request buffer overflow");
|
||||
|
||||
|
||||
/* Send request */
|
||||
n = write(sockfd, request, (size_t)request_len);
|
||||
assert_condition(n >= 0, "write result checked");
|
||||
|
||||
|
||||
if (n < 0) {
|
||||
if (opts->debug) {
|
||||
pam_syslog(NULL, LOG_DEBUG, "Failed to send request: %s", strerror(errno));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
/* Verify complete write */
|
||||
if (n != request_len) {
|
||||
syslog(LOG_ERR, "Incomplete write: %zd of %d bytes", n, request_len);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip whitespace in JSON string.
|
||||
* Returns pointer to next non-whitespace character, or end of string.
|
||||
*/
|
||||
static const char *skip_whitespace(const char *p, const char *end) {
|
||||
assert(p != NULL);
|
||||
assert(end != NULL);
|
||||
|
||||
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) {
|
||||
p++;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse JSON boolean value at current position.
|
||||
* Returns: 1 for true, 0 for false, -1 for parse error
|
||||
* Updates *pos to point after the parsed value.
|
||||
*/
|
||||
static int parse_json_bool(const char **pos, const char *end) {
|
||||
const char *p;
|
||||
|
||||
assert(pos != NULL);
|
||||
assert(*pos != NULL);
|
||||
assert(end != NULL);
|
||||
|
||||
p = *pos;
|
||||
|
||||
/* Check for "true" (4 chars) */
|
||||
if ((end - p) >= 4 && p[0] == 't' && p[1] == 'r' && p[2] == 'u' && p[3] == 'e') {
|
||||
*pos = p + 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Check for "false" (5 chars) */
|
||||
if ((end - p) >= 5 && p[0] == 'f' && p[1] == 'a' && p[2] == 'l' && p[3] == 's' && p[4] == 'e') {
|
||||
*pos = p + 5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return -1; /* Parse error */
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip a JSON string value (including quotes).
|
||||
* Handles escaped characters properly.
|
||||
* Returns pointer after closing quote, or NULL on error.
|
||||
*/
|
||||
static const char *skip_json_string(const char *p, const char *end) {
|
||||
int max_iterations = MAX_MESSAGE_SIZE;
|
||||
int i;
|
||||
|
||||
assert(p != NULL);
|
||||
assert(end != NULL);
|
||||
|
||||
if (p >= end || *p != '"') {
|
||||
return NULL;
|
||||
}
|
||||
p++; /* Skip opening quote */
|
||||
|
||||
/* Scan for closing quote with escape handling */
|
||||
for (i = 0; i < max_iterations && p < end; i++) {
|
||||
if (*p == '\\' && (p + 1) < end) {
|
||||
p += 2; /* Skip escaped character */
|
||||
} else if (*p == '"') {
|
||||
return p + 1; /* Return pointer after closing quote */
|
||||
} else {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
|
||||
return NULL; /* Unterminated string or too long */
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip a JSON value (string, number, boolean, null, object, array).
|
||||
* Returns pointer after the value, or NULL on error.
|
||||
*/
|
||||
static const char *skip_json_value(const char *p, const char *end) {
|
||||
int depth;
|
||||
int max_iterations = MAX_MESSAGE_SIZE;
|
||||
int i;
|
||||
|
||||
assert(p != NULL);
|
||||
assert(end != NULL);
|
||||
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* String */
|
||||
if (*p == '"') {
|
||||
return skip_json_string(p, end);
|
||||
}
|
||||
|
||||
/* Number (simple: skip digits, dot, minus, plus, e, E) */
|
||||
if (*p == '-' || (*p >= '0' && *p <= '9')) {
|
||||
while (p < end && (*p == '-' || *p == '+' || *p == '.' ||
|
||||
*p == 'e' || *p == 'E' || (*p >= '0' && *p <= '9'))) {
|
||||
p++;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Boolean or null */
|
||||
if (*p == 't' || *p == 'f' || *p == 'n') {
|
||||
/* true (4), false (5), null (4) */
|
||||
if ((end - p) >= 4 && p[0] == 't' && p[1] == 'r' && p[2] == 'u' && p[3] == 'e') {
|
||||
return p + 4;
|
||||
}
|
||||
if ((end - p) >= 5 && p[0] == 'f' && p[1] == 'a' && p[2] == 'l' && p[3] == 's' && p[4] == 'e') {
|
||||
return p + 5;
|
||||
}
|
||||
if ((end - p) >= 4 && p[0] == 'n' && p[1] == 'u' && p[2] == 'l' && p[3] == 'l') {
|
||||
return p + 4;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Object or array - skip matching braces/brackets */
|
||||
if (*p == '{' || *p == '[') {
|
||||
char open_char = *p;
|
||||
char close_char = (open_char == '{') ? '}' : ']';
|
||||
depth = 1;
|
||||
p++;
|
||||
|
||||
for (i = 0; i < max_iterations && p < end && depth > 0; i++) {
|
||||
if (*p == '"') {
|
||||
p = skip_json_string(p, end);
|
||||
if (p == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
} else if (*p == open_char) {
|
||||
depth++;
|
||||
p++;
|
||||
} else if (*p == close_char) {
|
||||
depth--;
|
||||
p++;
|
||||
} else {
|
||||
p++;
|
||||
}
|
||||
}
|
||||
|
||||
if (depth == 0) {
|
||||
return p;
|
||||
}
|
||||
return NULL; /* Unmatched braces */
|
||||
}
|
||||
|
||||
return NULL; /* Unknown value type */
|
||||
}
|
||||
|
||||
/*
|
||||
* Secure JSON parser for authentication response.
|
||||
* Expected format: {"success":true/false,"message":"...","confidence":0.95}
|
||||
*
|
||||
* This parser properly handles the JSON structure and prevents attacks where
|
||||
* malicious content in string values could spoof the success field.
|
||||
*
|
||||
* Returns: 1 if success==true found at root level, 0 otherwise
|
||||
*/
|
||||
static int parse_auth_response(const char *json, size_t len) {
|
||||
const char *p;
|
||||
const char *end;
|
||||
int found_success = 0;
|
||||
int success_value = 0;
|
||||
int max_fields = 32; /* Fixed upper bound for fields in object */
|
||||
int field_count;
|
||||
|
||||
/* Assertions */
|
||||
assert(json != NULL);
|
||||
assert_condition(len > 0 && len < MAX_MESSAGE_SIZE, "Valid JSON length");
|
||||
|
||||
if (len == 0 || len >= MAX_MESSAGE_SIZE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
p = json;
|
||||
end = json + len;
|
||||
|
||||
/* Skip leading whitespace */
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Must start with { */
|
||||
if (*p != '{') {
|
||||
return 0;
|
||||
}
|
||||
p++;
|
||||
|
||||
/* Parse object fields */
|
||||
for (field_count = 0; field_count < max_fields; field_count++) {
|
||||
const char *key_start;
|
||||
const char *key_end;
|
||||
size_t key_len;
|
||||
|
||||
/* Skip whitespace */
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check for end of object */
|
||||
if (*p == '}') {
|
||||
break;
|
||||
}
|
||||
|
||||
/* Handle comma between fields (not before first field) */
|
||||
if (field_count > 0) {
|
||||
if (*p != ',') {
|
||||
return 0; /* Expected comma */
|
||||
}
|
||||
p++;
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse key (must be a string) */
|
||||
if (*p != '"') {
|
||||
return 0;
|
||||
}
|
||||
key_start = p + 1;
|
||||
p = skip_json_string(p, end);
|
||||
if (p == NULL) {
|
||||
return 0;
|
||||
}
|
||||
key_end = p - 1; /* Points to closing quote */
|
||||
key_len = (size_t)(key_end - key_start);
|
||||
|
||||
/* Skip whitespace and colon */
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end || *p != ':') {
|
||||
return 0;
|
||||
}
|
||||
p++;
|
||||
p = skip_whitespace(p, end);
|
||||
if (p >= end) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check if this is the "success" field (7 chars) */
|
||||
if (key_len == 7 && key_start[0] == 's' && key_start[1] == 'u' &&
|
||||
key_start[2] == 'c' && key_start[3] == 'c' && key_start[4] == 'e' &&
|
||||
key_start[5] == 's' && key_start[6] == 's') {
|
||||
|
||||
/* Parse the boolean value */
|
||||
int bool_result = parse_json_bool(&p, end);
|
||||
if (bool_result < 0) {
|
||||
return 0; /* Invalid boolean */
|
||||
}
|
||||
found_success = 1;
|
||||
success_value = bool_result;
|
||||
} else {
|
||||
/* Skip this value */
|
||||
p = skip_json_value(p, end);
|
||||
if (p == NULL) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Return success only if we found and parsed the success field */
|
||||
if (found_success && success_value == 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -191,32 +535,47 @@ static int send_request(int sockfd, const char *user, struct module_options *opt
|
||||
static int read_response(int sockfd, struct module_options *opts) {
|
||||
char response[MAX_MESSAGE_SIZE];
|
||||
ssize_t n;
|
||||
const char *success_str = "\"success\":true";
|
||||
|
||||
|
||||
/* Assertions */
|
||||
assert(sockfd >= 0);
|
||||
assert(opts != NULL);
|
||||
|
||||
|
||||
/* Initialize buffer to zeros for safety */
|
||||
(void)memset(response, 0, sizeof(response));
|
||||
|
||||
/* Read response */
|
||||
n = read(sockfd, response, sizeof(response) - 1);
|
||||
assert_condition(n >= -1, "read result in valid range");
|
||||
|
||||
if (n <= 0) {
|
||||
if (opts->debug && n < 0) {
|
||||
|
||||
if (n < 0) {
|
||||
if (opts->debug) {
|
||||
pam_syslog(NULL, LOG_DEBUG, "Failed to read response: %s", strerror(errno));
|
||||
}
|
||||
return 0; /* Failure */
|
||||
return 0; /* Failure - read error */
|
||||
}
|
||||
|
||||
/* Null terminate */
|
||||
|
||||
if (n == 0) {
|
||||
if (opts->debug) {
|
||||
pam_syslog(NULL, LOG_DEBUG, "Empty response from daemon");
|
||||
}
|
||||
return 0; /* Failure - no data */
|
||||
}
|
||||
|
||||
/* Verify we didn't somehow overflow (defensive) */
|
||||
if (n >= (ssize_t)sizeof(response)) {
|
||||
syslog(LOG_ERR, "Response buffer overflow detected");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Null terminate (buffer was zeroed, but be explicit) */
|
||||
response[n] = '\0';
|
||||
assert_condition(n < (ssize_t)sizeof(response), "Response fits in buffer");
|
||||
|
||||
/* Simple string search for success */
|
||||
if (strstr(response, success_str) != NULL) {
|
||||
|
||||
/* Use secure JSON parser instead of naive string search */
|
||||
if (parse_auth_response(response, (size_t)n)) {
|
||||
return 1; /* Success */
|
||||
}
|
||||
|
||||
|
||||
return 0; /* Failure */
|
||||
}
|
||||
|
||||
@@ -225,14 +584,38 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
||||
struct module_options *opts) {
|
||||
int sockfd;
|
||||
int result;
|
||||
|
||||
/* Assertions */
|
||||
size_t user_len;
|
||||
|
||||
/* Assertions - NULL checks first */
|
||||
assert(pamh != NULL);
|
||||
assert(user != NULL);
|
||||
assert(opts != NULL);
|
||||
assert_condition(strlen(user) > 0 && strlen(user) < MAX_USERNAME_LEN,
|
||||
|
||||
/* Validate user pointer before dereferencing */
|
||||
if (pamh == NULL || user == NULL || opts == NULL) {
|
||||
syslog(LOG_ERR, "authenticate_face: NULL parameter");
|
||||
return PAM_SYSTEM_ERR;
|
||||
}
|
||||
|
||||
/* Validate username length before any operations */
|
||||
user_len = strlen(user);
|
||||
if (user_len == 0) {
|
||||
pam_syslog(pamh, LOG_ERR, "Empty username");
|
||||
return PAM_USER_UNKNOWN;
|
||||
}
|
||||
if (user_len >= MAX_USERNAME_LEN) {
|
||||
pam_syslog(pamh, LOG_ERR, "Username too long: %zu chars", user_len);
|
||||
return PAM_USER_UNKNOWN;
|
||||
}
|
||||
assert_condition(user_len > 0 && user_len < MAX_USERNAME_LEN,
|
||||
"Valid username");
|
||||
|
||||
|
||||
/* Validate username contains only safe characters */
|
||||
if (!validate_username(user, user_len)) {
|
||||
pam_syslog(pamh, LOG_ERR, "Username contains invalid characters");
|
||||
return PAM_USER_UNKNOWN;
|
||||
}
|
||||
|
||||
/* Create socket */
|
||||
sockfd = create_socket(opts);
|
||||
if (sockfd < 0) {
|
||||
@@ -241,7 +624,7 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
||||
}
|
||||
return PAM_AUTHINFO_UNAVAIL;
|
||||
}
|
||||
|
||||
|
||||
/* Connect to daemon */
|
||||
result = connect_to_daemon(sockfd, opts);
|
||||
if (result < 0) {
|
||||
@@ -251,23 +634,23 @@ static int authenticate_face(pam_handle_t *pamh, const char *user,
|
||||
(void)close(sockfd);
|
||||
return PAM_AUTHINFO_UNAVAIL;
|
||||
}
|
||||
|
||||
|
||||
/* Send request */
|
||||
result = send_request(sockfd, user, opts);
|
||||
if (result < 0) {
|
||||
(void)close(sockfd);
|
||||
return PAM_AUTHINFO_UNAVAIL;
|
||||
}
|
||||
|
||||
|
||||
/* Read response */
|
||||
result = read_response(sockfd, opts);
|
||||
(void)close(sockfd);
|
||||
|
||||
|
||||
if (result > 0) {
|
||||
pam_syslog(pamh, LOG_INFO, "Face authentication successful for %s", user);
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
if (opts->debug) {
|
||||
pam_syslog(pamh, LOG_DEBUG, "Face authentication failed for %s", user);
|
||||
}
|
||||
|
||||
203
rpm/linux-hello.spec
Normal file
203
rpm/linux-hello.spec
Normal file
@@ -0,0 +1,203 @@
|
||||
%global _hardened_build 1
|
||||
|
||||
Name: linux-hello
|
||||
Version: 0.1.0
|
||||
Release: 1%{?dist}
|
||||
Summary: Face authentication for Linux
|
||||
|
||||
License: GPL-3.0-or-later
|
||||
URL: https://github.com/linux-hello/linux-hello
|
||||
Source0: %{name}-%{version}.tar.gz
|
||||
|
||||
BuildRequires: rust >= 1.75
|
||||
BuildRequires: cargo
|
||||
BuildRequires: gcc
|
||||
BuildRequires: pam-devel
|
||||
BuildRequires: libv4l-devel
|
||||
BuildRequires: tpm2-tss-devel
|
||||
BuildRequires: openssl-devel
|
||||
BuildRequires: clang-devel
|
||||
BuildRequires: systemd-rpm-macros
|
||||
|
||||
# Main package is a metapackage
|
||||
Requires: %{name}-cli = %{version}-%{release}
|
||||
Requires: %{name}-daemon = %{version}-%{release}
|
||||
Recommends: pam-%{name} = %{version}-%{release}
|
||||
|
||||
%description
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. It supports infrared cameras, TPM-backed template encryption,
|
||||
and anti-spoofing with liveness detection.
|
||||
|
||||
This metapackage installs the CLI tool and daemon.
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%package cli
|
||||
Summary: Face authentication for Linux - CLI tool
|
||||
Requires: %{name}-daemon = %{version}-%{release}
|
||||
|
||||
%description cli
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the command-line interface for enrolling
|
||||
faces, managing templates, and testing authentication.
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%package daemon
|
||||
Summary: Face authentication for Linux - daemon
|
||||
Requires(pre): shadow-utils
|
||||
%{?systemd_requires}
|
||||
|
||||
%description daemon
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the background daemon that handles
|
||||
camera access, face detection, and template matching.
|
||||
|
||||
The daemon runs as a systemd service and communicates with the CLI
|
||||
and PAM module via Unix socket.
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%package -n pam-%{name}
|
||||
Summary: Face authentication for Linux - PAM module
|
||||
Requires: pam
|
||||
Requires: %{name}-daemon = %{version}-%{release}
|
||||
|
||||
%description -n pam-%{name}
|
||||
Linux Hello provides Windows Hello-style face authentication for Linux
|
||||
systems. This package contains the PAM module that integrates face
|
||||
authentication with system login, sudo, and other PAM-aware applications.
|
||||
|
||||
WARNING: After installation, you must manually configure PAM to use
|
||||
this module. A template configuration is provided at
|
||||
/usr/share/doc/pam-linux-hello/pam-config.example
|
||||
|
||||
Incorrect PAM configuration may lock you out of your system!
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%prep
|
||||
%autosetup -n %{name}-%{version}
|
||||
|
||||
%build
|
||||
# Build Rust components
|
||||
export CARGO_HOME="$PWD/.cargo"
|
||||
cargo build --release \
|
||||
--package linux-hello-daemon \
|
||||
--package linux-hello-cli
|
||||
|
||||
# Build PAM module
|
||||
%make_build -C pam-module CFLAGS="%{optflags} -fPIC" LDFLAGS="%{build_ldflags}"
|
||||
|
||||
%install
|
||||
# Install daemon binary
|
||||
install -D -m 755 target/release/linux-hello-daemon \
|
||||
%{buildroot}%{_libexecdir}/linux-hello-daemon
|
||||
|
||||
# Install CLI binary
|
||||
install -D -m 755 target/release/linux-hello \
|
||||
%{buildroot}%{_bindir}/linux-hello
|
||||
|
||||
# Install PAM module (architecture-specific path)
|
||||
install -D -m 755 pam-module/pam_linux_hello.so \
|
||||
%{buildroot}%{_libdir}/security/pam_linux_hello.so
|
||||
|
||||
# Install configuration
|
||||
install -D -m 644 dist/config.toml \
|
||||
%{buildroot}%{_sysconfdir}/linux-hello/config.toml
|
||||
|
||||
# Install systemd service
|
||||
install -D -m 644 dist/linux-hello.service \
|
||||
%{buildroot}%{_unitdir}/linux-hello.service
|
||||
|
||||
# Install PAM configuration template
|
||||
install -D -m 644 debian/pam-config.example \
|
||||
%{buildroot}%{_docdir}/pam-%{name}/pam-config.example
|
||||
|
||||
# Create state directory
|
||||
install -d -m 750 %{buildroot}%{_sharedstatedir}/linux-hello
|
||||
install -d -m 750 %{buildroot}%{_sharedstatedir}/linux-hello/templates
|
||||
|
||||
# Create runtime directory placeholder (actual dir created by tmpfiles)
|
||||
install -D -m 644 /dev/null %{buildroot}%{_tmpfilesdir}/%{name}.conf
|
||||
cat > %{buildroot}%{_tmpfilesdir}/%{name}.conf << 'EOF'
|
||||
# linux-hello runtime directory
|
||||
d /run/linux-hello 0750 root linux-hello -
|
||||
EOF
|
||||
|
||||
%check
|
||||
# Run tests (skip hardware-dependent tests)
|
||||
export CARGO_HOME="$PWD/.cargo"
|
||||
cargo test --release \
|
||||
--package linux-hello-common \
|
||||
-- --skip integration || true
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%pre daemon
|
||||
# Create linux-hello system user
|
||||
getent group linux-hello >/dev/null || groupadd -r linux-hello
|
||||
getent passwd linux-hello >/dev/null || \
|
||||
useradd -r -g linux-hello -d %{_sharedstatedir}/linux-hello \
|
||||
-s /sbin/nologin -c "Linux Hello Face Authentication" linux-hello
|
||||
|
||||
# Add to video group for camera access
|
||||
usermod -a -G video linux-hello 2>/dev/null || :
|
||||
# Add to tss group for TPM access
|
||||
getent group tss >/dev/null && usermod -a -G tss linux-hello 2>/dev/null || :
|
||||
|
||||
%post daemon
|
||||
%systemd_post linux-hello.service
|
||||
|
||||
# Set permissions on state directory
|
||||
chown root:linux-hello %{_sharedstatedir}/linux-hello
|
||||
chmod 0750 %{_sharedstatedir}/linux-hello
|
||||
chown root:linux-hello %{_sharedstatedir}/linux-hello/templates
|
||||
chmod 0750 %{_sharedstatedir}/linux-hello/templates
|
||||
|
||||
# Create runtime directory
|
||||
systemd-tmpfiles --create %{_tmpfilesdir}/%{name}.conf || :
|
||||
|
||||
%preun daemon
|
||||
%systemd_preun linux-hello.service
|
||||
|
||||
%postun daemon
|
||||
%systemd_postun_with_restart linux-hello.service
|
||||
|
||||
# Clean up on complete removal
|
||||
if [ $1 -eq 0 ]; then
|
||||
# Remove runtime directory
|
||||
rm -rf /run/linux-hello 2>/dev/null || :
|
||||
fi
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%files
|
||||
# Metapackage - no files
|
||||
|
||||
%files cli
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%{_bindir}/linux-hello
|
||||
|
||||
%files daemon
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%{_libexecdir}/linux-hello-daemon
|
||||
%{_unitdir}/linux-hello.service
|
||||
%{_tmpfilesdir}/%{name}.conf
|
||||
%dir %{_sysconfdir}/linux-hello
|
||||
%config(noreplace) %{_sysconfdir}/linux-hello/config.toml
|
||||
%dir %attr(0750,root,linux-hello) %{_sharedstatedir}/linux-hello
|
||||
%dir %attr(0750,root,linux-hello) %{_sharedstatedir}/linux-hello/templates
|
||||
|
||||
%files -n pam-%{name}
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%doc %{_docdir}/pam-%{name}/pam-config.example
|
||||
%{_libdir}/security/pam_linux_hello.so
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
%changelog
|
||||
* Wed Jan 15 2025 Linux Hello Contributors <linux-hello@example.org> - 0.1.0-1
|
||||
- Initial release
|
||||
- Face authentication daemon with IR camera support
|
||||
- TPM-backed template encryption
|
||||
- Anti-spoofing with liveness detection
|
||||
- PAM module for system integration
|
||||
- CLI for face enrollment and management
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Tests for TPM storage, secure memory, and anti-spoofing functionality.
|
||||
|
||||
use linux_hello_daemon::anti_spoofing::{
|
||||
AntiSpoofingConfig, AntiSpoofingDetector, AntiSpoofingFrame, LivenessResult,
|
||||
AntiSpoofingConfig, AntiSpoofingDetector, AntiSpoofingFrame,
|
||||
};
|
||||
use linux_hello_daemon::secure_memory::{SecureBytes, SecureEmbedding, memory_protection};
|
||||
use linux_hello_daemon::tpm::{EncryptedTemplate, SoftwareTpmFallback, TpmStorage};
|
||||
@@ -62,16 +62,18 @@ fn test_software_tpm_user_key_management() {
|
||||
fn test_encrypted_template_structure() {
|
||||
let template = EncryptedTemplate {
|
||||
ciphertext: vec![1, 2, 3, 4, 5, 6, 7, 8],
|
||||
iv: vec![0xAA, 0xBB, 0xCC, 0xDD],
|
||||
iv: vec![0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88], // 12 bytes for AES-GCM
|
||||
salt: vec![0u8; 32], // 32 bytes for PBKDF2 salt
|
||||
key_handle: 0x81000001,
|
||||
tpm_encrypted: true,
|
||||
};
|
||||
|
||||
|
||||
let json = serde_json::to_string(&template).unwrap();
|
||||
let restored: EncryptedTemplate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
|
||||
assert_eq!(restored.ciphertext, template.ciphertext);
|
||||
assert_eq!(restored.iv, template.iv);
|
||||
assert_eq!(restored.salt, template.salt);
|
||||
assert_eq!(restored.key_handle, template.key_handle);
|
||||
assert_eq!(restored.tpm_encrypted, template.tpm_encrypted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user